Configure system-wide settings for AgenticAdvertising.org
+
+
+
+
+ These settings control how the system behaves. Changes take effect immediately.
+
+
+
+
+
Billing Notifications Channel
+
+ Choose which Slack channel receives billing notifications from Addie.
+ This includes new subscriptions, payments, payment failures, and cancellations.
+
+
+
+ Loading...
+
+
+
+
+
+
+ Only public channels the bot has access to are shown
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/public/admin-sidebar.js b/server/public/admin-sidebar.js
index bfdac7b825..5ce473bbb3 100644
--- a/server/public/admin-sidebar.js
+++ b/server/public/admin-sidebar.js
@@ -54,6 +54,7 @@
{
label: 'Settings',
items: [
+ { href: '/admin/settings', label: 'System Settings', icon: '⚙️' },
{ href: '/admin/insight-types', label: 'Insight Types', icon: '🏷️' },
{ href: '/admin/outreach', label: 'Outreach Config', icon: '📣' },
{ href: '/admin/insights', label: 'Raw Insights', icon: '🧠' },
diff --git a/server/src/db/migrations/106_system_settings.sql b/server/src/db/migrations/106_system_settings.sql
new file mode 100644
index 0000000000..9b5e5f0d2f
--- /dev/null
+++ b/server/src/db/migrations/106_system_settings.sql
@@ -0,0 +1,24 @@
+-- System settings for configurable application behavior
+-- Stores key-value settings like Slack channel IDs for various notification types
+
+CREATE TABLE IF NOT EXISTS system_settings (
+ key VARCHAR(100) PRIMARY KEY,
+ value JSONB NOT NULL,
+ description TEXT,
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by VARCHAR(255) -- workos_user_id of admin who last updated
+);
+
+-- Index for fast lookups
+CREATE INDEX IF NOT EXISTS idx_system_settings_updated_at ON system_settings(updated_at);
+
+-- Insert default settings
+INSERT INTO system_settings (key, value, description) VALUES
+ ('billing_slack_channel', '{"channel_id": null, "channel_name": null}', 'Slack channel for billing notifications (payments, invoices, subscriptions)')
+ON CONFLICT (key) DO NOTHING;
+
+-- Comments for documentation
+COMMENT ON TABLE system_settings IS 'Key-value store for system-wide configuration settings';
+COMMENT ON COLUMN system_settings.key IS 'Unique setting identifier';
+COMMENT ON COLUMN system_settings.value IS 'JSON value for the setting';
+COMMENT ON COLUMN system_settings.description IS 'Human-readable description of what this setting controls';
diff --git a/server/src/db/system-settings-db.ts b/server/src/db/system-settings-db.ts
new file mode 100644
index 0000000000..d63af1a3c7
--- /dev/null
+++ b/server/src/db/system-settings-db.ts
@@ -0,0 +1,92 @@
+/**
+ * Database layer for system settings
+ * Manages key-value configuration for application-wide settings
+ */
+
+import { query } from './client.js';
+
+// ============== Types ==============
+
+export interface SystemSetting {
+ key: string;
+ value: T;
+ description: string | null;
+ updated_at: Date;
+ updated_by: string | null;
+}
+
+export interface BillingChannelSetting {
+ channel_id: string | null;
+ channel_name: string | null;
+}
+
+// ============== Setting Keys ==============
+
+export const SETTING_KEYS = {
+ BILLING_SLACK_CHANNEL: 'billing_slack_channel',
+} as const;
+
+// ============== Generic Operations ==============
+
+/**
+ * Get a setting by key
+ */
+export async function getSetting(key: string): Promise {
+ const result = await query<{ value: T }>(
+ `SELECT value FROM system_settings WHERE key = $1`,
+ [key]
+ );
+ return result.rows[0]?.value ?? null;
+}
+
+/**
+ * Set a setting value
+ */
+export async function setSetting(
+ key: string,
+ value: T,
+ updatedBy?: string
+): Promise {
+ await query(
+ `INSERT INTO system_settings (key, value, updated_at, updated_by)
+ VALUES ($1, $2, NOW(), $3)
+ ON CONFLICT (key)
+ DO UPDATE SET value = $2, updated_at = NOW(), updated_by = $3`,
+ [key, JSON.stringify(value), updatedBy ?? null]
+ );
+}
+
+/**
+ * Get all settings
+ */
+export async function getAllSettings(): Promise {
+ const result = await query(
+ `SELECT * FROM system_settings ORDER BY key`
+ );
+ return result.rows;
+}
+
+// ============== Billing Channel Operations ==============
+
+/**
+ * Get the configured billing notification Slack channel
+ */
+export async function getBillingChannel(): Promise {
+ const result = await getSetting(SETTING_KEYS.BILLING_SLACK_CHANNEL);
+ return result ?? { channel_id: null, channel_name: null };
+}
+
+/**
+ * Set the billing notification Slack channel
+ */
+export async function setBillingChannel(
+ channelId: string | null,
+ channelName: string | null,
+ updatedBy?: string
+): Promise {
+ await setSetting(
+ SETTING_KEYS.BILLING_SLACK_CHANNEL,
+ { channel_id: channelId, channel_name: channelName },
+ updatedBy
+ );
+}
diff --git a/server/src/http.ts b/server/src/http.ts
index 023e78e406..0c5f01c599 100644
--- a/server/src/http.ts
+++ b/server/src/http.ts
@@ -40,7 +40,7 @@ import {
notifyPaymentSucceeded,
notifyPaymentFailed,
notifySubscriptionCancelled,
-} from "./notifications/slack.js";
+} from "./notifications/billing.js";
import { createAdminRouter } from "./routes/admin.js";
import { createAdminInsightsRouter } from "./routes/admin-insights.js";
import { createAddieAdminRouter } from "./routes/addie-admin.js";
@@ -49,7 +49,7 @@ import { sendAccountLinkedMessage, invalidateMemberContextCache, getAddieBoltRou
import { createSlackRouter } from "./routes/slack.js";
import { createWebhooksRouter } from "./routes/webhooks.js";
import { createWorkOSWebhooksRouter } from "./routes/workos-webhooks.js";
-import { createAdminSlackRouter, createAdminEmailRouter, createAdminFeedsRouter, createAdminNotificationChannelsRouter, createAdminUsersRouter } from "./routes/admin/index.js";
+import { createAdminSlackRouter, createAdminEmailRouter, createAdminFeedsRouter, createAdminNotificationChannelsRouter, createAdminUsersRouter, createAdminSettingsRouter } from "./routes/admin/index.js";
import { processFeedsToFetch } from "./addie/services/feed-fetcher.js";
import { processAlerts } from "./addie/services/industry-alerts.js";
import { createBillingRouter } from "./routes/billing.js";
@@ -494,6 +494,8 @@ export class HTTPServer {
this.app.use('/api/admin/notification-channels', adminNotificationChannelsRouter); // Notification Channels: /api/admin/notification-channels/*
const adminUsersRouter = createAdminUsersRouter();
this.app.use('/api/admin/users', adminUsersRouter); // Admin Users: /api/admin/users/*
+ const adminSettingsRouter = createAdminSettingsRouter();
+ this.app.use('/api/admin/settings', adminSettingsRouter); // Admin Settings: /api/admin/settings/*
// Mount billing routes (admin)
const { pageRouter: billingPageRouter, apiRouter: billingApiRouter } = createBillingRouter();
@@ -4015,6 +4017,10 @@ Disallow: /api/admin/
await this.serveHtmlWithConfig(req, res, 'admin-notification-channels.html');
});
+ this.app.get('/admin/settings', requireAuth, requireAdmin, async (req, res) => {
+ await this.serveHtmlWithConfig(req, res, 'admin-settings.html');
+ });
+
// Registry API endpoints (consolidated agents, publishers, lookups)
this.setupRegistryRoutes();
}
diff --git a/server/src/notifications/billing.ts b/server/src/notifications/billing.ts
new file mode 100644
index 0000000000..7dfc643c91
--- /dev/null
+++ b/server/src/notifications/billing.ts
@@ -0,0 +1,234 @@
+/**
+ * Billing notification service
+ *
+ * Sends billing-related notifications to the configured Slack channel via Addie.
+ * Replaces the old webhook-based notifications with channel-based messaging.
+ */
+
+import { createLogger } from '../logger.js';
+import { getBillingChannel } from '../db/system-settings-db.js';
+import { sendChannelMessage, isSlackConfigured } from '../slack/client.js';
+import type { SlackBlock, SlackTextObject } from '../slack/types.js';
+
+const logger = createLogger('billing-notifications');
+
+/**
+ * Format currency amount from cents to dollars
+ */
+function formatAmount(cents: number, currency: string = 'usd'): string {
+ const amount = cents / 100;
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currency.toUpperCase(),
+ }).format(amount);
+}
+
+/**
+ * Mask email address to prevent PII exposure
+ */
+function maskEmail(email: string | null | undefined): string {
+ if (!email) return '***';
+ const [local, domain] = email.split('@');
+ if (!domain) return '***';
+ const masked = local.charAt(0) + '***';
+ return `${masked}@${domain}`;
+}
+
+/**
+ * Helper to create a header block
+ */
+function headerBlock(text: string): SlackBlock {
+ return {
+ type: 'header',
+ text: { type: 'plain_text', text, emoji: true } as SlackTextObject,
+ };
+}
+
+/**
+ * Helper to create a section block with fields
+ */
+function sectionBlock(fields: Array<{ label: string; value: string }>): SlackBlock {
+ return {
+ type: 'section',
+ fields: fields.map(f => ({
+ type: 'mrkdwn' as const,
+ text: `*${f.label}:*\n${f.value}`,
+ })),
+ };
+}
+
+/**
+ * Send a billing notification to the configured channel
+ * Returns true if sent successfully, false if channel not configured or send failed
+ */
+async function sendBillingNotification(
+ text: string,
+ blocks: SlackBlock[]
+): Promise {
+ if (!isSlackConfigured()) {
+ logger.debug('Slack not configured, skipping billing notification');
+ return false;
+ }
+
+ const billingChannel = await getBillingChannel();
+ if (!billingChannel.channel_id) {
+ logger.debug('Billing channel not configured, skipping notification');
+ return false;
+ }
+
+ try {
+ const result = await sendChannelMessage(billingChannel.channel_id, { text, blocks });
+ if (result.ok) {
+ logger.info({ channel: billingChannel.channel_name }, 'Billing notification sent');
+ return true;
+ } else {
+ logger.warn({ error: result.error, channel: billingChannel.channel_id }, 'Failed to send billing notification');
+ return false;
+ }
+ } catch (error) {
+ logger.error({ error, channel: billingChannel.channel_id }, 'Error sending billing notification');
+ return false;
+ }
+}
+
+/**
+ * Notify when a new subscription is created
+ */
+export async function notifyNewSubscription(data: {
+ organizationName: string;
+ customerEmail: string;
+ productName?: string;
+ amount?: number;
+ currency?: string;
+ interval?: string;
+}): Promise {
+ const intervalText = data.interval === 'year' ? '/year' : '/month';
+ const amountText = data.amount ? formatAmount(data.amount, data.currency) + intervalText : 'Custom';
+
+ return sendBillingNotification(
+ `New Member: ${data.organizationName}`,
+ [
+ headerBlock('New Member!'),
+ sectionBlock([
+ { label: 'Organization', value: data.organizationName },
+ { label: 'Email', value: maskEmail(data.customerEmail) },
+ { label: 'Plan', value: data.productName || 'Membership' },
+ { label: 'Amount', value: amountText },
+ ]),
+ ]
+ );
+}
+
+/**
+ * Notify when a payment succeeds
+ */
+export async function notifyPaymentSucceeded(data: {
+ organizationName: string;
+ amount: number;
+ currency: string;
+ productName?: string;
+ isRecurring: boolean;
+}): Promise {
+ const paymentType = data.isRecurring ? 'Recurring Payment' : 'Payment';
+
+ return sendBillingNotification(
+ `${paymentType}: ${formatAmount(data.amount, data.currency)} from ${data.organizationName}`,
+ [
+ headerBlock(`${paymentType} Received`),
+ sectionBlock([
+ { label: 'Organization', value: data.organizationName },
+ { label: 'Amount', value: formatAmount(data.amount, data.currency) },
+ { label: 'Product', value: data.productName || 'Membership' },
+ { label: 'Type', value: data.isRecurring ? 'Recurring' : 'Initial' },
+ ]),
+ ]
+ );
+}
+
+/**
+ * Notify when a payment fails
+ */
+export async function notifyPaymentFailed(data: {
+ organizationName: string;
+ amount: number;
+ currency: string;
+ attemptCount: number;
+}): Promise {
+ return sendBillingNotification(
+ `Payment Failed: ${data.organizationName} - ${formatAmount(data.amount, data.currency)}`,
+ [
+ headerBlock('Payment Failed'),
+ sectionBlock([
+ { label: 'Organization', value: data.organizationName },
+ { label: 'Amount', value: formatAmount(data.amount, data.currency) },
+ { label: 'Attempt', value: `#${data.attemptCount}` },
+ ]),
+ ]
+ );
+}
+
+/**
+ * Notify when a subscription is cancelled
+ */
+export async function notifySubscriptionCancelled(data: {
+ organizationName: string;
+ productName?: string;
+}): Promise {
+ return sendBillingNotification(
+ `Subscription Cancelled: ${data.organizationName}`,
+ [
+ headerBlock('Subscription Cancelled'),
+ sectionBlock([
+ { label: 'Organization', value: data.organizationName },
+ { label: 'Plan', value: data.productName || 'Membership' },
+ ]),
+ ]
+ );
+}
+
+/**
+ * Notify when an invoice is sent
+ */
+export async function notifyInvoiceSent(data: {
+ organizationName: string;
+ contactEmail: string;
+ amount: number;
+ currency: string;
+ productName?: string;
+}): Promise {
+ return sendBillingNotification(
+ `Invoice Sent: ${formatAmount(data.amount, data.currency)} to ${data.organizationName}`,
+ [
+ headerBlock('Invoice Sent'),
+ sectionBlock([
+ { label: 'Organization', value: data.organizationName },
+ { label: 'Sent To', value: maskEmail(data.contactEmail) },
+ { label: 'Amount', value: formatAmount(data.amount, data.currency) },
+ { label: 'Product', value: data.productName || 'Membership' },
+ ]),
+ ]
+ );
+}
+
+/**
+ * Notify when a discount is applied
+ */
+export async function notifyDiscountApplied(data: {
+ organizationName: string;
+ discountCode: string;
+ discountPercent: number;
+ appliedBy: string;
+}): Promise {
+ return sendBillingNotification(
+ `Discount Applied: ${data.discountPercent}% off for ${data.organizationName}`,
+ [
+ headerBlock('Discount Applied'),
+ sectionBlock([
+ { label: 'Organization', value: data.organizationName },
+ { label: 'Discount', value: `${data.discountPercent}% off` },
+ { label: 'Code', value: data.discountCode },
+ { label: 'Applied By', value: data.appliedBy },
+ ]),
+ ]
+ );
+}
diff --git a/server/src/routes/admin/index.ts b/server/src/routes/admin/index.ts
index 91f4110e0f..b98a7f5805 100644
--- a/server/src/routes/admin/index.ts
+++ b/server/src/routes/admin/index.ts
@@ -9,6 +9,7 @@ export { createAdminEmailRouter } from './email.js';
export { createAdminFeedsRouter } from './feeds.js';
export { createAdminNotificationChannelsRouter } from './notification-channels.js';
export { createAdminUsersRouter } from './users.js';
+export { createAdminSettingsRouter } from './settings.js';
// Core admin route setup functions
export { setupProspectRoutes } from './prospects.js';
diff --git a/server/src/routes/admin/settings.ts b/server/src/routes/admin/settings.ts
new file mode 100644
index 0000000000..f90f58d5ad
--- /dev/null
+++ b/server/src/routes/admin/settings.ts
@@ -0,0 +1,125 @@
+/**
+ * Admin System Settings routes module
+ *
+ * Admin-only routes for managing system-wide configuration:
+ * - View/update billing notification channel
+ * - List available Slack channels for picker
+ */
+
+import { Router, Request, Response } from 'express';
+import { createLogger } from '../../logger.js';
+import { requireAuth, requireAdmin } from '../../middleware/auth.js';
+import {
+ getAllSettings,
+ getBillingChannel,
+ setBillingChannel,
+} from '../../db/system-settings-db.js';
+import { getSlackChannels, isSlackConfigured } from '../../slack/client.js';
+
+const logger = createLogger('admin-settings');
+
+export function createAdminSettingsRouter(): Router {
+ const router = Router();
+
+ // GET /api/admin/settings - Get all system settings
+ router.get('/', requireAuth, requireAdmin, async (_req: Request, res: Response) => {
+ try {
+ const settings = await getAllSettings();
+ const billingChannel = await getBillingChannel();
+
+ res.json({
+ settings,
+ billing_channel: billingChannel,
+ });
+ } catch (error) {
+ logger.error({ err: error }, 'Failed to get system settings');
+ res.status(500).json({
+ error: 'Failed to get system settings',
+ message: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ });
+
+ // GET /api/admin/settings/slack-channels - List available Slack channels for picker
+ router.get('/slack-channels', requireAuth, requireAdmin, async (_req: Request, res: Response) => {
+ try {
+ if (!isSlackConfigured()) {
+ res.status(400).json({
+ error: 'Slack not configured',
+ message: 'ADDIE_BOT_TOKEN is not set',
+ });
+ return;
+ }
+
+ // Get public channels the bot can see
+ const channels = await getSlackChannels({ types: 'public_channel', exclude_archived: true });
+
+ // Sort by name and return minimal info
+ const sorted = channels
+ .map(c => ({
+ id: c.id,
+ name: c.name,
+ is_private: c.is_private,
+ num_members: c.num_members,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ res.json({ channels: sorted });
+ } catch (error) {
+ logger.error({ err: error }, 'Failed to list Slack channels');
+ res.status(500).json({
+ error: 'Failed to list Slack channels',
+ message: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ });
+
+ // PUT /api/admin/settings/billing-channel - Update billing notification channel
+ router.put('/billing-channel', requireAuth, requireAdmin, async (req: Request, res: Response) => {
+ try {
+ const { channel_id, channel_name } = req.body;
+
+ // Allow null to clear the channel
+ if (channel_id !== null && channel_id !== undefined) {
+ // Validate channel ID format
+ if (typeof channel_id !== 'string' || !/^[CG][A-Z0-9]+$/.test(channel_id)) {
+ res.status(400).json({
+ error: 'Invalid channel ID format',
+ message: 'Channel ID should start with C or G followed by alphanumeric characters',
+ });
+ return;
+ }
+ }
+
+ // Validate channel name if provided
+ if (channel_name !== null && channel_name !== undefined) {
+ if (typeof channel_name !== 'string' || channel_name.length > 200) {
+ res.status(400).json({
+ error: 'Invalid channel name',
+ message: 'Channel name must be a string under 200 characters',
+ });
+ return;
+ }
+ }
+
+ const userId = req.user?.id;
+ await setBillingChannel(channel_id ?? null, channel_name ?? null, userId);
+
+ logger.info({ channel_id, channel_name, userId }, 'Billing channel updated');
+
+ const updated = await getBillingChannel();
+ res.json({
+ success: true,
+ billing_channel: updated,
+ });
+ } catch (error) {
+ logger.error({ err: error }, 'Failed to update billing channel');
+ res.status(500).json({
+ error: 'Failed to update billing channel',
+ message: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ });
+
+ return router;
+}