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
+79
View File
@@ -0,0 +1,79 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
pages: {
signIn: "/auth/login",
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Mot de passe", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user?.password) return null;
const valid = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!valid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
familyId: user.familyId,
role: user.role,
isSuperAdmin: user.isSuperAdmin,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.familyId = (user as { familyId?: string | null }).familyId;
token.role = (user as { role?: string }).role ?? "PARENT";
token.isSuperAdmin = (user as { isSuperAdmin?: boolean }).isSuperAdmin ?? false;
}
if (!user && token.id) {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { forcedLogoutAt: true },
});
if (dbUser?.forcedLogoutAt) {
const iat = typeof token.iat === "number" ? token.iat : 0;
if (dbUser.forcedLogoutAt.getTime() / 1000 > iat) {
return null;
}
}
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
(session.user as { familyId?: string | null }).familyId =
token.familyId as string | null;
(session.user as { role?: string }).role = token.role as string;
(session.user as { isSuperAdmin?: boolean }).isSuperAdmin = token.isSuperAdmin as boolean;
}
return session;
},
},
});
+78
View File
@@ -0,0 +1,78 @@
import { prisma } from "./prisma";
interface EmailPayload {
to: string;
subject: string;
html: string;
text?: string;
}
async function getConfig(key: string): Promise<string> {
const row = await prisma.systemConfig.findUnique({ where: { key } });
return row?.value ?? process.env[key.toUpperCase()] ?? "";
}
async function sendMailgun(payload: EmailPayload): Promise<void> {
const apiKey = await getConfig("mailgun_api_key");
const domain = await getConfig("mailgun_domain");
const from = await getConfig("mailgun_from");
if (!apiKey || !domain) throw new Error("Mailgun non configuré");
const formData = new FormData();
formData.append("from", from || `Grow <noreply@${domain}>`);
formData.append("to", payload.to);
formData.append("subject", payload.subject);
formData.append("html", payload.html);
if (payload.text) formData.append("text", payload.text);
const res = await fetch(`https://api.mailgun.net/v3/${domain}/messages`, {
method: "POST",
headers: { Authorization: `Basic ${Buffer.from(`api:${apiKey}`).toString("base64")}` },
body: formData,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Mailgun error: ${err}`);
}
}
async function sendSmtp(payload: EmailPayload): Promise<void> {
const host = await getConfig("smtp_host");
const port = parseInt(await getConfig("smtp_port") || "587");
const user = await getConfig("smtp_user");
const pass = await getConfig("smtp_pass");
const from = await getConfig("smtp_from");
if (!host) throw new Error("SMTP non configuré");
const nodemailer = await import("nodemailer");
const transport = nodemailer.default.createTransport({ host, port, auth: { user, pass } });
await transport.sendMail({ from, to: payload.to, subject: payload.subject, html: payload.html, text: payload.text });
}
export async function sendEmail(payload: EmailPayload): Promise<void> {
const provider = await getConfig("email_provider");
if (provider === "smtp") return sendSmtp(payload);
if (provider === "mailgun") return sendMailgun(payload);
// Fallback: check env vars directly
if (process.env.MAILGUN_API_KEY) return sendMailgun(payload);
if (process.env.SMTP_HOST) return sendSmtp(payload);
throw new Error("Aucun service email configuré");
}
export function resetPasswordEmail(name: string, resetUrl: string): { html: string; text: string } {
return {
text: `Bonjour ${name},\n\nCliquez sur ce lien pour réinitialiser votre mot de passe :\n${resetUrl}\n\nLien valide 1 heure.\n\nGrow`,
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2 style="color:#4f46e5">Grow — Réinitialisation du mot de passe</h2>
<p>Bonjour ${name},</p>
<p>Cliquez sur le bouton ci-dessous pour réinitialiser votre mot de passe. Le lien est valide pendant <strong>1 heure</strong>.</p>
<a href="${resetUrl}" style="display:inline-block;margin:16px 0;padding:12px 24px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;font-weight:600">
Réinitialiser mon mot de passe
</a>
<p style="color:#64748b;font-size:13px">Si vous n'avez pas demandé cette réinitialisation, ignorez cet email.</p>
<p style="color:#64748b;font-size:13px">Ou copiez ce lien : ${resetUrl}</p>
</div>
`,
};
}
+127
View File
@@ -0,0 +1,127 @@
export type EventType =
| "BREASTFEED"
| "BOTTLE"
| "DIAPER_WET"
| "DIAPER_STOOL"
| "NAP"
| "NIGHT_SLEEP"
| "MEDICATION"
| "TEMPERATURE";
export const EVENT_CONFIG: Record<
EventType,
{
label: string;
icon: string; // lucide icon name
color: string; // hex for inline use
colorClass: string; // tailwind text color
bgClass: string; // tailwind bg (light)
borderClass: string; // tailwind border
hasDuration: boolean;
hasTimer: boolean;
}
> = {
BREASTFEED: {
label: "Allaitement",
icon: "Heart",
color: "#f43f5e",
colorClass: "text-rose-500",
bgClass: "bg-rose-50",
borderClass: "border-rose-200",
hasDuration: true,
hasTimer: true,
},
BOTTLE: {
label: "Biberon",
icon: "GlassWater",
color: "#3b82f6",
colorClass: "text-blue-500",
bgClass: "bg-blue-50",
borderClass: "border-blue-200",
hasDuration: false,
hasTimer: false,
},
DIAPER_WET: {
label: "Couche mouillée",
icon: "Droplets",
color: "#06b6d4",
colorClass: "text-cyan-500",
bgClass: "bg-cyan-50",
borderClass: "border-cyan-200",
hasDuration: false,
hasTimer: false,
},
DIAPER_STOOL: {
label: "Selles",
icon: "CircleDot",
color: "#d97706",
colorClass: "text-amber-600",
bgClass: "bg-amber-50",
borderClass: "border-amber-200",
hasDuration: false,
hasTimer: false,
},
NAP: {
label: "Sieste",
icon: "Sunset",
color: "#7c3aed",
colorClass: "text-violet-600",
bgClass: "bg-violet-50",
borderClass: "border-violet-200",
hasDuration: true,
hasTimer: true,
},
NIGHT_SLEEP: {
label: "Sommeil nocturne",
icon: "Moon",
color: "#4338ca",
colorClass: "text-indigo-700",
bgClass: "bg-indigo-50",
borderClass: "border-indigo-200",
hasDuration: true,
hasTimer: true,
},
MEDICATION: {
label: "Traitement",
icon: "Pill",
color: "#16a34a",
colorClass: "text-green-600",
bgClass: "bg-green-50",
borderClass: "border-green-200",
hasDuration: false,
hasTimer: false,
},
TEMPERATURE: {
label: "Température",
icon: "Thermometer",
color: "#dc2626",
colorClass: "text-red-600",
bgClass: "bg-red-50",
borderClass: "border-red-200",
hasDuration: false,
hasTimer: false,
},
};
export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[];
export function formatDuration(startedAt: Date, endedAt: Date): string {
const ms = endedAt.getTime() - startedAt.getTime();
const totalMinutes = Math.floor(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0) return `${hours}h${minutes.toString().padStart(2, "0")}`;
return `${minutes}min`;
}
export function formatTimeAgo(date: Date): string {
const now = new Date();
const ms = now.getTime() - date.getTime();
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `il y a ${days}j`;
if (hours > 0) return `il y a ${hours}h${(minutes % 60).toString().padStart(2, "0")}`;
if (minutes > 0) return `il y a ${minutes}min`;
return "à l'instant";
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
const STORAGE_KEY = "grow_notification_thresholds";
export interface NotificationThresholds {
feedAlertHours: number; // alert if no feed in X hours
enabled: boolean;
}
export function getThresholds(): NotificationThresholds {
if (typeof window === "undefined") return { feedAlertHours: 3, enabled: false };
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { feedAlertHours: 3, enabled: false };
return JSON.parse(raw);
}
export function saveThresholds(t: NotificationThresholds): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
}
export async function requestPermission(): Promise<boolean> {
if (!("Notification" in window)) return false;
if (Notification.permission === "granted") return true;
const result = await Notification.requestPermission();
return result === "granted";
}
export function sendNotification(title: string, body: string): void {
if (typeof window === "undefined") return;
if (Notification.permission !== "granted") return;
new Notification(title, {
body,
icon: "/icon.svg",
badge: "/icon.svg",
tag: "grow-alert",
});
}
export function checkFeedAlert(lastFeedAt: Date | null, thresholds: NotificationThresholds): void {
if (!thresholds.enabled || !lastFeedAt) return;
const hoursSince = (Date.now() - lastFeedAt.getTime()) / 3600000;
if (hoursSince >= thresholds.feedAlertHours) {
const h = Math.floor(hoursSince);
const m = Math.floor((hoursSince - h) * 60);
sendNotification(
"🍼 Rappel repas — Grow",
`Dernier repas il y a ${h}h${m.toString().padStart(2, "0")}. Heure de nourrir bébé ?`
);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createClient() {
const adapter = new PrismaPg(process.env.DATABASE_URL!);
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
}
export const prisma = globalForPrisma.prisma ?? createClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+31
View File
@@ -0,0 +1,31 @@
import { prisma } from "./prisma";
async function getConfig(key: string): Promise<string> {
const row = await prisma.systemConfig.findUnique({ where: { key } });
return row?.value ?? process.env[key.toUpperCase()] ?? "";
}
export async function sendPushover(userKey: string, message: string, title = "Grow"): Promise<void> {
const token = await getConfig("pushover_app_token");
if (!token) throw new Error("Pushover non configuré (PUSHOVER_APP_TOKEN manquant)");
const res = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, user: userKey, message, title }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`Pushover error: ${JSON.stringify(err)}`);
}
}
export async function notifyFamilyFeedAlert(familyId: string, message: string): Promise<void> {
const users = await prisma.user.findMany({
where: { familyId, pushoverUserKey: { not: null } },
select: { pushoverUserKey: true },
});
await Promise.allSettled(
users.map((u) => sendPushover(u.pushoverUserKey!, message))
);
}
+53
View File
@@ -0,0 +1,53 @@
// WHO Child Growth Standards — Weight-for-age (girls & boys combined approximation)
// Values in kg, indexed by age in weeks (052)
export const WHO_WEIGHT_PERCENTILES: Record<number, number[]> = {
// [P3, P10, P25, P50, P75, P90, P97] in kg
0: [2.4, 2.6, 2.9, 3.3, 3.7, 4.0, 4.3],
1: [2.6, 2.9, 3.1, 3.5, 3.9, 4.3, 4.6],
2: [2.9, 3.2, 3.5, 4.0, 4.4, 4.8, 5.1],
4: [3.4, 3.8, 4.2, 4.8, 5.3, 5.8, 6.2],
6: [4.0, 4.4, 4.9, 5.5, 6.1, 6.6, 7.1],
8: [4.5, 5.0, 5.5, 6.2, 6.9, 7.5, 8.0],
10: [4.9, 5.4, 6.0, 6.7, 7.5, 8.1, 8.7],
12: [5.3, 5.8, 6.4, 7.2, 8.0, 8.7, 9.3],
16: [5.8, 6.4, 7.0, 7.8, 8.7, 9.4, 10.1],
20: [6.3, 6.9, 7.6, 8.5, 9.5, 10.2, 11.0],
24: [6.6, 7.3, 8.0, 9.0, 10.0, 10.9, 11.7],
28: [6.9, 7.6, 8.4, 9.4, 10.5, 11.4, 12.2],
32: [7.2, 7.9, 8.7, 9.8, 10.9, 11.8, 12.7],
36: [7.4, 8.1, 9.0, 10.1, 11.3, 12.3, 13.2],
40: [7.6, 8.3, 9.2, 10.4, 11.6, 12.7, 13.6],
44: [7.7, 8.5, 9.5, 10.7, 11.9, 13.0, 14.0],
48: [7.9, 8.7, 9.7, 10.9, 12.2, 13.3, 14.3],
52: [8.1, 8.9, 9.9, 11.2, 12.5, 13.6, 14.7],
};
export const PERCENTILE_LABELS = ["P3", "P10", "P25", "P50", "P75", "P90", "P97"];
export const PERCENTILE_COLORS = [
"#e2e8f0",
"#cbd5e1",
"#94a3b8",
"#64748b",
"#94a3b8",
"#cbd5e1",
"#e2e8f0",
];
export function getAgeInWeeks(birthDate: Date, measureDate: Date): number {
const ms = measureDate.getTime() - birthDate.getTime();
return Math.floor(ms / (7 * 24 * 60 * 60 * 1000));
}
export function interpolatePercentile(ageWeeks: number, percentileIndex: number): number | null {
const ages = Object.keys(WHO_WEIGHT_PERCENTILES).map(Number).sort((a, b) => a - b);
const lower = ages.filter((a) => a <= ageWeeks).at(-1);
const upper = ages.filter((a) => a >= ageWeeks)[0];
if (lower === undefined || upper === undefined) return null;
if (lower === upper) return WHO_WEIGHT_PERCENTILES[lower][percentileIndex];
const ratio = (ageWeeks - lower) / (upper - lower);
const lowerVal = WHO_WEIGHT_PERCENTILES[lower][percentileIndex];
const upperVal = WHO_WEIGHT_PERCENTILES[upper][percentileIndex];
return lowerVal + ratio * (upperVal - lowerVal);
}