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:
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user