feat: daily digest, photo timeline, milk expiry cron, batch delete, duration chart
- /api/cron/daily-digest: last feed/diaper, 24h sleep total, next med due - /photos: photo grid grouped by day, full-screen lightbox with prev/next - /api/cron/milk-expiry: push alert for lots expiring within 24h - Timeline: select mode with checkboxes, confirm + bulk DELETE - Stats: avg NAP/NIGHT_SLEEP duration trend dual-line chart (30d) - Nav: Photos link added under "Vie du bébé" drawer group Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendPushover } from "@/lib/push-notify";
|
||||
|
||||
// Call daily (e.g. 07:00) with:
|
||||
// POST /api/cron/daily-digest
|
||||
// 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 || "";
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
function fmtTime(date: Date): string {
|
||||
return date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Paris" });
|
||||
}
|
||||
|
||||
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 since = new Date(Date.now() - 24 * 3600 * 1000);
|
||||
|
||||
const families = await prisma.family.findMany({
|
||||
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;
|
||||
|
||||
for (const baby of family.babies) {
|
||||
const events = await prisma.event.findMany({
|
||||
where: { babyId: baby.id, startedAt: { gte: since } },
|
||||
orderBy: { startedAt: "desc" },
|
||||
});
|
||||
|
||||
const lines: string[] = [`☀️ Résumé — ${baby.name}`, ""];
|
||||
|
||||
// Last feed
|
||||
const lastFeed = events.find((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
|
||||
if (lastFeed) {
|
||||
const label = lastFeed.type === "BREASTFEED" ? "allaitement" : "biberon";
|
||||
const ago = fmtMs(Date.now() - new Date(lastFeed.startedAt).getTime());
|
||||
lines.push(`🍼 Dernier repas : ${label} il y a ${ago} (${fmtTime(new Date(lastFeed.startedAt))})`);
|
||||
} else {
|
||||
lines.push("🍼 Aucun repas dans les 24h");
|
||||
}
|
||||
|
||||
// Last diaper
|
||||
const lastDiaper = events.find((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL");
|
||||
if (lastDiaper) {
|
||||
const ago = fmtMs(Date.now() - new Date(lastDiaper.startedAt).getTime());
|
||||
lines.push(`🧷 Dernière couche : il y a ${ago}`);
|
||||
} else {
|
||||
lines.push("🧷 Aucune couche dans les 24h");
|
||||
}
|
||||
|
||||
// Sleep total
|
||||
const sleepEvents = events.filter(
|
||||
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
|
||||
);
|
||||
const sleepMs = sleepEvents.reduce(
|
||||
(acc, e) => acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()),
|
||||
0
|
||||
);
|
||||
if (sleepMs > 0) {
|
||||
lines.push(`😴 Sommeil total : ${fmtMs(sleepMs)}`);
|
||||
}
|
||||
|
||||
// Next medication due
|
||||
const medEvents = events.filter((e) => e.type === "MEDICATION");
|
||||
if (medEvents.length > 0) {
|
||||
const byProfile: Record<string, { lastAt: Date; intervalHours: number; name: string }> = {};
|
||||
for (const ev of medEvents) {
|
||||
const meta = (ev.metadata ?? {}) as Record<string, unknown>;
|
||||
const profileId = meta.profileId as string | undefined;
|
||||
const name = (meta.name as string) ?? "Médicament";
|
||||
const intervalHours = (meta.intervalHours as number) ?? 6;
|
||||
if (profileId && (!byProfile[profileId] || new Date(ev.startedAt) > byProfile[profileId].lastAt)) {
|
||||
byProfile[profileId] = { lastAt: new Date(ev.startedAt), intervalHours, name };
|
||||
}
|
||||
}
|
||||
for (const { lastAt, intervalHours, name } of Object.values(byProfile)) {
|
||||
const nextAt = new Date(lastAt.getTime() + intervalHours * 3600000);
|
||||
if (nextAt > new Date()) {
|
||||
const inMs = nextAt.getTime() - Date.now();
|
||||
lines.push(`💊 ${name} : prochain dans ${fmtMs(inMs)} (${fmtTime(nextAt)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = lines.join("\n");
|
||||
|
||||
for (const user of family.users) {
|
||||
if (user.pushoverUserKey) {
|
||||
await sendPushover(user.pushoverUserKey, message, `☀️ Digest — ${baby.name}`);
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, sent });
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendPushover } from "@/lib/push-notify";
|
||||
|
||||
// Call daily with:
|
||||
// POST /api/cron/milk-expiry
|
||||
// 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 || "";
|
||||
}
|
||||
|
||||
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 windowEnd = new Date(Date.now() + 24 * 3600 * 1000);
|
||||
|
||||
const expiringLots = await prisma.milkStock.findMany({
|
||||
where: {
|
||||
used: false,
|
||||
expiresAt: { lte: windowEnd },
|
||||
},
|
||||
include: {
|
||||
baby: {
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { pushoverUserKey: { not: null } },
|
||||
select: { pushoverUserKey: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { expiresAt: "asc" },
|
||||
});
|
||||
|
||||
if (expiringLots.length === 0) {
|
||||
return NextResponse.json({ ok: true, sent: 0 });
|
||||
}
|
||||
|
||||
// Group by family
|
||||
const byFamily = new Map<string, { users: { pushoverUserKey: string | null }[]; lines: string[]; babyName: string }>();
|
||||
|
||||
for (const lot of expiringLots) {
|
||||
const familyId = lot.baby.family.id;
|
||||
if (!byFamily.has(familyId)) {
|
||||
byFamily.set(familyId, { users: lot.baby.family.users, lines: [], babyName: lot.baby.name });
|
||||
}
|
||||
const entry = byFamily.get(familyId)!;
|
||||
const expiresIn = lot.expiresAt.getTime() - Date.now();
|
||||
const hoursLeft = Math.max(0, Math.floor(expiresIn / 3600000));
|
||||
const loc = lot.location === "fridge" ? "frigo" : lot.location === "freezer" ? "congélateur" : "ambiant";
|
||||
const alreadyExpired = expiresIn <= 0;
|
||||
entry.lines.push(
|
||||
alreadyExpired
|
||||
? `⚠️ ${lot.volume} mL (${loc}) — expiré`
|
||||
: `🥛 ${lot.volume} mL (${loc}) — expire dans ${hoursLeft}h`
|
||||
);
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
for (const { users, lines, babyName } of byFamily.values()) {
|
||||
const message = [`🥛 Stock lait — ${babyName}`, "", ...lines].join("\n");
|
||||
for (const user of users) {
|
||||
if (user.pushoverUserKey) {
|
||||
await sendPushover(user.pushoverUserKey, message, `🥛 Alerte stock lait`);
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, sent, lots: expiringLots.length });
|
||||
}
|
||||
Reference in New Issue
Block a user