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,
|
||||
"tag": "0008_pediatrician_name",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1748880000000,
|
||||
"tag": "0009_notifications",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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<string> {
|
||||
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<string>) {
|
||||
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<Notification[]>([]);
|
||||
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
||||
const [items, setItems] = useState<AppNotification[]>([]);
|
||||
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 = 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 allIds = new Set(notifications.map(n => n.id));
|
||||
setReadIds(allIds);
|
||||
saveReadSet(allIds);
|
||||
};
|
||||
|
||||
const unreadCount = notifications.filter(n => !readIds.has(n.id)).length;
|
||||
const unreadCount = items.filter(n => !n.isRead).length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
|
||||
{/* 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>
|
||||
<h1 className="text-xl font-bold">Notifications</h1>
|
||||
<h1 className="text-xl font-bold flex-1">Notifications</h1>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
</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 ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<div className="text-4xl mb-3 animate-pulse">🔔</div>
|
||||
<p className="text-sm">Loading…</p>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<div className="text-6xl mb-4">🔔</div>
|
||||
<p className="font-medium">All caught up!</p>
|
||||
<p className="text-sm mt-1">No overdue or due-today vaccinations</p>
|
||||
<p className="font-semibold text-gray-600 dark:text-gray-300">All caught up!</p>
|
||||
<p className="text-sm mt-1 text-gray-400">No pending reminders right now</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map(notif => {
|
||||
const isRead = readIds.has(notif.id);
|
||||
const isOverdue = notif.status === "overdue";
|
||||
return (
|
||||
items.map(notif => (
|
||||
<button
|
||||
key={notif.id}
|
||||
onClick={() => markRead(notif.id)}
|
||||
className={`w-full text-left p-4 rounded-xl transition-colors ${
|
||||
isRead
|
||||
? "bg-gray-50 dark:bg-gray-800/60"
|
||||
: "bg-white dark:bg-gray-800 shadow-sm"
|
||||
onClick={() => markRead(notif)}
|
||||
className={`w-full text-left rounded-2xl border transition-all active:scale-[0.98] ${
|
||||
notif.isRead
|
||||
? "bg-gray-50 dark:bg-gray-800/40 border-gray-100 dark:border-gray-700/40 opacity-60"
|
||||
: `${notifColor(notif.type)} border shadow-sm`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl mt-0.5">{isOverdue ? "🚨" : "💉"}</span>
|
||||
<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={`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}
|
||||
</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}
|
||||
</div>
|
||||
{notif.dueDate && (
|
||||
{notif.metadata?.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" })}
|
||||
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>
|
||||
{!isRead && (
|
||||
<div className="w-2 h-2 bg-rose-400 rounded-full mt-2 flex-shrink-0" />
|
||||
{!notif.isRead && (
|
||||
<div className="w-2.5 h-2.5 bg-rose-400 rounded-full mt-1.5 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<div className="px-4 mt-6 pb-8">
|
||||
<p className="text-center text-xs text-gray-400">
|
||||
Showing overdue & due-today IAP vaccinations for {notifications[0]?.childName || "your child"}
|
||||
</p>
|
||||
{/* Legend */}
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="px-4 pb-8">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 justify-center text-xs text-gray-400">
|
||||
<span>🚨 Vaccine overdue</span>
|
||||
<span>💉 Due today</span>
|
||||
<span>🍼 Log reminder</span>
|
||||
<span>📸 Memory nudge</span>
|
||||
<span>👚 Wardrobe nudge</span>
|
||||
</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_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[] = [];
|
||||
|
|
|
|||
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,8 +1,29 @@
|
|||
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 },
|
||||
|
|
@ -29,76 +50,206 @@ const IAP_SCHEDULE = [
|
|||
{ 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: "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");
|
||||
|
||||
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 || children.length === 0) {
|
||||
return NextResponse.json({ notifications: [] });
|
||||
}
|
||||
|
||||
if (!children[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);
|
||||
const birthDate = new Date(child.birth_date as string);
|
||||
|
||||
// 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
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 });
|
||||
} catch (error) {
|
||||
console.error("Notifications error:", error);
|
||||
return NextResponse.json({ error: String(error) }, { 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