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:
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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é ?`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// WHO Child Growth Standards — Weight-for-age (girls & boys combined approximation)
|
||||
// Values in kg, indexed by age in weeks (0–52)
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user