wacrm/src/app/api/ai/usage/route.ts
Arnas Donauskas e132cc7a15 fix(ai): address code-review findings on auto-reply polish
Correctness:
- Resume AI now releases ANY assignment (not just the caller's own).
  A handoff-assigned thread previously stayed assigned after resume, so
  the eligibility gate silently kept the bot muted — Resume was a no-op.
- GET /api/ai/usage: guard days >= 1 (Number(null)===0 is finite, so the
  30-day default was unreachable and the window collapsed to 1 day).
- GET /api/ai/usage: align the query cutoff to the start of the oldest
  charted local day so rows in the oldest partial day no longer count in
  totals while missing from the daily series (totals now match the bars).
- ai-thread-banner: cache account AI-status per accountId and never cache
  a transient fetch failure — fixes cross-account leak and the banner
  staying hidden all session after one failed /api/ai/config call.
- /api/ai/draft: construct the service-role client inside a guard and
  fire-and-forget the usage log, so a missing service-role key (or any
  log failure) can't 500 an already-generated, already-billed draft.

Efficiency:
- Don't await usage logging on the reply hot path (auto-reply + draft);
  logAiUsage swallows its own errors, so fire-and-forget is safe and
  removes a DB round-trip from the customer-facing send.

Reuse / consistency:
- usage route reuses date-utils (daysAgoStart/lastNDayKeys/localDayKey)
  and now buckets by LOCAL day like every other dashboard chart (was UTC).
- extract formatCompactNumber (shared with formatCurrencyShort); drop the
  hand-rolled month table in favour of date-fns; extract
  fetchAccountMembers/memberLabel and use them in the AI settings picker.

Resume still resets the per-conversation reply count (a deliberate,
manual, rate-limited re-engagement — documented in the handler).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:35:41 +03:00

142 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { daysAgoStart, lastNDayKeys, localDayKey } from '@/lib/dashboard/date-utils'
// Rows are aggregated in-process over a bounded window. An active
// account writes a handful of rows per conversation, so 30 days sits
// comfortably under this cap; we surface `truncated` when it doesn't so
// the UI can say "showing a partial window" rather than under-reporting
// silently.
const MAX_ROWS = 10_000
const DEFAULT_WINDOW_DAYS = 30
interface UsageRow {
created_at: string
mode: 'auto_reply' | 'draft'
provider: string
model: string
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
/**
* GET /api/ai/usage?days=30 (admin+)
*
* Token-spend summary for the account's BYO key over the last `days`
* (1–90, default 30): totals, per-mode + per-model breakdowns, and a
* zero-filled daily series for charting. Admin-only, mirroring the
* `ai_usage_log` SELECT policy — spend is billing-class.
*/
export async function GET(request: Request) {
try {
const { supabase, accountId } = await requireRole('admin')
const url = new URL(request.url)
const rawDays = Number(url.searchParams.get('days'))
// Guard `>= 1`, not just `isFinite`: a missing/blank param is
// Number(null)/Number('') === 0, which is finite — without the lower
// bound the default would never apply and the window would collapse
// to a single day.
const days =
Number.isFinite(rawDays) && rawDays >= 1
? Math.min(90, Math.floor(rawDays))
: DEFAULT_WINDOW_DAYS
// Align the query cutoff to the START of the oldest local day we'll
// chart (not a rolling `now - N*24h` instant). Otherwise rows in the
// oldest partial day would be counted in the totals but fall outside
// every daily bucket, so the chart's bars wouldn't sum to the
// headline total. Local-day boundaries match every other dashboard
// chart (see lib/dashboard/date-utils).
const since = daysAgoStart(days - 1)
const { data, error } = await supabase
.from('ai_usage_log')
.select(
'created_at, mode, provider, model, prompt_tokens, completion_tokens, total_tokens',
)
.eq('account_id', accountId)
.gte('created_at', since.toISOString())
.order('created_at', { ascending: false })
.limit(MAX_ROWS + 1)
if (error) {
console.error('[ai/usage GET] fetch error:', error)
return NextResponse.json(
{ error: 'Failed to load usage' },
{ status: 500 },
)
}
const all = (data ?? []) as UsageRow[]
const truncated = all.length > MAX_ROWS
const rows = truncated ? all.slice(0, MAX_ROWS) : all
// Totals.
let promptTokens = 0
let completionTokens = 0
let totalTokens = 0
// Per-mode + per-model tallies.
const byMode = {
auto_reply: { calls: 0, tokens: 0 },
draft: { calls: 0, tokens: 0 },
}
const modelMap = new Map<
string,
{ model: string; provider: string; calls: number; tokens: number }
>()
// Zero-filled daily buckets so the chart shows quiet days as gaps,
// not missing points. Local-day keys, oldest → newest — the same
// helper every other dashboard chart uses, so day boundaries agree.
const daily = new Map<string, { date: string; tokens: number; calls: number }>()
for (const key of lastNDayKeys(days)) {
daily.set(key, { date: key, tokens: 0, calls: 0 })
}
for (const r of rows) {
promptTokens += r.prompt_tokens
completionTokens += r.completion_tokens
totalTokens += r.total_tokens
// `mode` is DB-CHECK-constrained to these two values.
byMode[r.mode].calls += 1
byMode[r.mode].tokens += r.total_tokens
const mk = `${r.provider}:${r.model}`
const m =
modelMap.get(mk) ??
{ model: r.model, provider: r.provider, calls: 0, tokens: 0 }
m.calls += 1
m.tokens += r.total_tokens
modelMap.set(mk, m)
const bucket = daily.get(localDayKey(r.created_at))
if (bucket) {
bucket.tokens += r.total_tokens
bucket.calls += 1
}
}
const byModel = [...modelMap.values()].sort((a, b) => b.tokens - a.tokens)
return NextResponse.json({
window_days: days,
truncated,
totals: {
calls: rows.length,
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: totalTokens,
},
by_mode: byMode,
by_model: byModel,
daily: [...daily.values()],
})
} catch (err) {
return toErrorResponse(err)
}
}