Fix admin analytics crash — cartesian join + undefended render
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 <noreply@anthropic.com>
This commit is contained in:
parent
e53c51f044
commit
94d9b234f8
2 changed files with 58 additions and 22 deletions
|
|
@ -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));
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue