feat: sleep chart, bottle-from-stock, growth alert cron

- SleepTimeline component in stats: 7-night bar chart, 19h–11h window,
  NIGHT_SLEEP (indigo) and NAP (violet) blocks, pure CSS positioning
- Bottle modal: fetch available maternal milk lots, chip picker,
  auto-selects oldest, marks selected lot as used after save
- /api/cron/growth-alert: Bearer-auth POST, configurable threshold via
  growth_alert_days SystemConfig key (default 30 days), Pushover alert
  to all family users per baby with no recent weight log
- Admin config ALLOWED_KEYS: add cron_secret + growth_alert_days

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 15:58:13 +02:00
parent 24daa5eb15
commit 81d7e5ad73
5 changed files with 229 additions and 6 deletions
+2
View File
@@ -17,6 +17,8 @@ const ALLOWED_KEYS = [
"pushover_app_token",
"allow_registration",
"maintenance_mode",
"cron_secret",
"growth_alert_days",
];
async function requireSuperAdmin() {
+79
View File
@@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { sendPushover } from "@/lib/push-notify";
// Call daily with:
// POST /api/cron/growth-alert
// Authorization: Bearer <CRON_SECRET>
async function getCronSecret(): Promise<string> {
const row = await prisma.systemConfig.findUnique({ where: { key: "cron_secret" } });
return row?.value || process.env.CRON_SECRET || "";
}
async function getAlertDays(): Promise<number> {
const row = await prisma.systemConfig.findUnique({ where: { key: "growth_alert_days" } });
const n = parseInt(row?.value ?? "");
return isNaN(n) || n <= 0 ? 30 : n;
}
export async function POST(req: Request) {
const secret = await getCronSecret();
if (secret) {
const authHeader = req.headers.get("authorization") ?? "";
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
}
const alertDays = await getAlertDays();
const threshold = new Date(Date.now() - alertDays * 24 * 3600 * 1000);
const families = await prisma.family.findMany({
include: {
babies: {
include: {
growthLogs: {
orderBy: { date: "desc" },
take: 1,
},
},
},
users: {
where: { pushoverUserKey: { not: null } },
select: { pushoverUserKey: true },
},
},
});
const alerts: { familyId: string; babyName: string }[] = [];
for (const family of families) {
if (family.users.length === 0) continue;
for (const baby of family.babies) {
const lastLog = baby.growthLogs[0];
const lastDate = lastLog ? new Date(lastLog.date) : null;
if (!lastDate || lastDate < threshold) {
alerts.push({ familyId: family.id, babyName: baby.name });
const daysSince = lastDate
? Math.floor((Date.now() - lastDate.getTime()) / 86400000)
: null;
const message = daysSince !== null
? `Pas de mesure de croissance depuis ${daysSince} jours pour ${baby.name}. Pensez à peser votre bébé !`
: `Aucune mesure de croissance enregistrée pour ${baby.name}. Pensez à peser votre bébé !`;
for (const user of family.users) {
if (user.pushoverUserKey) {
await sendPushover(user.pushoverUserKey, message, `📏 Rappel croissance — ${baby.name}`);
}
}
}
}
}
return NextResponse.json({ ok: true, alertsSent: alerts.length, alerts, alertDays });
}