From 23a365309b99192cd38f8b72d244cdef93806c27 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 24 May 2026 09:38:34 +0530 Subject: [PATCH] feat: admin orphaned family management + user journey analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Orphaned families (0 members) now visible with amber badge in /admin/families with filter tab and explicit "Delete Family + Data" cascade delete button - Delete confirmation shows exact counts: children, logs, memories - Delete child button added to /admin/children with count confirmation - New DELETE /api/admin/families/[id] โ€” full cascade delete (children, feeds, diapers_logs, sleeps, vaccinations, growth, memories, chat, etc.) - New GET/DELETE /api/admin/children/[id] โ€” cascade child delete with counts - Extended families GET to include logCount + memoryCount per family - New /api/admin/engagement โ€” feature adoption %, per-family engagement table, AI usage stats (30d), daily activity chart using correct table names - /admin/analytics fully redesigned: adoption funnel bars, per-family engagement table (sortable, filterable by activity), AI cost tab with INR breakdown - Fixes wrong table names in old analytics (activity_logs, growth_records โ†’ real tables) Co-Authored-By: Claude Sonnet 4.6 --- src/app/admin/analytics/page.tsx | 370 +++++++++++++++++------ src/app/admin/children/page.tsx | 79 ++++- src/app/admin/families/page.tsx | 81 ++++- src/app/api/admin/children/[id]/route.ts | 71 +++++ src/app/api/admin/engagement/route.ts | 162 ++++++++++ src/app/api/admin/families/[id]/route.ts | 63 ++++ src/app/api/admin/families/route.ts | 10 +- 7 files changed, 721 insertions(+), 115 deletions(-) create mode 100644 src/app/api/admin/children/[id]/route.ts create mode 100644 src/app/api/admin/engagement/route.ts create mode 100644 src/app/api/admin/families/[id]/route.ts diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index c5b83b5..3b83fc1 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -2,133 +2,313 @@ import { useEffect, useState } from "react"; -interface EngagementStats { - totalLogs: number; - totalMedicines: number; - totalVaccinations: number; - totalGrowth: number; - totalMemories: number; - totalChatSessions: number; - familyCount: number; - logsByDay: { date: string; count: number }[]; - topFeatures: { name: string; count: number }[]; +interface FeatureAdoption { + name: string; + count: number; + pct: number; } +interface FamilyEngagement { + id: string; + name: string; + tier: string; + createdAt: string | null; + lastActivity: string | null; + activeStatus: "7d" | "30d" | "inactive" | "never"; + feedCount: number; + diaperCount: number; + sleepCount: number; + vaccinationCount: number; + growthCount: number; + memoryCount: number; + chatCount: number; + totalLogs: number; +} + +interface EngagementData { + totalFamilies: number; + featureAdoption: FeatureAdoption[]; + families: FamilyEngagement[]; + activitySummary: { active7d: number; active30d: number; neverActive: number; total: number }; + aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number }; + logsByDay: { date: string; count: number }[]; +} + +type Tab = "overview" | "families" | "ai"; +type SortField = "lastActivity" | "totalLogs" | "name"; +type ActivityFilter = "all" | "7d" | "30d" | "inactive" | "never"; + export default function AdminAnalytics() { - const [stats, setStats] = useState(null); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + const [tab, setTab] = useState("overview"); + const [sortField, setSortField] = useState("lastActivity"); + const [sortAsc, setSortAsc] = useState(false); + const [activityFilter, setActivityFilter] = useState("all"); useEffect(() => { - fetchAnalytics(); + fetch("/api/admin/engagement", { credentials: "include" }) + .then(r => r.json()) + .then(d => { setData(d); setLoading(false); }) + .catch(() => setLoading(false)); }, []); - const fetchAnalytics = async () => { - try { - const res = await fetch("/api/admin/analytics", { credentials: "include" }); - const data = await res.json(); - setStats(data); - } catch (err) { - console.error("Failed to fetch analytics:", err); - } - setLoading(false); + if (loading) return
Loading...
; + if (!data) return
Failed to load analytics
; + + const handleSort = (field: SortField) => { + if (sortField === field) setSortAsc(a => !a); + else { setSortField(field); setSortAsc(false); } }; - if (loading || !stats) { - return
Loading...
; - } + const sortedFamilies = [...data.families] + .filter(f => activityFilter === "all" || f.activeStatus === activityFilter) + .sort((a, b) => { + let diff = 0; + if (sortField === "lastActivity") { + const ta = a.lastActivity ? new Date(a.lastActivity).getTime() : 0; + const tb = b.lastActivity ? new Date(b.lastActivity).getTime() : 0; + diff = tb - ta; + } else if (sortField === "totalLogs") { + diff = b.totalLogs - a.totalLogs; + } else { + diff = a.name.localeCompare(b.name); + } + return sortAsc ? -diff : diff; + }); + + const maxLog = Math.max(...data.logsByDay.map(d => d.count), 1); + const { aiUsage, activitySummary } = data; + const costINR = (aiUsage.totalCostPaise / 100).toFixed(2); + const costPerFamily = aiUsage.familiesUsingAI > 0 + ? (aiUsage.totalCostPaise / 100 / aiUsage.familiesUsingAI).toFixed(2) + : "0.00"; return (
-
-

Analytics

-

Feature usage and engagement

-
- - {/* Usage Overview */} -
- - - - - - -
- - {/* Activity Chart */} -
-

Daily Activity (Last 30 days)

-
- {stats.logsByDay.slice(-30).map((d, i) => ( -
-
l.count), 1)) * 100, 100)}%`, - minHeight: d.count > 0 ? "4px" : "0" - }} - /> -
{d.date?.slice(5) || ""}
-
+
+
+

Analytics

+

User journey and feature engagement

+
+
+ {(["overview", "families", "ai"] as Tab[]).map(t => ( + ))}
- {stats.logsByDay.length === 0 && ( -
- No activity data yet -
- )}
- {/* Feature Breakdown */} -
-
-

Top Features

-
- {stats.topFeatures.map((feature, i) => ( -
- {feature.name} - {feature.count} -
+ {/* Activity Summary Cards โ€” always visible */} +
+ + + + +
+ + {tab === "overview" && ( + <> + {/* Feature Adoption Funnel */} +
+

Feature Adoption

+
+ {data.featureAdoption.map(f => ( +
+
{f.name}
+
+
+
+
+ {f.pct}% + ({f.count}) +
+
+ ))} + {data.featureAdoption.length === 0 && ( +
No data yet
+ )} +
+
+ + {/* Daily Activity Chart */} +
+

Daily Activity โ€” Last 30 Days

+
+ {data.logsByDay.slice(-30).map((d, i) => ( +
+
0 ? "4px" : "0" + }} + /> +
{d.date?.slice(5) || ""}
+
+ ))} + {data.logsByDay.length === 0 && ( +
No activity in the last 30 days
+ )} +
+
+ + )} + + {tab === "families" && ( +
+
+ {(["all", "7d", "30d", "inactive", "never"] as ActivityFilter[]).map(f => ( + ))} - {stats.topFeatures.length === 0 && ( -
No data yet
+ {sortedFamilies.length} families +
+ +
+ + + + + + + + + + + + + {sortedFamilies.map(f => ( + + + + + + + + + ))} + +
FamilyTierFeatures UsedAI Chats
+
{f.name}
+
{f.createdAt?.slice(0, 10)}
+
+ + {f.tier} + + + {f.lastActivity ? ( + + {f.lastActivity.slice(0, 10)} + + ) : ( + Never + )} + {f.totalLogs} +
+ {f.feedCount > 0 && } + {f.sleepCount > 0 && } + {f.diaperCount > 0 && } + {f.vaccinationCount > 0 && } + {f.growthCount > 0 && } + {f.memoryCount > 0 && } + {f.chatCount > 0 && } + {f.totalLogs === 0 && f.memoryCount === 0 && f.chatCount === 0 && ( + None + )} +
+
{f.chatCount || "โ€”"}
+ {sortedFamilies.length === 0 && ( +
No families match this filter
)}
+ )} -
-

Engagement Summary

-
-
- Avg Logs per Family + {tab === "ai" && ( +
+
+ + + + +
+ +
+

AI Cost Breakdown

+
+ Total cost (30 days) + โ‚น{costINR} +
+
+ Avg cost per AI-using family + โ‚น{costPerFamily} +
+
+ Total tokens used + {aiUsage.totalTokens.toLocaleString()} +
+
+ Families using AI - {stats.totalLogs > 0 && stats.familyCount > 0 - ? (stats.totalLogs / stats.familyCount).toFixed(1) - : "0"} - -
-
- Avg Children per Family - 1.0 -
-
- Chat Adoption - - {stats.totalChatSessions > 0 ? "Active" : "None"} + {aiUsage.familiesUsingAI} / {data.totalFamilies} + + ({data.totalFamilies > 0 ? Math.round(aiUsage.familiesUsingAI / data.totalFamilies * 100) : 0}%) +
-
+ )}
); } -function StatCard({ label, value, icon }: { label: string; value: number; icon: string }) { +function SummaryCard({ label, value, color, sub }: { label: string; value: string | number; color: string; sub?: string }) { return (
-
{icon}
-
{value}
-
{label}
+
{value}
+
{label}
+ {sub &&
{sub}
}
); -} \ No newline at end of file +} + +function FeatureBadge({ emoji, label }: { emoji: string; label: string }) { + return ( + + {emoji} + + ); +} + +function SortHeader({ label, field, current, asc, onSort }: { + label: string; field: SortField; current: SortField; asc: boolean; onSort: (f: SortField) => void; +}) { + return ( + onSort(field)} + > + {label} {current === field ? (asc ? "โ†‘" : "โ†“") : "โ†•"} + + ); +} diff --git a/src/app/admin/children/page.tsx b/src/app/admin/children/page.tsx index e727eb9..2de9b89 100644 --- a/src/app/admin/children/page.tsx +++ b/src/app/admin/children/page.tsx @@ -16,11 +16,19 @@ export default function AdminChildren() { const [children, setChildren] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); + const [deletingId, setDeletingId] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); useEffect(() => { fetchChildren(); }, []); + const showMessage = (msg: string, type: "success" | "error") => { + if (type === "success") { setSuccess(msg); setTimeout(() => setSuccess(null), 3000); } + else { setError(msg); setTimeout(() => setError(null), 5000); } + }; + const fetchChildren = async () => { try { const res = await fetch("/api/admin/children", { credentials: "include" }); @@ -32,9 +40,48 @@ export default function AdminChildren() { setLoading(false); }; + const handleDeleteChild = async (child: Child) => { + // Fetch counts for the confirmation message + let confirmMsg = `Delete ${child.name}? This will permanently delete all their logs, growth records, vaccinations, and associated data. This cannot be undone.`; + try { + const res = await fetch(`/api/admin/children/${child.id}`, { credentials: "include" }); + if (res.ok) { + const counts = await res.json(); + const total = + Number(counts.feed_count || 0) + + Number(counts.diaper_count || 0) + + Number(counts.sleep_count || 0); + const parts: string[] = []; + if (total > 0) parts.push(`${total} activity logs`); + if (Number(counts.growth_count || 0) > 0) parts.push(`${counts.growth_count} growth records`); + if (Number(counts.vaccination_count || 0) > 0) parts.push(`${counts.vaccination_count} vaccination records`); + if (parts.length > 0) { + confirmMsg = `Delete ${child.name}? This will permanently delete ${parts.join(", ")}, and all associated data. This cannot be undone.`; + } + } + } catch {} + + if (!window.confirm(confirmMsg)) return; + + setDeletingId(child.id); + try { + const res = await fetch(`/api/admin/children/${child.id}`, { + method: "DELETE", + credentials: "include", + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Delete failed"); + showMessage(`${child.name} and all data deleted`, "success"); + setChildren(prev => prev.filter(c => c.id !== child.id)); + } catch (err: any) { + showMessage(err.message, "error"); + } + setDeletingId(null); + }; + const filteredChildren = children.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()) || - c.familyName.toLowerCase().includes(search.toLowerCase()) + (c.familyName || "").toLowerCase().includes(search.toLowerCase()) ); const exportCSV = () => { @@ -49,12 +96,22 @@ export default function AdminChildren() { a.click(); }; - if (loading) { - return
Loading...
; - } + if (loading) return
Loading...
; return (
+ {error && ( +
+ โš  {error} + +
+ )} + {success && ( +
+ โœ“ {success} +
+ )} +

Children

@@ -78,6 +135,7 @@ export default function AdminChildren() { Birth Date Age Family + Actions @@ -89,6 +147,17 @@ export default function AdminChildren() { {child.age} {child.familyName} + + + ))} @@ -99,4 +168,4 @@ export default function AdminChildren() {
); -} \ No newline at end of file +} diff --git a/src/app/admin/families/page.tsx b/src/app/admin/families/page.tsx index 29e501c..d6f3801 100644 --- a/src/app/admin/families/page.tsx +++ b/src/app/admin/families/page.tsx @@ -20,6 +20,8 @@ interface Family { createdAt: string; userCount: number; childCount: number; + logCount: number; + memoryCount: number; members: Member[]; } @@ -31,6 +33,7 @@ export default function AdminFamilies() { const [showMembers, setShowMembers] = useState(null); const [tierChanging, setTierChanging] = useState(null); const [removingMemberId, setRemovingMemberId] = useState(null); + const [deletingFamilyId, setDeletingFamilyId] = useState(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); @@ -39,7 +42,7 @@ export default function AdminFamilies() { const [newFamilyName, setNewFamilyName] = useState(""); const [creatingFamily, setCreatingFamily] = useState(false); - // Add member form โ€” separate email/role state to avoid lost-role bug + // Add member form const [memberForms, setMemberForms] = useState>({}); useEffect(() => { @@ -123,6 +126,30 @@ export default function AdminFamilies() { setRemovingMemberId(null); }; + const handleDeleteFamily = async (family: Family) => { + const parts: string[] = []; + if (family.childCount > 0) parts.push(`${family.childCount} ${family.childCount === 1 ? "child" : "children"}`); + if (family.logCount > 0) parts.push(`${family.logCount} activity logs`); + if (family.memoryCount > 0) parts.push(`${family.memoryCount} memories`); + const detail = parts.length > 0 ? ` This will permanently delete ${parts.join(", ")}, and all associated data.` : ""; + if (!window.confirm(`Delete "${family.name}"?${detail} This cannot be undone.`)) return; + + setDeletingFamilyId(family.id); + try { + const res = await fetch(`/api/admin/families/${family.id}`, { + method: "DELETE", + credentials: "include", + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to delete family"); + showMessage(`"${family.name}" and all data deleted`, "success"); + setFamilies(prev => prev.filter(f => f.id !== family.id)); + } catch (err: any) { + showMessage(err.message, "error"); + } + setDeletingFamilyId(null); + }; + const handleTierChange = async (familyId: string, currentTier: string) => { const newTier = currentTier === "pro" ? "free" : "pro"; const label = newTier === "pro" ? "Upgrade to Pro?" : "Downgrade to Free?"; @@ -159,14 +186,16 @@ export default function AdminFamilies() { const filteredFamilies = families.filter((f) => { const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase()); + const isOrphaned = f.userCount === 0; + if (tierFilter === "orphaned") return matchesSearch && isOrphaned; const matchesTier = tierFilter === "all" || f.tier === tierFilter; return matchesSearch && matchesTier; }); const exportCSV = () => { - const headers = ["Name", "Tier", "Users", "Children", "Max Children", "Max Members", "Created"]; + const headers = ["Name", "Tier", "Users", "Children", "Logs", "Max Children", "Max Members", "Created"]; const rows = filteredFamilies.map((f) => [ - f.name, f.tier, f.userCount, f.childCount, f.maxChildren, f.maxMembers, f.createdAt, + f.name, f.tier, f.userCount, f.childCount, f.logCount, f.maxChildren, f.maxMembers, f.createdAt, ]); const csv = [headers, ...rows].map((row) => row.join(",")).join("\n"); const blob = new Blob([csv], { type: "text/csv" }); @@ -181,6 +210,7 @@ export default function AdminFamilies() { const proCount = families.filter(f => f.tier === "pro").length; const freeCount = families.filter(f => f.tier !== "pro").length; + const orphanedCount = families.filter(f => f.userCount === 0).length; return (
@@ -201,6 +231,7 @@ export default function AdminFamilies() {

Families

{families.length} total ยท {proCount} Pro ยท {freeCount} Free + {orphanedCount > 0 && <> ยท {orphanedCount} Orphaned}

@@ -245,6 +276,7 @@ export default function AdminFamilies() { +
@@ -257,6 +289,7 @@ export default function AdminFamilies() { Tier Members Children + Logs Limits Created Actions @@ -265,12 +298,18 @@ export default function AdminFamilies() { {filteredFamilies.map((family) => { const memberForm = memberForms[family.id] || { email: "", role: "caregiver" }; + const isOrphaned = family.userCount === 0; return ( <> - +
{family.name}
{family.id.slice(0, 8)}โ€ฆ
+ {isOrphaned && ( + + No members + + )} {family.tier} @@ -285,6 +324,7 @@ export default function AdminFamilies() { {family.childCount} + {family.logCount} {family.maxChildren} kids ยท {family.maxMembers} members @@ -292,21 +332,34 @@ export default function AdminFamilies() { {family.createdAt?.slice(0, 10)} - +
+ + {isOrphaned && ( + + )} +
{showMembers === family.id && ( - +
Members
diff --git a/src/app/api/admin/children/[id]/route.ts b/src/app/api/admin/children/[id]/route.ts new file mode 100644 index 0000000..1f01d40 --- /dev/null +++ b/src/app/api/admin/children/[id]/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/admin-auth"; +import { sql } from "@/db"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(request); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + try { + const { id: childId } = await params; + const rows = await sql` + SELECT + COUNT(DISTINCT fd.id) as feed_count, + COUNT(DISTINCT dl.id) as diaper_count, + COUNT(DISTINCT sl.id) as sleep_count, + COUNT(DISTINCT v.id) as vaccination_count, + COUNT(DISTINCT g.id) as growth_count + FROM children c + 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 + WHERE c.id = ${childId} + `; + return NextResponse.json(rows[0] || {}); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(request); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + try { + const { id: childId } = await params; + if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 }); + + // Delete chat messages first (FK on session_id) + const sessionRows = await sql`SELECT id FROM chat_sessions WHERE child_id = ${childId}`; + const sessionIds = sessionRows.map((r: any) => r.id); + if (sessionIds.length > 0) { + await sql`DELETE FROM chat_messages WHERE session_id = ANY(${sessionIds})`; + } + await sql`DELETE FROM chat_sessions WHERE child_id = ${childId}`; + await sql`DELETE FROM milestone_achievements WHERE child_id = ${childId}`; + await sql`DELETE FROM illness_logs WHERE child_id = ${childId}`; + await sql`DELETE FROM doctor_visits WHERE child_id = ${childId}`; + await sql`DELETE FROM allergies WHERE child_id = ${childId}`; + await sql`DELETE FROM medicines WHERE child_id = ${childId}`; + await sql`DELETE FROM medications WHERE child_id = ${childId}`; + await sql`DELETE FROM growth WHERE child_id = ${childId}`; + await sql`DELETE FROM vaccinations WHERE child_id = ${childId}`; + await sql`DELETE FROM sleeps WHERE child_id = ${childId}`; + await sql`DELETE FROM diapers_logs WHERE child_id = ${childId}`; + await sql`DELETE FROM feeds WHERE child_id = ${childId}`; + await sql`DELETE FROM children WHERE id = ${childId}`; + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Admin child delete error:", error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/admin/engagement/route.ts b/src/app/api/admin/engagement/route.ts new file mode 100644 index 0000000..b93034b --- /dev/null +++ b/src/app/api/admin/engagement/route.ts @@ -0,0 +1,162 @@ +import { NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/admin-auth"; +import { sql } from "@/db"; + +export async function GET(request: Request) { + const auth = await requireAdmin(request); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + try { + // A. Feature adoption โ€” % of families that have used each feature at least once + const adoptionRows = await sql` + SELECT + COUNT(DISTINCT f.id)::int as total_families, + COUNT(DISTINCT f.id) FILTER (WHERE fd.id IS NOT NULL)::int as families_feeding, + COUNT(DISTINCT f.id) FILTER (WHERE dl.id IS NOT NULL)::int as families_diapers, + COUNT(DISTINCT f.id) FILTER (WHERE sl.id IS NOT NULL)::int as families_sleeping, + COUNT(DISTINCT f.id) FILTER (WHERE v.id IS NOT NULL)::int as families_vaccinating, + COUNT(DISTINCT f.id) FILTER (WHERE g.id IS NOT NULL)::int as families_growth, + COUNT(DISTINCT f.id) FILTER (WHERE mem.id IS NOT NULL)::int as families_memories, + COUNT(DISTINCT f.id) FILTER (WHERE cs.id IS NOT NULL)::int as families_ai + FROM families f + LEFT JOIN children c ON c.family_id = f.id + LEFT JOIN LATERAL (SELECT id FROM feeds WHERE child_id = c.id LIMIT 1) fd ON true + LEFT JOIN LATERAL (SELECT id FROM diapers_logs WHERE child_id = c.id LIMIT 1) dl ON true + LEFT JOIN LATERAL (SELECT id FROM sleeps WHERE child_id = c.id LIMIT 1) sl ON true + LEFT JOIN LATERAL (SELECT id FROM vaccinations WHERE child_id = c.id LIMIT 1) v ON true + LEFT JOIN LATERAL (SELECT id FROM growth WHERE child_id = c.id LIMIT 1) g ON true + LEFT JOIN LATERAL (SELECT id FROM memories WHERE family_id = f.id LIMIT 1) mem ON true + LEFT JOIN LATERAL (SELECT id FROM chat_sessions WHERE child_id = c.id LIMIT 1) cs ON true + `; + + const adoption = adoptionRows[0] || {}; + const total = Number(adoption.total_families) || 1; + + const featureAdoption = [ + { name: "Feed Logs", count: Number(adoption.families_feeding) || 0, pct: Math.round((Number(adoption.families_feeding) || 0) / total * 100) }, + { name: "Diaper Logs", count: Number(adoption.families_diapers) || 0, pct: Math.round((Number(adoption.families_diapers) || 0) / total * 100) }, + { name: "Sleep Logs", count: Number(adoption.families_sleeping) || 0, pct: Math.round((Number(adoption.families_sleeping) || 0) / total * 100) }, + { name: "Vaccinations", count: Number(adoption.families_vaccinating) || 0, pct: Math.round((Number(adoption.families_vaccinating) || 0) / total * 100) }, + { name: "Growth Tracking", count: Number(adoption.families_growth) || 0, pct: Math.round((Number(adoption.families_growth) || 0) / total * 100) }, + { name: "Memories", count: Number(adoption.families_memories) || 0, pct: Math.round((Number(adoption.families_memories) || 0) / total * 100) }, + { 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 + const familyRows = await sql` + 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) + ) 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 + 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 + GROUP BY f.id, f.name, f.tier, f.created_at + ORDER BY last_activity DESC NULLS LAST + `; + + const now = Date.now(); + const families = familyRows.map((f: any) => { + const lastActivity = f.last_activity ? new Date(f.last_activity).toISOString() : null; + const msSince = lastActivity ? now - new Date(lastActivity).getTime() : Infinity; + const activeStatus = !lastActivity ? "never" : msSince < 7 * 86400_000 ? "7d" : msSince < 30 * 86400_000 ? "30d" : "inactive"; + return { + id: f.id, + name: f.name, + tier: f.tier || "free", + createdAt: f.created_at ? new Date(f.created_at).toISOString() : null, + lastActivity, + activeStatus, + feedCount: Number(f.feed_count) || 0, + diaperCount: Number(f.diaper_count) || 0, + sleepCount: Number(f.sleep_count) || 0, + vaccinationCount: Number(f.vaccination_count) || 0, + growthCount: Number(f.growth_count) || 0, + memoryCount: Number(f.memory_count) || 0, + chatCount: Number(f.chat_count) || 0, + totalLogs: + Number(f.feed_count) + Number(f.diaper_count) + Number(f.sleep_count) + + Number(f.vaccination_count) + Number(f.growth_count), + }; + }); + + // C. Activity summary counts + const active7d = families.filter(f => f.activeStatus === "7d").length; + const active30d = families.filter(f => f.activeStatus === "30d").length; + const neverActive = families.filter(f => f.activeStatus === "never").length; + + // D. AI usage last 30 days + let aiUsage = { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 }; + try { + const aiRows = await sql` + SELECT + COUNT(*)::int as total_calls, + COALESCE(SUM(total_tokens), 0)::int as total_tokens, + COALESCE(SUM(cost_estimate_paise), 0)::numeric as total_cost_paise, + COUNT(DISTINCT family_id)::int as families_using_ai + FROM ai_usage + WHERE created_at > NOW() - INTERVAL '30 days' + `; + const row = aiRows[0] || {}; + aiUsage = { + totalCalls: Number(row.total_calls) || 0, + totalTokens: Number(row.total_tokens) || 0, + totalCostPaise: Number(row.total_cost_paise) || 0, + familiesUsingAI: Number(row.families_using_ai) || 0, + }; + } catch {} + + // E. Daily activity for chart (last 30 days, all log types combined) + const dailyRows = await sql` + SELECT day::date as date, SUM(cnt)::int as count + FROM ( + SELECT DATE(logged_at) as day, COUNT(*) as cnt FROM feeds WHERE logged_at > NOW() - INTERVAL '30 days' GROUP BY day + UNION ALL + SELECT DATE(logged_at) as day, COUNT(*) as cnt FROM diapers_logs WHERE logged_at > NOW() - INTERVAL '30 days' GROUP BY day + UNION ALL + SELECT DATE(started_at) as day, COUNT(*) as cnt FROM sleeps WHERE started_at > NOW() - INTERVAL '30 days' GROUP BY day + UNION ALL + SELECT DATE(created_at) as day, COUNT(*) as cnt FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day + ) sub + GROUP BY day + ORDER BY day + `; + + const logsByDay = dailyRows.map((r: any) => ({ + date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0], + count: Number(r.count), + })); + + return NextResponse.json({ + totalFamilies: total, + featureAdoption, + families, + activitySummary: { active7d, active30d, neverActive, total }, + aiUsage, + logsByDay, + }); + } catch (error) { + console.error("Admin engagement error:", error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/admin/families/[id]/route.ts b/src/app/api/admin/families/[id]/route.ts new file mode 100644 index 0000000..cede57a --- /dev/null +++ b/src/app/api/admin/families/[id]/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/admin-auth"; +import { sql } from "@/db"; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(request); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + try { + const { id: familyId } = await params; + if (!familyId) return NextResponse.json({ error: "familyId required" }, { status: 400 }); + + // Get child IDs before deleting + const childRows = await sql`SELECT id FROM children WHERE family_id = ${familyId}`; + const childIds = childRows.map((r: any) => r.id); + + if (childIds.length > 0) { + // Delete child data in FK order + const chatSessionRows = await sql`SELECT id FROM chat_sessions WHERE child_id = ANY(${childIds})`; + const sessionIds = chatSessionRows.map((r: any) => r.id); + if (sessionIds.length > 0) { + await sql`DELETE FROM chat_messages WHERE session_id = ANY(${sessionIds})`; + } + await sql`DELETE FROM chat_sessions WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM milestone_achievements WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM illness_logs WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM doctor_visits WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM allergies WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM medicines WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM medications WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM growth WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM vaccinations WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM sleeps WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM diapers_logs WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM feeds WHERE child_id = ANY(${childIds})`; + await sql`DELETE FROM children WHERE family_id = ${familyId}`; + } + + // Delete family-level data + await sql`DELETE FROM family_invites WHERE family_id = ${familyId}`; + await sql`DELETE FROM medication_doses WHERE family_id = ${familyId}`; + const profileRows = await sql`SELECT id FROM member_profiles WHERE family_id = ${familyId}`; + const profileIds = profileRows.map((r: any) => r.id); + if (profileIds.length > 0) { + await sql`DELETE FROM recommended_products WHERE profile_id = ANY(${profileIds})`; + } + await sql`DELETE FROM member_profiles WHERE family_id = ${familyId}`; + await sql`DELETE FROM memories WHERE family_id = ${familyId}`; + await sql`DELETE FROM attachments WHERE family_id = ${familyId}`; + await sql`UPDATE support_tickets SET family_id = NULL WHERE family_id = ${familyId}`; + await sql`UPDATE audit_log SET family_id = NULL WHERE family_id = ${familyId}`; + await sql`DELETE FROM family_members WHERE family_id = ${familyId}`; + await sql`DELETE FROM families WHERE id = ${familyId}`; + + return NextResponse.json({ success: true, deletedChildren: childIds.length }); + } catch (error) { + console.error("Admin family delete error:", error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/admin/families/route.ts b/src/app/api/admin/families/route.ts index 7bb93f0..4bc0186 100644 --- a/src/app/api/admin/families/route.ts +++ b/src/app/api/admin/families/route.ts @@ -17,10 +17,16 @@ export async function GET(request: Request) { f.max_members, f.created_at, COUNT(DISTINCT fm.user_id) as user_count, - COUNT(DISTINCT c.id) as child_count + COUNT(DISTINCT c.id) as child_count, + COUNT(DISTINCT fd.id) + COUNT(DISTINCT dl.id) + COUNT(DISTINCT sl.id) as log_count, + COUNT(DISTINCT mem.id) as memory_count FROM families f LEFT JOIN family_members fm ON fm.family_id = f.id 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 memories mem ON mem.family_id = f.id GROUP BY f.id ORDER BY f.created_at DESC `; @@ -58,6 +64,8 @@ export async function GET(request: Request) { createdAt: f.created_at ? new Date(f.created_at).toISOString() : null, userCount: Number(f.user_count) || 0, childCount: Number(f.child_count) || 0, + logCount: Number(f.log_count) || 0, + memoryCount: Number(f.memory_count) || 0, members: memberMap.get(f.id) || [], })), });