feat: admin orphaned family management + user journey analytics

- 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 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 09:38:34 +05:30
parent c2695435f2
commit 23a365309b
7 changed files with 721 additions and 115 deletions

View file

@ -2,133 +2,313 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface EngagementStats { interface FeatureAdoption {
totalLogs: number; name: string;
totalMedicines: number; count: number;
totalVaccinations: number; pct: number;
totalGrowth: number;
totalMemories: number;
totalChatSessions: number;
familyCount: number;
logsByDay: { date: string; count: number }[];
topFeatures: { name: string; count: 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() { export default function AdminAnalytics() {
const [stats, setStats] = useState<EngagementStats | null>(null); const [data, setData] = useState<EngagementData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>("overview");
const [sortField, setSortField] = useState<SortField>("lastActivity");
const [sortAsc, setSortAsc] = useState(false);
const [activityFilter, setActivityFilter] = useState<ActivityFilter>("all");
useEffect(() => { useEffect(() => {
fetchAnalytics(); fetch("/api/admin/engagement", { credentials: "include" })
.then(r => r.json())
.then(d => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, []); }, []);
const fetchAnalytics = async () => { if (loading) return <div className="p-6 text-white">Loading...</div>;
try { if (!data) return <div className="p-6 text-red-400">Failed to load analytics</div>;
const res = await fetch("/api/admin/analytics", { credentials: "include" });
const data = await res.json(); const handleSort = (field: SortField) => {
setStats(data); if (sortField === field) setSortAsc(a => !a);
} catch (err) { else { setSortField(field); setSortAsc(false); }
console.error("Failed to fetch analytics:", err);
}
setLoading(false);
}; };
if (loading || !stats) { const sortedFamilies = [...data.families]
return <div className="p-6 text-white">Loading...</div>; .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 ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div> <div className="flex justify-between items-start">
<h1 className="text-2xl font-bold">Analytics</h1> <div>
<p className="text-gray-400">Feature usage and engagement</p> <h1 className="text-2xl font-bold">Analytics</h1>
</div> <p className="text-gray-400">User journey and feature engagement</p>
</div>
{/* Usage Overview */} <div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> {(["overview", "families", "ai"] as Tab[]).map(t => (
<StatCard label="Activity Logs" value={stats.totalLogs} icon="📝" /> <button
<StatCard label="Medicines" value={stats.totalMedicines} icon="💊" /> key={t}
<StatCard label="Vaccinations" value={stats.totalVaccinations} icon="💉" /> onClick={() => setTab(t)}
<StatCard label="Growth" value={stats.totalGrowth} icon="📏" /> className={`px-3 py-1.5 rounded-md text-sm font-medium capitalize transition-colors ${tab === t ? "bg-gray-600 text-white" : "text-gray-400 hover:text-white"}`}
<StatCard label="Memories" value={stats.totalMemories} icon="📸" /> >
<StatCard label="Chat Sessions" value={stats.totalChatSessions} icon="💬" /> {t === "ai" ? "AI Usage" : t}
</div> </button>
{/* Activity Chart */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Daily Activity (Last 30 days)</h3>
<div className="h-48 flex items-end gap-1">
{stats.logsByDay.slice(-30).map((d, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-rose-500 rounded-t"
style={{
height: `${Math.min((d.count / Math.max(...stats.logsByDay.map(l => l.count), 1)) * 100, 100)}%`,
minHeight: d.count > 0 ? "4px" : "0"
}}
/>
<div className="text-[8px] text-gray-500">{d.date?.slice(5) || ""}</div>
</div>
))} ))}
</div> </div>
{stats.logsByDay.length === 0 && (
<div className="h-48 flex items-center justify-center text-gray-500">
No activity data yet
</div>
)}
</div> </div>
{/* Feature Breakdown */} {/* Activity Summary Cards — always visible */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-800 p-6 rounded-xl"> <SummaryCard label="Active 7d" value={activitySummary.active7d} color="text-emerald-400" sub={`of ${activitySummary.total}`} />
<h3 className="text-lg font-semibold mb-4">Top Features</h3> <SummaryCard label="Active 30d" value={activitySummary.active30d} color="text-blue-400" sub={`of ${activitySummary.total}`} />
<div className="space-y-3"> <SummaryCard label="Inactive" value={activitySummary.total - activitySummary.active7d - activitySummary.active30d - activitySummary.neverActive} color="text-amber-400" sub="no activity >30d" />
{stats.topFeatures.map((feature, i) => ( <SummaryCard label="Never Active" value={activitySummary.neverActive} color="text-gray-500" sub="0 logs ever" />
<div key={i} className="flex justify-between items-center"> </div>
<span>{feature.name}</span>
<span className="font-bold text-rose-400">{feature.count}</span> {tab === "overview" && (
</div> <>
{/* Feature Adoption Funnel */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-5">Feature Adoption</h3>
<div className="space-y-3">
{data.featureAdoption.map(f => (
<div key={f.name} className="flex items-center gap-4">
<div className="w-32 text-sm text-gray-300 shrink-0">{f.name}</div>
<div className="flex-1 bg-gray-700 rounded-full h-5 relative overflow-hidden">
<div
className="h-full bg-rose-500 rounded-full transition-all"
style={{ width: `${f.pct}%` }}
/>
</div>
<div className="w-24 text-right text-sm">
<span className="font-bold text-white">{f.pct}%</span>
<span className="text-gray-500 ml-1">({f.count})</span>
</div>
</div>
))}
{data.featureAdoption.length === 0 && (
<div className="text-gray-500 text-sm">No data yet</div>
)}
</div>
</div>
{/* Daily Activity Chart */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Daily Activity Last 30 Days</h3>
<div className="h-40 flex items-end gap-1">
{data.logsByDay.slice(-30).map((d, i) => (
<div key={i} title={`${d.date}: ${d.count}`} className="flex-1 flex flex-col items-center gap-1 group">
<div
className="w-full bg-rose-500 rounded-t group-hover:bg-rose-400 transition-colors"
style={{
height: `${Math.min((d.count / maxLog) * 100, 100)}%`,
minHeight: d.count > 0 ? "4px" : "0"
}}
/>
<div className="text-[8px] text-gray-500">{d.date?.slice(5) || ""}</div>
</div>
))}
{data.logsByDay.length === 0 && (
<div className="w-full text-center text-gray-500 text-sm self-center">No activity in the last 30 days</div>
)}
</div>
</div>
</>
)}
{tab === "families" && (
<div className="bg-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-700 flex gap-2 flex-wrap">
{(["all", "7d", "30d", "inactive", "never"] as ActivityFilter[]).map(f => (
<button
key={f}
onClick={() => setActivityFilter(f)}
className={`px-3 py-1 rounded-lg text-sm transition-colors ${activityFilter === f ? "bg-rose-500 text-white" : "bg-gray-700 text-gray-300 hover:bg-gray-600"}`}
>
{f === "all" ? "All" : f === "7d" ? "Active 7d" : f === "30d" ? "Active 30d" : f === "inactive" ? "Inactive >30d" : "Never Active"}
{f !== "all" && (
<span className="ml-1.5 text-xs opacity-70">
{data.families.filter(x => x.activeStatus === f).length}
</span>
)}
</button>
))} ))}
{stats.topFeatures.length === 0 && ( <span className="text-gray-500 text-sm self-center ml-auto">{sortedFamilies.length} families</span>
<div className="text-gray-500">No data yet</div> </div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
<th className="px-4 py-3 text-left text-sm font-medium">Tier</th>
<SortHeader label="Last Active" field="lastActivity" current={sortField} asc={sortAsc} onSort={handleSort} />
<SortHeader label="Total Logs" field="totalLogs" current={sortField} asc={sortAsc} onSort={handleSort} />
<th className="px-4 py-3 text-left text-sm font-medium">Features Used</th>
<th className="px-4 py-3 text-left text-sm font-medium">AI Chats</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{sortedFamilies.map(f => (
<tr key={f.id} className="hover:bg-gray-750">
<td className="px-4 py-3">
<div className="font-medium">{f.name}</div>
<div className="text-xs text-gray-500">{f.createdAt?.slice(0, 10)}</div>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded font-medium ${f.tier === "pro" ? "bg-rose-500/20 text-rose-400" : "bg-gray-700 text-gray-400"}`}>
{f.tier}
</span>
</td>
<td className="px-4 py-3 text-sm">
{f.lastActivity ? (
<span className={f.activeStatus === "7d" ? "text-emerald-400" : f.activeStatus === "30d" ? "text-blue-400" : "text-gray-500"}>
{f.lastActivity.slice(0, 10)}
</span>
) : (
<span className="text-gray-600">Never</span>
)}
</td>
<td className="px-4 py-3 text-sm font-medium">{f.totalLogs}</td>
<td className="px-4 py-3">
<div className="flex gap-1 flex-wrap">
{f.feedCount > 0 && <FeatureBadge emoji="🍼" label="Feed" />}
{f.sleepCount > 0 && <FeatureBadge emoji="💤" label="Sleep" />}
{f.diaperCount > 0 && <FeatureBadge emoji="🚼" label="Diaper" />}
{f.vaccinationCount > 0 && <FeatureBadge emoji="💉" label="Vacc" />}
{f.growthCount > 0 && <FeatureBadge emoji="📏" label="Growth" />}
{f.memoryCount > 0 && <FeatureBadge emoji="📸" label="Memory" />}
{f.chatCount > 0 && <FeatureBadge emoji="💬" label="AI" />}
{f.totalLogs === 0 && f.memoryCount === 0 && f.chatCount === 0 && (
<span className="text-gray-600 text-xs">None</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-300">{f.chatCount || "—"}</td>
</tr>
))}
</tbody>
</table>
{sortedFamilies.length === 0 && (
<div className="p-8 text-center text-gray-500">No families match this filter</div>
)} )}
</div> </div>
</div> </div>
)}
<div className="bg-gray-800 p-6 rounded-xl"> {tab === "ai" && (
<h3 className="text-lg font-semibold mb-4">Engagement Summary</h3> <div className="space-y-4">
<div className="space-y-3"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex justify-between items-center"> <SummaryCard label="API Calls (30d)" value={aiUsage.totalCalls} color="text-blue-400" />
<span className="text-gray-400">Avg Logs per Family</span> <SummaryCard label="Total Tokens" value={aiUsage.totalTokens.toLocaleString()} color="text-purple-400" />
<SummaryCard label="Cost (30d)" value={`${costINR}`} color="text-amber-400" />
<SummaryCard label="Families Using AI" value={aiUsage.familiesUsingAI} color="text-emerald-400" />
</div>
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
<h3 className="text-lg font-semibold">AI Cost Breakdown</h3>
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
<span className="text-gray-400">Total cost (30 days)</span>
<span className="font-bold">{costINR}</span>
</div>
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
<span className="text-gray-400">Avg cost per AI-using family</span>
<span className="font-bold">{costPerFamily}</span>
</div>
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
<span className="text-gray-400">Total tokens used</span>
<span className="font-bold">{aiUsage.totalTokens.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm py-2">
<span className="text-gray-400">Families using AI</span>
<span className="font-bold"> <span className="font-bold">
{stats.totalLogs > 0 && stats.familyCount > 0 {aiUsage.familiesUsingAI} / {data.totalFamilies}
? (stats.totalLogs / stats.familyCount).toFixed(1) <span className="text-gray-500 ml-1 text-xs">
: "0"} ({data.totalFamilies > 0 ? Math.round(aiUsage.familiesUsingAI / data.totalFamilies * 100) : 0}%)
</span> </span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">Avg Children per Family</span>
<span className="font-bold">1.0</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">Chat Adoption</span>
<span className="font-bold text-emerald-400">
{stats.totalChatSessions > 0 ? "Active" : "None"}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> )}
</div> </div>
); );
} }
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 ( return (
<div className="bg-gray-800 p-4 rounded-xl"> <div className="bg-gray-800 p-4 rounded-xl">
<div className="text-2xl mb-1">{icon}</div> <div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xl font-bold">{value}</div> <div className="text-sm text-gray-300 mt-0.5">{label}</div>
<div className="text-xs text-gray-400">{label}</div> {sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
</div> </div>
); );
} }
function FeatureBadge({ emoji, label }: { emoji: string; label: string }) {
return (
<span title={label} className="text-xs bg-gray-700 px-1.5 py-0.5 rounded">
{emoji}
</span>
);
}
function SortHeader({ label, field, current, asc, onSort }: {
label: string; field: SortField; current: SortField; asc: boolean; onSort: (f: SortField) => void;
}) {
return (
<th
className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:text-white select-none"
onClick={() => onSort(field)}
>
{label} {current === field ? (asc ? "↑" : "↓") : "↕"}
</th>
);
}

View file

@ -16,11 +16,19 @@ export default function AdminChildren() {
const [children, setChildren] = useState<Child[]>([]); const [children, setChildren] = useState<Child[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchChildren(); 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 () => { const fetchChildren = async () => {
try { try {
const res = await fetch("/api/admin/children", { credentials: "include" }); const res = await fetch("/api/admin/children", { credentials: "include" });
@ -32,9 +40,48 @@ export default function AdminChildren() {
setLoading(false); 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) => const filteredChildren = children.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()) || c.name.toLowerCase().includes(search.toLowerCase()) ||
c.familyName.toLowerCase().includes(search.toLowerCase()) (c.familyName || "").toLowerCase().includes(search.toLowerCase())
); );
const exportCSV = () => { const exportCSV = () => {
@ -49,12 +96,22 @@ export default function AdminChildren() {
a.click(); a.click();
}; };
if (loading) { if (loading) return <div className="p-6 text-white">Loading...</div>;
return <div className="p-6 text-white">Loading...</div>;
}
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{error && (
<div className="bg-red-500/15 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm flex justify-between items-center">
<span> {error}</span>
<button onClick={() => setError(null)} className="ml-4 opacity-60 hover:opacity-100"></button>
</div>
)}
{success && (
<div className="bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 px-4 py-3 rounded-xl text-sm">
{success}
</div>
)}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-2xl font-bold">Children</h1> <h1 className="text-2xl font-bold">Children</h1>
@ -78,6 +135,7 @@ export default function AdminChildren() {
<th className="px-4 py-3 text-left text-sm font-medium">Birth Date</th> <th className="px-4 py-3 text-left text-sm font-medium">Birth Date</th>
<th className="px-4 py-3 text-left text-sm font-medium">Age</th> <th className="px-4 py-3 text-left text-sm font-medium">Age</th>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th> <th className="px-4 py-3 text-left text-sm font-medium">Family</th>
<th className="px-4 py-3 text-left text-sm font-medium">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-700"> <tbody className="divide-y divide-gray-700">
@ -89,6 +147,17 @@ export default function AdminChildren() {
</td> </td>
<td className="px-4 py-3 text-sm text-gray-400">{child.age}</td> <td className="px-4 py-3 text-sm text-gray-400">{child.age}</td>
<td className="px-4 py-3">{child.familyName}</td> <td className="px-4 py-3">{child.familyName}</td>
<td className="px-4 py-3">
<Button
variant="danger"
size="sm"
loading={deletingId === child.id}
disabled={!!deletingId}
onClick={() => handleDeleteChild(child)}
>
Delete
</Button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -99,4 +168,4 @@ export default function AdminChildren() {
</div> </div>
</div> </div>
); );
} }

View file

@ -20,6 +20,8 @@ interface Family {
createdAt: string; createdAt: string;
userCount: number; userCount: number;
childCount: number; childCount: number;
logCount: number;
memoryCount: number;
members: Member[]; members: Member[];
} }
@ -31,6 +33,7 @@ export default function AdminFamilies() {
const [showMembers, setShowMembers] = useState<string | null>(null); const [showMembers, setShowMembers] = useState<string | null>(null);
const [tierChanging, setTierChanging] = useState<string | null>(null); const [tierChanging, setTierChanging] = useState<string | null>(null);
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null); const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
const [deletingFamilyId, setDeletingFamilyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
@ -39,7 +42,7 @@ export default function AdminFamilies() {
const [newFamilyName, setNewFamilyName] = useState(""); const [newFamilyName, setNewFamilyName] = useState("");
const [creatingFamily, setCreatingFamily] = useState(false); const [creatingFamily, setCreatingFamily] = useState(false);
// Add member form — separate email/role state to avoid lost-role bug // Add member form
const [memberForms, setMemberForms] = useState<Record<string, { email: string; role: string }>>({}); const [memberForms, setMemberForms] = useState<Record<string, { email: string; role: string }>>({});
useEffect(() => { useEffect(() => {
@ -123,6 +126,30 @@ export default function AdminFamilies() {
setRemovingMemberId(null); 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 handleTierChange = async (familyId: string, currentTier: string) => {
const newTier = currentTier === "pro" ? "free" : "pro"; const newTier = currentTier === "pro" ? "free" : "pro";
const label = newTier === "pro" ? "Upgrade to Pro?" : "Downgrade to Free?"; const label = newTier === "pro" ? "Upgrade to Pro?" : "Downgrade to Free?";
@ -159,14 +186,16 @@ export default function AdminFamilies() {
const filteredFamilies = families.filter((f) => { const filteredFamilies = families.filter((f) => {
const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase()); 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; const matchesTier = tierFilter === "all" || f.tier === tierFilter;
return matchesSearch && matchesTier; return matchesSearch && matchesTier;
}); });
const exportCSV = () => { 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) => [ 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 csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" }); 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 proCount = families.filter(f => f.tier === "pro").length;
const freeCount = 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 ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
@ -201,6 +231,7 @@ export default function AdminFamilies() {
<h1 className="text-2xl font-bold">Families</h1> <h1 className="text-2xl font-bold">Families</h1>
<p className="text-gray-400"> <p className="text-gray-400">
{families.length} total · <span className="text-rose-400">{proCount} Pro</span> · <span className="text-gray-500">{freeCount} Free</span> {families.length} total · <span className="text-rose-400">{proCount} Pro</span> · <span className="text-gray-500">{freeCount} Free</span>
{orphanedCount > 0 && <> · <span className="text-amber-400">{orphanedCount} Orphaned</span></>}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@ -245,6 +276,7 @@ export default function AdminFamilies() {
<option value="all">All Tiers</option> <option value="all">All Tiers</option>
<option value="free">Free</option> <option value="free">Free</option>
<option value="pro">Pro</option> <option value="pro">Pro</option>
<option value="orphaned">Orphaned (0 members)</option>
</Select> </Select>
</div> </div>
@ -257,6 +289,7 @@ export default function AdminFamilies() {
<th className="px-4 py-3 text-left text-sm font-medium">Tier</th> <th className="px-4 py-3 text-left text-sm font-medium">Tier</th>
<th className="px-4 py-3 text-left text-sm font-medium">Members</th> <th className="px-4 py-3 text-left text-sm font-medium">Members</th>
<th className="px-4 py-3 text-left text-sm font-medium">Children</th> <th className="px-4 py-3 text-left text-sm font-medium">Children</th>
<th className="px-4 py-3 text-left text-sm font-medium">Logs</th>
<th className="px-4 py-3 text-left text-sm font-medium">Limits</th> <th className="px-4 py-3 text-left text-sm font-medium">Limits</th>
<th className="px-4 py-3 text-left text-sm font-medium">Created</th> <th className="px-4 py-3 text-left text-sm font-medium">Created</th>
<th className="px-4 py-3 text-left text-sm font-medium">Actions</th> <th className="px-4 py-3 text-left text-sm font-medium">Actions</th>
@ -265,12 +298,18 @@ export default function AdminFamilies() {
<tbody className="divide-y divide-gray-700"> <tbody className="divide-y divide-gray-700">
{filteredFamilies.map((family) => { {filteredFamilies.map((family) => {
const memberForm = memberForms[family.id] || { email: "", role: "caregiver" }; const memberForm = memberForms[family.id] || { email: "", role: "caregiver" };
const isOrphaned = family.userCount === 0;
return ( return (
<> <>
<tr key={family.id} className="hover:bg-gray-750"> <tr key={family.id} className={`hover:bg-gray-750 ${isOrphaned ? "bg-amber-950/20" : ""}`}>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-medium">{family.name}</div> <div className="font-medium">{family.name}</div>
<div className="text-xs text-gray-600 font-mono">{family.id.slice(0, 8)}</div> <div className="text-xs text-gray-600 font-mono">{family.id.slice(0, 8)}</div>
{isOrphaned && (
<span className="text-xs text-amber-400 bg-amber-400/10 px-1.5 py-0.5 rounded mt-0.5 inline-block">
No members
</span>
)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge> <Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
@ -285,6 +324,7 @@ export default function AdminFamilies() {
</Button> </Button>
</td> </td>
<td className="px-4 py-3 text-gray-300">{family.childCount}</td> <td className="px-4 py-3 text-gray-300">{family.childCount}</td>
<td className="px-4 py-3 text-sm text-gray-400">{family.logCount}</td>
<td className="px-4 py-3 text-sm text-gray-500"> <td className="px-4 py-3 text-sm text-gray-500">
{family.maxChildren} kids · {family.maxMembers} members {family.maxChildren} kids · {family.maxMembers} members
</td> </td>
@ -292,21 +332,34 @@ export default function AdminFamilies() {
{family.createdAt?.slice(0, 10)} {family.createdAt?.slice(0, 10)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Button <div className="flex gap-1.5">
variant={family.tier === "pro" ? "secondary" : "primary"} <Button
size="sm" variant={family.tier === "pro" ? "secondary" : "primary"}
onClick={() => handleTierChange(family.id, family.tier)} size="sm"
loading={tierChanging === family.id} onClick={() => handleTierChange(family.id, family.tier)}
disabled={!!tierChanging} loading={tierChanging === family.id}
> disabled={!!tierChanging || !!deletingFamilyId}
{family.tier === "pro" ? "↓ Free" : "↑ Pro"} >
</Button> {family.tier === "pro" ? "↓ Free" : "↑ Pro"}
</Button>
{isOrphaned && (
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteFamily(family)}
loading={deletingFamilyId === family.id}
disabled={!!deletingFamilyId || !!tierChanging}
>
Delete
</Button>
)}
</div>
</td> </td>
</tr> </tr>
{showMembers === family.id && ( {showMembers === family.id && (
<tr key={`${family.id}-members`}> <tr key={`${family.id}-members`}>
<td colSpan={7} className="bg-gray-900 px-6 py-4"> <td colSpan={8} className="bg-gray-900 px-6 py-4">
<div className="space-y-3"> <div className="space-y-3">
<div className="text-xs text-gray-500 font-semibold uppercase">Members</div> <div className="text-xs text-gray-500 font-semibold uppercase">Members</div>

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -17,10 +17,16 @@ export async function GET(request: Request) {
f.max_members, f.max_members,
f.created_at, f.created_at,
COUNT(DISTINCT fm.user_id) as user_count, 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 FROM families f
LEFT JOIN family_members fm ON fm.family_id = f.id LEFT JOIN family_members fm ON fm.family_id = f.id
LEFT JOIN children c ON c.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 GROUP BY f.id
ORDER BY f.created_at DESC 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, createdAt: f.created_at ? new Date(f.created_at).toISOString() : null,
userCount: Number(f.user_count) || 0, userCount: Number(f.user_count) || 0,
childCount: Number(f.child_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) || [], members: memberMap.get(f.id) || [],
})), })),
}); });