feat: DB-backed notification system with vaccine + activity nudges

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 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-29 09:33:51 +05:30
parent ee4bcc4498
commit 678cf65d70
6 changed files with 429 additions and 163 deletions

View file

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

View file

@ -64,6 +64,13 @@
"when": 1748566800000, "when": 1748566800000,
"tag": "0008_pediatrician_name", "tag": "0008_pediatrician_name",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1748880000000,
"tag": "0009_notifications",
"breakpoints": true
} }
] ]
} }

View file

@ -1,147 +1,184 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useFamily } from "@/app/FamilyProvider"; import { useFamily } from "@/app/FamilyProvider";
import { fmtDate } from "@/lib/date-ist"; import { fmtDate } from "@/lib/date-ist";
interface Notification { interface AppNotification {
id: string; id: string;
type: string; type: string;
title: string; title: string;
message: string; message: string;
dueDate?: string; actionUrl?: string;
status?: string; isRead: boolean;
childName?: string; scheduledFor?: string;
createdAt: string;
metadata?: { vaccineName?: string; dueDate?: string };
} }
const READ_KEY = "tia_read_notifications"; function notifIcon(type: string, metadata?: { dueDate?: string }): string {
if (type.startsWith("vaccine_")) {
function getReadSet(): Set<string> { const isOverdue = metadata?.dueDate && metadata.dueDate < new Date().toISOString().slice(0, 10);
try { return isOverdue ? "🚨" : "💉";
const raw = localStorage.getItem(READ_KEY); }
return new Set(raw ? JSON.parse(raw) : []); if (type === "log_nudge") return "🍼";
} catch { return new Set(); } if (type === "memory_nudge") return "📸";
if (type === "garment_nudge") return "👚";
return "🔔";
} }
function saveReadSet(ids: Set<string>) { function notifColor(type: string): string {
localStorage.setItem(READ_KEY, JSON.stringify([...ids])); 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() { export default function NotificationsPage() {
const router = useRouter(); const router = useRouter();
const { childId } = useFamily(); const { childId } = useFamily();
const [notifications, setNotifications] = useState<Notification[]>([]); const [items, setItems] = useState<AppNotification[]>([]);
const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [markingAll, setMarkingAll] = useState(false);
useEffect(() => { const fetchNotifications = useCallback(async () => {
setReadIds(getReadSet());
}, []);
useEffect(() => {
if (!childId) return; if (!childId) return;
fetch(`/api/notifications?childId=${childId}`) setLoading(true);
.then(r => r.ok ? r.json() : { notifications: [] }) try {
.then(d => setNotifications(d.notifications || [])) const res = await fetch(`/api/notifications?childId=${childId}`);
.catch(() => setNotifications([])) const data = await res.json();
.finally(() => setLoading(false)); setItems(data.notifications || []);
} catch { /* swallow — show empty state */ }
setLoading(false);
}, [childId]); }, [childId]);
const markRead = (id: string) => { useEffect(() => { fetchNotifications(); }, [fetchNotifications]);
setReadIds(prev => {
const next = new Set(prev); const markRead = async (notif: AppNotification) => {
next.add(id); if (notif.isRead) {
saveReadSet(next); // Navigate immediately if already read
return next; 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 = 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 markAllRead = () => { const unreadCount = items.filter(n => !n.isRead).length;
const allIds = new Set(notifications.map(n => n.id));
setReadIds(allIds);
saveReadSet(allIds);
};
const unreadCount = notifications.filter(n => !readIds.has(n.id)).length;
return ( return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800"> <div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
{/* Header */} {/* Header */}
<div className="p-4 flex items-center gap-3"> <div className="sticky top-0 z-20 bg-rose-50/80 dark:bg-gray-900/80 backdrop-blur-sm px-4 py-3 flex items-center gap-3 border-b border-rose-100 dark:border-gray-700">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button> <button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-xl font-bold">Notifications</h1> <h1 className="text-xl font-bold flex-1">Notifications</h1>
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="ml-1 px-2 py-0.5 bg-rose-400 text-white text-xs font-bold rounded-full"> <span className="px-2.5 py-0.5 bg-rose-400 text-white text-xs font-bold rounded-full">
{unreadCount} {unreadCount}
</span> </span>
)} )}
{unreadCount > 0 && ( {unreadCount > 0 && (
<button <button
onClick={markAllRead} onClick={markAllRead}
className="ml-auto text-sm text-rose-500 font-medium" disabled={markingAll}
className="text-sm text-rose-500 font-medium disabled:opacity-50"
> >
Mark all read {markingAll ? "Marking…" : "Mark all read"}
</button> </button>
)} )}
</div> </div>
<div className="px-4 space-y-2"> {/* Section hint */}
{!loading && items.length > 0 && (
<p className="px-4 pt-3 pb-1 text-xs text-gray-400 dark:text-gray-500">
Tap a notification to go to the relevant page
</p>
)}
{/* List */}
<div className="px-4 py-2 space-y-2 pb-24">
{loading ? ( {loading ? (
<div className="text-center py-20 text-gray-400"> <div className="text-center py-20 text-gray-400">
<div className="text-4xl mb-3 animate-pulse">🔔</div> <div className="text-4xl mb-3 animate-pulse">🔔</div>
<p className="text-sm">Loading</p> <p className="text-sm">Loading</p>
</div> </div>
) : notifications.length === 0 ? ( ) : items.length === 0 ? (
<div className="text-center py-20 text-gray-400"> <div className="text-center py-20 text-gray-400">
<div className="text-6xl mb-4">🔔</div> <div className="text-6xl mb-4">🔔</div>
<p className="font-medium">All caught up!</p> <p className="font-semibold text-gray-600 dark:text-gray-300">All caught up!</p>
<p className="text-sm mt-1">No overdue or due-today vaccinations</p> <p className="text-sm mt-1 text-gray-400">No pending reminders right now</p>
</div> </div>
) : ( ) : (
notifications.map(notif => { items.map(notif => (
const isRead = readIds.has(notif.id);
const isOverdue = notif.status === "overdue";
return (
<button <button
key={notif.id} key={notif.id}
onClick={() => markRead(notif.id)} onClick={() => markRead(notif)}
className={`w-full text-left p-4 rounded-xl transition-colors ${ className={`w-full text-left rounded-2xl border transition-all active:scale-[0.98] ${
isRead notif.isRead
? "bg-gray-50 dark:bg-gray-800/60" ? "bg-gray-50 dark:bg-gray-800/40 border-gray-100 dark:border-gray-700/40 opacity-60"
: "bg-white dark:bg-gray-800 shadow-sm" : `${notifColor(notif.type)} border shadow-sm`
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3 p-4">
<span className="text-xl mt-0.5">{isOverdue ? "🚨" : "💉"}</span> <span className="text-2xl mt-0.5 flex-shrink-0">{notifIcon(notif.type, notif.metadata)}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className={`font-medium text-sm ${isRead ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-white"}`}> <div className={`font-semibold text-sm ${notif.isRead ? "text-gray-400 dark:text-gray-500" : "text-gray-900 dark:text-white"}`}>
{notif.title} {notif.title}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 leading-snug">
{notif.message} {notif.message}
</div> </div>
{notif.dueDate && ( {notif.metadata?.dueDate && (
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1"> <div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Due: {fmtDate(notif.dueDate + "T00:00:00Z", { day: "numeric", month: "short", year: "numeric" })} Due: {fmtDate(notif.metadata.dueDate + "T00:00:00Z", { day: "numeric", month: "short", year: "numeric" })}
</div>
)}
{notif.actionUrl && !notif.isRead && (
<div className="text-xs text-rose-400 dark:text-rose-500 mt-1.5 font-medium">
Tap to open
</div> </div>
)} )}
</div> </div>
{!isRead && ( {!notif.isRead && (
<div className="w-2 h-2 bg-rose-400 rounded-full mt-2 flex-shrink-0" /> <div className="w-2.5 h-2.5 bg-rose-400 rounded-full mt-1.5 flex-shrink-0" />
)} )}
</div> </div>
</button> </button>
); ))
})
)} )}
</div> </div>
{notifications.length > 0 && ( {/* Legend */}
<div className="px-4 mt-6 pb-8"> {!loading && items.length > 0 && (
<p className="text-center text-xs text-gray-400"> <div className="px-4 pb-8">
Showing overdue &amp; due-today IAP vaccinations for {notifications[0]?.childName || "your child"} <div className="flex flex-wrap gap-x-4 gap-y-1 justify-center text-xs text-gray-400">
</p> <span>🚨 Vaccine overdue</span>
<span>💉 Due today</span>
<span>🍼 Log reminder</span>
<span>📸 Memory nudge</span>
<span>👚 Wardrobe nudge</span>
</div>
</div> </div>
)} )}
</div> </div>

View file

@ -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_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_reactions_post_idx ON circle_post_reactions(post_id)`,
`CREATE INDEX IF NOT EXISTS circle_invites_token_idx ON circle_invites(token)`, `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[] = []; const results: string[] = [];

View file

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

View file

@ -1,8 +1,29 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/db"; 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 = [ const IAP_SCHEDULE = [
{ name: "BCG", weeks: 0 }, { name: "BCG", weeks: 0 },
{ name: "OPV-0", weeks: 0 }, { name: "OPV-0", weeks: 0 },
@ -29,76 +50,206 @@ const IAP_SCHEDULE = [
{ name: "Vitamin A-2", weeks: 96 }, { name: "Vitamin A-2", weeks: 96 },
{ name: "OPV-5", weeks: 96 }, { name: "OPV-5", weeks: 96 },
{ name: "DPT-Booster-2", weeks: 208 }, { name: "DPT-Booster-2", weeks: 208 },
{ name: "Tetanus and adult diphtheria (Td)", 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 { try {
const auth = await requireFamily(); const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId"); const childId = searchParams.get("childId");
if (!childId) { if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
const ownership = await requireOwnership(childId, "children", "Child"); const istDate = getISTDate();
if (!ownership.success) return NextResponse.json({ error: ownership.error }, { status: ownership.status }); const istHour = getISTHour();
const weekSlot = getISTWeekMonday();
// Get child's birth date // ── Fetch child info ──────────────────────────────────────────────────────
const children = await sql` 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: [] });
if (!children || children.length === 0) {
return NextResponse.json({ notifications: [] });
}
const child = children[0]; const child = children[0];
const birthDate = new Date(child.birth_date); const birthDate = new Date(child.birth_date as string);
const today = new Date();
today.setHours(0, 0, 0, 0);
// Get already given vaccines // ── 1. Vaccine notifications ──────────────────────────────────────────────
const given = await sql` const given = await sql`
SELECT vaccine_name, given_date FROM vaccinations SELECT vaccine_name FROM vaccinations
WHERE child_id = ${childId} AND status = 'given' 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 today = new Date(istDate + "T00:00:00Z"); // IST midnight as Date for comparison
const notifications = [];
for (const vaccine of IAP_SCHEDULE) {
if (givenMap.has(vaccine.name)) continue;
// Calculate due date for (const v of IAP_SCHEDULE) {
const dueDate = new Date(birthDate); if (givenSet.has(v.name)) continue;
dueDate.setDate(dueDate.getDate() + vaccine.weeks * 7);
dueDate.setHours(0, 0, 0, 0);
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 // Only notify if due today or overdue
if (diffDays <= 0) { if (dueDateStr > istDate) continue;
notifications.push({
id: `vaccine-${vaccine.name}`, const diffDays = Math.round((today.getTime() - dueDate.getTime()) / 86_400_000);
type: "vaccine", const isToday = diffDays === 0;
title: diffDays === 0 ? "Vaccine Due Today" : "Vaccine Overdue",
message: `${vaccine.name} is ${diffDays === 0 ? "due today" : `${Math.abs(diffDays)} days overdue`}`, await sql`
vaccineName: vaccine.name, INSERT INTO notifications
dueDate: dueDate.toISOString().split("T")[0], (family_id, child_id, type, title, message, action_url, scheduled_for, metadata)
status: diffDays === 0 ? "due_today" : "overdue", VALUES (
childId, ${familyId}, ${childId},
childName: child.name, ${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
`;
}
}
// ── 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<string, unknown>[]).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 });
} }
} }
return NextResponse.json({ notifications }); // ─── PATCH — mark all notifications as read for this family+child ─────────────
} catch (error) { export async function PATCH(request: NextRequest) {
console.error("Notifications error:", error); try {
return NextResponse.json({ error: String(error) }, { status: 500 }); 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 });
} }
} }