feat: pump tracker, photo log, milk stock, medication profiles, calendar, reminders, audit log, edit events

New pages:
- /calendar — monthly grid with event dots, day detail on tap
- /medications — CRUD for medication profiles (presets + custom, dose/unit/intervals/crisis mode)
- /milk — milk stock management with location-based expiry, alerts, mark-as-used
- /reminders — recurring medication reminder CRUD with Pushover notifications

New APIs:
- /api/medication-profiles — profiles CRUD + last-intakes endpoint (auto-seeds 10 presets per family)
- /api/milk — milk stock CRUD with auto-calculated expiry
- /api/reminders + /api/reminders/check — reminder CRUD + cron-callable check endpoint
- /api/upload — multipart photo upload to public/uploads/
- /api/invite/send-email — email invitation with family invite link
- /api/admin/audit — last 100 audit log entries (superadmin only)

Event modal improvements:
- PUMP event type (side selector + volume + timer)
- MEDICATION: profile chip picker, next-dose timing status, crisis mode toggle
- Photo attachment (upload + thumbnail preview)
- Datetime inputs split into date + time (iOS Safari datetime-local fix)
- Edit mode via initialEvent prop (pre-fills all fields, saves via PATCH)
- Timer now shows h:mm:ss when >= 1 hour

Timeline:
- Modify button opens pre-filled edit modal per event
- Photo thumbnail in expanded view

Dashboard:
- PUMP in quick-log
- Medication status card (too-soon / available with next-dose time)

