import { all } from "../db"; import { accountDb, stmt } from "../scoped-db"; import { invalidateUnread } from "../user-inbox"; import { randomId } from "../crypto"; import { publicUrl } from "../events/emit"; import { meterDef } from "~/lib/meters"; import { crossedThreshold } from "~/lib/plans"; import { periodOf } from "./record"; import { usageSummary } from "./summary"; type AlertCandidate = { subject: string; threshold: number; title: string }; /** Pure: which thresholds a summary has crossed (per included meter, plus the spending limit). */ export function alertCandidates(summary: Awaited>): AlertCandidate[] { const out: AlertCandidate[] = []; for (const meter of summary.meters) { const limit = meter.hardCap ?? meter.included; if (limit === null || limit <= 0) continue; const threshold = crossedThreshold(meter.quantity, limit); if (threshold === null) continue; const label = meterDef(meter.meter)?.label ?? meter.meter; out.push({ subject: meter.meter, threshold, title: `${label}: ${threshold}% of this month's allowance used` }); } if (summary.spendingLimitCents !== null && summary.spendingLimitCents > 0) { const threshold = crossedThreshold(summary.totalCents, summary.spendingLimitCents); if (threshold !== null) { out.push({ subject: "spend", threshold, title: `Spending: ${threshold}% of your spending limit used` }); } } return out; } async function recipients(accountId: string): Promise<{ ids: string[]; url: string }> { const account = await all<{ kind: string; name: string }>("SELECT kind, name FROM accounts WHERE id = ?", accountId); if (account[0]?.kind === "org") { const owners = await all<{ user_id: string }>( "SELECT user_id FROM org_members WHERE org_id = ? AND role = 'owner' LIMIT 50", accountId, ); return { ids: owners.map((row) => row.user_id), url: publicUrl(`/orgs/${account[0].name}/billing`) }; } return { ids: account.length ? [accountId] : [], url: publicUrl("/settings/billing") }; } /** * The single delivery point for budget alerts. Interim: writes notifications rows directly; switch to * roadmap/notify's notifyQuotaThreshold once both branches are merged. */ async function deliverAlerts(accountId: string, alerts: AlertCandidate[], now: number): Promise { const target = await recipients(accountId); for (const userId of target.ids) { await accountDb(userId).batch( alerts.map((alert) => stmt( "INSERT INTO notifications (id, user_id, repo_id, reason, title, url, created_at) VALUES (?, ?, NULL, 'billing', ?, ?, ?)", randomId(), userId, alert.title, target.url, now, ), ), ); } await invalidateUnread(target.ids); } /** * Sends the account's budget alerts at 50/80/100%. Each (account, period, subject, threshold) fires once: the * usage_alerts insert is the dedupe. Run by the account's alarm a few minutes after its usage or gauges change * (account-state/tasks.ts), never by a sweep over accounts. */ export async function sendAccountBudgetAlerts(accountId: string, now = Date.now()): Promise { const period = periodOf(now); const candidates = alertCandidates(await usageSummary(accountId, period)); if (candidates.length === 0) return; const results = await accountDb(accountId).batch( candidates.map((candidate) => stmt( "INSERT OR IGNORE INTO usage_alerts (account_id, period, subject, threshold, created_at) VALUES (?, ?, ?, ?, ?)", accountId, period, candidate.subject, candidate.threshold, now, ), ), ); const fresh = candidates.filter((_, index) => results[index].meta.changes > 0); if (fresh.length > 0) await deliverAlerts(accountId, fresh, now); }