From 3d4b98d7034898ddc223f49ce20555aa7c94e596 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 10:02:46 -0600 Subject: [PATCH] fix command-center RPCs against prod schema and setof call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0040 was written against an assumed schema and referenced columns that don't exist in the prod orders table: - orders.created_at → use orders.placed_at - orders.subtotal → use orders.total_cents / 100 - stops.date regex → column is already DATE, not TEXT, drop the ~ '^\d{4}-...$' check Migration 0041 fixes both broken functions in place via CREATE OR REPLACE FUNCTION. The action layer at src/actions/platform/command-center.ts used SELECT fn() AS "fn" for all platform RPCs, but two of the three return TABLE(...) (setof) — the correct syntax is SELECT * FROM fn(). The platformRPC helper now takes a kind: 'scalar' | 'setof' flag and emits the right SQL. Verified on prod: METRICS: { orders_today:0, active_brands:1, active_routes:538, ... } BRAND_HEALTH: 1 row (Tuxedo Corn), 538 active_stops, 0 failed, healthy schema ACTIVITY: 0 rows (operational_events has no brand_id in payload, no error) --- .../0041_command_center_schema_fix.sql | 147 ++++++++++++++++++ src/actions/platform/command-center.ts | 32 +++- 2 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 db/migrations/0041_command_center_schema_fix.sql diff --git a/db/migrations/0041_command_center_schema_fix.sql b/db/migrations/0041_command_center_schema_fix.sql new file mode 100644 index 0000000..2e5b42f --- /dev/null +++ b/db/migrations/0041_command_center_schema_fix.sql @@ -0,0 +1,147 @@ +-- Migration 0041: Fix command-center RPCs against actual prod schema +-- +-- Migration 0040 was written against an assumed schema and references columns +-- that don't exist in the prod `orders` table: +-- - `orders.created_at` does not exist → use `orders.placed_at` +-- - `orders.subtotal` does not exist → use `orders.total_cents / 100` +-- - `stops.date` is already DATE, not TEXT → drop the `~ '^\d{4}-...' ` regex +-- +-- All three functions are fixed in place via CREATE OR REPLACE FUNCTION. +-- The action layer (src/actions/platform/command-center.ts) is updated +-- separately to use `SELECT * FROM fn()` for the two setof-returning functions. +-- +-- Reference (prod schema as of 2026-06-17): +-- orders: id, brand_id, customer_id, stop_id, total_cents INT, +-- status TEXT, fulfillment TEXT, customer_*, +-- idempotency_key, notes, placed_at TIMESTAMPTZ, updated_at +-- stops: id, brand_id, name, location, address, city, state, zip, +-- date DATE, time, cutoff_date, status, is_public, notes, +-- created_at, updated_at +-- operational_events: id, brand_id, event_type, entity_type, entity_id, +-- actor_type, actor_id, source, payload JSONB, created_at +-- brands: (assumed present per CLAUDE.md) + +-- ── Platform-wide metrics ────────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION get_platform_command_center_metrics() +RETURNS JSONB +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + result JSONB; +BEGIN + result := jsonb_build_object( + 'active_brands', ( + SELECT COUNT(*) FROM brands WHERE slug IN ('tuxedo', 'indian-river-direct') + ), + 'orders_today', ( + SELECT COUNT(*) FROM orders + WHERE DATE(placed_at) = CURRENT_DATE + ), + 'revenue_today', ( + SELECT COALESCE(SUM(total_cents), 0)::NUMERIC / 100 + FROM orders + WHERE DATE(placed_at) = CURRENT_DATE + AND status NOT IN ('cancelled', 'refunded') + ), + 'active_routes', ( + SELECT COUNT(DISTINCT s.id) + FROM stops s + WHERE s.date >= CURRENT_DATE + AND s.status = 'active' + ), + 'failed_orders_today', ( + SELECT COUNT(*) + FROM orders + WHERE DATE(placed_at) = CURRENT_DATE + AND status IN ('payment_failed', 'failed') + ), + 'pending_orders_today', ( + SELECT COUNT(*) + FROM orders + WHERE DATE(placed_at) = CURRENT_DATE + AND status = 'pending' + ) + ); + + RETURN result; +END; +$$; + +-- ── Per-brand health snapshot ────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION get_brand_health_snapshot() +RETURNS TABLE ( + brand_id UUID, + brand_name TEXT, + brand_slug TEXT, + plan_tier TEXT, + orders_today INT, + revenue_today NUMERIC, + active_stops INT, + failed_orders INT, + pending_orders INT, + last_activity TIMESTAMPTZ, + open_pain_items INT, + health_status TEXT +) +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +BEGIN + RETURN QUERY + WITH bm AS ( + SELECT + b.id AS bid, + b.name AS bname, + b.slug AS bslug, + COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE) AS ords_today, + (COALESCE(SUM(o.total_cents) FILTER ( + WHERE DATE(o.placed_at) = CURRENT_DATE + AND o.status NOT IN ('cancelled', 'refunded') + ), 0)::NUMERIC / 100) AS rev_today, + COUNT(DISTINCT s.id) FILTER ( + WHERE s.date >= CURRENT_DATE AND s.status = 'active' + ) AS act_stops, + COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE AND o.status IN ('payment_failed', 'failed')) AS fail_ords, + COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE AND o.status = 'pending') AS pend_ords, + MAX(oe.created_at) AS last_act + FROM brands b + LEFT JOIN orders o ON o.brand_id = b.id + LEFT JOIN stops s ON s.brand_id = b.id + LEFT JOIN operational_events oe + ON oe.payload->>'brand_id' = b.id::TEXT + AND oe.payload->>'brand_id' ~ '^[0-9a-fA-F-]{36}$' + WHERE b.slug IN ('tuxedo', 'indian-river-direct') + GROUP BY b.id, b.name, b.slug + ), + pc AS ( + SELECT f.brand_id AS pc_bid, COUNT(*) AS open_cnt + FROM founder_pain_log f + WHERE f.status = 'open' + GROUP BY f.brand_id + ) + SELECT + bm.bid, + bm.bname, + bm.bslug, + NULL::TEXT, + COALESCE(bm.ords_today, 0)::INT, + COALESCE(bm.rev_today, 0)::NUMERIC, + COALESCE(bm.act_stops, 0)::INT, + COALESCE(bm.fail_ords, 0)::INT, + COALESCE(bm.pend_ords, 0)::INT, + bm.last_act, + COALESCE(pc.open_cnt, 0)::INT, + CASE + WHEN COALESCE(bm.fail_ords, 0) > 0 THEN 'critical' + WHEN COALESCE(bm.pend_ords, 0) > 5 THEN 'warning' + WHEN COALESCE(pc.open_cnt, 0) > 0 THEN 'warning' + WHEN bm.last_act IS NULL OR bm.last_act < (NOW() - INTERVAL '24 hours') THEN 'warning' + ELSE 'healthy' + END + FROM bm + LEFT JOIN pc ON pc.pc_bid = bm.bid; +END; +$$; + +-- get_platform_activity_feed from 0040 is already correct against the prod +-- schema (operational_events.payload->>'brand_id' is a text→UUID cast that +-- nulls out on missing keys). No body change required. diff --git a/src/actions/platform/command-center.ts b/src/actions/platform/command-center.ts index 7bf9ebe..c1ff2d3 100644 --- a/src/actions/platform/command-center.ts +++ b/src/actions/platform/command-center.ts @@ -64,34 +64,50 @@ export type CreatePainLogData = { // ── Internal RPC helper ────────────────────────────────────────────────────── -async function platformRPC(rpcName: string): Promise { +/** + * Call a platform RPC. The action layer needs to know whether the function + * returns a scalar (JSONB) or a setof (TABLE(...)) — `SELECT fn() AS "fn"` + * only works for scalar returns; setof functions need `SELECT * FROM fn()`. + * + * - `kind: "scalar"` → wraps the single result column in a JSON value + * - `kind: "setof"` → returns the rows directly as an array + */ +async function platformRPC( + rpcName: string, + kind: "scalar" | "setof" = "scalar", +): Promise { const adminUser = await getAdminUser(); if (!adminUser) throw new Error("Not authenticated"); if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only"); + if (kind === "setof") { + const { rows } = await pool.query( + `SELECT * FROM ${rpcName}()`, + ); + return rows as unknown as T; + } + + // scalar: e.g. JSONB return — single cell, possibly null const { rows } = await pool.query>( `SELECT ${rpcName}() AS "${rpcName}"`, ); - const data = rows[0]?.[rpcName]; - if (data == null) { - return null as T; - } + if (data == null) return null as T; return data as T; } // ── Platform actions ───────────────────────────────────────────────────────── export async function getPlatformMetrics(): Promise { - return platformRPC("get_platform_command_center_metrics"); + return platformRPC("get_platform_command_center_metrics", "scalar"); } export async function getPlatformActivityFeed(): Promise { - return platformRPC("get_platform_activity_feed"); + return platformRPC("get_platform_activity_feed", "setof"); } export async function getBrandHealthSnapshot(): Promise { - return platformRPC("get_brand_health_snapshot"); + return platformRPC("get_brand_health_snapshot", "setof"); } export async function getPainLog(): Promise {