From 0bcde7d762f0832c5002451b4fd26b6adac4c5dd Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 3 Jan 2026 14:57:33 -0500 Subject: [PATCH 1/2] Add configurable billing notification channel for Addie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hardcoded webhook-based billing notifications with a configurable Slack channel that admins can select via a new System Settings page. This allows billing notifications (new subscriptions, payments, failures, cancellations) to be sent to a dedicated channel instead of the general admin channel. - Add system_settings table for key-value config storage - Add admin settings API with Slack channel picker - Add admin-settings.html page under Settings > System Settings - Create billing notification service using sendChannelMessage - Update http.ts to use new billing notifications 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/tame-hounds-swim.md | 2 + server/public/admin-settings.html | 350 ++++++++++++++++++ server/public/admin-sidebar.js | 1 + .../src/db/migrations/105_system_settings.sql | 24 ++ server/src/db/system-settings-db.ts | 92 +++++ server/src/http.ts | 10 +- server/src/notifications/billing.ts | 234 ++++++++++++ server/src/routes/admin/index.ts | 1 + server/src/routes/admin/settings.ts | 125 +++++++ 9 files changed, 837 insertions(+), 2 deletions(-) create mode 100644 .changeset/tame-hounds-swim.md create mode 100644 server/public/admin-settings.html create mode 100644 server/src/db/migrations/105_system_settings.sql create mode 100644 server/src/db/system-settings-db.ts create mode 100644 server/src/notifications/billing.ts create mode 100644 server/src/routes/admin/settings.ts diff --git a/.changeset/tame-hounds-swim.md b/.changeset/tame-hounds-swim.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/tame-hounds-swim.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/admin-settings.html b/server/public/admin-settings.html new file mode 100644 index 0000000000..e56d19ef75 --- /dev/null +++ b/server/public/admin-settings.html @@ -0,0 +1,350 @@ + + + + + + + Admin - System Settings - AgenticAdvertising.org + + + + + + +
+ +
+ Loading... +
+ + + + + + 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/105_system_settings.sql b/server/src/db/migrations/105_system_settings.sql new file mode 100644 index 0000000000..9b5e5f0d2f --- /dev/null +++ b/server/src/db/migrations/105_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; +} From 515faa7bab44b60781de1ba2d126548711c63a28 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 3 Jan 2026 15:02:42 -0500 Subject: [PATCH 2/2] Fix duplicate migration number - rename 105 to 106 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main branch already has 105_addie_email_event_type.sql, so renaming the system_settings migration to 106. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../{105_system_settings.sql => 106_system_settings.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/src/db/migrations/{105_system_settings.sql => 106_system_settings.sql} (100%) diff --git a/server/src/db/migrations/105_system_settings.sql b/server/src/db/migrations/106_system_settings.sql similarity index 100% rename from server/src/db/migrations/105_system_settings.sql rename to server/src/db/migrations/106_system_settings.sql