Initial commit — Grow baby tracker

Next.js 16 App Router, Prisma + PostgreSQL, NextAuth v5 JWT.

Features: dashboard quick-log, timeline, growth charts (WHO percentiles),
stats, journal/notes, doctor notes, milestones, vaccinations, settings,
superadmin panel. Mobile-first with sidebar nav + bottom nav + quick-add FAB.
Dark mode, PWA push notifications, multi-family invite system.

Docker: multi-stage Dockerfile + docker-compose with postgres service.
This commit is contained in:
2026-06-13 01:52:46 +02:00
commit cd16c354c0
94 changed files with 14354 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const [families, babies, events, growthLogs, journalNotes, milestones] = await Promise.all([
prisma.family.findMany({
include: { users: { select: { id: true, name: true, email: true, role: true, isSuperAdmin: true, createdAt: true } } },
}),
prisma.baby.findMany(),
prisma.event.findMany({ orderBy: { createdAt: "desc" }, take: 10000 }),
prisma.growthLog.findMany(),
prisma.journalNote.findMany(),
prisma.milestone.findMany(),
]);
const payload = {
exportedAt: new Date().toISOString(),
families,
babies,
events,
growthLogs,
journalNotes,
milestones,
};
const filename = `grow-backup-${new Date().toISOString().slice(0, 10)}.json`;
return new NextResponse(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
const ALLOWED_KEYS = [
"email_provider",
"mailgun_api_key",
"mailgun_domain",
"mailgun_from",
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_pass",
"smtp_from",
"pushover_app_token",
"allow_registration",
"maintenance_mode",
];
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const configs = await prisma.systemConfig.findMany();
const result: Record<string, string> = {};
for (const c of configs) result[c.key] = c.value;
return NextResponse.json(result);
}
export async function POST(req: Request) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const body: Record<string, string> = await req.json();
await Promise.all(
Object.entries(body)
.filter(([key]) => ALLOWED_KEYS.includes(key))
.map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
})
)
);
return NextResponse.json({ ok: true });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const families = await prisma.family.findMany({
select: {
id: true,
name: true,
inviteCode: true,
createdAt: true,
_count: {
select: {
users: true,
babies: true,
},
},
},
orderBy: { createdAt: "asc" },
});
return NextResponse.json(families);
}
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
function generateInviteCode(): string {
return crypto.randomUUID().replace(/-/g, "").slice(0, 25);
}
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
const inviteCode = generateInviteCode();
await prisma.family.update({
where: { id },
data: { inviteCode },
});
return NextResponse.json({ inviteCode });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const [userCount, familyCount, babyCount, eventCount, growthLogCount] = await Promise.all([
prisma.user.count(),
prisma.family.count(),
prisma.baby.count(),
prisma.event.count(),
prisma.growthLog.count(),
]);
return NextResponse.json({ userCount, familyCount, babyCount, eventCount, growthLogCount });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function POST() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
if (!session.user?.email) {
return NextResponse.json({ error: "Email de l'administrateur introuvable" }, { status: 400 });
}
try {
await sendEmail({
to: session.user!.email as string,
subject: "Test email — Grow",
html: "<p>La configuration email fonctionne correctement.</p>",
text: "La configuration email fonctionne correctement.",
});
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
return NextResponse.json({ error: message }, { status: 502 });
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user!.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function POST() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const [appTokenRow, user] = await Promise.all([
prisma.systemConfig.findUnique({ where: { key: "pushover_app_token" } }),
prisma.user.findUnique({ where: { id: session.user!.id }, select: { pushoverUserKey: true } }),
]);
const appToken = appTokenRow?.value ?? process.env.PUSHOVER_APP_TOKEN ?? "";
const userKey = user?.pushoverUserKey ?? "";
if (!appToken) {
return NextResponse.json({ error: "Token application Pushover non configuré" }, { status: 400 });
}
if (!userKey) {
return NextResponse.json({ error: "Clé utilisateur Pushover non configurée" }, { status: 400 });
}
const formData = new FormData();
formData.append("token", appToken);
formData.append("user", userKey);
formData.append("title", "Grow — Test de notification");
formData.append("message", "La configuration Pushover fonctionne correctement.");
const res = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
body: formData,
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: `Erreur Pushover : ${text}` }, { status: 502 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
if (id === session.user?.id) {
return NextResponse.json({ error: "Impossible de vous déconnecter vous-même" }, { status: 400 });
}
await prisma.user.update({
where: { id },
data: { forcedLogoutAt: new Date() },
});
return NextResponse.json({ ok: true });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
const VALID_ROLES = ["PARENT", "READONLY", "DOCTOR"] as const;
type ValidRole = typeof VALID_ROLES[number];
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
const body: { role?: string } = await req.json();
if (!body.role || !(VALID_ROLES as readonly string[]).includes(body.role)) {
return NextResponse.json({ error: "Rôle invalide" }, { status: 400 });
}
const updated = await prisma.user.update({
where: { id },
data: { role: body.role as ValidRole },
select: { id: true, name: true, email: true, role: true },
});
return NextResponse.json(updated);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const { id } = await params;
if (id === session.user?.id) {
return NextResponse.json({ error: "Impossible de supprimer votre propre compte" }, { status: 400 });
}
await prisma.user.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const SUPERADMIN_EMAIL = process.env.SUPERADMIN_EMAIL;
async function requireSuperAdmin() {
const session = await auth();
if (!session?.user?.id) return null;
if (SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL) return null;
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.isSuperAdmin && session.user.email !== SUPERADMIN_EMAIL) return null;
return session;
}
export async function GET() {
const session = await requireSuperAdmin();
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
role: true,
isSuperAdmin: true,
familyId: true,
family: { select: { name: true } },
createdAt: true,
},
orderBy: { createdAt: "asc" },
});
return NextResponse.json(users);
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { sendEmail, resetPasswordEmail } from "@/lib/email";
export async function POST(req: Request) {
const { email } = await req.json();
if (!email) return NextResponse.json({ error: "Email requis" }, { status: 400 });
const user = await prisma.user.findUnique({ where: { email } });
// Always return success to prevent user enumeration
if (!user) return NextResponse.json({ ok: true });
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
const reset = await prisma.passwordResetToken.create({
data: { userId: user.id, expiresAt },
});
const origin = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const resetUrl = `${origin}/auth/reset-password?token=${reset.token}`;
try {
const { html, text } = resetPasswordEmail(user.name, resetUrl);
await sendEmail({ to: user.email, subject: "Réinitialisation de votre mot de passe — Grow", html, text });
} catch (err) {
console.error("Email send failed:", err);
// Still succeed silently to avoid leaking info
}
return NextResponse.json({ ok: true });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function POST(req: Request) {
const { token, password } = await req.json();
if (!token || !password || password.length < 8) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
const reset = await prisma.passwordResetToken.findUnique({ where: { token } });
if (!reset || reset.used || reset.expiresAt < new Date()) {
return NextResponse.json({ error: "Lien expiré ou invalide" }, { status: 400 });
}
const hashed = await bcrypt.hash(password, 12);
await Promise.all([
prisma.user.update({ where: { id: reset.userId }, data: { password: hashed } }),
prisma.passwordResetToken.update({ where: { id: reset.id }, data: { used: true } }),
]);
return NextResponse.json({ ok: true });
}
+25
View File
@@ -0,0 +1,25 @@
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 { name, birthDate, gender } = await req.json();
const baby = await prisma.baby.update({
where: { id },
data: {
...(name ? { name } : {}),
...(birthDate ? { birthDate: new Date(birthDate) } : {}),
...(gender !== undefined ? { gender } : {}),
},
});
return NextResponse.json(baby);
}
+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 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 },
include: {
family: {
include: {
babies: true,
users: { select: { id: true, name: true, email: true, role: true } },
},
},
},
});
return NextResponse.json(user?.family ?? null);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { name, birthDate, gender } = await req.json();
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.familyId) return NextResponse.json({ error: "Aucune famille" }, { status: 400 });
const baby = await prisma.baby.create({
data: { name, birthDate: new Date(birthDate), gender, familyId: user.familyId },
});
return NextResponse.json(baby);
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
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 userRole = (session.user as { role?: string }).role;
if (userRole !== "DOCTOR") {
return NextResponse.json({ error: "Réservé aux médecins" }, { status: 403 });
}
const { id } = await params;
const note = await prisma.doctorNote.findUnique({ where: { id } });
if (!note) {
return NextResponse.json({ error: "Observation introuvable" }, { status: 404 });
}
if (note.userId !== session.user.id) {
return NextResponse.json({ error: "Vous ne pouvez supprimer que vos propres observations" }, { status: 403 });
}
await prisma.doctorNote.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+53
View File
@@ -0,0 +1,53 @@
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 notes = await prisma.doctorNote.findMany({
where: { babyId },
orderBy: { date: "desc" },
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(notes);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userRole = (session.user as { role?: string }).role;
if (userRole !== "DOCTOR") {
return NextResponse.json({ error: "Réservé aux médecins" }, { status: 403 });
}
const body = await req.json();
const { babyId, content, date } = body;
if (!babyId || !content || !date) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const noteDate = new Date(date);
noteDate.setHours(12, 0, 0, 0);
const note = await prisma.doctorNote.create({
data: {
babyId,
userId: session.user.id,
content,
date: noteDate,
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(note, { status: 201 });
}
+41
View File
@@ -0,0 +1,41 @@
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 event = await prisma.event.update({
where: { id },
data: {
...(body.notes !== undefined ? { notes: body.notes } : {}),
...(body.startedAt ? { startedAt: new Date(body.startedAt) } : {}),
...(body.endedAt !== undefined
? { endedAt: body.endedAt ? new Date(body.endedAt) : null }
: {}),
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(event);
}
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.event.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+67
View File
@@ -0,0 +1,67 @@
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");
const limit = parseInt(searchParams.get("limit") ?? "50");
const offset = parseInt(searchParams.get("offset") ?? "0");
const type = searchParams.get("type");
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const where: Record<string, unknown> = { babyId };
if (type) where.type = type;
if (dateFrom || dateTo) {
where.startedAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(dateTo) } : {}),
};
}
const [events, total] = await Promise.all([
prisma.event.findMany({
where,
orderBy: { startedAt: "desc" },
take: limit,
skip: offset,
include: { user: { select: { id: true, name: true } } },
}),
prisma.event.count({ where }),
]);
return NextResponse.json({ events, total });
}
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, type, startedAt, endedAt, notes, metadata } = body;
if (!babyId || !type || !startedAt) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const event = await prisma.event.create({
data: {
babyId,
userId: session.user.id,
type,
startedAt: new Date(startedAt),
endedAt: endedAt ? new Date(endedAt) : null,
notes: notes ?? null,
metadata: metadata ?? null,
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(event, { status: 201 });
}
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
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");
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
const outputFormat = searchParams.get("format");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const where: Record<string, unknown> = { babyId };
if (dateFrom || dateTo) {
where.startedAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(dateTo) } : {}),
};
}
const events = await prisma.event.findMany({
where,
orderBy: { startedAt: "asc" },
include: { user: { select: { name: true } } },
});
if (outputFormat === "json") {
return NextResponse.json(events.map((e) => ({
type: e.type,
startedAt: e.startedAt,
endedAt: e.endedAt,
notes: e.notes,
metadata: e.metadata,
user: e.user.name,
})));
}
const rows = [
["Date", "Heure", "Type", "Durée (min)", "Noté par", "Notes", "Détails"].join(","),
...events.map((e) => {
const duration =
e.endedAt
? Math.round((e.endedAt.getTime() - e.startedAt.getTime()) / 60000)
: "";
const details = e.metadata ? JSON.stringify(e.metadata).replace(/"/g, "'") : "";
return [
format(e.startedAt, "dd/MM/yyyy", { locale: fr }),
format(e.startedAt, "HH:mm"),
EVENT_CONFIG[e.type as EventType]?.label ?? e.type,
duration,
e.user.name,
`"${(e.notes ?? "").replace(/"/g, "'")}"`,
`"${details}"`,
].join(",");
}),
].join("\n");
return new NextResponse(rows, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="grow-export-${format(new Date(), "yyyy-MM-dd")}.csv"`,
},
});
}
+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 role = (session.user as { role?: string }).role;
if (role === "READONLY") return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
const { id } = await params;
const { date, weight, height, headCirc, notes } = await req.json();
const log = await prisma.growthLog.update({
where: { id },
data: {
...(date ? { date: new Date(date) } : {}),
...(weight !== undefined ? { weight: weight ? weight * 1000 : null } : {}),
...(height !== undefined ? { height: height ?? null } : {}),
...(headCirc !== undefined ? { headCirc: headCirc ?? null } : {}),
...(notes !== undefined ? { notes: notes || null } : {}),
},
});
return NextResponse.json(log);
}
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 role = (session.user as { role?: string }).role;
if (role === "READONLY") return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
const { id } = await params;
await prisma.growthLog.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+43
View File
@@ -0,0 +1,43 @@
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 logs = await prisma.growthLog.findMany({
where: { babyId },
orderBy: { date: "asc" },
});
return NextResponse.json(logs);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { babyId, date, weight, height, headCirc, notes } = await req.json();
if (!babyId || !date) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const log = await prisma.growthLog.create({
data: {
babyId,
date: new Date(date),
weight: weight ?? null,
height: height ?? null,
headCirc: headCirc ?? null,
notes: notes ?? null,
},
});
return NextResponse.json(log, { status: 201 });
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
const VALID_ROLES = ["PARENT", "READONLY", "DOCTOR"] as const;
type Role = (typeof VALID_ROLES)[number];
export async function POST(req: Request) {
const { name, email, password, inviteCode, role } = await req.json();
if (!name || !email || !password || !inviteCode) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const assignedRole: Role = VALID_ROLES.includes(role) ? role : "PARENT";
const family = await prisma.family.findUnique({ where: { inviteCode } });
if (!family) {
return NextResponse.json({ error: "Code d'invitation invalide" }, { status: 404 });
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "Email déjà utilisé" }, { status: 409 });
}
const hashed = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { name, email, password: hashed, familyId: family.id, role: assignedRole },
});
return NextResponse.json({ id: user.id, email: user.email, name: user.name, role: user.role });
}
+61
View File
@@ -0,0 +1,61 @@
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();
if (body.content === undefined) {
return NextResponse.json({ error: "content requis" }, { status: 400 });
}
const existing = await prisma.journalNote.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "Note introuvable" }, { status: 404 });
const userRole = (session.user as { role?: string }).role;
const isOwner = existing.userId === session.user.id;
const isParent = userRole === "PARENT";
if (!isOwner && !isParent) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const note = await prisma.journalNote.update({
where: { id },
data: { content: body.content },
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(note);
}
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;
const existing = await prisma.journalNote.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "Note introuvable" }, { status: 404 });
const userRole = (session.user as { role?: string }).role;
const isOwner = existing.userId === session.user.id;
const isParent = userRole === "PARENT";
if (!isOwner && !isParent) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
await prisma.journalNote.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+64
View File
@@ -0,0 +1,64 @@
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");
const date = searchParams.get("date");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const where: Record<string, unknown> = { babyId };
if (date) {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(date);
end.setHours(23, 59, 59, 999);
where.date = { gte: start, lte: end };
}
const notes = await prisma.journalNote.findMany({
where,
orderBy: { date: "desc" },
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(notes);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userRole = (session.user as { role?: string }).role;
if (userRole === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const body = await req.json();
const { babyId, date, content } = body;
if (!babyId || !date || !content) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const noteDate = new Date(date);
noteDate.setHours(12, 0, 0, 0);
const note = await prisma.journalNote.create({
data: {
babyId,
userId: session.user.id,
date: noteDate,
content,
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(note, { status: 201 });
}
+48
View File
@@ -0,0 +1,48 @@
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 role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
const body = await req.json();
const milestone = await prisma.milestone.update({
where: { id },
data: {
...(body.title !== undefined ? { title: body.title } : {}),
...(body.date !== undefined ? { date: new Date(body.date) } : {}),
...(body.notes !== undefined ? { notes: body.notes } : {}),
...(body.icon !== undefined ? { icon: body.icon } : {}),
},
});
return NextResponse.json(milestone);
}
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 role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
await prisma.milestone.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+49
View File
@@ -0,0 +1,49 @@
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 milestones = await prisma.milestone.findMany({
where: { babyId },
orderBy: { date: "desc" },
});
return NextResponse.json(milestones);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const body = await req.json();
const { babyId, title, date, notes, icon } = body;
if (!babyId || !title || !date) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const milestone = await prisma.milestone.create({
data: {
babyId,
title,
date: new Date(date),
notes: notes ?? null,
icon: icon ?? null,
},
});
return NextResponse.json(milestone, { status: 201 });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notifyFamilyFeedAlert } from "@/lib/push-notify";
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { babyId, thresholdHours } = await req.json();
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const baby = await prisma.baby.findUnique({
where: { id: babyId },
select: { name: true, familyId: true },
});
if (!baby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
const lastFeed = await prisma.event.findFirst({
where: { babyId, type: { in: ["BREASTFEED", "BOTTLE"] } },
orderBy: { startedAt: "desc" },
});
if (!lastFeed) {
return NextResponse.json({ sent: false, reason: "no_feed_data" });
}
const hoursAgo = (Date.now() - lastFeed.startedAt.getTime()) / 3600000;
const threshold = thresholdHours ?? 3;
if (hoursAgo < threshold) {
return NextResponse.json({ sent: false, reason: "threshold_not_reached", hoursAgo });
}
const hStr = Math.floor(hoursAgo);
const mStr = Math.round((hoursAgo % 1) * 60);
const message = `${baby.name} n'a pas mangé depuis ${hStr}h${mStr.toString().padStart(2, "0")} — pensez à la prochaine tétée.`;
try {
await notifyFamilyFeedAlert(baby.familyId, message);
return NextResponse.json({ sent: true, hoursAgo });
} catch (err) {
return NextResponse.json({ sent: false, error: String(err) }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const { name, email, password } = await req.json();
if (!name || !email || !password) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
// Check if registration is disabled (superadmin can always register)
const isSuperAdminEmail = !!process.env.SUPERADMIN_EMAIL && email === process.env.SUPERADMIN_EMAIL;
if (!isSuperAdminEmail) {
const regConfig = await prisma.systemConfig.findUnique({ where: { key: "allow_registration" } });
if (regConfig && regConfig.value === "false") {
return NextResponse.json({ error: "Les inscriptions sont désactivées." }, { status: 403 });
}
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "Email déjà utilisé" }, { status: 409 });
}
const hashed = await bcrypt.hash(password, 12);
const family = await prisma.family.create({
data: { name: `Famille de ${name}` },
});
const isSuperAdmin = !!process.env.SUPERADMIN_EMAIL && email === process.env.SUPERADMIN_EMAIL;
const user = await prisma.user.create({
data: { name, email, password: hashed, familyId: family.id, isSuperAdmin },
});
return NextResponse.json({ id: user.id, email: user.email, name: user.name });
}
+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?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { pushoverUserKey: true } });
return NextResponse.json({ pushoverUserKey: user?.pushoverUserKey ?? "" });
}
export async function PATCH(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { pushoverUserKey } = await req.json();
await prisma.user.update({ where: { id: session.user.id }, data: { pushoverUserKey: pushoverUserKey || null } });
return NextResponse.json({ ok: true });
}
+55
View File
@@ -0,0 +1,55 @@
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 role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
const body = await req.json();
const vaccination = await prisma.vaccination.update({
where: { id },
data: {
...(body.name !== undefined ? { name: body.name } : {}),
...(body.date !== undefined
? { date: body.date ? new Date(body.date) : null }
: {}),
...(body.dueDate !== undefined
? { dueDate: body.dueDate ? new Date(body.dueDate) : null }
: {}),
...(body.notes !== undefined ? { notes: body.notes } : {}),
...(body.done !== undefined ? { done: body.done } : {}),
},
});
return NextResponse.json(vaccination);
}
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 role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const { id } = await params;
await prisma.vaccination.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";
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 vaccinations = await prisma.vaccination.findMany({
where: { babyId },
orderBy: [{ dueDate: "asc" }, { date: "asc" }],
});
// Move nulls last for dueDate
const withDue = vaccinations.filter((v) => v.dueDate !== null);
const withoutDue = vaccinations.filter((v) => v.dueDate === null);
return NextResponse.json([...withDue, ...withoutDue]);
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const role = (session.user as { role?: string }).role;
if (role === "READONLY") {
return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 });
}
const body = await req.json();
const { babyId, name, date, dueDate, notes } = body;
if (!babyId || !name) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const vaccination = await prisma.vaccination.create({
data: {
babyId,
name,
date: date ? new Date(date) : null,
dueDate: dueDate ? new Date(dueDate) : null,
notes: notes ?? null,
},
});
return NextResponse.json(vaccination, { status: 201 });
}