diff --git a/src/app/api/ai/autoreply/[conversationId]/route.ts b/src/app/api/ai/autoreply/[conversationId]/route.ts index 83a5fb0..aaa69fb 100644 --- a/src/app/api/ai/autoreply/[conversationId]/route.ts +++ b/src/app/api/ai/autoreply/[conversationId]/route.ts @@ -47,7 +47,7 @@ export async function POST(request: Request, { params }: Params) { // Confirm the conversation is in the caller's account before writing. const { data: conv, error: convErr } = await supabase .from('conversations') - .select('id, assigned_agent_id') + .select('id') .eq('id', conversationId) .eq('account_id', accountId) .maybeSingle() @@ -67,12 +67,20 @@ export async function POST(request: Request, { params }: Params) { if (paused) { if (assignToMe) update.assigned_agent_id = userId } 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_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 diff --git a/src/app/api/ai/draft/route.ts b/src/app/api/ai/draft/route.ts index ad50fcf..0f35995 100644 --- a/src/app/api/ai/draft/route.ts +++ b/src/app/api/ai/draft/route.ts @@ -107,16 +107,24 @@ export async function POST(request: Request) { const { text, usage } = await generateReply({ config, systemPrompt, messages }) // Record spend on the account's BYO key. Best-effort + via the - // service role (the log has no `authenticated` INSERT policy); a - // failure here must not fail the draft the agent is waiting on. - await logAiUsage(supabaseAdmin(), { - accountId, - conversationId, - mode: 'draft', - provider: config.provider, - model: config.model, - usage, - }) + // service role (the log has no `authenticated` INSERT policy). This + // must not fail or delay the draft the agent is waiting on, so: + // - the whole thing is wrapped (constructing the admin client throws + // if the service-role key is unset — that must not 500 the draft); + // - it's fire-and-forget (`void`), not awaited, so the response + // isn't held for a DB round-trip. + try { + void logAiUsage(supabaseAdmin(), { + 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 }) } catch (err) { diff --git a/src/app/api/ai/usage/route.ts b/src/app/api/ai/usage/route.ts index 983e95b..cc5973a 100644 --- a/src/app/api/ai/usage/route.ts +++ b/src/app/api/ai/usage/route.ts @@ -1,5 +1,6 @@ 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 @@ -19,11 +20,6 @@ interface UsageRow { 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+) * @@ -38,11 +34,22 @@ export async function GET(request: Request) { const url = new URL(request.url) const rawDays = Number(url.searchParams.get('days')) - const days = Number.isFinite(rawDays) - ? Math.min(90, Math.max(1, Math.floor(rawDays))) - : DEFAULT_WINDOW_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 - 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 .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, - // 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() - for (let i = days - 1; i >= 0; i--) { - const key = dayKey(new Date(Date.now() - i * 24 * 60 * 60 * 1000)) + for (const key of lastNDayKeys(days)) { daily.set(key, { date: key, tokens: 0, calls: 0 }) } @@ -94,9 +101,9 @@ export async function GET(request: Request) { completionTokens += r.completion_tokens totalTokens += r.total_tokens - const mode = r.mode === 'draft' ? 'draft' : 'auto_reply' - byMode[mode].calls += 1 - byMode[mode].tokens += 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 = @@ -106,7 +113,7 @@ export async function GET(request: Request) { m.tokens += r.total_tokens modelMap.set(mk, m) - const bucket = daily.get(dayKey(new Date(r.created_at))) + const bucket = daily.get(localDayKey(r.created_at)) if (bucket) { bucket.tokens += r.total_tokens bucket.calls += 1 diff --git a/src/components/agents/ai-usage.tsx b/src/components/agents/ai-usage.tsx index 085cc65..d24f16f 100644 --- a/src/components/agents/ai-usage.tsx +++ b/src/components/agents/ai-usage.tsx @@ -21,6 +21,8 @@ import { } from '@/components/ui/select'; import { Skeleton } from '@/components/dashboard/skeleton'; import { BarChart } from '@/components/tremor/bar-chart'; +import { formatCompactNumber } from '@/lib/currency'; +import { format, parseISO } from 'date-fns'; interface UsageResponse { window_days: number; @@ -46,23 +48,6 @@ interface UsageResponse { 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 * billing-class), mirroring the `ai_usage_log` SELECT policy and the @@ -110,7 +95,8 @@ export function AiUsageCard() { if (profileLoading || !canView) return null; 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; return ( @@ -157,16 +143,16 @@ export function AiUsageCard() { ) : ( <>
- +
@@ -180,7 +166,7 @@ export function AiUsageCard() { index="day" categories={['Tokens']} colors={['violet']} - valueFormatter={(v) => fmt(v)} + valueFormatter={(v) => formatCompactNumber(v)} showLegend={false} yAxisWidth={48} className="h-[200px]" @@ -205,7 +191,7 @@ export function AiUsageCard() { - {fmt(m.tokens)} tok · {m.calls}{' '} + {formatCompactNumber(m.tokens)} tok · {m.calls}{' '} {m.calls === 1 ? 'call' : 'calls'} diff --git a/src/components/inbox/ai-thread-banner.tsx b/src/components/inbox/ai-thread-banner.tsx index fb94edd..a45c7b7 100644 --- a/src/components/inbox/ai-thread-banner.tsx +++ b/src/components/inbox/ai-thread-banner.tsx @@ -4,30 +4,41 @@ import { useState, useEffect, useCallback } from "react"; import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; +import { useAuth } from "@/hooks/use-auth"; // ------------------------------------------------------------ -// Account AI status is the same for every conversation, so fetch it -// once per session and share it across thread switches instead of -// hitting /api/ai/config every time the agent opens a new chat. The -// promise is cached so concurrent mounts dedupe onto one request. +// Account AI status is the same for every conversation, so cache it per +// account and reuse it across thread switches instead of hitting +// /api/ai/config every time the agent opens a chat. +// +// 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 { autoReplyOn: boolean; } -let statusPromise: Promise | null = null; +const statusCache = new Map(); -async function fetchAiAccountStatus(): Promise { - if (!statusPromise) { - statusPromise = fetch("/api/ai/config", { cache: "no-store" }) - .then((r) => (r.ok ? r.json() : null)) - .then((j) => ({ - // AI auto-reply is "live" only when configured, the master - // switch is on, and the inbound bot is enabled. - autoReplyOn: !!(j?.configured && j?.is_active && j?.auto_reply_enabled), - })) - .catch(() => ({ autoReplyOn: false })); +async function fetchAiAccountStatus(accountId: string): Promise { + const cached = statusCache.get(accountId); + if (cached) return cached; + try { + const res = await fetch("/api/ai/config", { cache: "no-store" }); + if (!res.ok) return { autoReplyOn: false }; // don't cache a transient failure + const j = await res.json(); + const status = { + // AI auto-reply is "live" only when configured, the master switch + // 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 { @@ -66,6 +77,7 @@ export function AiThreadBanner({ currentUserId, onChange, }: AiThreadBannerProps) { + const { accountId } = useAuth(); const [autoReplyOn, setAutoReplyOn] = useState(null); const [busy, setBusy] = useState(false); // Optimistic local mirror of the pause flag so the banner flips @@ -75,12 +87,13 @@ export function AiThreadBanner({ useEffect(() => setPaused(disabled), [conversationId, disabled]); useEffect(() => { + if (!accountId) return; let alive = true; - fetchAiAccountStatus().then((s) => alive && setAutoReplyOn(s.autoReplyOn)); + fetchAiAccountStatus(accountId).then((s) => alive && setAutoReplyOn(s.autoReplyOn)); return () => { alive = false; }; - }, []); + }, [accountId]); const toggle = useCallback( async (paused: boolean) => { diff --git a/src/components/settings/ai-config.tsx b/src/components/settings/ai-config.tsx index 4c4307a..7e609e3 100644 --- a/src/components/settings/ai-config.tsx +++ b/src/components/settings/ai-config.tsx @@ -29,6 +29,7 @@ import { AiKnowledgeCard } from './ai-knowledge'; import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults'; import type { AiProvider } from '@/lib/ai/types'; import type { AccountMember } from '@/types'; +import { fetchAccountMembers, memberLabel } from '@/lib/account/members'; const MASKED_KEY = '••••••••••••••••'; @@ -118,16 +119,7 @@ export function AiConfig() { // Members populate the handoff-target picker. Best-effort — on an // older deployment without the endpoint the picker just shows the // queue option. - void (async () => { - 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. - } - })(); + void fetchAccountMembers().then(setMembers); }, [accountId, fetchConfig]); // Swap the model default when the provider changes, unless the user @@ -491,7 +483,7 @@ export function AiConfig() { {members.map((m) => ( - {m.full_name || m.email || m.user_id} + {memberLabel(m)} ))} diff --git a/src/lib/account/members.ts b/src/lib/account/members.ts new file mode 100644 index 0000000..fc5eefb --- /dev/null +++ b/src/lib/account/members.ts @@ -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 { + 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; +} diff --git a/src/lib/ai/auto-reply.ts b/src/lib/ai/auto-reply.ts index 5c75d20..df758f9 100644 --- a/src/lib/ai/auto-reply.ts +++ b/src/lib/ai/auto-reply.ts @@ -118,10 +118,12 @@ export async function dispatchInboundToAiReply( messages, }) - // Record token spend on the account's BYO key (best-effort — never - // blocks or fails the reply). Logged regardless of handoff: the - // provider call happened either way. - await logAiUsage(db, { + // Record token spend on the account's BYO key. Fire-and-forget so it + // never adds latency to the customer-facing send: `logAiUsage` + // swallows its own errors, so the floating promise can't reject. + // Logged regardless of handoff — the provider call happened either + // way. + void logAiUsage(db, { accountId, conversationId, mode: 'auto_reply', diff --git a/src/lib/currency.ts b/src/lib/currency.ts index c85f3eb..50d405c 100644 --- a/src/lib/currency.ts +++ b/src/lib/currency.ts @@ -90,8 +90,17 @@ export function formatCurrencyShort( ): string { const code = currency || DEFAULT_CURRENCY; const symbol = CURRENCIES.find((c) => c.code === code)?.symbol ?? `${code} `; - const v = Number(value || 0); - 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)}`; + return `${symbol}${formatCompactNumber(value)}`; +} + +/** + * 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); }