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:
parent
ee4bcc4498
commit
678cf65d70
6 changed files with 429 additions and 163 deletions
38
drizzle/0009_notifications.sql
Normal file
38
drizzle/0009_notifications.sql
Normal 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);
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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 = () => {
|
const markAllRead = async () => {
|
||||||
const allIds = new Set(notifications.map(n => n.id));
|
if (!childId) return;
|
||||||
setReadIds(allIds);
|
setMarkingAll(true);
|
||||||
saveReadSet(allIds);
|
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 (
|
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);
|
<button
|
||||||
const isOverdue = notif.status === "overdue";
|
key={notif.id}
|
||||||
return (
|
onClick={() => markRead(notif)}
|
||||||
<button
|
className={`w-full text-left rounded-2xl border transition-all active:scale-[0.98] ${
|
||||||
key={notif.id}
|
notif.isRead
|
||||||
onClick={() => markRead(notif.id)}
|
? "bg-gray-50 dark:bg-gray-800/40 border-gray-100 dark:border-gray-700/40 opacity-60"
|
||||||
className={`w-full text-left p-4 rounded-xl transition-colors ${
|
: `${notifColor(notif.type)} border shadow-sm`
|
||||||
isRead
|
}`}
|
||||||
? "bg-gray-50 dark:bg-gray-800/60"
|
>
|
||||||
: "bg-white dark:bg-gray-800 shadow-sm"
|
<div className="flex items-start gap-3 p-4">
|
||||||
}`}
|
<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 items-start gap-3">
|
<div className={`font-semibold text-sm ${notif.isRead ? "text-gray-400 dark:text-gray-500" : "text-gray-900 dark:text-white"}`}>
|
||||||
<span className="text-xl mt-0.5">{isOverdue ? "🚨" : "💉"}</span>
|
{notif.title}
|
||||||
<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"}`}>
|
|
||||||
{notif.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{notif.message}
|
|
||||||
</div>
|
|
||||||
{notif.dueDate && (
|
|
||||||
<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" })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{!isRead && (
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 leading-snug">
|
||||||
<div className="w-2 h-2 bg-rose-400 rounded-full mt-2 flex-shrink-0" />
|
{notif.message}
|
||||||
|
</div>
|
||||||
|
{notif.metadata?.dueDate && (
|
||||||
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
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>
|
||||||
</button>
|
{!notif.isRead && (
|
||||||
);
|
<div className="w-2.5 h-2.5 bg-rose-400 rounded-full mt-1.5 flex-shrink-0" />
|
||||||
})
|
)}
|
||||||
|
</div>
|
||||||
|
</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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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[] = [];
|
||||||
|
|
|
||||||
29
src/app/api/notifications/[id]/route.ts
Normal file
29
src/app/api/notifications/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,104 +1,255 @@
|
||||||
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 },
|
||||||
{ name: "HepB-1", weeks: 0 },
|
{ name: "HepB-1", weeks: 0 },
|
||||||
{ name: "OPV-1", weeks: 6 },
|
{ name: "OPV-1", weeks: 6 },
|
||||||
{ name: "Pentavalent-1", weeks: 6 },
|
{ name: "Pentavalent-1", weeks: 6 },
|
||||||
{ name: "PCV-1", weeks: 6 },
|
{ name: "PCV-1", weeks: 6 },
|
||||||
{ name: "Rota-1", weeks: 6 },
|
{ name: "Rota-1", weeks: 6 },
|
||||||
{ name: "OPV-2", weeks: 10 },
|
{ name: "OPV-2", weeks: 10 },
|
||||||
{ name: "Pentavalent-2", weeks: 10 },
|
{ name: "Pentavalent-2", weeks: 10 },
|
||||||
{ name: "PCV-2", weeks: 10 },
|
{ name: "PCV-2", weeks: 10 },
|
||||||
{ name: "Rota-2", weeks: 10 },
|
{ name: "Rota-2", weeks: 10 },
|
||||||
{ name: "OPV-3", weeks: 14 },
|
{ name: "OPV-3", weeks: 14 },
|
||||||
{ name: "Pentavalent-3", weeks: 14 },
|
{ name: "Pentavalent-3", weeks: 14 },
|
||||||
{ name: "PCV-3", weeks: 14 },
|
{ name: "PCV-3", weeks: 14 },
|
||||||
{ name: "Rota-3", weeks: 14 },
|
{ name: "Rota-3", weeks: 14 },
|
||||||
{ name: "MR-1", weeks: 48 },
|
{ name: "MR-1", weeks: 48 },
|
||||||
{ name: "JE-1", weeks: 48 },
|
{ name: "JE-1", weeks: 48 },
|
||||||
{ name: "Vitamin A-1", weeks: 48 },
|
{ name: "Vitamin A-1", weeks: 48 },
|
||||||
{ name: "OPV-4", weeks: 48 },
|
{ name: "OPV-4", weeks: 48 },
|
||||||
{ name: "MR-2", weeks: 96 },
|
{ name: "MR-2", weeks: 96 },
|
||||||
{ name: "JE-2", weeks: 96 },
|
{ name: "JE-2", weeks: 96 },
|
||||||
{ name: "DPT-Booster-1", weeks: 96 },
|
{ name: "DPT-Booster-1", weeks: 96 },
|
||||||
{ 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: [] });
|
||||||
|
const child = children[0];
|
||||||
|
const birthDate = new Date(child.birth_date as string);
|
||||||
|
|
||||||
if (!children || children.length === 0) {
|
// ── 1. Vaccine notifications ──────────────────────────────────────────────
|
||||||
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
|
|
||||||
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
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ notifications });
|
// ── 3. Memory nudge — no photo shared today ───────────────────────────────
|
||||||
} catch (error) {
|
const memCount = await sql`
|
||||||
console.error("Notifications error:", error);
|
SELECT COUNT(*) AS total FROM memories
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue