From 339167bd33e4dbc2aa6eb8e304d648d993167cc6 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 2 Jun 2026 22:51:40 +0530 Subject: [PATCH 1/3] feat(database): add get_credit_spend_digest aggregation RPC Single-query aggregation over usage_events powering the credit-spend visibility digest cron (chat#1767). Returns a ranked jsonb array of the top-N accounts by total credits spent over a time window, each with turn count, token totals, main-vs-subagent split, and a per-model cents breakdown, enriched with account name + most-recent email. Empty window returns an empty array so the caller can no-op. Co-Authored-By: Claude Opus 4.8 --- .../20260602000000_credit_spend_digest.sql | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 supabase/migrations/20260602000000_credit_spend_digest.sql diff --git a/supabase/migrations/20260602000000_credit_spend_digest.sql b/supabase/migrations/20260602000000_credit_spend_digest.sql new file mode 100644 index 0000000..39e477d --- /dev/null +++ b/supabase/migrations/20260602000000_credit_spend_digest.sql @@ -0,0 +1,101 @@ +-- Credit-spend visibility digest aggregation. +-- +-- Powers a 10-minute Vercel Cron in the api (`/api/internal/credit-spend-digest`) +-- that posts a top-spenders summary to Telegram. Aggregation lives in the +-- database (one round trip) rather than pulling rows into JS: the function +-- buckets `usage_events` over a time window and returns the top N accounts by +-- total credits spent, each enriched with how that spend breaks down. +-- +-- Args: +-- p_since lower bound on usage_events.created_at (inclusive). The caller +-- passes `now() - interval '10 minutes'`; the window is stateless +-- (minor boundary drift is accepted). +-- p_limit max number of accounts to return, ranked by total spend desc. +-- +-- Returns a jsonb array (ranked, highest spend first). Each element: +-- { +-- account_id, account_name, account_email, +-- total_cents, -- total credits spent in window +-- turn_count, -- number of usage_events rows +-- input_tokens, output_tokens, cached_input_tokens, +-- tool_calls, +-- main_cents, subagent_cents, -- main vs subagent split (agent_type) +-- by_model -- { "": , ... } +-- } +-- Empty window -> '[]'. The caller treats that as a no-op (no Telegram ping). +-- +-- account_email is the most-recent email per account (account_emails ordered +-- by updated_at desc). model_id NULL is bucketed under 'unknown' in by_model. + +CREATE OR REPLACE FUNCTION public.get_credit_spend_digest( + p_since timestamptz, + p_limit integer DEFAULT 10 +) RETURNS jsonb + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = public, pg_temp +AS $$ + WITH windowed AS ( + SELECT * + FROM public.usage_events + WHERE created_at >= p_since + ), + emails AS ( + SELECT DISTINCT ON (account_id) account_id, email + FROM public.account_emails + ORDER BY account_id, updated_at DESC + ), + model_breakdown AS ( + SELECT account_id, + jsonb_object_agg(model_id, cents ORDER BY cents DESC) AS by_model + FROM ( + SELECT account_id, + coalesce(model_id, 'unknown') AS model_id, + sum(credits_deducted_cents) AS cents + FROM windowed + GROUP BY account_id, coalesce(model_id, 'unknown') + ) m + GROUP BY account_id + ), + per_account AS ( + SELECT account_id, + sum(credits_deducted_cents) AS total_cents, + count(*) AS turn_count, + sum(input_tokens) AS input_tokens, + sum(output_tokens) AS output_tokens, + sum(cached_input_tokens) AS cached_input_tokens, + sum(tool_call_count) AS tool_calls, + coalesce(sum(credits_deducted_cents) FILTER (WHERE agent_type = 'main'), 0) AS main_cents, + coalesce(sum(credits_deducted_cents) FILTER (WHERE agent_type = 'subagent'), 0) AS subagent_cents + FROM windowed + GROUP BY account_id + ) + SELECT coalesce(jsonb_agg(obj ORDER BY total_cents DESC), '[]'::jsonb) + FROM ( + SELECT pa.total_cents, + jsonb_build_object( + 'account_id', pa.account_id, + 'account_name', a.name, + 'account_email', e.email, + 'total_cents', pa.total_cents, + 'turn_count', pa.turn_count, + 'input_tokens', pa.input_tokens, + 'output_tokens', pa.output_tokens, + 'cached_input_tokens', pa.cached_input_tokens, + 'tool_calls', pa.tool_calls, + 'main_cents', pa.main_cents, + 'subagent_cents', pa.subagent_cents, + 'by_model', coalesce(mb.by_model, '{}'::jsonb) + ) AS obj + FROM per_account pa + LEFT JOIN public.accounts a ON a.id = pa.account_id + LEFT JOIN emails e ON e.account_id = pa.account_id + LEFT JOIN model_breakdown mb ON mb.account_id = pa.account_id + ORDER BY pa.total_cents DESC + LIMIT p_limit + ) ranked; +$$; + +GRANT EXECUTE ON FUNCTION public.get_credit_spend_digest(timestamptz, integer) + TO authenticated, service_role; From 95e125dd526fc79ecef0d2553c05189650674e3b Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 2 Jun 2026 22:58:19 +0530 Subject: [PATCH 2/3] fix(database): restrict credit-spend digest RPC to service_role + clamp limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: the function exposed cross-account spend + email data — SECURITY DEFINER plus the implicit PUBLIC grant and an authenticated grant let any logged-in user read it via PostgREST. Drop SECURITY DEFINER (the service_role cron caller bypasses RLS anyway), revoke PUBLIC, and grant execute to service_role only. Also clamp p_limit (coalesce NULL -> 10, bound to [1, 1000]) so an unbounded/NULL/negative limit can't return the whole table or error. Co-Authored-By: Claude Opus 4.8 --- .../migrations/20260602000000_credit_spend_digest.sql | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/supabase/migrations/20260602000000_credit_spend_digest.sql b/supabase/migrations/20260602000000_credit_spend_digest.sql index 39e477d..1c0a1d4 100644 --- a/supabase/migrations/20260602000000_credit_spend_digest.sql +++ b/supabase/migrations/20260602000000_credit_spend_digest.sql @@ -33,7 +33,6 @@ CREATE OR REPLACE FUNCTION public.get_credit_spend_digest( ) RETURNS jsonb LANGUAGE sql STABLE - SECURITY DEFINER SET search_path = public, pg_temp AS $$ WITH windowed AS ( @@ -93,9 +92,14 @@ AS $$ LEFT JOIN emails e ON e.account_id = pa.account_id LEFT JOIN model_breakdown mb ON mb.account_id = pa.account_id ORDER BY pa.total_cents DESC - LIMIT p_limit + LIMIT greatest(least(coalesce(p_limit, 10), 1000), 1) ) ranked; $$; +-- Trusted backend only: the api cron calls this with the service_role key. +-- Cross-account spend + email data must never be reachable by end users, so +-- revoke the implicit PUBLIC grant and do not grant to `authenticated`. +REVOKE EXECUTE ON FUNCTION public.get_credit_spend_digest(timestamptz, integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION public.get_credit_spend_digest(timestamptz, integer) - TO authenticated, service_role; + TO service_role; From ed8dafc7b0bfa9626a4586bd7efc755719913023 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 2 Jun 2026 23:10:41 +0530 Subject: [PATCH 3/3] docs(database): trim verbose header comment on credit-spend digest RPC Co-Authored-By: Claude Opus 4.8 --- .../20260602000000_credit_spend_digest.sql | 35 ++++--------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/supabase/migrations/20260602000000_credit_spend_digest.sql b/supabase/migrations/20260602000000_credit_spend_digest.sql index 1c0a1d4..7a7f7f9 100644 --- a/supabase/migrations/20260602000000_credit_spend_digest.sql +++ b/supabase/migrations/20260602000000_credit_spend_digest.sql @@ -1,31 +1,10 @@ --- Credit-spend visibility digest aggregation. --- --- Powers a 10-minute Vercel Cron in the api (`/api/internal/credit-spend-digest`) --- that posts a top-spenders summary to Telegram. Aggregation lives in the --- database (one round trip) rather than pulling rows into JS: the function --- buckets `usage_events` over a time window and returns the top N accounts by --- total credits spent, each enriched with how that spend breaks down. --- --- Args: --- p_since lower bound on usage_events.created_at (inclusive). The caller --- passes `now() - interval '10 minutes'`; the window is stateless --- (minor boundary drift is accepted). --- p_limit max number of accounts to return, ranked by total spend desc. --- --- Returns a jsonb array (ranked, highest spend first). Each element: --- { --- account_id, account_name, account_email, --- total_cents, -- total credits spent in window --- turn_count, -- number of usage_events rows --- input_tokens, output_tokens, cached_input_tokens, --- tool_calls, --- main_cents, subagent_cents, -- main vs subagent split (agent_type) --- by_model -- { "": , ... } --- } --- Empty window -> '[]'. The caller treats that as a no-op (no Telegram ping). --- --- account_email is the most-recent email per account (account_emails ordered --- by updated_at desc). model_id NULL is bucketed under 'unknown' in by_model. +-- Top-spenders aggregation for the credit-spend digest cron. +-- Returns a ranked jsonb array (highest spend first) of the top p_limit +-- accounts by total credits over [p_since, now]; '[]' when empty. Each element: +-- { account_id, account_name, account_email, total_cents, turn_count, +-- input_tokens, output_tokens, cached_input_tokens, tool_calls, +-- main_cents, subagent_cents, by_model: {"": } }. +-- account_email = most-recent per account; NULL model_id bucketed as 'unknown'. CREATE OR REPLACE FUNCTION public.get_credit_spend_digest( p_since timestamptz,