mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
142 lines
4.6 KiB
TypeScript
142 lines
4.6 KiB
TypeScript
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)
|
||
}
|
||
}
|