Schema additions: MedicationProfile, MedcationReminder, MilkStock, AuditLog models

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 15:01:38 +02:00
parent 091af949ee
commit 020539e2e2
31 changed files with 2206 additions and 63 deletions
+18
View File
@@ -0,0 +1,18 @@
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?.email) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
if (session.user.email !== process.env.SUPERADMIN_EMAIL) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const logs = await prisma.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 100,
});
return NextResponse.json(logs);
}
+3
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/audit";
export async function PATCH(
req: Request,
@@ -25,6 +26,7 @@ export async function PATCH(
include: { user: { select: { id: true, name: true } } },
});
await logAudit(session, { action: "event.update", targetId: id });
return NextResponse.json(event);
}
@@ -37,5 +39,6 @@ export async function DELETE(
const { id } = await params;
await prisma.event.delete({ where: { id } });
await logAudit(session, { action: "event.delete", targetId: id });
return new NextResponse(null, { status: 204 });
}
+3
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/audit";
export async function GET(req: Request) {
const session = await auth();
@@ -63,5 +64,7 @@ export async function POST(req: Request) {
include: { user: { select: { id: true, name: true } } },
});
await logAudit(session, { action: "event.create", targetId: event.id, detail: `${type} pour bébé ${babyId}` });
return NextResponse.json(event, { status: 201 });
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
const VALID_ROLES = ["PARENT", "READONLY", "DOCTOR"] as const;
type Role = (typeof VALID_ROLES)[number];
const ROLE_LABELS: Record<Role, string> = {
PARENT: "Parent",
READONLY: "Lecture seule",
DOCTOR: "Médecin",
};
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { email, role } = await req.json();
if (!email || !role || !VALID_ROLES.includes(role)) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: { family: true },
});
if (!user?.family) {
return NextResponse.json({ error: "Famille introuvable" }, { status: 404 });
}
const origin = req.headers.get("origin") ?? process.env.NEXTAUTH_URL ?? "";
const inviteUrl = `${origin}/auth/invite/${user.family.inviteCode}?role=${role}`;
const assignedRole = role as Role;
try {
await sendEmail({
to: email,
subject: `Invitation Grow — ${user.family.name}`,
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2 style="color:#4f46e5">Grow — Invitation</h2>
<p><strong>${user.name}</strong> vous invite à rejoindre la famille <strong>${user.family.name}</strong> sur Grow.</p>
<p>Votre rôle : <strong>${ROLE_LABELS[assignedRole]}</strong></p>
<a href="${inviteUrl}" style="display:inline-block;margin:16px 0;padding:12px 24px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;font-weight:600">
Rejoindre la famille
</a>
<p style="color:#64748b;font-size:13px">Ou copiez ce lien : ${inviteUrl}</p>
</div>
`,
text: `${user.name} vous invite à rejoindre ${user.family.name} sur Grow.\n\nRôle : ${ROLE_LABELS[assignedRole]}\n\nLien : ${inviteUrl}`,
});
} catch {
return NextResponse.json({ error: "Envoi email impossible — vérifiez la config email." }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,40 @@
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 profile = await prisma.medicationProfile.update({
where: { id },
data: {
...(body.name !== undefined ? { name: body.name } : {}),
...(body.molecule !== undefined ? { molecule: body.molecule } : {}),
...(body.defaultDose !== undefined ? { defaultDose: body.defaultDose } : {}),
...(body.unit !== undefined ? { unit: body.unit } : {}),
...(body.intervalHours !== undefined ? { intervalHours: parseFloat(body.intervalHours) } : {}),
...(body.minIntervalHours !== undefined ? { minIntervalHours: body.minIntervalHours ? parseFloat(body.minIntervalHours) : null } : {}),
},
});
return NextResponse.json(profile);
}
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.medicationProfile.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Returns the most recent MEDICATION event per medication name for a given baby.
// Used to compute next-allowed-dose times in the UI.
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
// Fetch last 100 medication events — enough to find latest per med
const events = await prisma.event.findMany({
where: { babyId, type: "MEDICATION" },
orderBy: { startedAt: "desc" },
take: 100,
select: { id: true, startedAt: true, metadata: true },
});
// Deduplicate: keep only the latest per medication name
const latest: Record<string, { id: string; startedAt: Date; dose?: string; unit?: string }> = {};
for (const ev of events) {
const meta = ev.metadata as Record<string, unknown> | null;
const name = String(meta?.name ?? "");
if (name && !latest[name]) {
latest[name] = {
id: ev.id,
startedAt: ev.startedAt,
dose: meta?.dose ? String(meta.dose) : undefined,
unit: meta?.unit ? String(meta.unit) : undefined,
};
}
}
return NextResponse.json(latest);
}
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { MEDICATION_PRESETS } from "@/lib/medication-presets";
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 profiles = await prisma.medicationProfile.findMany({
where: { familyId: user.familyId },
orderBy: [{ isPreset: "desc" }, { name: "asc" }],
});
// Auto-seed presets for this family if none exist yet
if (profiles.length === 0) {
await prisma.medicationProfile.createMany({
data: MEDICATION_PRESETS.map((p) => ({
familyId: user.familyId!,
name: p.name,
molecule: p.molecule,
defaultDose: p.defaultDose ?? null,
unit: p.unit,
intervalHours: p.intervalHours,
minIntervalHours: p.minIntervalHours,
isPreset: true,
})),
});
const seeded = await prisma.medicationProfile.findMany({
where: { familyId: user.familyId },
orderBy: [{ isPreset: "desc" }, { name: "asc" }],
});
return NextResponse.json(seeded);
}
return NextResponse.json(profiles);
}
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: 400 });
const body = await req.json();
const { name, molecule, defaultDose, unit, intervalHours, minIntervalHours } = body;
if (!name || !intervalHours) return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
const profile = await prisma.medicationProfile.create({
data: {
familyId: user.familyId,
name,
molecule: molecule ?? null,
defaultDose: defaultDose ?? null,
unit: unit ?? "mL",
intervalHours: parseFloat(intervalHours),
minIntervalHours: minIntervalHours ? parseFloat(minIntervalHours) : null,
isPreset: false,
},
});
return NextResponse.json(profile, { status: 201 });
}
+39
View File
@@ -0,0 +1,39 @@
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 data: Record<string, unknown> = {};
if (body.volume !== undefined) data.volume = parseFloat(body.volume);
if (body.notes !== undefined) data.notes = body.notes;
if (body.location !== undefined) data.location = body.location;
if (body.expiresAt !== undefined) data.expiresAt = new Date(body.expiresAt);
if (body.used !== undefined) {
data.used = body.used;
data.usedAt = body.used ? new Date() : null;
}
const stock = await prisma.milkStock.update({ where: { id }, data });
return NextResponse.json(stock);
}
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.milkStock.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Default expiry hours by location
const EXPIRY_HOURS: Record<string, number> = {
room: 4,
fridge: 96, // 4 days
freezer: 4320, // 6 months
};
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const stocks = await prisma.milkStock.findMany({
where: { babyId },
orderBy: { storedAt: "desc" },
});
return NextResponse.json(stocks);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const body = await req.json();
const { babyId, volume, storedAt, location, notes, expiryHours } = body;
if (!babyId || !volume) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const loc = location ?? "fridge";
const storedDate = storedAt ? new Date(storedAt) : new Date();
const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge;
const expiresAt = new Date(storedDate.getTime() + hours * 60 * 60 * 1000);
const stock = await prisma.milkStock.create({
data: {
babyId,
volume: parseFloat(volume),
storedAt: storedDate,
expiresAt,
location: loc,
notes: notes ?? null,
},
});
return NextResponse.json(stock, { status: 201 });
}
+40
View File
@@ -0,0 +1,40 @@
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 reminder = await prisma.medicationReminder.update({
where: { id },
data: {
...(body.name !== undefined ? { name: body.name } : {}),
...(body.dose !== undefined ? { dose: body.dose } : {}),
...(body.unit !== undefined ? { unit: body.unit } : {}),
...(body.intervalHours !== undefined ? { intervalHours: parseFloat(body.intervalHours) } : {}),
...(body.startAt !== undefined ? { startAt: new Date(body.startAt) } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
},
});
return NextResponse.json(reminder);
}
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.medicationReminder.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { notifyFamilyFeedAlert } from "@/lib/push-notify";
export async function POST() {
const now = new Date();
const reminders = await prisma.medicationReminder.findMany({
where: { enabled: true },
include: { baby: { include: { family: true } } },
});
let sent = 0;
for (const reminder of reminders) {
const intervalMs = reminder.intervalHours * 60 * 60 * 1000;
const nextDue = new Date(
(reminder.lastSentAt ?? reminder.startAt).getTime() + intervalMs
);
if (now >= nextDue) {
const dosePart = reminder.dose ? `${reminder.dose}${reminder.unit ? " " + reminder.unit : ""}` : "";
const message = `Rappel médicament : ${reminder.name}${dosePart} pour ${reminder.baby.name}`;
try {
await notifyFamilyFeedAlert(reminder.baby.familyId, message);
await prisma.medicationReminder.update({
where: { id: reminder.id },
data: { lastSentAt: now },
});
sent++;
} catch {
// continue with other reminders even if one fails
}
}
}
return NextResponse.json({ checked: reminders.length, sent });
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const reminders = await prisma.medicationReminder.findMany({
where: { babyId },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(reminders);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const body = await req.json();
const { babyId, name, dose, unit, intervalHours, startAt } = body;
if (!babyId || !name || !intervalHours || !startAt) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const reminder = await prisma.medicationReminder.create({
data: {
babyId,
name,
dose: dose ?? null,
unit: unit ?? null,
intervalHours: parseFloat(intervalHours),
startAt: new Date(startAt),
},
});
return NextResponse.json(reminder, { status: 201 });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { writeFile } from "fs/promises";
import { join } from "path";
import { randomUUID } from "crypto";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Fichier manquant" }, { status: 400 });
if (!ALLOWED_TYPES.includes(file.type)) return NextResponse.json({ error: "Type de fichier non supporté" }, { status: 400 });
if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 5 MB)" }, { status: 400 });
const ext = file.type.split("/")[1].replace("jpeg", "jpg");
const filename = `${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const uploadDir = join(process.cwd(), "public", "uploads");
await writeFile(join(uploadDir, filename), buffer);
return NextResponse.json({ url: `/uploads/${filename}` });
}