From 94d9b234f8069e1c9e4451cfa46ba4ef36ec6c49 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 30 May 2026 00:03:48 +0530 Subject: [PATCH] =?UTF-8?q?Fix=20admin=20analytics=20crash=20=E2=80=94=20c?= =?UTF-8?q?artesian=20join=20+=20undefended=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /admin/analytics page calls /api/admin/engagement, whose per-family query LEFT JOINed feeds × diapers_logs × sleeps × vaccinations × growth × chat_sessions before GROUP BY — a cartesian explosion that times out for any family with real activity, 500ing the route. The page then ran [...data.families] on the error object and white-screened. - Pre-aggregate each log table to one row per child (CTEs) before joining, eliminating the row explosion; memories aggregated per family. - Route error fallback now returns the full shape (safe empties) not {error}. - Page normalizes the response into the full EngagementData shape so a bad response renders an empty state instead of crashing. Co-Authored-By: Claude Opus 4.8 --- src/app/admin/analytics/page.tsx | 14 +++++- src/app/api/admin/engagement/route.ts | 66 ++++++++++++++++++--------- 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index 3b83fc1..92e413a 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -49,7 +49,19 @@ export default function AdminAnalytics() { useEffect(() => { fetch("/api/admin/engagement", { credentials: "include" }) .then(r => r.json()) - .then(d => { setData(d); setLoading(false); }) + .then(d => { + // Normalize into the full EngagementData shape so a malformed/error + // response can never crash the render (e.g. [...data.families]). + setData({ + totalFamilies: Number(d?.totalFamilies) || 0, + featureAdoption: Array.isArray(d?.featureAdoption) ? d.featureAdoption : [], + families: Array.isArray(d?.families) ? d.families : [], + activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0, ...(d?.activitySummary || {}) }, + aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, ...(d?.aiUsage || {}) }, + logsByDay: Array.isArray(d?.logsByDay) ? d.logsByDay : [], + }); + setLoading(false); + }) .catch(() => setLoading(false)); }, []); diff --git a/src/app/api/admin/engagement/route.ts b/src/app/api/admin/engagement/route.ts index b93034b..b24566e 100644 --- a/src/app/api/admin/engagement/route.ts +++ b/src/app/api/admin/engagement/route.ts @@ -42,35 +42,49 @@ export async function GET(request: Request) { { name: "AI Chat", count: Number(adoption.families_ai) || 0, pct: Math.round((Number(adoption.families_ai) || 0) / total * 100) }, ].sort((a, b) => b.pct - a.pct); - // B. Per-family engagement table + // B. Per-family engagement table. + // + // IMPORTANT: do NOT LEFT JOIN all the raw log tables directly — joining + // feeds × diapers_logs × sleeps × vaccinations × growth × chat_sessions in + // one query produces a cartesian product (millions of intermediate rows per + // active family) that times out / OOMs in production and 500s this route. + // Instead pre-aggregate each table to one row per child, then join those + // small aggregates. Memories are per-family, so aggregate by family_id. const familyRows = await sql` + WITH feed_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM feeds GROUP BY child_id), + diaper_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM diapers_logs GROUP BY child_id), + sleep_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(started_at) AS last_at FROM sleeps GROUP BY child_id), + vacc_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM vaccinations GROUP BY child_id), + growth_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM growth GROUP BY child_id), + chat_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM chat_sessions GROUP BY child_id), + mem_agg AS (SELECT family_id, COUNT(*)::int AS cnt, MAX(created_at) AS last_at FROM memories GROUP BY family_id) SELECT f.id, f.name, f.tier, f.created_at, GREATEST( - MAX(fd.logged_at), - MAX(dl.logged_at), - MAX(sl.started_at), - MAX(mem.created_at) + MAX(fa.last_at), + MAX(da.last_at), + MAX(sa.last_at), + MAX(ma.last_at) ) as last_activity, - COUNT(DISTINCT fd.id)::int as feed_count, - COUNT(DISTINCT dl.id)::int as diaper_count, - COUNT(DISTINCT sl.id)::int as sleep_count, - COUNT(DISTINCT v.id)::int as vaccination_count, - COUNT(DISTINCT g.id)::int as growth_count, - COUNT(DISTINCT mem.id)::int as memory_count, - COUNT(DISTINCT cs.id)::int as chat_count + COALESCE(SUM(fa.cnt), 0)::int as feed_count, + COALESCE(SUM(da.cnt), 0)::int as diaper_count, + COALESCE(SUM(sa.cnt), 0)::int as sleep_count, + COALESCE(SUM(va.cnt), 0)::int as vaccination_count, + COALESCE(SUM(ga.cnt), 0)::int as growth_count, + COALESCE(MAX(ma.cnt), 0)::int as memory_count, + COALESCE(SUM(ca.cnt), 0)::int as chat_count FROM families f - LEFT JOIN children c ON c.family_id = f.id - LEFT JOIN feeds fd ON fd.child_id = c.id - LEFT JOIN diapers_logs dl ON dl.child_id = c.id - LEFT JOIN sleeps sl ON sl.child_id = c.id - LEFT JOIN vaccinations v ON v.child_id = c.id - LEFT JOIN growth g ON g.child_id = c.id - LEFT JOIN memories mem ON mem.family_id = f.id - LEFT JOIN chat_sessions cs ON cs.child_id = c.id + LEFT JOIN children c ON c.family_id = f.id + LEFT JOIN feed_agg fa ON fa.child_id = c.id + LEFT JOIN diaper_agg da ON da.child_id = c.id + LEFT JOIN sleep_agg sa ON sa.child_id = c.id + LEFT JOIN vacc_agg va ON va.child_id = c.id + LEFT JOIN growth_agg ga ON ga.child_id = c.id + LEFT JOIN chat_agg ca ON ca.child_id = c.id + LEFT JOIN mem_agg ma ON ma.family_id = f.id GROUP BY f.id, f.name, f.tier, f.created_at ORDER BY last_activity DESC NULLS LAST `; @@ -157,6 +171,16 @@ export async function GET(request: Request) { }); } catch (error) { console.error("Admin engagement error:", error); - return NextResponse.json({ error: String(error) }, { status: 500 }); + // Return the full shape (with safe empties) instead of a bare {error} so the + // analytics page renders an empty state rather than crashing on data.families. + return NextResponse.json({ + totalFamilies: 0, + featureAdoption: [], + families: [], + activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0 }, + aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 }, + logsByDay: [], + error: String(error), + }); } }