Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions backend/alerting/domain/alertingRepository.ts
Original file line number Diff line number Diff line change
@@ -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<UsageAlertConfig | null> {
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<void> {
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<void> {
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<UsageAlert[]> {
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<UsageAlert | null> {
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<any[]> {
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<void> {
await this.pool.query(
`INSERT INTO overage_approvals (subscription_id, user_id, approved, created_at)
VALUES ($1, $2, $3, now())`,
[subscriptionId, userId, approved]
);
}
}
159 changes: 159 additions & 0 deletions backend/alerting/domain/alertingService.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
sendEmail(userId: string, subscriptionId: string, htmlContent: string): Promise<void>;
sendPush(userId: string, title: string, body: string): Promise<void>;
sendSms(userId: string, message: string): Promise<void>;
}

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<void> {
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<void> {
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<void> {
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<MeterUsageSnapshot | null> {
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<any | null> {
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<UsageAlertConfig>): Promise<void> {
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<void> {
await this.repository.recordOverageApproval(subscriptionId, userId, approved);
}
}
95 changes: 95 additions & 0 deletions backend/alerting/domain/notificationTemplates.ts
Original file line number Diff line number Diff line change
@@ -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 `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #f8f9fa; padding: 15px; border-radius: 4px; }
.alert-level { font-weight: bold; color: ${this.getColorByLevel(data.threshold_level)}; }
.metrics { margin: 20px 0; }
.metric-row { display: flex; justify-content: space-between; padding: 8px 0; }
.cta { margin-top: 20px; }
.button { background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>⚠️ Usage Alert: <span class="alert-level">${data.threshold_level}%</span> Threshold Reached</h2>
<p>for <strong>${data.subscription_name}</strong> by ${data.merchant_name}</p>
</div>

<div class="metrics">
<div class="metric-row">
<span>Current Usage:</span>
<strong>${data.current_usage.toLocaleString()} / ${data.limit.toLocaleString()} units</strong>
</div>
<div class="metric-row">
<span>Percentage:</span>
<strong>${percentage.toFixed(1)}%</strong>
</div>
<div class="metric-row">
<span>Burn Rate:</span>
<strong>${data.burned_rate.toFixed(2)} units/min</strong>
</div>
<div class="metric-row">
<span>Projected Limit Reached:</span>
<strong>${completionTime}</strong>
</div>
</div>

<div class="cta">
<p>To manage your plan or enable overage billing:</p>
<a href="https://app.subtrackr.io/usage-settings" class="button">View Usage Settings</a>
</div>
</div>
</body>
</html>
`;
}

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
}
}
Loading