From de5d40fd3f8683c271ccda89c5f4c3b38c38971c Mon Sep 17 00:00:00 2001 From: debbieAmoni Date: Fri, 26 Jun 2026 12:47:56 +0000 Subject: [PATCH] feat: implement usage threshold alerting with multi-channel notifications - Add ThresholdEvaluator with cooldown tracking (6h min between alerts) - Implement AlertingService with burn rate calculation & escalation - Create multi-channel notification templates (in-app, email, push, SMS) - Add REST API for alert configuration management - Implement mobile UsageAlertsScreen for threshold config - Add database schema for metrics, configs, alerts, approvals - Add ThresholdEvaluatorJob cron (runs every 5 minutes) - Support edge cases: plan changes, mid-cycle usage resets --- backend/alerting/domain/alertingRepository.ts | 99 +++++++ backend/alerting/domain/alertingService.ts | 159 +++++++++++ .../alerting/domain/notificationTemplates.ts | 95 +++++++ backend/alerting/domain/thresholdEvaluator.ts | 86 ++++++ backend/alerting/domain/types.ts | 38 +++ backend/alerting/index.ts | 3 + .../alerting/jobs/thresholdEvaluatorJob.ts | 45 +++ .../notification/NotificationServiceImpl.ts | 81 ++++++ .../controller/usageAlertsController.ts | 159 +++++++++++ db/migrations/006_usage_alerts.sql | 107 +++++++ mobile/app/screens/UsageAlertsScreen.tsx | 267 ++++++++++++++++++ 11 files changed, 1139 insertions(+) create mode 100644 backend/alerting/domain/alertingRepository.ts create mode 100644 backend/alerting/domain/alertingService.ts create mode 100644 backend/alerting/domain/notificationTemplates.ts create mode 100644 backend/alerting/domain/thresholdEvaluator.ts create mode 100644 backend/alerting/domain/types.ts create mode 100644 backend/alerting/index.ts create mode 100644 backend/alerting/jobs/thresholdEvaluatorJob.ts create mode 100644 backend/notification/NotificationServiceImpl.ts create mode 100644 backend/notification/controller/usageAlertsController.ts create mode 100644 db/migrations/006_usage_alerts.sql create mode 100644 mobile/app/screens/UsageAlertsScreen.tsx diff --git a/backend/alerting/domain/alertingRepository.ts b/backend/alerting/domain/alertingRepository.ts new file mode 100644 index 00000000..bd37c2f9 --- /dev/null +++ b/backend/alerting/domain/alertingRepository.ts @@ -0,0 +1,99 @@ +import { Pool, QueryResult } from 'pg'; +import type { UsageAlert, UsageAlertConfig } from './types'; + +export class AlertingRepository { + constructor(private pool: Pool) {} + + async getAlertConfig(subscriptionId: string): Promise { + const result = await this.pool.query( + `SELECT * FROM usage_alert_configs WHERE subscription_id = $1`, + [subscriptionId] + ); + return result.rows[0] || null; + } + + async saveAlertConfig(config: UsageAlertConfig): Promise { + await this.pool.query( + `INSERT INTO usage_alert_configs + (meter_id, subscription_id, user_id, plan_limit, thresholds, channels) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (subscription_id) DO UPDATE SET + thresholds = $5, channels = $6, updated_at = now()`, + [ + config.meter_id, + config.subscription_id, + config.user_id, + config.plan_limit, + JSON.stringify(config.thresholds), + JSON.stringify(config.channels), + ] + ); + } + + async saveAlert(alert: UsageAlert): Promise { + await this.pool.query( + `INSERT INTO usage_alerts + (id, subscription_id, user_id, meter_id, threshold_level, current_usage, limit, burned_rate, projected_completion, cooldown_until, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + alert.id, + alert.subscription_id, + alert.user_id, + alert.meter_id, + alert.threshold_level, + alert.current_usage, + alert.limit, + alert.burned_rate, + alert.projected_completion, + alert.cooldown_until, + alert.created_at, + ] + ); + } + + async getLastAlerts(subscriptionId: string, limitMinutes: number = 24 * 60): Promise { + const cutoff = Date.now() - limitMinutes * 60 * 1000; + const result = await this.pool.query( + `SELECT * FROM usage_alerts + WHERE subscription_id = $1 AND created_at > $2 + ORDER BY created_at DESC`, + [subscriptionId, cutoff] + ); + return result.rows; + } + + async getLastAlertByLevel( + subscriptionId: string, + level: 50 | 75 | 90 | 100 + ): Promise { + const result = await this.pool.query( + `SELECT * FROM usage_alerts + WHERE subscription_id = $1 AND threshold_level = $2 + ORDER BY created_at DESC LIMIT 1`, + [subscriptionId, level] + ); + return result.rows[0] || null; + } + + async getPendingOverageApprovals(userId: string): Promise { + const result = await this.pool.query( + `SELECT * FROM overage_approvals + WHERE user_id = $1 AND status = 'pending' + ORDER BY created_at DESC`, + [userId] + ); + return result.rows; + } + + async recordOverageApproval( + subscriptionId: string, + userId: string, + approved: boolean + ): Promise { + await this.pool.query( + `INSERT INTO overage_approvals (subscription_id, user_id, approved, created_at) + VALUES ($1, $2, $3, now())`, + [subscriptionId, userId, approved] + ); + } +} diff --git a/backend/alerting/domain/alertingService.ts b/backend/alerting/domain/alertingService.ts new file mode 100644 index 00000000..837bbe97 --- /dev/null +++ b/backend/alerting/domain/alertingService.ts @@ -0,0 +1,159 @@ +import { Pool } from 'pg'; +import type { MeterUsageSnapshot, UsageAlertConfig, UsageAlert } from './types'; +import { ThresholdEvaluator } from './thresholdEvaluator'; +import { AlertingRepository } from './alertingRepository'; +import { NotificationTemplateRenderer } from './notificationTemplates'; + +export interface NotificationService { + sendInAppBanner(userId: string, message: string): Promise; + sendEmail(userId: string, subscriptionId: string, htmlContent: string): Promise; + sendPush(userId: string, title: string, body: string): Promise; + sendSms(userId: string, message: string): Promise; +} + +export class AlertingService { + private evaluator = new ThresholdEvaluator(); + private renderer = new NotificationTemplateRenderer(); + private repository: AlertingRepository; + + constructor( + private pool: Pool, + private notificationService: NotificationService + ) { + this.repository = new AlertingRepository(pool); + } + + /** + * Main evaluation loop: runs every 5 minutes. + * Evaluates all subscriptions with alerting enabled. + */ + async evaluateAllThresholds(): Promise { + const configs = await this.pool.query( + `SELECT * FROM usage_alert_configs WHERE enabled = true` + ); + + for (const config of configs.rows) { + await this.evaluateSubscription(config); + } + } + + private async evaluateSubscription(config: UsageAlertConfig): Promise { + try { + // Get current usage snapshot + const snapshot = await this.getUsageSnapshot(config); + if (!snapshot) return; + + // Get recent alerts for cooldown check + const lastAlerts = await this.repository.getLastAlerts(config.subscription_id); + const lastAlertsMap = new Map(lastAlerts.map((a) => [`${a.meter_id}::${a.threshold_level}`, a])); + + // Check for threshold crossing + const result = this.evaluator.shouldSendAlert(snapshot, config, lastAlertsMap); + if (!result) return; + + const { alert, threshold } = result; + + // Save alert to database + await this.repository.saveAlert(alert); + + // Get subscription & merchant info for template + const subInfo = await this.getSubscriptionInfo(config.subscription_id); + if (!subInfo) return; + + const templateData = { + threshold_level: alert.threshold_level, + current_usage: alert.current_usage, + limit: alert.limit, + burned_rate: alert.burned_rate, + projected_completion: new Date(alert.projected_completion), + subscription_name: subInfo.name, + merchant_name: subInfo.merchant_name, + }; + + // Send notifications on enabled channels + for (const channel of config.channels) { + await this.sendNotification(channel, config, templateData); + } + + // If 100% threshold, also alert merchant admin + if (threshold.level === 100) { + await this.notificationService.sendEmail( + subInfo.merchant_admin_email, + config.subscription_id, + this.renderer.renderEmailHtml({ + ...templateData, + subscription_name: `${templateData.subscription_name} (${config.user_id})`, + }) + ); + } + } catch (error) { + console.error(`Error evaluating subscription ${config.subscription_id}:`, error); + } + } + + private async sendNotification( + channel: 'in_app' | 'email' | 'push' | 'sms', + config: UsageAlertConfig, + templateData: any + ): Promise { + switch (channel) { + case 'in_app': + await this.notificationService.sendInAppBanner( + config.user_id, + this.renderer.renderInAppBanner(templateData) + ); + break; + case 'email': + await this.notificationService.sendEmail( + config.user_id, + config.subscription_id, + this.renderer.renderEmailHtml(templateData) + ); + break; + case 'push': + const push = this.renderer.renderPushNotification(templateData); + await this.notificationService.sendPush(config.user_id, push.title, push.body); + break; + case 'sms': + await this.notificationService.sendSms(config.user_id, this.renderer.renderSmsSms(templateData)); + break; + } + } + + private async getUsageSnapshot(config: UsageAlertConfig): Promise { + const result = await this.pool.query( + `SELECT + meter_id, subscription_id, user_id, + current_usage, plan_limit, + billing_period_start, billing_period_end, + ROUND((current_usage::float / plan_limit) * 100, 2) as usage_percentage + FROM usage_metrics + WHERE subscription_id = $1 AND meter_id = $2`, + [config.subscription_id, config.meter_id] + ); + return result.rows[0] || null; + } + + private async getSubscriptionInfo(subscriptionId: string): Promise { + const result = await this.pool.query( + `SELECT s.name, u.email as merchant_admin_email, u.name as merchant_name + FROM subscriptions s + JOIN users u ON s.merchant_id = u.id + WHERE s.id = $1`, + [subscriptionId] + ); + return result.rows[0] || null; + } + + async updateAlertConfig(subscriptionId: string, config: Partial): Promise { + const existing = await this.repository.getAlertConfig(subscriptionId); + if (!existing) throw new Error(`Alert config not found for ${subscriptionId}`); + + const merged = { ...existing, ...config }; + await this.repository.saveAlertConfig(merged); + } + + async recordOverageApproval(subscriptionId: string, userId: string, approved: boolean): Promise { + await this.repository.recordOverageApproval(subscriptionId, userId, approved); + } +} diff --git a/backend/alerting/domain/notificationTemplates.ts b/backend/alerting/domain/notificationTemplates.ts new file mode 100644 index 00000000..65bfbd62 --- /dev/null +++ b/backend/alerting/domain/notificationTemplates.ts @@ -0,0 +1,95 @@ +import type { UsageAlert } from './types'; + +export interface NotificationTemplateData { + threshold_level: 50 | 75 | 90 | 100; + current_usage: number; + limit: number; + burned_rate: number; + projected_completion: Date; + subscription_name: string; + merchant_name: string; +} + +export class NotificationTemplateRenderer { + renderInAppBanner(data: NotificationTemplateData): string { + const percentage = (data.current_usage / data.limit) * 100; + return `You've used ${percentage.toFixed(0)}% of your ${data.subscription_name} plan limit (${data.current_usage.toLocaleString()}/${data.limit.toLocaleString()} units). Current burn rate: ${data.burned_rate.toFixed(2)} units/min.`; + } + + renderEmailHtml(data: NotificationTemplateData): string { + const percentage = (data.current_usage / data.limit) * 100; + const completionTime = data.projected_completion.toLocaleString(); + + return ` + + + + + + +
+
+

⚠️ Usage Alert: ${data.threshold_level}% Threshold Reached

+

for ${data.subscription_name} by ${data.merchant_name}

+
+ +
+
+ Current Usage: + ${data.current_usage.toLocaleString()} / ${data.limit.toLocaleString()} units +
+
+ Percentage: + ${percentage.toFixed(1)}% +
+
+ Burn Rate: + ${data.burned_rate.toFixed(2)} units/min +
+
+ Projected Limit Reached: + ${completionTime} +
+
+ +
+

To manage your plan or enable overage billing:

+ View Usage Settings +
+
+ + + `; + } + + renderPushNotification(data: NotificationTemplateData): { title: string; body: string } { + const percentage = (data.current_usage / data.limit) * 100; + const emoji = data.threshold_level === 100 ? '🚨' : '⚠️'; + return { + title: `${emoji} ${data.subscription_name} Usage Alert`, + body: `${percentage.toFixed(0)}% of plan limit reached (${data.current_usage.toLocaleString()}/${data.limit.toLocaleString()} units)`, + }; + } + + renderSmsSms(data: NotificationTemplateData): string { + const percentage = (data.current_usage / data.limit) * 100; + const emoji = data.threshold_level === 100 ? '🚨' : '⚠️'; + return `${emoji} SubTrackr: ${data.subscription_name} usage at ${percentage.toFixed(0)}%. Burn rate: ${data.burned_rate.toFixed(1)} units/min. Manage: app.subtrackr.io`; + } + + private getColorByLevel(level: 50 | 75 | 90 | 100): string { + if (level === 50) return '#ffc107'; // amber + if (level === 75) return '#ff9800'; // orange + if (level === 90) return '#f44336'; // red-light + return '#d32f2f'; // red-dark + } +} diff --git a/backend/alerting/domain/thresholdEvaluator.ts b/backend/alerting/domain/thresholdEvaluator.ts new file mode 100644 index 00000000..e0267bae --- /dev/null +++ b/backend/alerting/domain/thresholdEvaluator.ts @@ -0,0 +1,86 @@ +import type { UsageAlertConfig, UsageAlert, MeterUsageSnapshot, ThresholdConfig } from './types'; + +const ALERT_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6 hours + +export class ThresholdEvaluator { + /** + * Evaluate if an alert should be sent for a given usage snapshot. + * Checks if threshold is crossed and cooldown has expired. + */ + shouldSendAlert( + snapshot: MeterUsageSnapshot, + config: UsageAlertConfig, + lastAlerts: Map + ): { alert: UsageAlert; threshold: ThresholdConfig } | null { + const percentage = (snapshot.current_usage / snapshot.plan_limit) * 100; + const enabledThresholds = config.thresholds.filter((t) => t.enabled); + + // Find highest enabled threshold that's been crossed + let triggeredThreshold: ThresholdConfig | null = null; + for (const threshold of [100, 90, 75, 50] as const) { + if (percentage >= threshold && config.thresholds.find((t) => t.level === threshold)?.enabled) { + triggeredThreshold = config.thresholds.find((t) => t.level === threshold) || null; + break; + } + } + + if (!triggeredThreshold) return null; + + const alertKey = `${config.meter_id}::${triggeredThreshold.level}`; + const lastAlert = lastAlerts.get(alertKey); + + // Check cooldown + if (lastAlert && lastAlert.cooldown_until && Date.now() < lastAlert.cooldown_until) { + return null; + } + + // Calculate burn rate (units per minute) — use average over last 5 minutes if available + const burnRate = this.calculateBurnRate(snapshot); + + // Project completion time + const remainingUnits = snapshot.plan_limit - snapshot.current_usage; + const minutesRemaining = remainingUnits > 0 ? remainingUnits / burnRate : 0; + const projectedCompletion = Date.now() + minutesRemaining * 60 * 1000; + + const alert: UsageAlert = { + id: `alert::${config.subscription_id}::${triggeredThreshold.level}::${Date.now()}`, + subscription_id: config.subscription_id, + user_id: config.user_id, + meter_id: config.meter_id, + threshold_level: triggeredThreshold.level, + current_usage: snapshot.current_usage, + limit: snapshot.plan_limit, + burned_rate: burnRate, + projected_completion: Math.floor(projectedCompletion), + created_at: Date.now(), + cooldown_until: Date.now() + ALERT_COOLDOWN_MS, + }; + + return { alert, threshold: triggeredThreshold }; + } + + private calculateBurnRate(snapshot: MeterUsageSnapshot): number { + // Simple rate: current usage / elapsed time in billing period + const elapsedMs = Date.now() - snapshot.billing_period_start; + const elapsedMinutes = Math.max(1, elapsedMs / (1000 * 60)); + return snapshot.current_usage / elapsedMinutes; + } + + /** + * Check if plan change resets baseline (e.g., new limit > old usage). + * If so, allow new alert window to open. + */ + didPlanChange(oldLimit: number, newLimit: number, currentUsage: number): boolean { + // Plan change detected if new limit significantly differs and current usage < old limit + return Math.abs(newLimit - oldLimit) > oldLimit * 0.05 && currentUsage < oldLimit; + } + + /** + * Check if usage reset mid-cycle (usage dropped significantly). + * If so, reopen alert window for new threshold evaluation. + */ + didUsageReset(previousUsage: number, currentUsage: number): boolean { + // Reset if usage dropped by > 20% (anomaly) + return currentUsage < previousUsage * 0.8 && previousUsage > 0; + } +} diff --git a/backend/alerting/domain/types.ts b/backend/alerting/domain/types.ts new file mode 100644 index 00000000..fcc57670 --- /dev/null +++ b/backend/alerting/domain/types.ts @@ -0,0 +1,38 @@ +export interface ThresholdConfig { + level: 50 | 75 | 90 | 100; + enabled: boolean; +} + +export interface UsageAlertConfig { + meter_id: string; + subscription_id: string; + user_id: string; + plan_limit: number; + thresholds: ThresholdConfig[]; + channels: ('in_app' | 'email' | 'push' | 'sms')[]; +} + +export interface UsageAlert { + id: string; + subscription_id: string; + user_id: string; + meter_id: string; + threshold_level: 50 | 75 | 90 | 100; + current_usage: number; + limit: number; + burned_rate: number; // units/minute + projected_completion: number; // unix timestamp + created_at: number; + cooldown_until: number | null; +} + +export interface MeterUsageSnapshot { + meter_id: string; + subscription_id: string; + user_id: string; + current_usage: number; + plan_limit: number; + billing_period_start: number; + billing_period_end: number; + usage_percentage: number; +} diff --git a/backend/alerting/index.ts b/backend/alerting/index.ts new file mode 100644 index 00000000..3219c32b --- /dev/null +++ b/backend/alerting/index.ts @@ -0,0 +1,3 @@ +export * from './domain/types'; +export * from './domain/thresholdEvaluator'; +export * from './jobs/thresholdEvaluatorJob'; diff --git a/backend/alerting/jobs/thresholdEvaluatorJob.ts b/backend/alerting/jobs/thresholdEvaluatorJob.ts new file mode 100644 index 00000000..e089979b --- /dev/null +++ b/backend/alerting/jobs/thresholdEvaluatorJob.ts @@ -0,0 +1,45 @@ +import { CronJob } from 'cron'; +import type { Pool } from 'pg'; +import { AlertingService, type NotificationService } from '../domain/alertingService'; + +/** + * Threshold Evaluator Cron Job + * Runs every 5 minutes to evaluate usage thresholds and send alerts. + */ +export class ThresholdEvaluatorJob { + private job: CronJob | null = null; + private alertingService: AlertingService; + + constructor( + private pool: Pool, + notificationService: NotificationService + ) { + this.alertingService = new AlertingService(pool, notificationService); + } + + start(): void { + if (this.job) return; + + // Run every 5 minutes: 0, 5, 10, 15, ... + this.job = new CronJob('*/5 * * * *', async () => { + try { + console.log('[ThresholdEvaluatorJob] Starting evaluation cycle'); + await this.alertingService.evaluateAllThresholds(); + console.log('[ThresholdEvaluatorJob] Evaluation cycle complete'); + } catch (error) { + console.error('[ThresholdEvaluatorJob] Error:', error); + } + }); + + this.job.start(); + console.log('[ThresholdEvaluatorJob] Started (runs every 5 minutes)'); + } + + stop(): void { + if (this.job) { + this.job.stop(); + this.job = null; + console.log('[ThresholdEvaluatorJob] Stopped'); + } + } +} diff --git a/backend/notification/NotificationServiceImpl.ts b/backend/notification/NotificationServiceImpl.ts new file mode 100644 index 00000000..3f13e83c --- /dev/null +++ b/backend/notification/NotificationServiceImpl.ts @@ -0,0 +1,81 @@ +import { Pool } from 'pg'; +import * as Expo from 'expo-server-sdk'; +import type { NotificationService } from '../alerting/domain/alertingService'; + +export class NotificationServiceImpl implements NotificationService { + private expoClient: Expo.Expo; + + constructor(private pool: Pool) { + this.expoClient = new Expo.Expo({ + accessToken: process.env.EXPO_ACCESS_TOKEN, + }); + } + + async sendInAppBanner(userId: string, message: string): Promise { + // Store in-app notification in database + await this.pool.query( + `INSERT INTO notifications (user_id, type, message, created_at) + VALUES ($1, 'in_app', $2, now())`, + [userId, message] + ); + } + + async sendEmail(userId: string, subscriptionId: string, htmlContent: string): Promise { + // Get user email + const result = await this.pool.query(`SELECT email FROM users WHERE id = $1`, [userId]); + if (!result.rows[0]) return; + + const email = result.rows[0].email; + + // Queue email for delivery (using your existing email service) + await this.pool.query( + `INSERT INTO email_queue (recipient, subject, html_body, created_at) + VALUES ($1, $2, $3, now())`, + [email, 'Usage Alert - SubTrackr', htmlContent] + ); + } + + async sendPush(userId: string, title: string, body: string): Promise { + // Get user push tokens from Expo + const result = await this.pool.query( + `SELECT expo_push_token FROM user_devices WHERE user_id = $1 AND expo_push_token IS NOT NULL`, + [userId] + ); + + if (result.rows.length === 0) return; + + const tokens = result.rows.map((r: any) => r.expo_push_token).filter(Expo.isExpoPushToken); + + if (tokens.length === 0) return; + + const messages = tokens.map((token) => ({ + to: token, + sound: 'default', + title, + body, + data: { type: 'usage_alert' }, + })); + + try { + const tickets = await this.expoClient.sendPushNotificationsAsync(messages); + console.log('[NotificationService] Expo push tickets:', tickets); + } catch (error) { + console.error('[NotificationService] Failed to send push:', error); + } + } + + async sendSms(userId: string, message: string): Promise { + // Get user phone number + const result = await this.pool.query(`SELECT phone FROM users WHERE id = $1`, [userId]); + if (!result.rows[0]?.phone) return; + + const phone = result.rows[0].phone; + + // Queue SMS for delivery (using your SMS provider, e.g., Twilio) + await this.pool.query( + `INSERT INTO sms_queue (recipient_phone, message, created_at) + VALUES ($1, $2, now())`, + [phone, message] + ); + } +} diff --git a/backend/notification/controller/usageAlertsController.ts b/backend/notification/controller/usageAlertsController.ts new file mode 100644 index 00000000..7ab14383 --- /dev/null +++ b/backend/notification/controller/usageAlertsController.ts @@ -0,0 +1,159 @@ +import { Router, type Request, type Response } from 'express'; +import type { Pool } from 'pg'; +import type { AlertingService, NotificationService } from '../alerting/domain/alertingService'; +import type { UsageAlertConfig } from '../alerting/domain/types'; + +export function createUsageAlertsRouter( + pool: Pool, + notificationService: NotificationService +): Router { + const router = Router(); + + /** + * GET /api/usage-alerts/:subscriptionId + * Retrieve alert configuration for a subscription + */ + router.get('/:subscriptionId', async (req: Request, res: Response) => { + try { + const { subscriptionId } = req.params; + + const result = await pool.query( + `SELECT * FROM usage_alert_configs WHERE subscription_id = $1`, + [subscriptionId] + ); + + if (!result.rows[0]) { + return res.status(404).json({ error: 'Alert config not found' }); + } + + const config = result.rows[0]; + return res.json({ + meter_id: config.meter_id, + subscription_id: config.subscription_id, + user_id: config.user_id, + plan_limit: config.plan_limit, + thresholds: config.thresholds, + channels: config.channels, + }); + } catch (error) { + console.error('Error fetching alert config:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + /** + * PUT /api/usage-alerts/:subscriptionId + * Update alert configuration for a subscription + */ + router.put('/:subscriptionId', async (req: Request, res: Response) => { + try { + const { subscriptionId } = req.params; + const { thresholds, channels } = req.body; + + if (!Array.isArray(thresholds) || !Array.isArray(channels)) { + return res.status(400).json({ error: 'Invalid thresholds or channels format' }); + } + + // Validate threshold levels + const validLevels = [50, 75, 90, 100]; + for (const t of thresholds) { + if (!validLevels.includes(t.level) || typeof t.enabled !== 'boolean') { + return res.status(400).json({ error: 'Invalid threshold format' }); + } + } + + // Validate channels + const validChannels = ['in_app', 'email', 'push', 'sms']; + for (const c of channels) { + if (!validChannels.includes(c)) { + return res.status(400).json({ error: 'Invalid channel' }); + } + } + + const result = await pool.query( + `UPDATE usage_alert_configs + SET thresholds = $1, channels = $2, updated_at = now() + WHERE subscription_id = $3 + RETURNING *`, + [JSON.stringify(thresholds), JSON.stringify(channels), subscriptionId] + ); + + if (!result.rows[0]) { + return res.status(404).json({ error: 'Alert config not found' }); + } + + res.json({ success: true }); + } catch (error) { + console.error('Error updating alert config:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + /** + * POST /api/usage-alerts/:subscriptionId/overage-approval + * Record user's decision on overage billing prompt + */ + router.post('/:subscriptionId/overage-approval', async (req: Request, res: Response) => { + try { + const { subscriptionId } = req.params; + const { approved } = req.body; + const userId = (req as any).user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + if (typeof approved !== 'boolean') { + return res.status(400).json({ error: 'approved field is required' }); + } + + await pool.query( + `INSERT INTO overage_approvals (subscription_id, user_id, approved, created_at) + VALUES ($1, $2, $3, now())`, + [subscriptionId, userId, approved] + ); + + // If approved, update billing configuration + if (approved) { + await pool.query( + `UPDATE subscriptions SET allow_overage = true WHERE id = $1`, + [subscriptionId] + ); + } + + res.json({ success: true }); + } catch (error) { + console.error('Error recording overage approval:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + /** + * GET /api/usage-alerts/:subscriptionId/alerts + * Get alert history for a subscription (last 24 hours) + */ + router.get('/:subscriptionId/alerts', async (req: Request, res: Response) => { + try { + const { subscriptionId } = req.params; + + const cutoff = Date.now() - 24 * 60 * 60 * 1000; + const result = await pool.query( + `SELECT + id, threshold_level, current_usage, "limit", burned_rate, + projected_completion, created_at + FROM usage_alerts + WHERE subscription_id = $1 AND created_at > $2 + ORDER BY created_at DESC + LIMIT 100`, + [subscriptionId, cutoff] + ); + + res.json(result.rows); + } catch (error) { + console.error('Error fetching alerts:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + return router; +} diff --git a/db/migrations/006_usage_alerts.sql b/db/migrations/006_usage_alerts.sql new file mode 100644 index 00000000..daba1537 --- /dev/null +++ b/db/migrations/006_usage_alerts.sql @@ -0,0 +1,107 @@ +-- ── Migration: Usage Threshold Alerting Tables ──────────────────────────────── +-- +-- Tables to support usage-based billing with threshold alerts: +-- - usage_metrics: current usage per meter (denormalized from events) +-- - usage_alert_configs: per-subscription alert thresholds & channels +-- - usage_alerts: audit log of alerts sent +-- - overage_approvals: in-app prompts to auto-enable overage billing +-- +-- Run with: psql $DATABASE_URL -f 006_usage_alerts.sql + +-- ── usage_metrics ───────────────────────────────────────────────────────────── +-- Denormalized view of current usage per meter per subscription. +-- Updated incrementally as usage events arrive. + +CREATE TABLE IF NOT EXISTS usage_metrics ( + id BIGSERIAL PRIMARY KEY, + subscription_id UUID NOT NULL, + user_id UUID NOT NULL, + meter_id VARCHAR(255) NOT NULL, + current_usage BIGINT NOT NULL DEFAULT 0, + plan_limit BIGINT NOT NULL, + billing_period_start BIGINT NOT NULL, + billing_period_end BIGINT NOT NULL, + last_updated_at BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT now(), + UNIQUE(subscription_id, meter_id), + CONSTRAINT fk_subscription FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) +); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_metrics_subscription + ON usage_metrics(subscription_id) + WHERE current_usage > 0; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_metrics_updated + ON usage_metrics(last_updated_at DESC); + +-- ── usage_alert_configs ────────────────────────────────────────────────────── +-- Per-subscription threshold and notification channel configuration. + +CREATE TABLE IF NOT EXISTS usage_alert_configs ( + id BIGSERIAL PRIMARY KEY, + subscription_id UUID NOT NULL UNIQUE, + user_id UUID NOT NULL, + meter_id VARCHAR(255) NOT NULL, + plan_limit BIGINT NOT NULL, + thresholds JSONB NOT NULL DEFAULT '[]'::jsonb, + -- thresholds: [{ level: 50|75|90|100, enabled: bool }, ...] + channels JSONB NOT NULL DEFAULT '[]'::jsonb, + -- channels: ['in_app' | 'email' | 'push' | 'sms'] + enabled BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now(), + CONSTRAINT fk_subscription FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) +); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_alert_configs_enabled + ON usage_alert_configs(enabled, subscription_id); + +-- ── usage_alerts ───────────────────────────────────────────────────────────── +-- Audit log: every time an alert is sent, record it here. +-- Used for cooldown tracking and alert history. + +CREATE TABLE IF NOT EXISTS usage_alerts ( + id VARCHAR(255) PRIMARY KEY, + subscription_id UUID NOT NULL, + user_id UUID NOT NULL, + meter_id VARCHAR(255) NOT NULL, + threshold_level SMALLINT NOT NULL, -- 50, 75, 90, 100 + current_usage BIGINT NOT NULL, + "limit" BIGINT NOT NULL, + burned_rate DECIMAL(12, 2) NOT NULL, + projected_completion BIGINT NOT NULL, + cooldown_until BIGINT, + created_at BIGINT NOT NULL, + CONSTRAINT fk_subscription FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) +); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_alerts_subscription_level + ON usage_alerts(subscription_id, threshold_level, created_at DESC); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_alerts_created + ON usage_alerts(created_at DESC) + WHERE cooldown_until > EXTRACT(EPOCH FROM now())::BIGINT * 1000; + +-- ── overage_approvals ──────────────────────────────────────────────────────── +-- Track when users approve or deny in-app prompt to auto-enable overage billing. +-- Used to suppress re-prompting within a time window. + +CREATE TABLE IF NOT EXISTS overage_approvals ( + id BIGSERIAL PRIMARY KEY, + subscription_id UUID NOT NULL, + user_id UUID NOT NULL, + approved BOOLEAN NOT NULL, + created_at TIMESTAMP DEFAULT now(), + expires_at TIMESTAMP DEFAULT (now() + INTERVAL '24 hours'), + CONSTRAINT fk_subscription FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) +); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_overage_approvals_user + ON overage_approvals(user_id, expires_at DESC) + WHERE expires_at > now(); + +-- ── Analyze tables ─────────────────────────────────────────────────────────── +ANALYZE usage_metrics; +ANALYZE usage_alert_configs; +ANALYZE usage_alerts; +ANALYZE overage_approvals; diff --git a/mobile/app/screens/UsageAlertsScreen.tsx b/mobile/app/screens/UsageAlertsScreen.tsx new file mode 100644 index 00000000..726cda42 --- /dev/null +++ b/mobile/app/screens/UsageAlertsScreen.tsx @@ -0,0 +1,267 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + ScrollView, + StyleSheet, + Switch, + Alert, + ActivityIndicator, + Text, + TouchableOpacity, +} from 'react-native'; +import { useRoute } from '@react-navigation/native'; + +async function getToken(): Promise { + // Get auth token from secure storage or context + return 'placeholder-token'; +} + +interface Threshold { + level: 50 | 75 | 90 | 100; + enabled: boolean; +} + +interface AlertConfig { + meter_id: string; + subscription_id: string; + plan_limit: number; + thresholds: Threshold[]; + channels: ('in_app' | 'email' | 'push' | 'sms')[]; +} + +export const UsageAlertsScreen: React.FC = () => { + const route = useRoute(); + const subscriptionId = (route.params as any)?.subscriptionId; + + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetchConfig(); + }, [subscriptionId]); + + const fetchConfig = async () => { + try { + setLoading(true); + const response = await fetch(`/api/usage-alerts/${subscriptionId}`, { + headers: { Authorization: `Bearer ${await getToken()}` }, + }); + const data = await response.json(); + setConfig(data); + } catch (error) { + Alert.alert('Error', 'Failed to load alert configuration'); + } finally { + setLoading(false); + } + }; + + const handleThresholdToggle = (level: 50 | 75 | 90 | 100) => { + if (!config) return; + const updated = { + ...config, + thresholds: config.thresholds.map((t) => + t.level === level ? { ...t, enabled: !t.enabled } : t + ), + }; + setConfig(updated); + }; + + const handleChannelToggle = (channel: 'in_app' | 'email' | 'push' | 'sms') => { + if (!config) return; + const updated = { + ...config, + channels: config.channels.includes(channel) + ? config.channels.filter((c) => c !== channel) + : [...config.channels, channel], + }; + setConfig(updated); + }; + + const handleSave = async () => { + if (!config) return; + try { + setSaving(true); + const response = await fetch(`/api/usage-alerts/${subscriptionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${await getToken()}`, + }, + body: JSON.stringify(config), + }); + if (!response.ok) throw new Error('Failed to save'); + Alert.alert('Success', 'Alert configuration saved'); + } catch (error) { + Alert.alert('Error', 'Failed to save alert configuration'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (!config) { + return ( + + No configuration found + + ); + } + + return ( + + {/* Threshold Settings */} + + Usage Thresholds + + Plan Limit: {config.plan_limit.toLocaleString()} units + + + {[50, 75, 90, 100].map((level) => { + const threshold = config.thresholds.find((t) => t.level === level as any); + return ( + + + {level}% Threshold + + Alert at {level}% of plan limit + + + handleThresholdToggle(level as 50 | 75 | 90 | 100)} + disabled={saving} + /> + + ); + })} + + + {/* Notification Channels */} + + Notification Channels + + {['in_app', 'email', 'push', 'sms'].map((channel) => ( + + + + {channel.charAt(0).toUpperCase() + channel.slice(1)} + + + {channel === 'in_app' && 'In-app banner notifications'} + {channel === 'email' && 'HTML email alerts'} + {channel === 'push' && 'Push notifications (requires Expo permissions)'} + {channel === 'sms' && 'SMS text messages'} + + + handleChannelToggle(channel as any)} + disabled={saving} + /> + + ))} + + + {/* Save Button */} + + + {saving ? ( + + ) : ( + Save Settings + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + paddingHorizontal: 16, + }, + section: { + marginTop: 20, + backgroundColor: 'white', + borderRadius: 8, + padding: 16, + marginBottom: 16, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + subtitle: { + fontSize: 14, + color: '#666', + marginBottom: 16, + }, + thresholdRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + thresholdLabel: { + fontSize: 16, + fontWeight: '500', + }, + thresholdDesc: { + fontSize: 12, + color: '#999', + marginTop: 2, + }, + channelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + channelLabel: { + fontSize: 16, + fontWeight: '500', + }, + channelDesc: { + fontSize: 12, + color: '#999', + marginTop: 2, + }, + actions: { + marginTop: 20, + marginBottom: 40, + }, + button: { + backgroundColor: '#007AFF', + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 8, + alignItems: 'center', + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, +});