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);
}