mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 17:53:19 +08:00
feat(server): per-user AI usage tracking, cost alerting, and endpoint hardening
Usage tracking: - new UsageEvent collection records every STT/TTS/LLM call per user with provider, model, volume (audio seconds, characters, tokens) and an estimated USD cost; self-hosted providers (Speaches/Piper) and cache hits record at $0 - pricing table for whisper-1, tts-1, gpt-5-nano & co. in server/utils/usage.ts - weekly KPI mail gains an "AI-Nutzung & Kosten" section: weekly and rolling 30-day cost, per-kind breakdown, top 5 users by cost - quota alert mail when rolling 30-day cost exceeds USAGE_ALERT_USD (default $5), at most once per calendar month (UsageAlertDelivery) Hardening: - /api/atc/say now requires an authenticated session (middleware exemption removed); useFlightLabAudio sends the bearer token - /api/service/tools/latency requires auth (was a public LLM endpoint) - per-user rate limits: PTT 20/min, say 60/min, latency 5/min - cron endpoints (waitlist-drip, weekly-kpi-report) require a shared secret via ?secret= or x-cron-secret (CRON_SECRET, falls back to KPI_CRON_SECRET); allowed with a warning while unset so existing deployments keep working - PTT records the actual transcribed audio duration for billing accuracy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
37
server/utils/cron.ts
Normal file
37
server/utils/cron.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { createError, getHeader, getQuery } from 'h3'
|
||||
|
||||
let warnedMissingSecret = false
|
||||
|
||||
/**
|
||||
* Guards cron endpoints. Accepts the secret via `?secret=` query param
|
||||
* (matches the existing KPI_CRON_SECRET convention, easy to use in a
|
||||
* Coolify scheduled-task URL) or via the `x-cron-secret` header.
|
||||
*
|
||||
* When neither CRON_SECRET nor KPI_CRON_SECRET is configured the request
|
||||
* is allowed with a loud warning so existing deployments keep working
|
||||
* until the env var is set.
|
||||
*/
|
||||
export function requireCronSecret(event: H3Event) {
|
||||
const secret = (process.env.CRON_SECRET || process.env.KPI_CRON_SECRET || '').trim()
|
||||
|
||||
if (!secret) {
|
||||
if (!warnedMissingSecret) {
|
||||
console.warn(
|
||||
'[cron] CRON_SECRET is not set — cron endpoints are publicly callable. ' +
|
||||
'Set CRON_SECRET and append ?secret=<value> to your scheduled-task URLs.',
|
||||
)
|
||||
warnedMissingSecret = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const provided =
|
||||
(typeof query.secret === 'string' ? query.secret : '') ||
|
||||
(getHeader(event, 'x-cron-secret') || '')
|
||||
|
||||
if (provided !== secret) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid cron secret.' })
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { InvitationCode } from '../models/InvitationCode'
|
||||
import { LandingAnalyticsEvent } from '../models/LandingAnalyticsEvent'
|
||||
import { ProductUsageSession } from '../models/ProductUsageSession'
|
||||
import { WaitlistEntry } from '../models/WaitlistEntry'
|
||||
import { summarizeUsage, getRollingCostUsd, type UsageSummary } from './usage'
|
||||
|
||||
export type KpiProduct = 'classroom' | 'liveatc'
|
||||
|
||||
@@ -53,6 +54,7 @@ export type WeeklyKpiReport = {
|
||||
smartGoals: SmartGoal[]
|
||||
landingTrend: { date: string; views: number; scrolled: number; waitlistEntries: number }[]
|
||||
feedback: FeedbackItem[]
|
||||
aiUsage: UsageSummary & { rolling30dCostUsd: number }
|
||||
}
|
||||
|
||||
function startOfUtcDay(date: Date) {
|
||||
@@ -189,6 +191,11 @@ export async function buildWeeklyKpiReport(now = new Date()): Promise<WeeklyKpiR
|
||||
.lean(),
|
||||
])
|
||||
|
||||
const [usageSummary, rolling30dCostUsd] = await Promise.all([
|
||||
summarizeUsage(periodStart, periodEnd),
|
||||
getRollingCostUsd(30, now),
|
||||
])
|
||||
|
||||
const waitlistCounts = getProductCounts(waitlistByProductRows)
|
||||
const activatedCounts = getProductCounts(activatedByProductRows)
|
||||
const feedbackCounts = getProductCounts(feedbackByProductRows)
|
||||
@@ -305,9 +312,20 @@ export async function buildWeeklyKpiReport(now = new Date()): Promise<WeeklyKpiR
|
||||
from: doc.name || doc.email || doc.discordHandle || 'anonymous',
|
||||
summary: pickFeedbackSummary(doc),
|
||||
})),
|
||||
aiUsage: { ...usageSummary, rolling30dCostUsd },
|
||||
}
|
||||
}
|
||||
|
||||
function formatUsd(value: number) {
|
||||
return `$${(Math.round(value * 10000) / 10000).toFixed(4)}`
|
||||
}
|
||||
|
||||
function formatTokens(value: number) {
|
||||
if (value >= 1_000_000) return `${Math.round((value / 1_000_000) * 10) / 10}M`
|
||||
if (value >= 1_000) return `${Math.round((value / 1_000) * 10) / 10}k`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -334,6 +352,16 @@ export function renderWeeklyKpiText(report: WeeklyKpiReport) {
|
||||
'Produkte:',
|
||||
...report.products.map((product) => `- ${product.product}: Waitlist ${product.waitlistEntries}, User ${product.activatedUsers}, Nutzung ${formatSeconds(product.averageUsageSeconds)} avg, Feedback ${product.feedbackCount}`),
|
||||
'',
|
||||
'AI-Nutzung (diese Woche):',
|
||||
`- Kosten: ${formatUsd(report.aiUsage.costUsd)} (letzte 30 Tage: ${formatUsd(report.aiUsage.rolling30dCostUsd)})`,
|
||||
`- STT: ${formatSeconds(report.aiUsage.sttSeconds)} Audio (${report.aiUsage.byKind.stt.events} Requests, ${formatUsd(report.aiUsage.byKind.stt.costUsd)})`,
|
||||
`- TTS: ${formatTokens(report.aiUsage.ttsCharacters)} Zeichen (${report.aiUsage.byKind.tts.events} Requests, ${formatUsd(report.aiUsage.byKind.tts.costUsd)})`,
|
||||
`- LLM: ${formatTokens(report.aiUsage.llmInputTokens)} in / ${formatTokens(report.aiUsage.llmOutputTokens)} out Tokens (${report.aiUsage.byKind.llm.events} Requests, ${formatUsd(report.aiUsage.byKind.llm.costUsd)})`,
|
||||
'Top User nach Kosten:',
|
||||
...(report.aiUsage.topUsers.length
|
||||
? report.aiUsage.topUsers.map((u) => `- ${u.email}: ${formatUsd(u.costUsd)} (${u.events} Requests)`)
|
||||
: ['- keine Nutzungsdaten in diesem Zeitraum']),
|
||||
'',
|
||||
'Feedback:',
|
||||
...report.feedback.map((item) => `- ${item.product} ${item.excitement}/5 ${item.from}: ${item.summary}`),
|
||||
]
|
||||
@@ -373,6 +401,33 @@ export function renderWeeklyKpiEmail(report: WeeklyKpiReport) {
|
||||
</tr>`)
|
||||
.join('')
|
||||
|
||||
const usageTopUserRows = report.aiUsage.topUsers.length
|
||||
? report.aiUsage.topUsers.map((u) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(u.email)}</td>
|
||||
<td>${u.events}</td>
|
||||
<td>${escapeHtml(formatUsd(u.costUsd))}</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="3">Keine Nutzungsdaten in diesem Zeitraum.</td></tr>'
|
||||
|
||||
const usageKindRows = (['stt', 'tts', 'llm'] as const)
|
||||
.map((kind) => {
|
||||
const row = report.aiUsage.byKind[kind]
|
||||
const detail = kind === 'stt'
|
||||
? `${formatSeconds(report.aiUsage.sttSeconds)} Audio`
|
||||
: kind === 'tts'
|
||||
? `${formatTokens(report.aiUsage.ttsCharacters)} Zeichen`
|
||||
: `${formatTokens(report.aiUsage.llmInputTokens)} in / ${formatTokens(report.aiUsage.llmOutputTokens)} out`
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${kind.toUpperCase()}</strong></td>
|
||||
<td>${row.events}</td>
|
||||
<td>${escapeHtml(detail)}</td>
|
||||
<td>${escapeHtml(formatUsd(row.costUsd))}</td>
|
||||
</tr>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const feedbackRows = report.feedback.length
|
||||
? report.feedback.map((item) => `
|
||||
<tr>
|
||||
@@ -432,6 +487,11 @@ export function renderWeeklyKpiEmail(report: WeeklyKpiReport) {
|
||||
<table><thead><tr><th>KPI</th><th>Ziel</th><th>Ist</th><th>Status</th></tr></thead><tbody>${goalRows}</tbody></table>
|
||||
<h2>Produkte</h2>
|
||||
<table><thead><tr><th>Produkt</th><th>Waitlist</th><th>Neue User</th><th>Nutzung Ø</th><th>Feedback</th></tr></thead><tbody>${productRows}</tbody></table>
|
||||
<h2>AI-Nutzung & Kosten</h2>
|
||||
<p class="muted">Diese Woche: <strong>${escapeHtml(formatUsd(report.aiUsage.costUsd))}</strong> · Letzte 30 Tage: <strong>${escapeHtml(formatUsd(report.aiUsage.rolling30dCostUsd))}</strong></p>
|
||||
<table><thead><tr><th>Art</th><th>Requests</th><th>Volumen</th><th>Kosten</th></tr></thead><tbody>${usageKindRows}</tbody></table>
|
||||
<h2>Top User nach Kosten</h2>
|
||||
<table><thead><tr><th>User</th><th>Requests</th><th>Kosten</th></tr></thead><tbody>${usageTopUserRows}</tbody></table>
|
||||
<h2>Landing Verlauf</h2>
|
||||
<table><thead><tr><th>Datum</th><th>Views</th><th>Scrolled</th><th>Waitlist</th></tr></thead><tbody>${trendRows}</tbody></table>
|
||||
<h2>Feedback</h2>
|
||||
|
||||
65
server/utils/rateLimit.ts
Normal file
65
server/utils/rateLimit.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { createError } from 'h3'
|
||||
|
||||
interface WindowEntry {
|
||||
windowStart: number
|
||||
count: number
|
||||
}
|
||||
|
||||
// In-memory fixed-window limiter. Per process — good enough for a single
|
||||
// Coolify instance; swap for a shared store if the app ever scales out.
|
||||
const windows = new Map<string, WindowEntry>()
|
||||
|
||||
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000
|
||||
let lastCleanup = Date.now()
|
||||
|
||||
function cleanup(now: number, windowMs: number) {
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return
|
||||
lastCleanup = now
|
||||
for (const [key, entry] of windows) {
|
||||
if (now - entry.windowStart > windowMs * 2) {
|
||||
windows.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getClientIp(event: H3Event): string {
|
||||
const forwarded = event.node.req.headers['x-forwarded-for']
|
||||
if (typeof forwarded === 'string' && forwarded.trim()) {
|
||||
return forwarded.split(',')[0]!.trim()
|
||||
}
|
||||
return event.node.req.socket?.remoteAddress || 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws 429 when `key` exceeds `limit` requests per `windowMs`.
|
||||
* Use a user id as key where available, client IP otherwise.
|
||||
*/
|
||||
export function enforceRateLimit(
|
||||
event: H3Event,
|
||||
bucket: string,
|
||||
key: string,
|
||||
limit: number,
|
||||
windowMs = 60_000,
|
||||
) {
|
||||
const now = Date.now()
|
||||
cleanup(now, windowMs)
|
||||
|
||||
const mapKey = `${bucket}:${key}`
|
||||
const entry = windows.get(mapKey)
|
||||
|
||||
if (!entry || now - entry.windowStart >= windowMs) {
|
||||
windows.set(mapKey, { windowStart: now, count: 1 })
|
||||
return
|
||||
}
|
||||
|
||||
entry.count += 1
|
||||
if (entry.count > limit) {
|
||||
const retryAfter = Math.ceil((entry.windowStart + windowMs - now) / 1000)
|
||||
event.node.res.setHeader('Retry-After', String(Math.max(retryAfter, 1)))
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: 'Too many requests — slow down and try again shortly.',
|
||||
})
|
||||
}
|
||||
}
|
||||
165
server/utils/usage.ts
Normal file
165
server/utils/usage.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import mongoose from 'mongoose'
|
||||
import { UsageEvent, type UsageKind, type UsageProvider } from '../models/UsageEvent'
|
||||
|
||||
// OpenAI list prices (USD). Self-hosted providers (speaches/piper) cost 0.
|
||||
// Update here when models or prices change.
|
||||
const LLM_PRICES_PER_1M_TOKENS: Record<string, { input: number; output: number }> = {
|
||||
'gpt-5-nano': { input: 0.05, output: 0.4 },
|
||||
'gpt-5-mini': { input: 0.25, output: 2.0 },
|
||||
'gpt-5': { input: 1.25, output: 10.0 },
|
||||
'gpt-4o-mini': { input: 0.15, output: 0.6 },
|
||||
'gpt-4o': { input: 2.5, output: 10.0 },
|
||||
}
|
||||
|
||||
const STT_PRICES_PER_MINUTE: Record<string, number> = {
|
||||
'whisper-1': 0.006,
|
||||
'gpt-4o-transcribe': 0.006,
|
||||
'gpt-4o-mini-transcribe': 0.003,
|
||||
}
|
||||
|
||||
const TTS_PRICES_PER_1M_CHARS: Record<string, number> = {
|
||||
'tts-1': 15,
|
||||
'tts-1-hd': 30,
|
||||
'gpt-4o-mini-tts': 12,
|
||||
}
|
||||
|
||||
export interface UsageInput {
|
||||
user?: mongoose.Types.ObjectId | string | null
|
||||
sessionId?: string
|
||||
kind: UsageKind
|
||||
provider: UsageProvider
|
||||
model: string
|
||||
endpoint: string
|
||||
audioSeconds?: number
|
||||
characters?: number
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
}
|
||||
|
||||
export function estimateCostUsd(input: UsageInput): number {
|
||||
if (input.provider !== 'openai') {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (input.kind === 'llm') {
|
||||
const price = LLM_PRICES_PER_1M_TOKENS[input.model]
|
||||
if (!price) return 0
|
||||
const inputCost = ((input.inputTokens || 0) / 1_000_000) * price.input
|
||||
const outputCost = ((input.outputTokens || 0) / 1_000_000) * price.output
|
||||
return inputCost + outputCost
|
||||
}
|
||||
|
||||
if (input.kind === 'stt') {
|
||||
const perMinute = STT_PRICES_PER_MINUTE[input.model]
|
||||
if (!perMinute) return 0
|
||||
return ((input.audioSeconds || 0) / 60) * perMinute
|
||||
}
|
||||
|
||||
if (input.kind === 'tts') {
|
||||
const per1M = TTS_PRICES_PER_1M_CHARS[input.model]
|
||||
if (!per1M) return 0
|
||||
return ((input.characters || 0) / 1_000_000) * per1M
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Fire-and-forget usage recording — must never break the calling endpoint. */
|
||||
export async function recordUsage(input: UsageInput): Promise<void> {
|
||||
try {
|
||||
await UsageEvent.create({
|
||||
user: input.user || undefined,
|
||||
sessionId: input.sessionId,
|
||||
kind: input.kind,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
endpoint: input.endpoint,
|
||||
audioSeconds: input.audioSeconds,
|
||||
characters: input.characters,
|
||||
inputTokens: input.inputTokens,
|
||||
outputTokens: input.outputTokens,
|
||||
costUsd: estimateCostUsd(input),
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[usage] Recording usage event failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
events: number
|
||||
costUsd: number
|
||||
sttSeconds: number
|
||||
ttsCharacters: number
|
||||
llmInputTokens: number
|
||||
llmOutputTokens: number
|
||||
byKind: Record<UsageKind, { events: number; costUsd: number }>
|
||||
topUsers: Array<{ email: string; events: number; costUsd: number }>
|
||||
}
|
||||
|
||||
export async function summarizeUsage(periodStart: Date, periodEnd: Date): Promise<UsageSummary> {
|
||||
const match = { createdAt: { $gte: periodStart, $lt: periodEnd } }
|
||||
|
||||
const [totals, kinds, topUsers] = await Promise.all([
|
||||
UsageEvent.aggregate([
|
||||
{ $match: match },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
events: { $sum: 1 },
|
||||
costUsd: { $sum: '$costUsd' },
|
||||
sttSeconds: { $sum: { $ifNull: ['$audioSeconds', 0] } },
|
||||
ttsCharacters: { $sum: { $ifNull: ['$characters', 0] } },
|
||||
llmInputTokens: { $sum: { $ifNull: ['$inputTokens', 0] } },
|
||||
llmOutputTokens: { $sum: { $ifNull: ['$outputTokens', 0] } },
|
||||
},
|
||||
},
|
||||
]),
|
||||
UsageEvent.aggregate([
|
||||
{ $match: match },
|
||||
{ $group: { _id: '$kind', events: { $sum: 1 }, costUsd: { $sum: '$costUsd' } } },
|
||||
]),
|
||||
UsageEvent.aggregate([
|
||||
{ $match: { ...match, user: { $ne: null } } },
|
||||
{ $group: { _id: '$user', events: { $sum: 1 }, costUsd: { $sum: '$costUsd' } } },
|
||||
{ $sort: { costUsd: -1, events: -1 } },
|
||||
{ $limit: 5 },
|
||||
{ $lookup: { from: 'users', localField: '_id', foreignField: '_id', as: 'userDoc' } },
|
||||
]),
|
||||
])
|
||||
|
||||
const t = totals[0] || {}
|
||||
const byKind: UsageSummary['byKind'] = {
|
||||
stt: { events: 0, costUsd: 0 },
|
||||
tts: { events: 0, costUsd: 0 },
|
||||
llm: { events: 0, costUsd: 0 },
|
||||
}
|
||||
for (const row of kinds) {
|
||||
if (row._id === 'stt' || row._id === 'tts' || row._id === 'llm') {
|
||||
byKind[row._id as UsageKind] = { events: row.events || 0, costUsd: row.costUsd || 0 }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events: t.events || 0,
|
||||
costUsd: t.costUsd || 0,
|
||||
sttSeconds: t.sttSeconds || 0,
|
||||
ttsCharacters: t.ttsCharacters || 0,
|
||||
llmInputTokens: t.llmInputTokens || 0,
|
||||
llmOutputTokens: t.llmOutputTokens || 0,
|
||||
byKind,
|
||||
topUsers: topUsers.map((row: any) => ({
|
||||
email: row.userDoc?.[0]?.email || String(row._id),
|
||||
events: row.events || 0,
|
||||
costUsd: row.costUsd || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRollingCostUsd(days: number, now = new Date()): Promise<number> {
|
||||
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
const rows = await UsageEvent.aggregate([
|
||||
{ $match: { createdAt: { $gte: start, $lt: now } } },
|
||||
{ $group: { _id: null, costUsd: { $sum: '$costUsd' } } },
|
||||
])
|
||||
return rows[0]?.costUsd || 0
|
||||
}
|
||||
73
server/utils/usageAlert.ts
Normal file
73
server/utils/usageAlert.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { UsageAlertDelivery } from '../models/UsageAlertDelivery'
|
||||
import { sendMail } from './notifications'
|
||||
import { getRollingCostUsd, summarizeUsage } from './usage'
|
||||
|
||||
const DEFAULT_THRESHOLD_USD = 5
|
||||
const DEFAULT_RECIPIENT = 'opensquawk-kpi@faktorxmensch.com'
|
||||
|
||||
function getThresholdUsd() {
|
||||
const parsed = Number.parseFloat(process.env.USAGE_ALERT_USD || '')
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_THRESHOLD_USD
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an alert mail when the rolling 30-day AI cost crosses the threshold
|
||||
* (USAGE_ALERT_USD, default $5). At most one alert per calendar month.
|
||||
* Designed to be called from cron endpoints — never throws.
|
||||
*/
|
||||
export async function maybeSendUsageQuotaAlert(now = new Date()) {
|
||||
try {
|
||||
const thresholdUsd = getThresholdUsd()
|
||||
const costUsd = await getRollingCostUsd(30, now)
|
||||
|
||||
if (costUsd < thresholdUsd) {
|
||||
return { sent: false, costUsd, thresholdUsd }
|
||||
}
|
||||
|
||||
const monthKey = now.toISOString().slice(0, 7)
|
||||
const alreadySent = await UsageAlertDelivery.exists({ monthKey })
|
||||
if (alreadySent) {
|
||||
return { sent: false, skipped: 'already-sent', costUsd, thresholdUsd }
|
||||
}
|
||||
|
||||
const recipient = process.env.KPI_EMAIL_TO || DEFAULT_RECIPIENT
|
||||
const periodStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
const summary = await summarizeUsage(periodStart, now)
|
||||
|
||||
const topUsers = summary.topUsers.length
|
||||
? summary.topUsers.map((u) => `- ${u.email}: $${u.costUsd.toFixed(4)} (${u.events} Requests)`).join('\n')
|
||||
: '- keine User zugeordnet'
|
||||
|
||||
const text = [
|
||||
`Die geschätzten AI-Kosten der letzten 30 Tage liegen bei $${costUsd.toFixed(4)} und haben die Schwelle von $${thresholdUsd.toFixed(2)} überschritten.`,
|
||||
'',
|
||||
`STT: ${Math.round(summary.sttSeconds / 60)} Minuten Audio ($${summary.byKind.stt.costUsd.toFixed(4)})`,
|
||||
`TTS: ${summary.ttsCharacters} Zeichen ($${summary.byKind.tts.costUsd.toFixed(4)})`,
|
||||
`LLM: ${summary.llmInputTokens} in / ${summary.llmOutputTokens} out Tokens ($${summary.byKind.llm.costUsd.toFixed(4)})`,
|
||||
'',
|
||||
'Top User nach Kosten:',
|
||||
topUsers,
|
||||
'',
|
||||
'Es wird maximal eine Warnung pro Kalendermonat verschickt.',
|
||||
].join('\n')
|
||||
|
||||
const mailAccepted = await sendMail({
|
||||
to: recipient,
|
||||
subject: `OpenSquawk Kosten-Alarm: $${costUsd.toFixed(2)} in 30 Tagen (Schwelle $${thresholdUsd.toFixed(2)})`,
|
||||
text,
|
||||
})
|
||||
|
||||
await UsageAlertDelivery.create({
|
||||
monthKey,
|
||||
thresholdUsd,
|
||||
costUsd,
|
||||
recipient,
|
||||
sentAt: now,
|
||||
})
|
||||
|
||||
return { sent: true, mailAccepted, costUsd, thresholdUsd }
|
||||
} catch (error) {
|
||||
console.warn('[usage-alert] Quota alert check failed', error)
|
||||
return { sent: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user