From 678cf65d70bafd925532ef46b0c4f6501d53bb50 Mon Sep 17 00:00:00 2001 From: Mannu Date: Fri, 29 May 2026 09:33:51 +0530 Subject: [PATCH] feat: DB-backed notification system with vaccine + activity nudges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration (0009): notifications table with unique daily/weekly slots, is_read column, metadata JSONB for vaccine info. No more localStorage for read state — syncs across devices. API GET /api/notifications?childId=: - Generates vaccine notifications (upsert, filtered by given vaccines at query time) - log_nudge: if no feed/diaper/sleep logged today after noon IST - memory_nudge: if no photo added to memories today - garment_nudge: if wardrobe < 10 items (once per week slot) - Returns unread first, then recent read, limit 60 API PATCH /api/notifications — mark all read for family+child API PATCH /api/notifications/[id] — mark single notification read Page /notifications: - Fetches from real API (no hardcoded mock data) - Optimistic mark-read on tap, navigates to actionUrl - Colored cards per type (red=vaccine, amber=log, purple=memory, pink=garment) - Unread badge + Mark all read button in sticky header - Legend row at bottom debug-migration: added notifications table CREATE IF NOT EXISTS for hot-apply Co-Authored-By: Claude Sonnet 4.6 --- drizzle/0009_notifications.sql | 38 +++ drizzle/meta/_journal.json | 7 + src/app/(app)/notifications/page.tsx | 209 +++++++++------- src/app/api/debug-migration/route.ts | 4 + src/app/api/notifications/[id]/route.ts | 29 +++ src/app/api/notifications/route.ts | 305 ++++++++++++++++++------ 6 files changed, 429 insertions(+), 163 deletions(-) create mode 100644 drizzle/0009_notifications.sql create mode 100644 src/app/api/notifications/[id]/route.ts diff --git a/drizzle/0009_notifications.sql b/drizzle/0009_notifications.sql new file mode 100644 index 0000000..fa20788 --- /dev/null +++ b/drizzle/0009_notifications.sql @@ -0,0 +1,38 @@ +-- Notification system — persistent, DB-backed nudges and vaccine alerts +-- Replaces the previous compute-only approach so read/unread syncs across devices. + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + family_id UUID NOT NULL REFERENCES families(id) ON DELETE CASCADE, + child_id UUID REFERENCES children(id) ON DELETE CASCADE, + + -- Stable type codes: + -- Vaccine alerts : "vaccine_BCG", "vaccine_OPV-0", etc. + -- Daily nudges : "log_nudge", "memory_nudge" + -- Weekly nudge : "garment_nudge" + type VARCHAR(80) NOT NULL, + + title TEXT NOT NULL, + message TEXT NOT NULL, + action_url TEXT, + is_read BOOLEAN NOT NULL DEFAULT false, + + -- IST date this notification belongs to. + -- Nudges: today's IST date → ensures one per day per type + -- Vaccines: the vaccine due date → ensures one per vaccine (due date is fixed) + -- Garment: Monday of current IST week → ensures one per week + scheduled_for DATE NOT NULL, + + -- Arbitrary extra data (e.g. { "vaccineName": "BCG" } for vaccine rows) + metadata JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- One row per (family, child, type, date-slot) — prevents duplicate notifications +CREATE UNIQUE INDEX IF NOT EXISTS notifications_unique_slot + ON notifications(family_id, child_id, type, scheduled_for); + +-- Fast unread count + list query +CREATE INDEX IF NOT EXISTS notifications_family_child_idx + ON notifications(family_id, child_id, is_read, created_at DESC); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d673271..de5268c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1748566800000, "tag": "0008_pediatrician_name", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1748880000000, + "tag": "0009_notifications", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx index 86eb931..8c1e083 100644 --- a/src/app/(app)/notifications/page.tsx +++ b/src/app/(app)/notifications/page.tsx @@ -1,147 +1,184 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { useFamily } from "@/app/FamilyProvider"; import { fmtDate } from "@/lib/date-ist"; -interface Notification { +interface AppNotification { id: string; type: string; title: string; message: string; - dueDate?: string; - status?: string; - childName?: string; + actionUrl?: string; + isRead: boolean; + scheduledFor?: string; + createdAt: string; + metadata?: { vaccineName?: string; dueDate?: string }; } -const READ_KEY = "tia_read_notifications"; - -function getReadSet(): Set { - try { - const raw = localStorage.getItem(READ_KEY); - return new Set(raw ? JSON.parse(raw) : []); - } catch { return new Set(); } +function notifIcon(type: string, metadata?: { dueDate?: string }): string { + if (type.startsWith("vaccine_")) { + const isOverdue = metadata?.dueDate && metadata.dueDate < new Date().toISOString().slice(0, 10); + return isOverdue ? "🚨" : "💉"; + } + if (type === "log_nudge") return "🍼"; + if (type === "memory_nudge") return "📸"; + if (type === "garment_nudge") return "👚"; + return "🔔"; } -function saveReadSet(ids: Set) { - localStorage.setItem(READ_KEY, JSON.stringify([...ids])); +function notifColor(type: string): string { + if (type.startsWith("vaccine_")) return "bg-red-50 dark:bg-red-900/20 border-red-100 dark:border-red-800/40"; + if (type === "log_nudge") return "bg-amber-50 dark:bg-amber-900/20 border-amber-100 dark:border-amber-800/40"; + if (type === "memory_nudge") return "bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800/40"; + if (type === "garment_nudge") return "bg-pink-50 dark:bg-pink-900/20 border-pink-100 dark:border-pink-800/40"; + return "bg-white dark:bg-gray-800"; } export default function NotificationsPage() { - const router = useRouter(); - const { childId } = useFamily(); - const [notifications, setNotifications] = useState([]); - const [readIds, setReadIds] = useState>(new Set()); + const router = useRouter(); + const { childId } = useFamily(); + const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); + const [markingAll, setMarkingAll] = useState(false); - useEffect(() => { - setReadIds(getReadSet()); - }, []); - - useEffect(() => { + const fetchNotifications = useCallback(async () => { if (!childId) return; - fetch(`/api/notifications?childId=${childId}`) - .then(r => r.ok ? r.json() : { notifications: [] }) - .then(d => setNotifications(d.notifications || [])) - .catch(() => setNotifications([])) - .finally(() => setLoading(false)); + setLoading(true); + try { + const res = await fetch(`/api/notifications?childId=${childId}`); + const data = await res.json(); + setItems(data.notifications || []); + } catch { /* swallow — show empty state */ } + setLoading(false); }, [childId]); - const markRead = (id: string) => { - setReadIds(prev => { - const next = new Set(prev); - next.add(id); - saveReadSet(next); - return next; - }); + useEffect(() => { fetchNotifications(); }, [fetchNotifications]); + + const markRead = async (notif: AppNotification) => { + if (notif.isRead) { + // Navigate immediately if already read + if (notif.actionUrl) router.push(notif.actionUrl); + return; + } + // Optimistic update + setItems(prev => prev.map(n => n.id === notif.id ? { ...n, isRead: true } : n)); + try { + await fetch(`/api/notifications/${notif.id}`, { method: "PATCH" }); + } catch { /* no-op — optimistic state is fine */ } + if (notif.actionUrl) router.push(notif.actionUrl); }; - const markAllRead = () => { - const allIds = new Set(notifications.map(n => n.id)); - setReadIds(allIds); - saveReadSet(allIds); + const markAllRead = async () => { + if (!childId) return; + setMarkingAll(true); + setItems(prev => prev.map(n => ({ ...n, isRead: true }))); + try { + await fetch("/api/notifications", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ childId }), + }); + } catch { /* optimistic update already done */ } + setMarkingAll(false); }; - const unreadCount = notifications.filter(n => !readIds.has(n.id)).length; + const unreadCount = items.filter(n => !n.isRead).length; return (
+ {/* Header */} -
+
-

