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>
This commit is contained in:
Arnas Donauskas 2026-07-08 15:35:41 +03:00
parent c736e7a43f
commit e132cc7a15
9 changed files with 141 additions and 91 deletions

View File

@ -47,7 +47,7 @@ export async function POST(request: Request, { params }: Params) {
// Confirm the conversation is in the caller's account before writing. // Confirm the conversation is in the caller's account before writing.
const { data: conv, error: convErr } = await supabase const { data: conv, error: convErr } = await supabase
.from('conversations') .from('conversations')
.select('id, assigned_agent_id') .select('id')
.eq('id', conversationId) .eq('id', conversationId)
.eq('account_id', accountId) .eq('account_id', accountId)
.maybeSingle() .maybeSingle()
@ -67,12 +67,20 @@ export async function POST(request: Request, { params }: Params) {
if (paused) { if (paused) {
if (assignToMe) update.assigned_agent_id = userId if (assignToMe) update.assigned_agent_id = userId
} else { } else {
// Resuming: give the bot a clean slate on this thread. // Resuming hands the thread *back to the bot*. Clear the pause and
// the handoff note, and — crucially — release ANY assignment, not
// just the caller's own: the auto-reply eligibility gate stands
// down whenever a human is assigned, so leaving a stale assignee
// (e.g. the agent a prior handoff routed to) would silently keep
// the bot muted and make "Resume AI" a no-op. This is the explicit
// choice to let the bot own the thread again.
update.assigned_agent_id = null
// Give the bot a fresh reply budget on this thread. This is a
// deliberate, manual, rate-limited action (not automatable), so it
// can't be used to bypass the per-conversation cap at scale — it's
// a human choosing to re-engage the assistant.
update.ai_reply_count = 0 update.ai_reply_count = 0
update.ai_handoff_summary = null update.ai_handoff_summary = null
// Only release an assignment the caller holds — never yank a
// teammate's thread out from under them.
if (conv.assigned_agent_id === userId) update.assigned_agent_id = null
} }
const { error: upErr } = await supabase const { error: upErr } = await supabase

View File

@ -107,16 +107,24 @@ export async function POST(request: Request) {
const { text, usage } = await generateReply({ config, systemPrompt, messages }) const { text, usage } = await generateReply({ config, systemPrompt, messages })
// Record spend on the account's BYO key. Best-effort + via the // Record spend on the account's BYO key. Best-effort + via the
// service role (the log has no `authenticated` INSERT policy); a // service role (the log has no `authenticated` INSERT policy). This
// failure here must not fail the draft the agent is waiting on. // must not fail or delay the draft the agent is waiting on, so:
await logAiUsage(supabaseAdmin(), { // - the whole thing is wrapped (constructing the admin client throws
accountId, // if the service-role key is unset — that must not 500 the draft);
conversationId, // - it's fire-and-forget (`void`), not awaited, so the response
mode: 'draft', // isn't held for a DB round-trip.
provider: config.provider, try {
model: config.model, void logAiUsage(supabaseAdmin(), {
usage, accountId,
}) conversationId,
mode: 'draft',
provider: config.provider,
model: config.model,
usage,
})
} catch (logErr) {
console.error('[ai/draft] usage log skipped:', logErr)
}
return NextResponse.json({ draft: text }) return NextResponse.json({ draft: text })
} catch (err) { } catch (err) {

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account' 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 // Rows are aggregated in-process over a bounded window. An active
// account writes a handful of rows per conversation, so 30 days sits // account writes a handful of rows per conversation, so 30 days sits
@ -19,11 +20,6 @@ interface UsageRow {
total_tokens: number total_tokens: number
} }
/** UTC yyyy-mm-dd — stable regardless of server timezone. */
function dayKey(d: Date): string {
return d.toISOString().slice(0, 10)
}
/** /**
* GET /api/ai/usage?days=30 (admin+) * GET /api/ai/usage?days=30 (admin+)
* *
@ -38,11 +34,22 @@ export async function GET(request: Request) {
const url = new URL(request.url) const url = new URL(request.url)
const rawDays = Number(url.searchParams.get('days')) const rawDays = Number(url.searchParams.get('days'))
const days = Number.isFinite(rawDays) // Guard `>= 1`, not just `isFinite`: a missing/blank param is
? Math.min(90, Math.max(1, Math.floor(rawDays))) // Number(null)/Number('') === 0, which is finite — without the lower
: DEFAULT_WINDOW_DAYS // 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
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) // 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 const { data, error } = await supabase
.from('ai_usage_log') .from('ai_usage_log')
@ -82,10 +89,10 @@ export async function GET(request: Request) {
>() >()
// Zero-filled daily buckets so the chart shows quiet days as gaps, // Zero-filled daily buckets so the chart shows quiet days as gaps,
// not missing points. Keyed by UTC date, oldest → newest. // 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 }>() const daily = new Map<string, { date: string; tokens: number; calls: number }>()
for (let i = days - 1; i >= 0; i--) { for (const key of lastNDayKeys(days)) {
const key = dayKey(new Date(Date.now() - i * 24 * 60 * 60 * 1000))
daily.set(key, { date: key, tokens: 0, calls: 0 }) daily.set(key, { date: key, tokens: 0, calls: 0 })
} }
@ -94,9 +101,9 @@ export async function GET(request: Request) {
completionTokens += r.completion_tokens completionTokens += r.completion_tokens
totalTokens += r.total_tokens totalTokens += r.total_tokens
const mode = r.mode === 'draft' ? 'draft' : 'auto_reply' // `mode` is DB-CHECK-constrained to these two values.
byMode[mode].calls += 1 byMode[r.mode].calls += 1
byMode[mode].tokens += r.total_tokens byMode[r.mode].tokens += r.total_tokens
const mk = `${r.provider}:${r.model}` const mk = `${r.provider}:${r.model}`
const m = const m =
@ -106,7 +113,7 @@ export async function GET(request: Request) {
m.tokens += r.total_tokens m.tokens += r.total_tokens
modelMap.set(mk, m) modelMap.set(mk, m)
const bucket = daily.get(dayKey(new Date(r.created_at))) const bucket = daily.get(localDayKey(r.created_at))
if (bucket) { if (bucket) {
bucket.tokens += r.total_tokens bucket.tokens += r.total_tokens
bucket.calls += 1 bucket.calls += 1

View File

@ -21,6 +21,8 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Skeleton } from '@/components/dashboard/skeleton'; import { Skeleton } from '@/components/dashboard/skeleton';
import { BarChart } from '@/components/tremor/bar-chart'; import { BarChart } from '@/components/tremor/bar-chart';
import { formatCompactNumber } from '@/lib/currency';
import { format, parseISO } from 'date-fns';
interface UsageResponse { interface UsageResponse {
window_days: number; window_days: number;
@ -46,23 +48,6 @@ interface UsageResponse {
const WINDOWS = [7, 30, 90] as const; const WINDOWS = [7, 30, 90] as const;
/** Compact token count — 1234 → "1.2k", 1_200_000 → "1.2M". */
function fmt(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
/** "2026-07-08" → "Jul 8" for the chart axis. */
function shortDay(iso: string): string {
const [, m, d] = iso.split('-');
const month = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
][Number(m) - 1];
return `${month} ${Number(d)}`;
}
/** /**
* Token-spend dashboard for the account's BYO key. Admin-only (spend is * Token-spend dashboard for the account's BYO key. Admin-only (spend is
* billing-class), mirroring the `ai_usage_log` SELECT policy and the * billing-class), mirroring the `ai_usage_log` SELECT policy and the
@ -110,7 +95,8 @@ export function AiUsageCard() {
if (profileLoading || !canView) return null; if (profileLoading || !canView) return null;
const chartData = const chartData =
data?.daily.map((d) => ({ day: shortDay(d.date), Tokens: d.tokens })) ?? []; data?.daily.map((d) => ({ day: format(parseISO(d.date), 'MMM d'), Tokens: d.tokens })) ??
[];
const hasSpend = (data?.totals.total_tokens ?? 0) > 0; const hasSpend = (data?.totals.total_tokens ?? 0) > 0;
return ( return (
@ -157,16 +143,16 @@ export function AiUsageCard() {
) : ( ) : (
<> <>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Total tokens" value={fmt(data.totals.total_tokens)} /> <Stat label="Total tokens" value={formatCompactNumber(data.totals.total_tokens)} />
<Stat label="LLM calls" value={String(data.totals.calls)} /> <Stat label="LLM calls" value={String(data.totals.calls)} />
<Stat <Stat
label="Auto-reply" label="Auto-reply"
value={fmt(data.by_mode.auto_reply.tokens)} value={formatCompactNumber(data.by_mode.auto_reply.tokens)}
icon={Bot} icon={Bot}
/> />
<Stat <Stat
label="Drafts" label="Drafts"
value={fmt(data.by_mode.draft.tokens)} value={formatCompactNumber(data.by_mode.draft.tokens)}
icon={PencilLine} icon={PencilLine}
/> />
</div> </div>
@ -180,7 +166,7 @@ export function AiUsageCard() {
index="day" index="day"
categories={['Tokens']} categories={['Tokens']}
colors={['violet']} colors={['violet']}
valueFormatter={(v) => fmt(v)} valueFormatter={(v) => formatCompactNumber(v)}
showLegend={false} showLegend={false}
yAxisWidth={48} yAxisWidth={48}
className="h-[200px]" className="h-[200px]"
@ -205,7 +191,7 @@ export function AiUsageCard() {
</span> </span>
</span> </span>
<span className="flex-shrink-0 tabular-nums text-muted-foreground"> <span className="flex-shrink-0 tabular-nums text-muted-foreground">
{fmt(m.tokens)} tok · {m.calls}{' '} {formatCompactNumber(m.tokens)} tok · {m.calls}{' '}
{m.calls === 1 ? 'call' : 'calls'} {m.calls === 1 ? 'call' : 'calls'}
</span> </span>
</li> </li>

View File

@ -4,30 +4,41 @@ import { useState, useEffect, useCallback } from "react";
import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react"; import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/hooks/use-auth";
// ------------------------------------------------------------ // ------------------------------------------------------------
// Account AI status is the same for every conversation, so fetch it // Account AI status is the same for every conversation, so cache it per
// once per session and share it across thread switches instead of // account and reuse it across thread switches instead of hitting
// hitting /api/ai/config every time the agent opens a new chat. The // /api/ai/config every time the agent opens a chat.
// promise is cached so concurrent mounts dedupe onto one request. //
// Keyed by accountId (a multi-account user switching workspaces must not
// see the previous account's status), and only *successful* fetches are
// cached — a transient failure returns a default without poisoning the
// cache, so it retries on the next thread open rather than hiding the
// banner for the whole session.
// ------------------------------------------------------------ // ------------------------------------------------------------
interface AiAccountStatus { interface AiAccountStatus {
autoReplyOn: boolean; autoReplyOn: boolean;
} }
let statusPromise: Promise<AiAccountStatus> | null = null; const statusCache = new Map<string, AiAccountStatus>();
async function fetchAiAccountStatus(): Promise<AiAccountStatus> { async function fetchAiAccountStatus(accountId: string): Promise<AiAccountStatus> {
if (!statusPromise) { const cached = statusCache.get(accountId);
statusPromise = fetch("/api/ai/config", { cache: "no-store" }) if (cached) return cached;
.then((r) => (r.ok ? r.json() : null)) try {
.then((j) => ({ const res = await fetch("/api/ai/config", { cache: "no-store" });
// AI auto-reply is "live" only when configured, the master if (!res.ok) return { autoReplyOn: false }; // don't cache a transient failure
// switch is on, and the inbound bot is enabled. const j = await res.json();
autoReplyOn: !!(j?.configured && j?.is_active && j?.auto_reply_enabled), const status = {
})) // AI auto-reply is "live" only when configured, the master switch
.catch(() => ({ autoReplyOn: false })); // is on, and the inbound bot is enabled.
autoReplyOn: !!(j?.configured && j?.is_active && j?.auto_reply_enabled),
};
statusCache.set(accountId, status);
return status;
} catch {
return { autoReplyOn: false }; // don't cache
} }
return statusPromise;
} }
interface AiThreadBannerProps { interface AiThreadBannerProps {
@ -66,6 +77,7 @@ export function AiThreadBanner({
currentUserId, currentUserId,
onChange, onChange,
}: AiThreadBannerProps) { }: AiThreadBannerProps) {
const { accountId } = useAuth();
const [autoReplyOn, setAutoReplyOn] = useState<boolean | null>(null); const [autoReplyOn, setAutoReplyOn] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Optimistic local mirror of the pause flag so the banner flips // Optimistic local mirror of the pause flag so the banner flips
@ -75,12 +87,13 @@ export function AiThreadBanner({
useEffect(() => setPaused(disabled), [conversationId, disabled]); useEffect(() => setPaused(disabled), [conversationId, disabled]);
useEffect(() => { useEffect(() => {
if (!accountId) return;
let alive = true; let alive = true;
fetchAiAccountStatus().then((s) => alive && setAutoReplyOn(s.autoReplyOn)); fetchAiAccountStatus(accountId).then((s) => alive && setAutoReplyOn(s.autoReplyOn));
return () => { return () => {
alive = false; alive = false;
}; };
}, []); }, [accountId]);
const toggle = useCallback( const toggle = useCallback(
async (paused: boolean) => { async (paused: boolean) => {

View File

@ -29,6 +29,7 @@ import { AiKnowledgeCard } from './ai-knowledge';
import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults'; import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
import type { AiProvider } from '@/lib/ai/types'; import type { AiProvider } from '@/lib/ai/types';
import type { AccountMember } from '@/types'; import type { AccountMember } from '@/types';
import { fetchAccountMembers, memberLabel } from '@/lib/account/members';
const MASKED_KEY = '••••••••••••••••'; const MASKED_KEY = '••••••••••••••••';
@ -118,16 +119,7 @@ export function AiConfig() {
// Members populate the handoff-target picker. Best-effort — on an // Members populate the handoff-target picker. Best-effort — on an
// older deployment without the endpoint the picker just shows the // older deployment without the endpoint the picker just shows the
// queue option. // queue option.
void (async () => { void fetchAccountMembers().then(setMembers);
try {
const res = await fetch('/api/account/members', { cache: 'no-store' });
if (!res.ok) return;
const json = (await res.json()) as { members?: AccountMember[] };
setMembers(json.members ?? []);
} catch {
// ignore — picker falls back to "unassigned queue" only.
}
})();
}, [accountId, fetchConfig]); }, [accountId, fetchConfig]);
// Swap the model default when the provider changes, unless the user // Swap the model default when the provider changes, unless the user
@ -491,7 +483,7 @@ export function AiConfig() {
</SelectItem> </SelectItem>
{members.map((m) => ( {members.map((m) => (
<SelectItem key={m.user_id} value={m.user_id}> <SelectItem key={m.user_id} value={m.user_id}>
{m.full_name || m.email || m.user_id} {memberLabel(m)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@ -0,0 +1,25 @@
import type { AccountMember } from '@/types';
/**
* Fetch the current account's members from the API (which applies the
* email-visibility rules — agents/viewers don't see emails). Best-effort:
* returns `[]` on any error or on an older deployment without the
* endpoint, so callers can fall back to a queue-only / raw-id picker.
*
* Client-side only (uses `fetch` against the relative API route).
*/
export async function fetchAccountMembers(): Promise<AccountMember[]> {
try {
const res = await fetch('/api/account/members', { cache: 'no-store' });
if (!res.ok) return [];
const json = (await res.json()) as { members?: AccountMember[] };
return json.members ?? [];
} catch {
return [];
}
}
/** Display label for a member: full name → email → raw id. */
export function memberLabel(m: AccountMember): string {
return m.full_name || m.email || m.user_id;
}

View File

@ -118,10 +118,12 @@ export async function dispatchInboundToAiReply(
messages, messages,
}) })
// Record token spend on the account's BYO key (best-effort — never // Record token spend on the account's BYO key. Fire-and-forget so it
// blocks or fails the reply). Logged regardless of handoff: the // never adds latency to the customer-facing send: `logAiUsage`
// provider call happened either way. // swallows its own errors, so the floating promise can't reject.
await logAiUsage(db, { // Logged regardless of handoff — the provider call happened either
// way.
void logAiUsage(db, {
accountId, accountId,
conversationId, conversationId,
mode: 'auto_reply', mode: 'auto_reply',

View File

@ -90,8 +90,17 @@ export function formatCurrencyShort(
): string { ): string {
const code = currency || DEFAULT_CURRENCY; const code = currency || DEFAULT_CURRENCY;
const symbol = CURRENCIES.find((c) => c.code === code)?.symbol ?? `${code} `; const symbol = CURRENCIES.find((c) => c.code === code)?.symbol ?? `${code} `;
const v = Number(value || 0); return `${symbol}${formatCompactNumber(value)}`;
if (v >= 1_000_000) return `${symbol}${(v / 1_000_000).toFixed(1)}M`; }
if (v >= 1_000) return `${symbol}${(v / 1_000).toFixed(1)}k`;
return `${symbol}${v.toFixed(0)}`; /**
* Compact number for tight spaces (chart tiles, legends): 1_234 → "1.2k",
* 1_200_000 → "1.2M", 900 → "900". The unit-less core shared with
* {@link formatCurrencyShort}.
*/
export function formatCompactNumber(value: number): string {
const v = Number(value || 0);
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
return v.toFixed(0);
} }