feat: maintenance banner, activities log, pinned note, event templates, weekly summary cron

Maintenance mode banner:
- App layout reads SystemConfig maintenance_mode at server render time
- Amber sticky banner for regular users, dimmed indicator for superadmin

Baby activities (3 new event types):
- BATH (Bath icon, sky, timer+duration)
- WALK / Balade (Footprints icon, lime)
- TUMMY_TIME (Baby icon, amber)
- Added to EventType enum, EVENT_CONFIG, event-icon.tsx, dashboard quick-log

Pinned family note:
- Shared across all family members, shown at top of dashboard
- Inline edit with author + timestamp; /api/family/pinned-note GET/PATCH
- pinnedNote + pinnedNoteUpdatedAt + pinnedNoteAuthor fields on Family model

Event templates:
- Named quick-log presets per family (type + label + metadata)
- Dashboard row: tap to open pre-filled EventModal; hover ✕ to delete
- Dropdown to create new template; /api/event-templates GET/POST + /[id] PATCH/DELETE
- EventTemplate model in schema

Weekly summary cron:
- POST /api/cron/weekly-summary — auth via Bearer cron_secret (SystemConfig or env)
- Per family, per baby: feeds count, sleep total+avg, diaper count, latest weight
- Sends Pushover to all family members with pushoverUserKey
- Optional body.familyId to target one family

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 15:08:54 +02:00
parent 020539e2e2
commit 42c580d7e2
10 changed files with 570 additions and 15 deletions
+108
View File
@@ -0,0 +1,108 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { sendPushover } from "@/lib/push-notify";
import { formatDuration } from "@/lib/event-config";
// Call weekly (e.g. Sunday 20:00) with:
// POST /api/cron/weekly-summary
// Authorization: Bearer <CRON_SECRET>
// Body: {} (optional: { familyId } to target one family)
async function getCronSecret(): Promise<string> {
const row = await prisma.systemConfig.findUnique({ where: { key: "cron_secret" } });
return row?.value || process.env.CRON_SECRET || "";
}
function fmtMs(ms: number): string {
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
if (h > 0) return `${h}h${m.toString().padStart(2, "0")}`;
return `${m}min`;
}
export async function POST(req: Request) {
const secret = await getCronSecret();
if (secret) {
const auth = req.headers.get("authorization") ?? "";
if (auth !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
}
const body = await req.json().catch(() => ({}));
const targetFamilyId: string | undefined = body.familyId;
const since = new Date(Date.now() - 7 * 24 * 3600 * 1000);
const families = await prisma.family.findMany({
where: targetFamilyId ? { id: targetFamilyId } : undefined,
include: {
babies: true,
users: { where: { pushoverUserKey: { not: null } }, select: { pushoverUserKey: true } },
},
});
let sent = 0;
for (const family of families) {
if (family.users.length === 0) continue;
const lines: string[] = [`📊 Résumé semaine — ${family.name}`, ""];
for (const baby of family.babies) {
lines.push(`👶 ${baby.name}`);
const events = await prisma.event.findMany({
where: { babyId: baby.id, startedAt: { gte: since } },
orderBy: { startedAt: "asc" },
});
// Feedings
const feeds = events.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
if (feeds.length > 0) {
const perDay = (feeds.length / 7).toFixed(1);
lines.push(`🍼 Repas: ${feeds.length} au total (${perDay}/j)`);
}
// Sleep
const sleepEvents = events.filter(
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
);
if (sleepEvents.length > 0) {
const totalSleepMs = sleepEvents.reduce((acc, e) => {
return acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime());
}, 0);
const avgPerDay = totalSleepMs / 7;
lines.push(`😴 Sommeil: ${fmtMs(totalSleepMs)} total, ~${fmtMs(avgPerDay)}/j`);
}
// Diapers
const diapers = events.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL");
if (diapers.length > 0) {
lines.push(`👶 Couches: ${diapers.length} (${(diapers.length / 7).toFixed(1)}/j)`);
}
// Weight (latest growth log in the period)
const latestWeight = await prisma.growthLog.findFirst({
where: { babyId: baby.id, date: { gte: since }, weight: { not: null } },
orderBy: { date: "desc" },
});
if (latestWeight?.weight) {
lines.push(`⚖️ Poids: ${latestWeight.weight} kg`);
}
lines.push("");
}
const message = lines.join("\n").trim();
await Promise.allSettled(
family.users.map((u) =>
sendPushover(u.pushoverUserKey!, message, "Grow — Résumé hebdomadaire")
)
);
sent += family.users.length;
}
return NextResponse.json({ ok: true, sent, families: families.length });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const body = await req.json();
const template = await prisma.eventTemplate.update({
where: { id },
data: {
...(body.label !== undefined ? { label: body.label } : {}),
...(body.type !== undefined ? { type: body.type } : {}),
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
},
});
return NextResponse.json(template);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
await prisma.eventTemplate.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { familyId: true },
});
if (!user?.familyId) return NextResponse.json([]);
const templates = await prisma.eventTemplate.findMany({
where: { familyId: user.familyId },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(templates);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { familyId: true },
});
if (!user?.familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 404 });
const body = await req.json();
const template = await prisma.eventTemplate.create({
data: {
familyId: user.familyId,
label: body.label,
type: body.type,
metadata: body.metadata ?? null,
sortOrder: body.sortOrder ?? 0,
},
});
return NextResponse.json(template, { status: 201 });
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { familyId: true },
});
if (!user?.familyId) return NextResponse.json({ note: null });
const family = await prisma.family.findUnique({
where: { id: user.familyId },
select: { pinnedNote: true, pinnedNoteUpdatedAt: true, pinnedNoteAuthor: true },
});
return NextResponse.json({
note: family?.pinnedNote ?? null,
updatedAt: family?.pinnedNoteUpdatedAt ?? null,
author: family?.pinnedNoteAuthor ?? null,
});
}
export async function PATCH(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { familyId: true, name: true },
});
if (!user?.familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 404 });
const { note } = await req.json();
const family = await prisma.family.update({
where: { id: user.familyId },
data: {
pinnedNote: note || null,
pinnedNoteUpdatedAt: new Date(),
pinnedNoteAuthor: session.user.name ?? user.name,
},
select: { pinnedNote: true, pinnedNoteUpdatedAt: true, pinnedNoteAuthor: true },
});
return NextResponse.json({
note: family.pinnedNote,
updatedAt: family.pinnedNoteUpdatedAt,
author: family.pinnedNoteAuthor,
});
}