Notifications

+

Notifications

{unreadCount > 0 && ( - + {unreadCount} )} {unreadCount > 0 && ( )}
-
+ {/* Section hint */} + {!loading && items.length > 0 && ( +

+ Tap a notification to go to the relevant page +

+ )} + + {/* List */} +
{loading ? (
🔔

Loading…

- ) : notifications.length === 0 ? ( + ) : items.length === 0 ? (
🔔
-

All caught up!

-

No overdue or due-today vaccinations

+

All caught up!

+

No pending reminders right now

) : ( - notifications.map(notif => { - const isRead = readIds.has(notif.id); - const isOverdue = notif.status === "overdue"; - return ( - - ); - }) + {!notif.isRead && ( +
+ )} +
+ + )) )}
- {notifications.length > 0 && ( -
-

- Showing overdue & due-today IAP vaccinations for {notifications[0]?.childName || "your child"} -

+ {/* Legend */} + {!loading && items.length > 0 && ( +
+
+ 🚨 Vaccine overdue + 💉 Due today + 🍼 Log reminder + 📸 Memory nudge + 👚 Wardrobe nudge +
)}
diff --git a/src/app/api/debug-migration/route.ts b/src/app/api/debug-migration/route.ts index b4f6efd..d1255a6 100644 --- a/src/app/api/debug-migration/route.ts +++ b/src/app/api/debug-migration/route.ts @@ -55,6 +55,10 @@ export async function POST(req: Request) { `CREATE INDEX IF NOT EXISTS circle_comments_post_idx ON circle_post_comments(post_id)`, `CREATE INDEX IF NOT EXISTS circle_reactions_post_idx ON circle_post_reactions(post_id)`, `CREATE INDEX IF NOT EXISTS circle_invites_token_idx ON circle_invites(token)`, + // 0009 — notifications table + `CREATE TABLE IF NOT EXISTS notifications (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), family_id UUID NOT NULL REFERENCES families(id) ON DELETE CASCADE, child_id UUID REFERENCES children(id) ON DELETE CASCADE, type VARCHAR(80) NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL, action_url TEXT, is_read BOOLEAN NOT NULL DEFAULT false, scheduled_for DATE NOT NULL, metadata JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, + `CREATE UNIQUE INDEX IF NOT EXISTS notifications_unique_slot ON notifications(family_id, child_id, type, scheduled_for)`, + `CREATE INDEX IF NOT EXISTS notifications_family_child_idx ON notifications(family_id, child_id, is_read, created_at DESC)`, ]; const results: string[] = []; diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts new file mode 100644 index 0000000..edf66bc --- /dev/null +++ b/src/app/api/notifications/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; + +// PATCH /api/notifications/[id] — mark a single notification as read +export async function PATCH( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id } = await params; + + // Only update rows belonging to this family (security check via family_id) + await sql` + UPDATE notifications + SET is_read = true + WHERE id = ${id} AND family_id = ${familyId} + `; + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("Notification PATCH [id] error:", err); + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 61625fa..607a062 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,104 +1,255 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { sql } from "@/db"; -import { requireFamily, requireOwnership } from "@/lib/auth"; +import { requireFamily } from "@/lib/auth"; -// IAP Vaccination Schedule (weeks from birth) +// ─── IST helpers (server-side) ──────────────────────────────────────────────── +const IST_OFFSET_MS = 5.5 * 60 * 60 * 1000; // UTC +5:30 + +function getISTDate(): string { + return new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Kolkata" }); // YYYY-MM-DD +} + +function getISTHour(): number { + const str = new Intl.DateTimeFormat("en-IN", { timeZone: "Asia/Kolkata", hour: "numeric", hour12: false }).format(new Date()); + const h = parseInt(str, 10); + return h === 24 ? 0 : h; +} + +/** Monday of the current IST week (for weekly garment nudge slot) */ +function getISTWeekMonday(): string { + const istNow = new Date(Date.now() + IST_OFFSET_MS); + const dow = istNow.getUTCDay(); // 0=Sun … 6=Sat + const mon = new Date(istNow.getTime() - ((dow === 0 ? 6 : dow - 1) * 86_400_000)); + return mon.toISOString().slice(0, 10); +} + +// ─── IAP vaccine schedule ───────────────────────────────────────────────────── const IAP_SCHEDULE = [ - { name: "BCG", weeks: 0 }, - { name: "OPV-0", weeks: 0 }, - { name: "HepB-1", weeks: 0 }, - { name: "OPV-1", weeks: 6 }, - { name: "Pentavalent-1", weeks: 6 }, - { name: "PCV-1", weeks: 6 }, - { name: "Rota-1", weeks: 6 }, - { name: "OPV-2", weeks: 10 }, - { name: "Pentavalent-2", weeks: 10 }, - { name: "PCV-2", weeks: 10 }, - { name: "Rota-2", weeks: 10 }, - { name: "OPV-3", weeks: 14 }, - { name: "Pentavalent-3", weeks: 14 }, - { name: "PCV-3", weeks: 14 }, - { name: "Rota-3", weeks: 14 }, - { name: "MR-1", weeks: 48 }, - { name: "JE-1", weeks: 48 }, - { name: "Vitamin A-1", weeks: 48 }, - { name: "OPV-4", weeks: 48 }, - { name: "MR-2", weeks: 96 }, - { name: "JE-2", weeks: 96 }, - { name: "DPT-Booster-1", weeks: 96 }, - { name: "Vitamin A-2", weeks: 96 }, - { name: "OPV-5", weeks: 96 }, - { name: "DPT-Booster-2", weeks: 208 }, - { name: "Tetanus and adult diphtheria (Td)", weeks: 208 }, + { name: "BCG", weeks: 0 }, + { name: "OPV-0", weeks: 0 }, + { name: "HepB-1", weeks: 0 }, + { name: "OPV-1", weeks: 6 }, + { name: "Pentavalent-1", weeks: 6 }, + { name: "PCV-1", weeks: 6 }, + { name: "Rota-1", weeks: 6 }, + { name: "OPV-2", weeks: 10 }, + { name: "Pentavalent-2", weeks: 10 }, + { name: "PCV-2", weeks: 10 }, + { name: "Rota-2", weeks: 10 }, + { name: "OPV-3", weeks: 14 }, + { name: "Pentavalent-3", weeks: 14 }, + { name: "PCV-3", weeks: 14 }, + { name: "Rota-3", weeks: 14 }, + { name: "MR-1", weeks: 48 }, + { name: "JE-1", weeks: 48 }, + { name: "Vitamin A-1", weeks: 48 }, + { name: "OPV-4", weeks: 48 }, + { name: "MR-2", weeks: 96 }, + { name: "JE-2", weeks: 96 }, + { name: "DPT-Booster-1", weeks: 96 }, + { name: "Vitamin A-2", weeks: 96 }, + { name: "OPV-5", weeks: 96 }, + { name: "DPT-Booster-2", weeks: 208 }, + { name: "Td", weeks: 208 }, ]; -export async function GET(request: Request) { +/** Safe type code for a vaccine name (no slashes or spaces) */ +function vaccineType(name: string) { + return `vaccine_${name.replace(/[^a-zA-Z0-9-]/g, "_")}`; +} + +// ─── GET — generate today's notifications then return all ──────────────────── +export async function GET(request: NextRequest) { try { const auth = await requireFamily(); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + const familyId = auth.session!.familyId!; const { searchParams } = new URL(request.url); - const childId = searchParams.get("childId"); + const childId = searchParams.get("childId"); - if (!childId) { - return NextResponse.json({ error: "childId required" }, { status: 400 }); - } + if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 }); - const ownership = await requireOwnership(childId, "children", "Child"); - if (!ownership.success) return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + const istDate = getISTDate(); + const istHour = getISTHour(); + const weekSlot = getISTWeekMonday(); - // Get child's birth date + // ── Fetch child info ────────────────────────────────────────────────────── const children = await sql` - SELECT id, name, birth_date FROM children WHERE id = ${childId} + SELECT id, name, birth_date FROM children + WHERE id = ${childId} AND family_id = ${familyId} `; + if (!children[0]) return NextResponse.json({ notifications: [] }); + const child = children[0]; + const birthDate = new Date(child.birth_date as string); - if (!children || children.length === 0) { - return NextResponse.json({ notifications: [] }); - } - - const child = children[0]; - const birthDate = new Date(child.birth_date); - const today = new Date(); - today.setHours(0, 0, 0, 0); - - // Get already given vaccines + // ── 1. Vaccine notifications ────────────────────────────────────────────── const given = await sql` - SELECT vaccine_name, given_date FROM vaccinations + SELECT vaccine_name FROM vaccinations WHERE child_id = ${childId} AND status = 'given' `; - const givenMap = new Set(given.map((v: any) => v.vaccine_name)); + const givenSet = new Set((given as unknown as { vaccine_name: string }[]).map(r => r.vaccine_name)); - // Calculate upcoming vaccine notifications - const notifications = []; - for (const vaccine of IAP_SCHEDULE) { - if (givenMap.has(vaccine.name)) continue; + const today = new Date(istDate + "T00:00:00Z"); // IST midnight as Date for comparison - // Calculate due date - const dueDate = new Date(birthDate); - dueDate.setDate(dueDate.getDate() + vaccine.weeks * 7); - dueDate.setHours(0, 0, 0, 0); + for (const v of IAP_SCHEDULE) { + if (givenSet.has(v.name)) continue; - const diffDays = Math.floor((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + const dueDate = new Date(birthDate.getTime() + v.weeks * 7 * 86_400_000); + const dueDateStr = dueDate.toISOString().slice(0, 10); - // Notify if due today or overdue - if (diffDays <= 0) { - notifications.push({ - id: `vaccine-${vaccine.name}`, - type: "vaccine", - title: diffDays === 0 ? "Vaccine Due Today" : "Vaccine Overdue", - message: `${vaccine.name} is ${diffDays === 0 ? "due today" : `${Math.abs(diffDays)} days overdue`}`, - vaccineName: vaccine.name, - dueDate: dueDate.toISOString().split("T")[0], - status: diffDays === 0 ? "due_today" : "overdue", - childId, - childName: child.name, - }); + // Only notify if due today or overdue + if (dueDateStr > istDate) continue; + + const diffDays = Math.round((today.getTime() - dueDate.getTime()) / 86_400_000); + const isToday = diffDays === 0; + + await sql` + INSERT INTO notifications + (family_id, child_id, type, title, message, action_url, scheduled_for, metadata) + VALUES ( + ${familyId}, ${childId}, + ${vaccineType(v.name)}, + ${isToday ? "Vaccine due today" : "Vaccine overdue"}, + ${isToday + ? `${v.name} is due today` + : `${v.name} is ${diffDays} day${diffDays === 1 ? "" : "s"} overdue`}, + '/medical', + ${dueDateStr}::date, + ${JSON.stringify({ vaccineName: v.name, dueDate: dueDateStr })}::jsonb + ) + ON CONFLICT (family_id, child_id, type, scheduled_for) DO NOTHING + `; + } + + // ── 2. Log nudge — no activity today past noon IST ──────────────────────── + if (istHour >= 12) { + const logCount = await sql` + SELECT + (SELECT COUNT(*) FROM feeds WHERE child_id = ${childId} AND (logged_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date) + + (SELECT COUNT(*) FROM diapers_logs WHERE child_id = ${childId} AND (logged_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date) + + (SELECT COUNT(*) FROM sleeps WHERE child_id = ${childId} AND (logged_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date) + AS total + `; + if (Number((logCount[0] as { total: string }).total) === 0) { + await sql` + INSERT INTO notifications + (family_id, child_id, type, title, message, action_url, scheduled_for) + VALUES ( + ${familyId}, ${childId}, 'log_nudge', + ${'No activity logged yet today'}, + ${`How is ${child.name as string} doing? Log a feed, diaper change, or sleep session`}, + '/activity', + ${istDate}::date + ) + ON CONFLICT (family_id, child_id, type, scheduled_for) DO NOTHING + `; } } - return NextResponse.json({ notifications }); - } catch (error) { - console.error("Notifications error:", error); - return NextResponse.json({ error: String(error) }, { status: 500 }); + // ── 3. Memory nudge — no photo shared today ─────────────────────────────── + const memCount = await sql` + SELECT COUNT(*) AS total FROM memories + WHERE family_id = ${familyId} + AND (created_at AT TIME ZONE 'Asia/Kolkata')::date = ${istDate}::date + `; + if (Number((memCount[0] as { total: string }).total) === 0) { + await sql` + INSERT INTO notifications + (family_id, child_id, type, title, message, action_url, scheduled_for) + VALUES ( + ${familyId}, ${childId}, 'memory_nudge', + ${'Capture a moment today 📸'}, + ${`Add a photo of ${child.name as string} to your memories — your future self will thank you`}, + '/memories', + ${istDate}::date + ) + ON CONFLICT (family_id, child_id, type, scheduled_for) DO NOTHING + `; + } + + // ── 4. Garment nudge — wardrobe has fewer than 10 items (weekly) ────────── + const garmentCount = await sql` + SELECT COUNT(*) AS total FROM garments + WHERE child_id = ${childId} AND status = 'active' + `; + if (Number((garmentCount[0] as { total: string }).total) < 10) { + await sql` + INSERT INTO notifications + (family_id, child_id, type, title, message, action_url, scheduled_for) + VALUES ( + ${familyId}, ${childId}, 'garment_nudge', + ${'Build out the wardrobe 👚'}, + ${`Add clothes to ${child.name as string}'s wardrobe to unlock outfit suggestions`}, + '/wardrobe/add', + ${weekSlot}::date + ) + ON CONFLICT (family_id, child_id, type, scheduled_for) DO NOTHING + `; + } + + // ── 5. Return all notifications, filtering out given vaccines ───────────── + const rows = await sql` + SELECT n.* + FROM notifications n + WHERE n.family_id = ${familyId} + AND n.child_id = ${childId} + AND ( + -- Non-vaccine rows: always include + n.type NOT LIKE 'vaccine_%' + OR + -- Vaccine rows: only if the vaccine hasn't been given + NOT EXISTS ( + SELECT 1 FROM vaccinations v + WHERE v.child_id = n.child_id + AND v.status = 'given' + AND v.vaccine_name = n.metadata->>'vaccineName' + ) + ) + ORDER BY n.is_read ASC, n.created_at DESC + LIMIT 60 + `; + + return NextResponse.json({ + notifications: (rows as Record[]).map(r => ({ + id: r.id, + type: r.type, + title: r.title, + message: r.message, + actionUrl: r.action_url, + isRead: r.is_read, + scheduledFor: r.scheduled_for, + createdAt: r.created_at, + metadata: r.metadata, + })), + }); + } catch (err) { + console.error("Notifications GET error:", err); + return NextResponse.json({ error: String(err) }, { status: 500 }); } -} \ No newline at end of file +} + +// ─── PATCH — mark all notifications as read for this family+child ───────────── +export async function PATCH(request: NextRequest) { + try { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const body = await request.json().catch(() => ({})) as { childId?: string }; + const childId = body.childId; + + if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 }); + + await sql` + UPDATE notifications + SET is_read = true + WHERE family_id = ${familyId} AND child_id = ${childId} AND is_read = false + `; + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("Notifications PATCH error:", err); + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +}