Initial commit: Family Planner application

Complete family planning application with:
- React frontend with TypeScript
- Node.js/Express backend with TypeScript
- Python ingestion service for document processing
- Planning ingestion service with LLM integration
- Shared UI components and type definitions
- OAuth integration for calendar synchronization
- Comprehensive documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
philippe
2025-10-14 10:43:33 +02:00
commit fdd72c1135
239 changed files with 44160 additions and 0 deletions

21
backend/.env.example Normal file
View File

@@ -0,0 +1,21 @@
# Server Configuration
PORT=5000
NODE_ENV=development
# CORS Configuration (comma-separated list of allowed origins)
CORS_ORIGIN=http://localhost:5173,http://localhost:3000
# Ingestion Service
INGESTION_URL=http://localhost:8000
# API Keys (NEVER commit these values - use actual keys in your .env file)
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_MODEL=gpt-4
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
# File Upload
MAX_FILE_SIZE_MB=5
UPLOAD_DIR=./public

22
backend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,22 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2020,
sourceType: "module"
},
env: {
node: true,
es2021: true
},
plugins: ["@typescript-eslint", "import"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/typescript",
"prettier"
],
rules: {
"@typescript-eslint/explicit-module-boundary-types": "off"
}
};

7
backend/.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 90,
"semi": true
}

47
backend/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "family-planner-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings=0",
"format": "prettier --write \"src/**/*.{ts,tsx,json}\""
},
"dependencies": {
"axios": "^1.12.2",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"dotenv-safe": "^9.1.0",
"express": "^4.19.0",
"express-rate-limit": "^8.1.0",
"helmet": "^8.1.0",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.2",
"winston": "^3.18.3",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/morgan": "^1.9.7",
"@types/multer": "^1.4.7",
"@types/node": "^20.11.0",
"@types/supertest": "^6.0.3",
"@vitest/ui": "^3.2.4",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.29.0",
"prettier": "^3.3.0",
"supertest": "^7.1.4",
"tsx": "^4.11.0",
"typescript": "^5.6.0",
"vitest": "^3.2.4"
}
}

32
backend/src/config/env.ts Normal file
View File

@@ -0,0 +1,32 @@
export type AppConfig = {
port: number;
corsOrigin: string;
ingestionServiceUrl: string;
nodeEnv: string;
openaiApiKey?: string;
openaiModel?: string;
rateLimitWindowMs: number;
rateLimitMaxRequests: number;
maxFileSizeMB: number;
};
export const loadConfig = (): AppConfig => {
const config: AppConfig = {
port: Number(process.env.PORT ?? 5000),
corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
ingestionServiceUrl: process.env.INGESTION_URL ?? "http://localhost:8000",
nodeEnv: process.env.NODE_ENV ?? "development",
openaiApiKey: process.env.OPENAI_API_KEY,
openaiModel: process.env.OPENAI_MODEL ?? "gpt-4",
rateLimitWindowMs: Number(process.env.RATE_LIMIT_WINDOW_MS ?? 900000),
rateLimitMaxRequests: Number(process.env.RATE_LIMIT_MAX_REQUESTS ?? 100),
maxFileSizeMB: Number(process.env.MAX_FILE_SIZE_MB ?? 5)
};
// Validate critical configuration
if (config.port < 1 || config.port > 65535) {
throw new Error(`Invalid PORT: ${config.port}. Must be between 1 and 65535.`);
}
return config;
};

View File

@@ -0,0 +1,7 @@
import { RequestHandler } from "express";
import { alertService } from "../services/alert-service";
export const listAlertsController: RequestHandler = async (_req, res) => {
const alerts = await alertService.listAlerts();
res.json(alerts);
};

View File

@@ -0,0 +1,117 @@
import { RequestHandler } from "express";
import { z } from "zod";
import { childService } from "../services/child-service";
const presetAvatarSchema = z.object({
kind: z.literal("preset"),
url: z.string().url(),
name: z.string().optional()
});
const customAvatarSchema = z.object({
kind: z.literal("custom"),
url: z.string().url(),
name: z.string().optional()
});
const avatarSchema = z.discriminatedUnion("kind", [presetAvatarSchema, customAvatarSchema]);
const createChildSchema = z.object({
fullName: z.string().min(2),
colorHex: z.string().regex(/^#([0-9a-fA-F]{6})$/),
email: z.string().email().optional(),
notes: z.string().optional(),
avatar: avatarSchema.optional()
});
const childIdParamSchema = z.object({
childId: z.string().min(1)
});
export const listChildrenController: RequestHandler = async (_req, res) => {
const children = await childService.listChildren();
res.json(children);
};
export const listArchivedChildrenController: RequestHandler = async (_req, res) => {
const archived = await childService.listArchivedChildren();
res.json(archived);
};
export const createChildController: RequestHandler = async (req, res, next) => {
try {
const payload = createChildSchema.parse(req.body);
const child = await childService.createChild(payload);
res.status(201).json(child);
} catch (error) {
next(error);
}
};
export const deleteChildController: RequestHandler = async (req, res, next) => {
try {
const { childId } = childIdParamSchema.parse(req.params);
const deleted = await childService.deleteChild(childId);
if (!deleted) {
res.status(404).json({ message: "Child not found" });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
};
export const restoreChildController: RequestHandler = async (req, res, next) => {
try {
const { childId } = childIdParamSchema.parse(req.params);
const restored = await childService.restoreChild(childId);
if (!restored) {
res.status(404).json({ message: "Child not found in archive" });
return;
}
res.json(restored);
} catch (error) {
next(error);
}
};
const updateChildSchema = z
.object({
fullName: z.string().min(2).optional(),
colorHex: z.string().regex(/^#([0-9a-fA-F]{6})$/).optional(),
email: z.string().email().optional(),
notes: z.string().optional(),
avatar: avatarSchema.optional().or(z.null()),
schoolRegion: z.enum(["zone-a", "zone-b", "zone-c", "corse", "monaco", "guadeloupe", "guyane", "martinique", "reunion", "mayotte"]).optional()
})
.refine((data) => Object.keys(data).length > 0, "Payload must contain au moins un champ");
export const updateChildController: RequestHandler = async (req, res, next) => {
try {
const { childId } = childIdParamSchema.parse(req.params);
const payload = updateChildSchema.parse(req.body);
const updated = await childService.updateChild(childId, payload);
if (!updated) {
res.status(404).json({ message: "Child not found" });
return;
}
res.json(updated);
} catch (error) {
next(error);
}
};
export const permanentlyDeleteChildController: RequestHandler = async (req, res, next) => {
try {
const { childId } = childIdParamSchema.parse(req.params);
const deleted = await childService.permanentlyDeleteChild(childId);
if (!deleted) {
res.status(404).json({ message: "Child not found in archive" });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,82 @@
import { RequestHandler } from "express";
import { z } from "zod";
import { grandParentService } from "../services/grandparent-service";
const presetAvatarSchema = z.object({
kind: z.literal("preset"),
url: z.string().url(),
name: z.string().optional()
});
const customAvatarSchema = z.object({
kind: z.literal("custom"),
url: z.string().url(),
name: z.string().optional()
});
const avatarSchema = z.discriminatedUnion("kind", [presetAvatarSchema, customAvatarSchema]);
const createGrandParentSchema = z.object({
fullName: z.string().min(2),
colorHex: z.string().regex(/^#([0-9a-fA-F]{6})$/),
email: z.string().email().optional(),
notes: z.string().optional(),
avatar: avatarSchema.optional()
});
const idParamSchema = z.object({ grandParentId: z.string().min(1) });
export const listGrandParentsController: RequestHandler = async (_req, res) => {
const grandparents = await grandParentService.listGrandParents();
res.json(grandparents);
};
export const createGrandParentController: RequestHandler = async (req, res, next) => {
try {
const payload = createGrandParentSchema.parse(req.body);
const grandParent = await grandParentService.createGrandParent(payload);
res.status(201).json(grandParent);
} catch (error) {
next(error);
}
};
const updateGrandParentSchema = z
.object({
fullName: z.string().min(2).optional(),
colorHex: z.string().regex(/^#([0-9a-fA-F]{6})$/).optional(),
email: z.string().email().optional(),
notes: z.string().optional(),
avatar: avatarSchema.optional().or(z.null())
})
.refine((data) => Object.keys(data).length > 0, "Payload must contain au moins un champ");
export const updateGrandParentController: RequestHandler = async (req, res, next) => {
try {
const { grandParentId } = idParamSchema.parse(req.params);
const payload = updateGrandParentSchema.parse(req.body);
const updated = await grandParentService.updateGrandParent(grandParentId, payload);
if (!updated) {
res.status(404).json({ message: "Grand-parent not found" });
return;
}
res.json(updated);
} catch (error) {
next(error);
}
};
export const deleteGrandParentController: RequestHandler = async (req, res, next) => {
try {
const { grandParentId } = idParamSchema.parse(req.params);
const deleted = await grandParentService.deleteGrandParent(grandParentId);
if (!deleted) {
res.status(404).json({ message: "Grand-parent not found" });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,70 @@
import { Request, Response } from "express";
import { getAllHolidays, getPublicHolidays, getSchoolHolidays } from "../services/holiday-service";
import { SchoolRegion } from "../models/child";
/**
* Récupère tous les jours fériés et congés scolaires
*/
export async function handleGetAllHolidays(req: Request, res: Response): Promise<void> {
try {
const region = req.query.region as SchoolRegion | undefined;
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const holidays = getAllHolidays(region, year);
res.json({
success: true,
holidays,
count: holidays.length
});
} catch (error) {
res.status(500).json({
success: false,
error: "Impossible de récupérer les jours fériés"
});
}
}
/**
* Récupère uniquement les jours fériés
*/
export async function handleGetPublicHolidays(req: Request, res: Response): Promise<void> {
try {
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const holidays = getPublicHolidays(year);
res.json({
success: true,
holidays,
count: holidays.length
});
} catch (error) {
res.status(500).json({
success: false,
error: "Impossible de récupérer les jours fériés"
});
}
}
/**
* Récupère uniquement les congés scolaires
*/
export async function handleGetSchoolHolidays(req: Request, res: Response): Promise<void> {
try {
const region = req.query.region as SchoolRegion | undefined;
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const holidays = getSchoolHolidays(region, year);
res.json({
success: true,
holidays,
count: holidays.length
});
} catch (error) {
res.status(500).json({
success: false,
error: "Impossible de récupérer les congés scolaires"
});
}
}

View File

@@ -0,0 +1,81 @@
import { RequestHandler } from "express";
import { z } from "zod";
import { parentService } from "../services/parent-service";
const presetAvatarSchema = z.object({
kind: z.literal("preset"),
url: z.string().url(),
name: z.string().optional()
});
const customAvatarSchema = z.object({
kind: z.literal("custom"),
url: z.string().url(),
name: z.string().optional()
});
const avatarSchema = z.discriminatedUnion("kind", [presetAvatarSchema, customAvatarSchema]);
const createParentSchema = z.object({
fullName: z.string().min(2),
colorHex: z.string().regex(/^#([0-9a-fA-F]{6})$/),
email: z.string().email().optional(),
notes: z.string().optional(),
avatar: avatarSchema.optional()
});
const parentIdParamSchema = z.object({ parentId: z.string().min(1) });
export const listParentsController: RequestHandler = async (_req, res) => {
const parents = await parentService.listParents();
res.json(parents);
};
export const createParentController: RequestHandler = async (req, res, next) => {
try {
const payload = createParentSchema.parse(req.body);
const parent = await parentService.createParent(payload);
res.status(201).json(parent);
} catch (error) {
next(error);
}
};
const updateParentSchema = z
.object({
fullName: z.string().min(2).optional(),
colorHex: z.string().regex(/^#([0-9a-fA-F]{6})$/).optional(),
email: z.string().email().optional(),
notes: z.string().optional(),
avatar: avatarSchema.optional().or(z.null())
})
.refine((data) => Object.keys(data).length > 0, "Payload must contain au moins un champ");
export const updateParentController: RequestHandler = async (req, res, next) => {
try {
const { parentId } = parentIdParamSchema.parse(req.params);
const payload = updateParentSchema.parse(req.body);
const updated = await parentService.updateParent(parentId, payload);
if (!updated) {
res.status(404).json({ message: "Parent not found" });
return;
}
res.json(updated);
} catch (error) {
next(error);
}
};
export const deleteParentController: RequestHandler = async (req, res, next) => {
try {
const { parentId } = parentIdParamSchema.parse(req.params);
const deleted = await parentService.deleteParent(parentId);
if (!deleted) {
res.status(404).json({ message: "Parent not found" });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,156 @@
import { Request, Response } from "express";
import {
listPersonalLeaves,
getPersonalLeavesByProfile,
getPersonalLeaveById,
createPersonalLeave,
updatePersonalLeave,
deletePersonalLeave
} from "../services/personal-leave-service";
/**
* Liste tous les congés personnels (ou filtrés par profileId)
*/
export async function handleListPersonalLeaves(req: Request, res: Response): Promise<void> {
try {
const profileId = req.query.profileId as string | undefined;
const leaves = profileId
? await getPersonalLeavesByProfile(profileId)
: await listPersonalLeaves();
res.json({
success: true,
leaves,
count: leaves.length
});
} catch (error) {
res.status(500).json({
success: false,
error: "Impossible de récupérer les congés personnels"
});
}
}
/**
* Récupère un congé personnel par son ID
*/
export async function handleGetPersonalLeave(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const leave = await getPersonalLeaveById(id);
if (!leave) {
res.status(404).json({
success: false,
error: "Congé personnel introuvable"
});
return;
}
res.json({
success: true,
leave
});
} catch (error) {
res.status(500).json({
success: false,
error: "Impossible de récupérer le congé personnel"
});
}
}
/**
* Crée un nouveau congé personnel
*/
export async function handleCreatePersonalLeave(req: Request, res: Response): Promise<void> {
try {
const { profileId, title, startDate, endDate, isAllDay, notes, source } = req.body;
if (!profileId || !title || !startDate || !endDate || isAllDay === undefined) {
res.status(400).json({
success: false,
error: "Champs requis manquants: profileId, title, startDate, endDate, isAllDay"
});
return;
}
const leave = await createPersonalLeave({
profileId,
title,
startDate,
endDate,
isAllDay,
notes,
source
});
res.status(201).json({
success: true,
leave
});
} catch (error) {
res.status(500).json({
success: false,
error: "Impossible de créer le congé personnel"
});
}
}
/**
* Met à jour un congé personnel
*/
export async function handleUpdatePersonalLeave(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const updates = req.body;
const leave = await updatePersonalLeave(id, updates);
if (!leave) {
res.status(404).json({
success: false,
error: "Congé personnel introuvable"
});
return;
}
res.json({
success: true,
leave
});
} catch (error) {
res.status(500).json({
success: false,
error: "Impossible de mettre à jour le congé personnel"
});
}
}
/**
* Supprime un congé personnel
*/
export async function handleDeletePersonalLeave(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const deleted = await deletePersonalLeave(id);
if (!deleted) {
res.status(404).json({
success: false,
error: "Congé personnel introuvable"
});
return;
}
res.json({
success: true,
message: "Congé personnel supprimé"
});
} catch (error) {
res.status(500).json({
success: false,
error: "Impossible de supprimer le congé personnel"
});
}
}

View File

@@ -0,0 +1,101 @@
import { RequestHandler } from "express";
import { z } from "zod";
import { scheduleService } from "../services/schedule-service";
const createScheduleSchema = z.object({
childId: z.string().min(1),
periodStart: z.string(),
periodEnd: z.string(),
activities: z.array(
z.object({
title: z.string(),
category: z.enum(["school", "sport", "medical", "event", "other"]),
startDateTime: z.string(),
endDateTime: z.string(),
location: z.string().optional(),
notes: z.string().optional()
})
),
sourceFileUrl: z.string().url().optional()
});
const getScheduleSchema = z.object({
scheduleId: z.string().min(1)
});
export const createScheduleController: RequestHandler = async (req, res, next) => {
try {
const payload = createScheduleSchema.parse(req.body);
const schedule = await scheduleService.createSchedule(payload);
res.status(201).json(schedule);
} catch (error) {
next(error);
}
};
export const getScheduleController: RequestHandler = async (req, res, next) => {
try {
const params = getScheduleSchema.parse(req.params);
const schedule = await scheduleService.getSchedule(params.scheduleId);
if (!schedule) {
res.status(404).json({ message: "Schedule not found" });
return;
}
res.json(schedule);
} catch (error) {
next(error);
}
};
const dayQuerySchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
childId: z.string().optional()
});
export const getDayActivitiesController: RequestHandler = async (req, res, next) => {
try {
const { date, childId } = dayQuerySchema.parse(req.query);
const items = await scheduleService.listActivitiesForDate(date, childId);
res.json({ date, items });
} catch (error) {
next(error);
}
};
const weekQuerySchema = z.object({
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
childId: z.string().optional()
});
export const getWeekActivitiesController: RequestHandler = async (req, res, next) => {
try {
const { start, childId } = weekQuerySchema.parse(req.query);
const items = await scheduleService.listActivitiesForWeek(start, childId);
res.json({ start, items });
} catch (error) {
next(error);
}
};
const monthQuerySchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/)
});
export const getMonthActivitiesController: RequestHandler = async (req, res, next) => {
try {
const { month } = monthQuerySchema.parse(req.query);
const items = await scheduleService.listActivitiesForMonth(month);
res.json({ month, items });
} catch (error) {
next(error);
}
};
export const listSchedulesController: RequestHandler = async (_req, res, next) => {
try {
const items = await scheduleService.listSchedules();
res.json(items);
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,88 @@
{
"version": 3,
"children": [
{
"id": "e999cec0-55b0-4036-82aa-ef96fe746132",
"fullName": "Robin Heyraud",
"colorHex": "#0ef129",
"createdAt": "2025-10-11T13:53:32.798Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/1.png",
"name": "1.png"
},
"schoolRegion": "zone-b"
},
{
"id": "82422b76-0f11-434c-8f5a-2212ca6a617b",
"fullName": "Timéo Heyraud",
"colorHex": "#ff1900",
"createdAt": "2025-10-11T12:51:52.952Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/4.png",
"name": "4.png"
},
"schoolRegion": "monaco"
},
{
"id": "d4e52e47-b130-4eb3-a761-dcc65b19aa6a",
"fullName": "Gabriel Heyraud",
"colorHex": "#fbe55b",
"createdAt": "2025-10-11T12:52:04.095Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/11.png",
"name": "11.png"
},
"schoolRegion": "monaco"
}
],
"archived": [
{
"id": "37055edd-4963-430d-a030-fb4ae2cae766",
"fullName": "hh",
"colorHex": "#5562ff",
"createdAt": "2025-10-13T19:44:16.468Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/12.png",
"name": "12.png"
},
"deletedAt": "2025-10-13T20:46:32.260Z"
},
{
"id": "aa777960-0de4-4c1c-ae7a-ddd2d55b42bc",
"fullName": "Timeo heyraud",
"colorHex": "#5562ff",
"createdAt": "2025-10-11T19:55:12.273Z",
"deletedAt": "2025-10-12T06:08:15.857Z"
}
],
"parents": [
{
"id": "9651ae46-b976-4113-ae5a-0658989a90f6",
"fullName": "philippe Heyraud",
"colorHex": "#1e00ff",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/3857f101-b23a-40e3-8633-7b09279388a1.gif",
"name": "onepiece02.gif"
},
"createdAt": "2025-10-12T12:35:59.899Z"
}
],
"grandparents": [
{
"id": "6a1fe431-e98f-42ba-a06f-903d3c38d23e",
"fullName": "Mimine Heyraud",
"colorHex": "#7d6cff",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/a59ab9d4-e498-4dfd-82e9-49d163d84b77.jpg",
"name": "One Piece Logo.jpg"
},
"createdAt": "2025-10-12T12:37:49.275Z"
}
]
}

View File

@@ -0,0 +1,88 @@
{
"version": 3,
"children": [
{
"id": "e999cec0-55b0-4036-82aa-ef96fe746132",
"fullName": "Robin Heyraud",
"colorHex": "#0ef129",
"createdAt": "2025-10-11T13:53:32.798Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/1.png",
"name": "1.png"
},
"schoolRegion": "zone-c"
},
{
"id": "82422b76-0f11-434c-8f5a-2212ca6a617b",
"fullName": "Timéo Heyraud",
"colorHex": "#ff1900",
"createdAt": "2025-10-11T12:51:52.952Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/4.png",
"name": "4.png"
},
"schoolRegion": "monaco"
},
{
"id": "d4e52e47-b130-4eb3-a761-dcc65b19aa6a",
"fullName": "Gabriel Heyraud",
"colorHex": "#fbe55b",
"createdAt": "2025-10-11T12:52:04.095Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/11.png",
"name": "11.png"
},
"schoolRegion": "monaco"
}
],
"archived": [
{
"id": "37055edd-4963-430d-a030-fb4ae2cae766",
"fullName": "hh",
"colorHex": "#5562ff",
"createdAt": "2025-10-13T19:44:16.468Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/12.png",
"name": "12.png"
},
"deletedAt": "2025-10-13T20:46:32.260Z"
},
{
"id": "aa777960-0de4-4c1c-ae7a-ddd2d55b42bc",
"fullName": "Timeo heyraud",
"colorHex": "#5562ff",
"createdAt": "2025-10-11T19:55:12.273Z",
"deletedAt": "2025-10-12T06:08:15.857Z"
}
],
"parents": [
{
"id": "9651ae46-b976-4113-ae5a-0658989a90f6",
"fullName": "philippe Heyraud",
"colorHex": "#1e00ff",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/3857f101-b23a-40e3-8633-7b09279388a1.gif",
"name": "onepiece02.gif"
},
"createdAt": "2025-10-12T12:35:59.899Z"
}
],
"grandparents": [
{
"id": "6a1fe431-e98f-42ba-a06f-903d3c38d23e",
"fullName": "Mimine Heyraud",
"colorHex": "#7d6cff",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/a59ab9d4-e498-4dfd-82e9-49d163d84b77.jpg",
"name": "One Piece Logo.jpg"
},
"createdAt": "2025-10-12T12:37:49.275Z"
}
]
}

View File

@@ -0,0 +1,88 @@
{
"version": 3,
"children": [
{
"id": "e999cec0-55b0-4036-82aa-ef96fe746132",
"fullName": "Robin Heyraud",
"colorHex": "#0ef129",
"createdAt": "2025-10-11T13:53:32.798Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/1.png",
"name": "1.png"
},
"schoolRegion": "monaco"
},
{
"id": "82422b76-0f11-434c-8f5a-2212ca6a617b",
"fullName": "Timéo Heyraud",
"colorHex": "#ff1900",
"createdAt": "2025-10-11T12:51:52.952Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/4.png",
"name": "4.png"
},
"schoolRegion": "monaco"
},
{
"id": "d4e52e47-b130-4eb3-a761-dcc65b19aa6a",
"fullName": "Gabriel Heyraud",
"colorHex": "#fbe55b",
"createdAt": "2025-10-11T12:52:04.095Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/11.png",
"name": "11.png"
},
"schoolRegion": "monaco"
}
],
"archived": [
{
"id": "37055edd-4963-430d-a030-fb4ae2cae766",
"fullName": "hh",
"colorHex": "#5562ff",
"createdAt": "2025-10-13T19:44:16.468Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/12.png",
"name": "12.png"
},
"deletedAt": "2025-10-13T20:46:32.260Z"
},
{
"id": "aa777960-0de4-4c1c-ae7a-ddd2d55b42bc",
"fullName": "Timeo heyraud",
"colorHex": "#5562ff",
"createdAt": "2025-10-11T19:55:12.273Z",
"deletedAt": "2025-10-12T06:08:15.857Z"
}
],
"parents": [
{
"id": "9651ae46-b976-4113-ae5a-0658989a90f6",
"fullName": "philippe Heyraud",
"colorHex": "#1e00ff",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/3857f101-b23a-40e3-8633-7b09279388a1.gif",
"name": "onepiece02.gif"
},
"createdAt": "2025-10-12T12:35:59.899Z"
}
],
"grandparents": [
{
"id": "6a1fe431-e98f-42ba-a06f-903d3c38d23e",
"fullName": "Mimine Heyraud",
"colorHex": "#7d6cff",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/a59ab9d4-e498-4dfd-82e9-49d163d84b77.jpg",
"name": "One Piece Logo.jpg"
},
"createdAt": "2025-10-12T12:37:49.275Z"
}
]
}

View File

@@ -0,0 +1,88 @@
{
"version": 3,
"children": [
{
"id": "e999cec0-55b0-4036-82aa-ef96fe746132",
"fullName": "Robin Heyraud",
"colorHex": "#0ef129",
"createdAt": "2025-10-11T13:53:32.798Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/1.png",
"name": "1.png"
},
"schoolRegion": "zone-a"
},
{
"id": "82422b76-0f11-434c-8f5a-2212ca6a617b",
"fullName": "Timéo Heyraud",
"colorHex": "#ff1900",
"createdAt": "2025-10-11T12:51:52.952Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/4.png",
"name": "4.png"
},
"schoolRegion": "monaco"
},
{
"id": "d4e52e47-b130-4eb3-a761-dcc65b19aa6a",
"fullName": "Gabriel Heyraud",
"colorHex": "#fbe55b",
"createdAt": "2025-10-11T12:52:04.095Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/11.png",
"name": "11.png"
},
"schoolRegion": "monaco"
}
],
"archived": [
{
"id": "37055edd-4963-430d-a030-fb4ae2cae766",
"fullName": "hh",
"colorHex": "#5562ff",
"createdAt": "2025-10-13T19:44:16.468Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/12.png",
"name": "12.png"
},
"deletedAt": "2025-10-13T20:46:32.260Z"
},
{
"id": "aa777960-0de4-4c1c-ae7a-ddd2d55b42bc",
"fullName": "Timeo heyraud",
"colorHex": "#5562ff",
"createdAt": "2025-10-11T19:55:12.273Z",
"deletedAt": "2025-10-12T06:08:15.857Z"
}
],
"parents": [
{
"id": "9651ae46-b976-4113-ae5a-0658989a90f6",
"fullName": "philippe Heyraud",
"colorHex": "#1e00ff",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/3857f101-b23a-40e3-8633-7b09279388a1.gif",
"name": "onepiece02.gif"
},
"createdAt": "2025-10-12T12:35:59.899Z"
}
],
"grandparents": [
{
"id": "6a1fe431-e98f-42ba-a06f-903d3c38d23e",
"fullName": "Mimine Heyraud",
"colorHex": "#7d6cff",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/a59ab9d4-e498-4dfd-82e9-49d163d84b77.jpg",
"name": "One Piece Logo.jpg"
},
"createdAt": "2025-10-12T12:37:49.275Z"
}
]
}

View File

@@ -0,0 +1,88 @@
{
"version": 3,
"children": [
{
"id": "e999cec0-55b0-4036-82aa-ef96fe746132",
"fullName": "Robin Heyraud",
"colorHex": "#0ef129",
"createdAt": "2025-10-11T13:53:32.798Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/1.png",
"name": "1.png"
},
"schoolRegion": "martinique"
},
{
"id": "82422b76-0f11-434c-8f5a-2212ca6a617b",
"fullName": "Timéo Heyraud",
"colorHex": "#ff1900",
"createdAt": "2025-10-11T12:51:52.952Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/4.png",
"name": "4.png"
},
"schoolRegion": "monaco"
},
{
"id": "d4e52e47-b130-4eb3-a761-dcc65b19aa6a",
"fullName": "Gabriel Heyraud",
"colorHex": "#fbe55b",
"createdAt": "2025-10-11T12:52:04.095Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/11.png",
"name": "11.png"
},
"schoolRegion": "monaco"
}
],
"archived": [
{
"id": "37055edd-4963-430d-a030-fb4ae2cae766",
"fullName": "hh",
"colorHex": "#5562ff",
"createdAt": "2025-10-13T19:44:16.468Z",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/12.png",
"name": "12.png"
},
"deletedAt": "2025-10-13T20:46:32.260Z"
},
{
"id": "aa777960-0de4-4c1c-ae7a-ddd2d55b42bc",
"fullName": "Timeo heyraud",
"colorHex": "#5562ff",
"createdAt": "2025-10-11T19:55:12.273Z",
"deletedAt": "2025-10-12T06:08:15.857Z"
}
],
"parents": [
{
"id": "9651ae46-b976-4113-ae5a-0658989a90f6",
"fullName": "philippe Heyraud",
"colorHex": "#1e00ff",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/3857f101-b23a-40e3-8633-7b09279388a1.gif",
"name": "onepiece02.gif"
},
"createdAt": "2025-10-12T12:35:59.899Z"
}
],
"grandparents": [
{
"id": "6a1fe431-e98f-42ba-a06f-903d3c38d23e",
"fullName": "Mimine Heyraud",
"colorHex": "#7d6cff",
"avatar": {
"kind": "custom",
"url": "http://localhost:5000/static/avatars/a59ab9d4-e498-4dfd-82e9-49d163d84b77.jpg",
"name": "One Piece Logo.jpg"
},
"createdAt": "2025-10-12T12:37:49.275Z"
}
]
}

View File

@@ -0,0 +1,93 @@
import { Request, Response, NextFunction } from "express";
import { ZodError } from "zod";
import { logger } from "../utils/logger";
export type ErrorResponse = {
error: string;
message: string;
details?: unknown;
timestamp: string;
};
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public details?: unknown
) {
super(message);
this.name = "AppError";
Error.captureStackTrace(this, this.constructor);
}
}
export const errorHandler = (
err: Error,
req: Request,
res: Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
next: NextFunction
): void => {
logger.error("API Error:", {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method
});
// Zod validation errors
if (err instanceof ZodError) {
const response: ErrorResponse = {
error: "Validation Error",
message: "Invalid request data",
details: err.errors,
timestamp: new Date().toISOString()
};
res.status(400).json(response);
return;
}
// Custom application errors
if (err instanceof AppError) {
const response: ErrorResponse = {
error: err.name,
message: err.message,
details: err.details,
timestamp: new Date().toISOString()
};
res.status(err.statusCode).json(response);
return;
}
// Multer file upload errors
if (err.name === "MulterError") {
const response: ErrorResponse = {
error: "File Upload Error",
message: err.message,
timestamp: new Date().toISOString()
};
res.status(400).json(response);
return;
}
// Default server error
const response: ErrorResponse = {
error: "Internal Server Error",
message:
process.env.NODE_ENV === "production"
? "An unexpected error occurred"
: err.message,
timestamp: new Date().toISOString()
};
res.status(500).json(response);
};
export const notFoundHandler = (req: Request, res: Response): void => {
const response: ErrorResponse = {
error: "Not Found",
message: `Route ${req.method} ${req.path} not found`,
timestamp: new Date().toISOString()
};
res.status(404).json(response);
};

View File

@@ -0,0 +1,223 @@
import multer, { FileFilterCallback } from "multer";
import path from "node:path";
import { randomUUID } from "node:crypto";
import type { Request } from "express";
import { loadConfig } from "../config/env";
import { logger } from "../utils/logger";
// Allowed file types with their MIME types and extensions
const ALLOWED_AVATARS = {
mimeTypes: ["image/png", "image/jpeg", "image/jpg", "image/webp"],
extensions: [".png", ".jpg", ".jpeg", ".webp"]
};
const ALLOWED_PLANNING_FILES = {
mimeTypes: [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"application/pdf",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
],
extensions: [".png", ".jpg", ".jpeg", ".webp", ".pdf", ".xls", ".xlsx"]
};
/**
* Validates file type based on MIME type and extension
* This helps prevent file type spoofing attacks
*/
function validateFileType(
file: Express.Multer.File,
allowedConfig: { mimeTypes: string[]; extensions: string[] }
): { valid: boolean; reason?: string } {
const ext = path.extname(file.originalname).toLowerCase();
const mimeType = file.mimetype.toLowerCase();
// Check MIME type
if (!allowedConfig.mimeTypes.includes(mimeType)) {
return {
valid: false,
reason: `Invalid MIME type: ${mimeType}. Allowed types: ${allowedConfig.mimeTypes.join(", ")}`
};
}
// Check file extension
if (!allowedConfig.extensions.includes(ext)) {
return {
valid: false,
reason: `Invalid file extension: ${ext}. Allowed extensions: ${allowedConfig.extensions.join(", ")}`
};
}
// Verify that MIME type matches extension (basic check)
if (mimeType.startsWith("image/") && ![".png", ".jpg", ".jpeg", ".webp"].includes(ext)) {
return {
valid: false,
reason: "MIME type and file extension mismatch"
};
}
if (mimeType === "application/pdf" && ext !== ".pdf") {
return {
valid: false,
reason: "MIME type and file extension mismatch"
};
}
return { valid: true };
}
/**
* Sanitizes filename to prevent directory traversal attacks
*/
function sanitizeFilename(filename: string): string {
// Remove any path components
let sanitized = path.basename(filename);
// Remove any potentially dangerous characters
sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, "_");
// Limit length
if (sanitized.length > 255) {
const ext = path.extname(sanitized);
const name = path.basename(sanitized, ext);
sanitized = name.substring(0, 255 - ext.length) + ext;
}
return sanitized;
}
/**
* File filter for avatar uploads
*/
const avatarFileFilter = (
req: Request,
file: Express.Multer.File,
callback: FileFilterCallback
): void => {
const validation = validateFileType(file, ALLOWED_AVATARS);
if (!validation.valid) {
logger.warn("Avatar upload rejected", {
filename: file.originalname,
mimetype: file.mimetype,
reason: validation.reason
});
callback(new Error(validation.reason || "Invalid file type"));
return;
}
callback(null, true);
};
/**
* File filter for planning document uploads
*/
const planningFileFilter = (
req: Request,
file: Express.Multer.File,
callback: FileFilterCallback
): void => {
const validation = validateFileType(file, ALLOWED_PLANNING_FILES);
if (!validation.valid) {
logger.warn("Planning upload rejected", {
filename: file.originalname,
mimetype: file.mimetype,
reason: validation.reason
});
callback(new Error(validation.reason || "Invalid file type"));
return;
}
callback(null, true);
};
/**
* Storage configuration for avatar uploads
*/
export function createAvatarStorage(uploadDir: string): multer.StorageEngine {
return multer.diskStorage({
destination: (_req: Request, _file: Express.Multer.File, callback) => {
callback(null, uploadDir);
},
filename: (_req: Request, file: Express.Multer.File, callback) => {
const ext = path.extname(file.originalname).toLowerCase();
const sanitizedExt = ext.match(/^\.(png|jpg|jpeg|webp)$/) ? ext : ".png";
const filename = `${randomUUID()}${sanitizedExt}`;
callback(null, filename);
}
});
}
/**
* Storage configuration for planning document uploads
*/
export function createPlanningStorage(uploadDir: string): multer.StorageEngine {
return multer.diskStorage({
destination: (_req: Request, _file: Express.Multer.File, callback) => {
callback(null, uploadDir);
},
filename: (_req: Request, file: Express.Multer.File, callback) => {
const ext = path.extname(file.originalname).toLowerCase();
const sanitizedExt = ext.match(/^\.(png|jpg|jpeg|webp|pdf|xls|xlsx)$/) ? ext : ".bin";
const filename = `${randomUUID()}${sanitizedExt}`;
callback(null, filename);
}
});
}
/**
* Configure multer for avatar uploads
*/
export function createAvatarUploader(uploadDir: string): multer.Multer {
const config = loadConfig();
return multer({
storage: createAvatarStorage(uploadDir),
fileFilter: avatarFileFilter,
limits: {
fileSize: config.maxFileSizeMB * 1024 * 1024, // Convert MB to bytes
files: 1,
fields: 10
}
});
}
/**
* Configure multer for planning document uploads
*/
export function createPlanningUploader(uploadDir: string): multer.Multer {
const config = loadConfig();
return multer({
storage: createPlanningStorage(uploadDir),
fileFilter: planningFileFilter,
limits: {
fileSize: config.maxFileSizeMB * 1024 * 1024, // Convert MB to bytes
files: 1,
fields: 10
}
});
}
/**
* Configure multer for in-memory diagnostic uploads (no persistence)
*/
export function createDiagnosticUploader(): multer.Multer {
const config = loadConfig();
return multer({
storage: multer.memoryStorage(),
fileFilter: planningFileFilter,
limits: {
fileSize: config.maxFileSizeMB * 1024 * 1024,
files: 1,
fields: 10
}
});
}
export { sanitizeFilename };

View File

@@ -0,0 +1,94 @@
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { Request, Response, NextFunction } from "express";
import { loadConfig } from "../config/env";
const config = loadConfig();
// Helmet configuration for security headers
export const helmetConfig = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'", config.corsOrigin],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"]
}
},
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" }
});
// Rate limiting configuration
export const apiLimiter = rateLimit({
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
max: Number(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
message: {
error: "Rate Limit Exceeded",
message: "Too many requests from this IP, please try again later.",
timestamp: new Date().toISOString()
},
standardHeaders: true,
legacyHeaders: false,
// Skip rate limiting for health checks
skip: (req: Request) => req.path === "/api/health"
});
// Stricter rate limiting for authentication endpoints
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
message: {
error: "Rate Limit Exceeded",
message: "Too many authentication attempts, please try again later.",
timestamp: new Date().toISOString()
},
skipSuccessfulRequests: true
});
// File upload rate limiter
export const uploadLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 20, // 20 uploads per hour
message: {
error: "Upload Limit Exceeded",
message: "Too many file uploads, please try again later.",
timestamp: new Date().toISOString()
}
});
// CORS origin validator
export const validateCorsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
const allowedOrigins = (process.env.CORS_ORIGIN || "http://localhost:5173")
.split(",")
.map((o) => o.trim());
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) {
callback(null, true);
return;
}
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS policy`));
}
};
// Request logging middleware
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
if (process.env.NODE_ENV !== "test") {
console.log(`${req.method} ${req.path} ${res.statusCode} - ${duration}ms`);
}
});
next();
};

View File

@@ -0,0 +1,11 @@
export type ActivityCategory = "school" | "sport" | "medical" | "event" | "other";
export type Activity = {
id: string;
title: string;
category: ActivityCategory;
startDateTime: string;
endDateTime: string;
location?: string;
notes?: string;
};

View File

@@ -0,0 +1,10 @@
export type Alert = {
id: string;
childId: string;
scheduleId: string;
activityId: string;
title: string;
triggerDateTime: string;
channel: "push" | "email" | "sms" | "device";
status: "pending" | "sent" | "dismissed";
};

View File

@@ -0,0 +1,27 @@
export type SchoolRegion =
| "zone-a"
| "zone-b"
| "zone-c"
| "corse"
| "monaco"
| "guadeloupe"
| "guyane"
| "martinique"
| "reunion"
| "mayotte";
export type Child = {
id: string;
fullName: string;
colorHex: string;
email?: string;
notes?: string;
schoolRegion?: SchoolRegion;
createdAt?: string;
avatar?: {
kind: "preset" | "custom";
url: string;
name?: string;
};
deletedAt?: string;
};

View File

@@ -0,0 +1,14 @@
export type GrandParent = {
id: string;
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: {
kind: "preset" | "custom";
url: string;
name?: string;
};
createdAt?: string;
};

View File

@@ -0,0 +1,13 @@
import { SchoolRegion } from "./child";
export type HolidayType = "school" | "public" | "custom";
export type Holiday = {
id: string;
title: string;
startDate: string;
endDate: string;
type: HolidayType;
description?: string;
zones?: SchoolRegion[];
};

View File

@@ -0,0 +1,13 @@
export type Parent = {
id: string;
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: {
kind: "preset" | "custom";
url: string;
name?: string;
};
createdAt?: string;
};

View File

@@ -0,0 +1,11 @@
export type PersonalLeave = {
id: string;
profileId: string;
title: string;
startDate: string;
endDate: string;
isAllDay: boolean;
notes?: string;
source?: "manual" | "calendar";
createdAt?: string;
};

View File

@@ -0,0 +1,15 @@
import { Activity } from "./activity";
export type Schedule = {
id: string;
childId: string;
periodStart: string;
periodEnd: string;
activities: Activity[];
sourceFileUrl?: string;
sourceFileName?: string;
sourceMimeType?: string;
exportCsvUrl?: string;
status: "processing" | "ready" | "failed";
createdAt: string;
};

View File

@@ -0,0 +1,6 @@
import { Router } from "express";
import { listAlertsController } from "../controllers/alerts";
export const alertRouter = Router();
alertRouter.get("/", listAlertsController);

View File

@@ -0,0 +1,179 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { z } from "zod";
import { randomUUID } from "node:crypto";
type CalendarProvider = "google" | "outlook";
type CalendarConnectionStatus = "connected" | "pending" | "error";
type ConnectedCalendar = {
id: string;
provider: CalendarProvider;
email: string;
label?: string;
status: CalendarConnectionStatus;
lastSyncedAt?: string;
createdAt?: string;
scopes?: string[];
shareWithFamily?: boolean;
};
const connectionsByProfile = new Map<string, ConnectedCalendar[]>();
const pendingStates = new Map<string, { profileId: string; provider: CalendarProvider; connectionId: string }>();
export const calendarRouter = Router();
const providerParam = z.object({ provider: z.enum(["google", "outlook"]) });
const oauthStartBody = z.object({ profileId: z.string().min(1), state: z.string().min(6) });
const oauthCompleteBody = z.object({
provider: z.enum(["google", "outlook"]),
state: z.string().min(6),
code: z.string().optional(),
error: z.string().optional(),
profileId: z.string().optional()
});
// Configuration OAuth (à mettre dans des variables d'environnement en production)
const OAUTH_CONFIG = {
google: {
clientId: process.env.GOOGLE_CLIENT_ID || "YOUR_GOOGLE_CLIENT_ID",
redirectUri: process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/api/calendar/oauth/callback",
scope: "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
},
outlook: {
clientId: process.env.OUTLOOK_CLIENT_ID || "YOUR_OUTLOOK_CLIENT_ID",
redirectUri: process.env.OUTLOOK_REDIRECT_URI || "http://localhost:3000/api/calendar/oauth/callback",
scope: "Calendars.Read Calendars.ReadWrite offline_access"
}
};
// Start OAuth: return a provider auth URL with all required parameters
calendarRouter.post("/:provider/oauth/start", (req: Request, res: Response) => {
try {
const { provider } = providerParam.parse(req.params);
const { profileId, state } = oauthStartBody.parse(req.body ?? {});
const connectionId = randomUUID();
pendingStates.set(state, { profileId, provider, connectionId });
const config = OAUTH_CONFIG[provider];
let authUrl: string;
if (provider === "google") {
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
response_type: "code",
scope: config.scope,
state: state,
access_type: "offline",
prompt: "consent"
});
authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
} else {
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
response_type: "code",
scope: config.scope,
state: state,
response_mode: "query"
});
authUrl = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${params.toString()}`;
}
res.json({ authUrl, state });
} catch (e) {
console.error("OAuth start error:", e);
res.status(400).json({ message: "Invalid request" });
}
});
// OAuth complete: create a connection entry (placeholder), mark success
calendarRouter.post("/oauth/complete", (req: Request, res: Response) => {
try {
const { provider, state, code, error, profileId } = oauthCompleteBody.parse(req.body ?? {});
if (error) return res.json({ success: false, error });
const pending = pendingStates.get(state);
if (!pending) return res.json({ success: false, error: "Invalid state" });
pendingStates.delete(state);
const pid = profileId ?? pending.profileId;
const list = connectionsByProfile.get(pid) ?? [];
const conn: ConnectedCalendar = {
id: pending.connectionId,
provider,
email: `${provider}@example.com`,
label: `Connexion OAuth (${new Date().toLocaleDateString()})`,
status: "connected",
createdAt: new Date().toISOString(),
scopes: [],
shareWithFamily: false
};
connectionsByProfile.set(pid, [...list, conn]);
res.json({ success: true, email: conn.email, label: conn.label, connectionId: conn.id, profileId: pid });
} catch (e) {
console.error("OAuth complete error:", e);
res.status(400).json({ success: false });
}
});
// Connect with credentials (placeholder)
const credentialsBody = z.object({
profileId: z.string().min(1),
email: z.string().email(),
password: z.string().min(1),
label: z.string().optional(),
shareWithFamily: z.boolean().optional()
});
calendarRouter.post("/:provider/credentials", (req: Request, res: Response) => {
try {
const { provider } = providerParam.parse(req.params);
const { profileId, email, label, shareWithFamily } = credentialsBody.parse(req.body ?? {});
const list = connectionsByProfile.get(profileId) ?? [];
const connection: ConnectedCalendar = {
id: randomUUID(),
provider,
email,
label: label ?? `Connexion ${provider}`,
status: "connected",
createdAt: new Date().toISOString(),
scopes: [],
shareWithFamily: !!shareWithFamily
};
connectionsByProfile.set(profileId, [...list, connection]);
res.json({ connection });
} catch (e) {
console.error("Credentials connection error:", e);
res.status(400).json({ message: "Invalid request" });
}
});
// List connections for a profile
calendarRouter.get("/:profileId/connections", (req: Request, res: Response) => {
const profileId = String(req.params.profileId ?? "");
const list = connectionsByProfile.get(profileId) ?? [];
res.json(list);
});
// Refresh/sync a connection (placeholder)
calendarRouter.post("/:profileId/connections/:connectionId/refresh", (req: Request, res: Response) => {
const profileId = String(req.params.profileId ?? "");
const connectionId = String(req.params.connectionId ?? "");
const list = connectionsByProfile.get(profileId) ?? [];
const idx = list.findIndex((c) => c.id === connectionId);
if (idx === -1) return res.status(404).json({ message: "Connection not found" });
const now = new Date().toISOString();
list[idx] = { ...list[idx], status: "connected", lastSyncedAt: now };
connectionsByProfile.set(profileId, list);
res.json({ status: "connected", lastSyncedAt: now });
});
// Remove a connection
calendarRouter.delete("/:profileId/connections/:connectionId", (req: Request, res: Response) => {
const profileId = String(req.params.profileId ?? "");
const connectionId = String(req.params.connectionId ?? "");
const list = connectionsByProfile.get(profileId) ?? [];
const filtered = list.filter((c) => c.id !== connectionId);
connectionsByProfile.set(profileId, filtered);
res.status(204).send();
});

View File

@@ -0,0 +1,20 @@
import { Router } from "express";
import {
createChildController,
listArchivedChildrenController,
restoreChildController,
updateChildController,
permanentlyDeleteChildController,
deleteChildController,
listChildrenController
} from "../controllers/children";
export const childrenRouter = Router();
childrenRouter.get("/", listChildrenController);
childrenRouter.get("/archived", listArchivedChildrenController);
childrenRouter.post("/", createChildController);
childrenRouter.post("/:childId/restore", restoreChildController);
childrenRouter.patch("/:childId", updateChildController);
childrenRouter.delete("/:childId", deleteChildController);
childrenRouter.delete("/:childId/permanent", permanentlyDeleteChildController);

View File

@@ -0,0 +1,15 @@
import { Router } from "express";
import {
createGrandParentController,
deleteGrandParentController,
listGrandParentsController,
updateGrandParentController
} from "../controllers/grandparents";
export const grandParentsRouter = Router();
grandParentsRouter.get("/", listGrandParentsController);
grandParentsRouter.post("/", createGrandParentController);
grandParentsRouter.patch("/:grandParentId", updateGrandParentController);
grandParentsRouter.delete("/:grandParentId", deleteGrandParentController);

View File

@@ -0,0 +1,10 @@
import { Router } from "express";
export const healthRouter = Router();
healthRouter.get("/", (_req, res) => {
res.json({
status: "ok",
timestamp: new Date().toISOString()
});
});

View File

@@ -0,0 +1,31 @@
import { Router } from "express";
import {
handleGetAllHolidays,
handleGetPublicHolidays,
handleGetSchoolHolidays
} from "../controllers/holidays";
const router = Router();
/**
* GET /api/holidays
* Récupère tous les jours fériés et congés scolaires
* Query params: region (zone-a|zone-b|zone-c|...), year (2024, 2025)
*/
router.get("/", handleGetAllHolidays);
/**
* GET /api/holidays/public
* Récupère uniquement les jours fériés
* Query params: year (2024, 2025)
*/
router.get("/public", handleGetPublicHolidays);
/**
* GET /api/holidays/school
* Récupère uniquement les congés scolaires
* Query params: region (zone-a|zone-b|zone-c|...), year (2024, 2025)
*/
router.get("/school", handleGetSchoolHolidays);
export default router;

View File

@@ -0,0 +1,24 @@
import { Express } from "express";
import { childrenRouter } from "./children";
import { scheduleRouter } from "./schedules";
import { alertRouter } from "./alerts";
import { uploadRouter } from "./uploads";
import { ingestionRouter } from "./ingestion";
import { parentsRouter } from "./parents";
import { grandParentsRouter } from "./grandparents";
import holidaysRouter from "./holidays";
import personalLeavesRouter from "./personal-leaves";
import { calendarRouter } from "./calendar";
export const registerRoutes = (app: Express) => {
app.use("/api/children", childrenRouter);
app.use("/api/parents", parentsRouter);
app.use("/api/grandparents", grandParentsRouter);
app.use("/api/schedules", scheduleRouter);
app.use("/api/alerts", alertRouter);
app.use("/api/uploads", uploadRouter);
app.use("/api/ingestion", ingestionRouter);
app.use("/api/holidays", holidaysRouter);
app.use("/api/personal-leaves", personalLeavesRouter);
app.use("/api/calendar", calendarRouter);
};

View File

@@ -0,0 +1,216 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { loadConfig } from "../config/env";
import { secretStore } from "../services/secret-store";
import { spawn } from "node:child_process";
import path from "node:path";
import fs from "node:fs";
export const ingestionRouter = Router();
ingestionRouter.get("/status", async (_req: Request, res: Response) => {
try {
const { ingestionServiceUrl } = loadConfig();
const resp = await fetch(`${ingestionServiceUrl}/health`);
const data = await resp.json();
res.json({ ok: resp.ok, data });
} catch (e) {
res.status(200).json({ ok: false });
}
});
ingestionRouter.get("/config", async (_req: Request, res: Response) => {
try {
const { ingestionServiceUrl } = loadConfig();
const resp = await fetch(`${ingestionServiceUrl}/config`);
const data = await resp.json();
res.json(data);
} catch (e) {
res.status(200).json({ openaiConfigured: false, model: null });
}
});
async function wait(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
async function isHealthy(baseUrl: string): Promise<boolean> {
try {
const resp = await fetch(`${baseUrl}/health`, { method: "GET" });
return resp.ok;
} catch {
return false;
}
}
async function pushOpenAIConfig(baseUrl: string) {
try {
const sec = await secretStore.get();
if (!sec.openaiApiKey) return;
await fetch(`${baseUrl}/config/openai`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: sec.openaiApiKey, model: sec.model })
});
} catch {}
}
ingestionRouter.post("/start", async (_req: Request, res: Response) => {
if (process.env.NODE_ENV === "production" && process.env.ALLOW_INGESTION_MAINTENANCE !== "true") {
return res.status(403).json({ ok: false, message: "Maintenance endpoint disabled in production" });
}
const { ingestionServiceUrl } = loadConfig();
if (await isHealthy(ingestionServiceUrl)) {
res.json({ ok: true });
return;
}
try {
const root = path.join(__dirname, "../../../");
const appDir = path.join(root, "ingestion-service", "src");
const args = ["-m", "uvicorn", "ingestion.main:app", "--reload", "--port", "8000", "--app-dir", appDir];
try {
const proc = spawn(process.platform === "win32" ? "py" : "python", args, {
cwd: root,
detached: true,
stdio: "ignore"
});
proc.unref?.();
} catch {
spawn("uvicorn", ["ingestion.main:app", "--reload", "--port", "8000", "--app-dir", appDir], {
cwd: root,
detached: true,
stdio: "ignore"
}).unref?.();
}
for (let i = 0; i < 20; i++) {
if (await isHealthy(ingestionServiceUrl)) break;
await wait(500);
}
if (await isHealthy(ingestionServiceUrl)) {
await pushOpenAIConfig(ingestionServiceUrl);
res.json({ ok: true });
return;
}
res.status(500).json({ ok: false });
} catch {
res.status(500).json({ ok: false });
}
});
function run(cmd: string, args: string[], opts: any = {}): Promise<{ ok: boolean; code: number | null; stdout: string; stderr: string }>{
return new Promise((resolve) => {
const child = spawn(cmd, args, { ...opts, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let err = "";
child.stdout?.on("data", (d) => (out += d.toString()));
child.stderr?.on("data", (d) => (err += d.toString()));
child.on("error", () => resolve({ ok: false, code: null, stdout: out, stderr: err }));
child.on("close", (code) => resolve({ ok: code === 0, code, stdout: out, stderr: err }));
});
}
function venvPython(root: string): string {
const vwin = path.join(root, "ingestion-service", ".venv", "Scripts", "python.exe");
const vposix = path.join(root, "ingestion-service", ".venv", "bin", "python");
return fs.existsSync(vwin) ? vwin : vposix;
}
async function repairIngestion(): Promise<{ ok: boolean; steps: string[] }>{
const steps: string[] = [];
const root = path.join(__dirname, "../../../");
const svcDir = path.join(root, "ingestion-service");
const venvDir = path.join(svcDir, ".venv");
// 1) Create venv if missing
if (!fs.existsSync(venvDir)) {
steps.push("Creating virtualenv (.venv)");
let created = false;
for (const pycmd of ["py", "python", "python3", "python3.11"]) {
const r = await run(pycmd, ["-m", "venv", ".venv"], { cwd: svcDir });
if (r.ok) { created = true; break; }
}
if (!created) return { ok: false, steps: [...steps, "Failed to create .venv (python not found)"] };
}
const py = venvPython(root);
if (!fs.existsSync(py)) return { ok: false, steps: [...steps, "venv python not found"] };
// 2) Upgrade pip and install deps
steps.push("Upgrading pip & installing deps");
await run(py, ["-m", "pip", "install", "-U", "pip", "wheel"], { cwd: svcDir });
await run(py, ["-m", "pip", "install",
"fastapi>=0.110.0",
"uvicorn[standard]>=0.30.0",
"python-multipart>=0.0.9",
"pillow>=10.0.0",
"pdfplumber>=0.11.0",
"openpyxl>=3.1.0",
"pymupdf>=1.24.0"
], { cwd: svcDir });
// 3) Start uvicorn
steps.push("Starting uvicorn in background");
try {
const proc = spawn(py, ["-m", "uvicorn", "ingestion.main:app", "--reload", "--port", "8000", "--app-dir", path.join(svcDir, "src")], {
cwd: root,
detached: true,
stdio: "ignore"
});
proc.unref?.();
} catch (e) {
steps.push("Failed to spawn uvicorn");
return { ok: false, steps };
}
// 4) Wait for health briefly
const { ingestionServiceUrl } = loadConfig();
for (let i = 0; i < 30; i++) {
try {
const resp = await fetch(`${ingestionServiceUrl}/health`);
if (resp.ok) return { ok: true, steps };
} catch {}
await wait(500);
}
steps.push("Health check still failing after start");
return { ok: false, steps };
}
ingestionRouter.post("/repair", async (_req: Request, res: Response) => {
if (process.env.NODE_ENV === "production" && process.env.ALLOW_INGESTION_MAINTENANCE !== "true") {
return res.status(403).json({ ok: false, steps: ["Maintenance endpoint disabled in production"] });
}
try {
const out = await repairIngestion();
if (out.ok) {
await pushOpenAIConfig(loadConfig().ingestionServiceUrl);
res.json({ ok: true, steps: out.steps });
} else {
res.status(500).json(out);
}
} catch (e) {
res.status(500).json({ ok: false, steps: ["Unexpected error during repair"] });
}
});
ingestionRouter.post("/config/openai", async (req: Request, res: Response) => {
const { ingestionServiceUrl } = loadConfig();
try {
// Persist on backend
const apiKey: string | undefined = req.body?.apiKey;
const model: string | undefined = req.body?.model;
if (apiKey) await secretStore.set({ openaiApiKey: apiKey });
if (model) await secretStore.set({ model });
// Best-effort push to ingestion if it's up; otherwise it will be pushed on next start
try {
const healthy = await isHealthy(ingestionServiceUrl);
if (healthy) {
await fetch(`${ingestionServiceUrl}/config/openai`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey, model })
}).catch(() => undefined);
}
} catch {}
res.json({ ok: true });
} catch (e) {
// Even if failing to contact ingestion, consider stored successfully
res.json({ ok: true, queued: true });
}
});

View File

@@ -0,0 +1,10 @@
import { Router } from "express";
import { createParentController, deleteParentController, listParentsController, updateParentController } from "../controllers/parents";
export const parentsRouter = Router();
parentsRouter.get("/", listParentsController);
parentsRouter.post("/", createParentController);
parentsRouter.patch("/:parentId", updateParentController);
parentsRouter.delete("/:parentId", deleteParentController);

View File

@@ -0,0 +1,44 @@
import { Router } from "express";
import {
handleListPersonalLeaves,
handleGetPersonalLeave,
handleCreatePersonalLeave,
handleUpdatePersonalLeave,
handleDeletePersonalLeave
} from "../controllers/personal-leaves";
const router = Router();
/**
* GET /api/personal-leaves
* Liste tous les congés personnels
* Query params: profileId (optionnel)
*/
router.get("/", handleListPersonalLeaves);
/**
* GET /api/personal-leaves/:id
* Récupère un congé personnel par son ID
*/
router.get("/:id", handleGetPersonalLeave);
/**
* POST /api/personal-leaves
* Crée un nouveau congé personnel
* Body: { profileId, title, startDate, endDate, isAllDay, notes?, source? }
*/
router.post("/", handleCreatePersonalLeave);
/**
* PUT /api/personal-leaves/:id
* Met à jour un congé personnel
*/
router.put("/:id", handleUpdatePersonalLeave);
/**
* DELETE /api/personal-leaves/:id
* Supprime un congé personnel
*/
router.delete("/:id", handleDeletePersonalLeave);
export default router;

View File

@@ -0,0 +1,18 @@
import { Router } from "express";
import {
createScheduleController,
getScheduleController,
getDayActivitiesController,
getWeekActivitiesController,
getMonthActivitiesController,
listSchedulesController
} from "../controllers/schedules";
export const scheduleRouter = Router();
scheduleRouter.post("/", createScheduleController);
scheduleRouter.get("/:scheduleId", getScheduleController);
scheduleRouter.get("/day/activities", getDayActivitiesController);
scheduleRouter.get("/week/activities", getWeekActivitiesController);
scheduleRouter.get("/month/activities", getMonthActivitiesController);
scheduleRouter.get("/", listSchedulesController);

View File

@@ -0,0 +1,505 @@
import { Router } from "express";
import type { Request, Response } from "express";
import multer, { FileFilterCallback } from "multer";
import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import { scheduleService } from "../services/schedule-service";
import { childService } from "../services/child-service";
import { loadConfig } from "../config/env";
import { secretStore } from "../services/secret-store";
import { spawn } from "node:child_process";
import { uploadLimiter } from "../middleware/security";
import FormData from "form-data";
import axios from "axios";
// __dirname in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const avatarsDir = path.join(__dirname, "../../public/avatars");
fs.mkdirSync(avatarsDir, { recursive: true });
const plansDir = path.join(__dirname, "../../public/plans");
fs.mkdirSync(plansDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req: Request, _file: Express.Multer.File, callback) => {
callback(null, avatarsDir);
},
filename: (_req: Request, file: Express.Multer.File, callback) => {
const ext = path.extname(file.originalname) || ".png";
callback(null, `${randomUUID()}${ext}`);
}
});
const fileFilter = (_req: Request, file: Express.Multer.File, callback: FileFilterCallback) => {
if (!file.mimetype.startsWith("image/")) {
callback(new Error("Invalid file type"));
return;
}
callback(null, true);
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024
}
});
export const uploadRouter = Router();
let ingestionStarting: Promise<void> | null = null;
let ingestionProcess: import("node:child_process").ChildProcess | null = null;
async function wait(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
async function isHealthy(baseUrl: string): Promise<boolean> {
try {
const resp = await fetch(`${baseUrl}/health`, { method: "GET" });
return resp.ok;
} catch {
return false;
}
}
async function pushOpenAIConfig(baseUrl: string) {
try {
const sec = await secretStore.get();
if (!sec.openaiApiKey) return;
await fetch(`${baseUrl}/config/openai`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: sec.openaiApiKey, model: sec.model })
});
} catch {}
}
async function ensureIngestionRunning(baseUrl: string): Promise<void> {
if (await isHealthy(baseUrl)) {
return;
}
if (!ingestionStarting) {
ingestionStarting = (async () => {
// try to spawn uvicorn for ingestion-service
const root = path.join(__dirname, "../../../");
const appDir = path.join(root, "ingestion-service", "src");
const args = ["-m", "uvicorn", "ingestion.main:app", "--reload", "--port", "8000", "--app-dir", appDir];
let proc: import("node:child_process").ChildProcess | null = null;
try {
proc = spawn(process.platform === "win32" ? "py" : "python", args, {
cwd: root,
stdio: "ignore",
detached: true
});
} catch {
try {
proc = spawn("uvicorn", ["ingestion.main:app", "--reload", "--port", "8000", "--app-dir", appDir], {
cwd: root,
stdio: "ignore",
detached: true
});
} catch {
// give up, will fail health check
}
}
if (proc) {
ingestionProcess = proc;
if (proc.pid) {
try { proc.unref?.(); } catch {}
}
}
// wait for health
for (let i = 0; i < 20; i++) {
if (await isHealthy(baseUrl)) break;
await wait(500);
}
if (await isHealthy(baseUrl)) {
await pushOpenAIConfig(baseUrl);
}
})();
}
try {
await ingestionStarting;
} finally {
ingestionStarting = null;
}
}
uploadRouter.post("/avatar", uploadLimiter, upload.single("avatar"), (req: Request, res: Response) => {
const file = req.file as Express.Multer.File | undefined;
if (!file) {
res.status(400).json({ message: "Aucun fichier recu" });
return;
}
const baseUrl = `${req.protocol}://${req.get("host") ?? ""}`;
const url = `${baseUrl}/static/avatars/${file.filename}`;
res.json({
url,
filename: file.filename,
originalName: file.originalname
});
});
uploadRouter.get("/avatars", (req: Request, res: Response) => {
try {
const baseUrl = `${req.protocol}://${req.get("host") ?? ""}`;
const files = fs
.readdirSync(avatarsDir, { withFileTypes: true })
.filter((dirent) => dirent.isFile())
.map((dirent) => ({
filename: dirent.name,
url: `${baseUrl}/static/avatars/${dirent.name}`
}));
res.json(files);
} catch (error) {
res.status(500).json({ message: "Impossible de lister les avatars." });
}
});
// --- Diagnostic endpoint: detect file handling path ---
const diagnoseUpload = multer({ storage: multer.memoryStorage() });
uploadRouter.post("/diagnose", uploadLimiter, diagnoseUpload.single("planning"), async (req: Request, res: Response) => {
try {
const file = req.file as Express.Multer.File | undefined;
if (!file) {
res.status(400).json({ message: "Aucun fichier reçu" });
return;
}
const name = file.originalname || file.filename || "fichier";
const mimetype = file.mimetype || "application/octet-stream";
const lower = name.toLowerCase();
let detectedType: "image" | "pdf" | "spreadsheet" | "unknown" = "unknown";
if (mimetype.startsWith("image/") || /\.(png|jpg|jpeg|webp)$/i.test(lower)) detectedType = "image";
else if (mimetype === "application/pdf" || /\.pdf$/i.test(lower)) detectedType = "pdf";
else if (/\.(xls|xlsx)$/i.test(lower)) detectedType = "spreadsheet";
let analysisPlan = "unknown";
let wouldUseVision = false;
switch (detectedType) {
case "image":
analysisPlan = "image: vision + règles";
wouldUseVision = true;
break;
case "pdf":
analysisPlan = "pdf: texte (pdfplumber) puis fallback vision si nécessaire";
wouldUseVision = true; // possible fallback
break;
case "spreadsheet":
analysisPlan = "tableur: openpyxl (lecture des cellules)";
wouldUseVision = false;
break;
default:
analysisPlan = "inconnu: tentative selon extension/mimetype";
}
const { ingestionServiceUrl } = loadConfig();
let ingestionHealthy = false;
try {
const ping = await fetch(`${ingestionServiceUrl}/health`);
ingestionHealthy = ping.ok;
} catch {}
res.json({
filename: name,
mimetype,
detectedType,
analysisPlan,
wouldUseVision,
ingestionHealthy
});
} catch (e) {
res.status(500).json({ message: "Diagnostic en échec" });
}
});
uploadRouter.post("/diagnose-run", uploadLimiter, diagnoseUpload.single("planning"), async (req: Request, res: Response) => {
try {
const file = req.file as Express.Multer.File | undefined;
if (!file) return res.status(400).json({ message: "Aucun fichier reçu" });
const name = file.originalname || file.filename || "fichier";
const mimetype = file.mimetype || "application/octet-stream";
const lower = name.toLowerCase();
let detectedType: "image" | "pdf" | "spreadsheet" | "unknown" = "unknown";
if (mimetype.startsWith("image/") || /\.(png|jpg|jpeg|webp)$/i.test(lower)) detectedType = "image";
else if (mimetype === "application/pdf" || /\.pdf$/i.test(lower)) detectedType = "pdf";
else if (/\.(xls|xlsx)$/i.test(lower)) detectedType = "spreadsheet";
const { ingestionServiceUrl } = loadConfig();
let activities: any[] = [];
let status = "processing";
try {
const u8 = new Uint8Array(file.buffer);
const blob = new Blob([u8], { type: mimetype });
const form = new FormData();
form.append("schedule_id", randomUUID());
form.append("child_id", "diagnostic");
form.append("file", blob, name);
const resp = await fetch(`${ingestionServiceUrl}/ingest`, { method: "POST", body: form as any });
if (resp.ok) {
const data = await resp.json();
activities = data.activities ?? [];
status = activities.length > 0 ? "ready" : "processing";
}
} catch (e) {}
const toISODate = (d: string) => new Date(d).toISOString().slice(0, 10);
const startField = (x: any) => x.start_date ?? x.startDateTime ?? x.start;
const endField = (x: any) => x.end_date ?? x.endDateTime ?? x.end;
const periodStart = activities.length ? toISODate(activities.map(startField).sort()[0]) : null;
const periodEnd = activities.length ? toISODate(activities.map(endField).sort().slice(-1)[0]) : null;
res.json({ filename: name, mimetype, detectedType, activitiesCount: activities.length, sample: activities.slice(0, 5), periodStart, periodEnd, status });
} catch (e) {
res.status(500).json({ message: "Diagnostic-run en échec" });
}
});
// Upload and analyze a planning (image/PDF/Excel)
const planStorage = multer.diskStorage({
destination: (_req: Request, _file: Express.Multer.File, cb) => cb(null, plansDir),
filename: (_req: Request, file: Express.Multer.File, cb) => {
const ext = path.extname(file.originalname) || ".bin";
cb(null, `${randomUUID()}${ext}`);
}
});
// Secure file filter: only allow images, PDFs, and Excel files
const planFileFilter = (_req: Request, file: Express.Multer.File, callback: FileFilterCallback) => {
const allowedMimeTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'application/pdf',
'application/vnd.ms-excel', // .xls
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' // .xlsx
];
const allowedExtensions = /\.(jpg|jpeg|png|webp|pdf|xls|xlsx)$/i;
const hasValidMime = allowedMimeTypes.includes(file.mimetype);
const hasValidExt = allowedExtensions.test(file.originalname);
if (hasValidMime && hasValidExt) {
callback(null, true);
} else {
callback(new Error('Type de fichier non autorisé. Formats acceptés: JPG, PNG, WEBP, PDF, XLS, XLSX'));
}
};
const planUpload = multer({
storage: planStorage,
fileFilter: planFileFilter,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB max
}
});
uploadRouter.post("/planning", uploadLimiter, planUpload.single("planning"), async (req: Request, res: Response) => {
try {
console.log("[uploads] /planning received");
const rawChildId = (req.body.childId as string) || (req.query.childId as string);
// Validate childId: must be a valid UUID to prevent path traversal
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!rawChildId || !uuidRegex.test(rawChildId)) {
console.warn("[uploads] invalid or missing childId:", rawChildId);
res.status(400).json({ message: "Parametre childId manquant ou invalide" });
return;
}
const childId = rawChildId; // Safe to use after validation
const file = req.file as Express.Multer.File | undefined;
if (!file) {
console.warn("[uploads] no file in request");
res.status(400).json({ message: "Aucun fichier recu" });
return;
}
// Move file into a per-child archive folder for easier browsing
// Safe because childId is validated as UUID
const childDir = path.join(plansDir, childId);
fs.mkdirSync(childDir, { recursive: true });
const newPath = path.join(childDir, file.filename);
try {
fs.renameSync(file.path, newPath);
// reflect new path back on the file object for later reads
(file as any).path = newPath;
} catch {
// keep original path if move fails
}
const baseUrl = `${req.protocol}://${req.get("host") ?? ""}`;
const sourceUrl = `${baseUrl}/static/plans/${encodeURIComponent(childId)}/${file.filename}`;
// Try to analyze with ingestion service (non-blocking)
const config = loadConfig();
let activities: Array<{ title: string; category: "school" | "sport" | "medical" | "event" | "other"; startDateTime: string; endDateTime: string; location?: string; notes?: string } > = [];
// Check if ingestion service is available (don't wait for startup)
const ingestionHealthy = await isHealthy(config.ingestionServiceUrl);
if (ingestionHealthy) {
try {
console.log("[uploads] preparing file for ingestion...");
const buffer = await fsp.readFile(file.path);
const fileType = file.mimetype || (file.filename.endsWith(".pdf") ? "application/pdf" : "application/octet-stream");
// Use form-data package for proper multipart support in Node.js
const form = new FormData();
const scheduleId = randomUUID();
form.append("schedule_id", scheduleId);
form.append("child_id", childId);
form.append("file", buffer, {
filename: file.originalname || file.filename,
contentType: fileType
});
console.log(`[uploads] sending to ingestion service (${file.originalname}, ${(buffer.length / 1024).toFixed(1)}KB)...`);
const resp = await axios.post(`${config.ingestionServiceUrl}/ingest`, form, {
headers: form.getHeaders(),
timeout: 60000, // 60 second timeout for OpenAI analysis
validateStatus: () => true // Don't throw on non-2xx status codes
});
console.log(`[uploads] ingestion response status: ${resp.status}`);
if (resp.status >= 200 && resp.status < 300) {
const data = resp.data;
console.log("[uploads] ingestion response data:", JSON.stringify(data).substring(0, 200));
const arr = (data.activities ?? []) as Array<any>;
activities = arr.map((a) => ({
title: a.title ?? "Activite",
category: (a.category as any) ?? "other",
startDateTime: a.start_date ?? a.startDateTime ?? new Date().toISOString(),
endDateTime: a.end_date ?? a.endDateTime ?? new Date().toISOString(),
location: a.location,
notes: a.notes
}));
console.log(`[uploads] ingestion returned ${activities.length} activities`);
} else {
const errorText = typeof resp.data === 'string' ? resp.data : JSON.stringify(resp.data);
console.warn(`[uploads] ingestion http ${resp.status}:`, errorText.substring(0, 500));
}
} catch (e) {
console.warn("[uploads] ingestion error (continuing with empty schedule):", e);
}
} else {
console.log("[uploads] ingestion service not available, creating empty schedule");
}
const toISODate = (d: string) => new Date(d).toISOString().slice(0, 10);
const periodStart = activities.length ? toISODate(activities.map(a => a.startDateTime).sort()[0]) : new Date().toISOString().slice(0,10);
const periodEnd = activities.length ? toISODate(activities.map(a => a.endDateTime).sort().slice(-1)[0]) : new Date().toISOString().slice(0,10);
// Get child information for standardized export
const children = await childService.listChildren();
const child = children.find(c => c.id === childId);
const childName = child?.fullName ?? childId;
const schedule = await scheduleService.createSchedule({
childId,
periodStart,
periodEnd,
activities,
sourceFileUrl: sourceUrl,
sourceFileName: file.originalname || file.filename,
sourceMimeType: file.mimetype
});
try {
console.log("[uploads] writing standardized JSON export for schedule", schedule.id);
const jsonName = `${schedule.id}-analysis.json`;
const jsonPath = path.join(plansDir, childId, jsonName);
// Create standardized JSON format
const standardizedData = {
version: "1.0",
calendar_scope: "weekly",
timezone: "Europe/Paris",
period: {
start: periodStart,
end: periodEnd
},
entities: [childName],
events: activities.map(a => ({
event_id: randomUUID(),
entity: childName,
title: a.title,
category: a.category,
start_datetime: a.startDateTime,
end_datetime: a.endDateTime,
location: a.location ?? "",
notes: a.notes ?? "",
recurrence: null
})),
extraction: {
method: ingestionHealthy ? "ingestion-service" : "empty",
timestamp: new Date().toISOString(),
source_file: file.originalname || file.filename
}
};
await fsp.writeFile(jsonPath, JSON.stringify(standardizedData, null, 2), { encoding: 'utf-8' });
const baseUrl2 = `${req.protocol}://${req.get("host") ?? ""}`;
const exportCsvUrl = `${baseUrl2}/static/plans/${encodeURIComponent(childId)}/${jsonName}`;
(schedule as any).exportCsvUrl = exportCsvUrl;
res.status(201).json({ schedule });
} catch {
console.warn("[uploads] failed to write JSON export to public, trying fallback temp");
try {
const jsonName = `${schedule.id}-analysis.json`;
const tempRoot = path.join(__dirname, "../../temp/plans");
const tempDir = path.join(tempRoot, childId);
await fsp.mkdir(tempDir, { recursive: true });
const jsonPath = path.join(tempDir, jsonName);
// Create standardized JSON format
const standardizedData = {
version: "1.0",
calendar_scope: "weekly",
timezone: "Europe/Paris",
period: {
start: periodStart,
end: periodEnd
},
entities: [childName],
events: activities.map(a => ({
event_id: randomUUID(),
entity: childName,
title: a.title,
category: a.category,
start_datetime: a.startDateTime,
end_datetime: a.endDateTime,
location: a.location ?? "",
notes: a.notes ?? "",
recurrence: null
})),
extraction: {
method: ingestionHealthy ? "ingestion-service" : "empty",
timestamp: new Date().toISOString(),
source_file: file.originalname || file.filename
}
};
await fsp.writeFile(jsonPath, JSON.stringify(standardizedData, null, 2), { encoding: 'utf-8' });
const baseUrl2 = `${req.protocol}://${req.get("host") ?? ""}`;
const exportCsvUrl = `${baseUrl2}/files/plans/${encodeURIComponent(childId)}/${jsonName}`;
(schedule as any).exportCsvUrl = exportCsvUrl;
res.status(201).json({ schedule });
} catch (e2) {
console.warn("[uploads] fallback JSON export failed", e2);
res.status(201).json({ schedule });
}
}
} catch (error: any) {
console.error("[uploads] fatal error", error);
res.status(500).json({ message: "Erreur pendant l'import du planning" });
}
});

81
backend/src/server.ts Normal file
View File

@@ -0,0 +1,81 @@
import dotenv from "dotenv";
import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import cors from "cors";
import morgan from "morgan";
import { registerRoutes } from "./routes";
import { healthRouter } from "./routes/health";
import { loadConfig } from "./config/env";
import { helmetConfig, apiLimiter, validateCorsOrigin } from "./middleware/security";
import { errorHandler, notFoundHandler } from "./middleware/error-handler";
import { logger } from "./utils/logger";
// Load environment variables
dotenv.config();
const config = loadConfig();
const app = express();
// __dirname for ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Security middleware - helmet should be first
app.use(helmetConfig);
// CORS with proper origin validation
app.use(
cors({
origin: validateCorsOrigin,
credentials: true,
maxAge: 86400 // 24 hours
})
);
// Rate limiting for all API routes
app.use("/api", apiLimiter);
// Body parsing with size limits
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// Static file serving
// In dist, server.js is in backend/dist, so public is ../public
app.use("/static", express.static(path.join(__dirname, "../public")));
// Serve fallback temp files (e.g., CSV exports if OneDrive blocks public writes)
app.use("/files", express.static(path.join(__dirname, "../temp")));
// Request logging
if (config.nodeEnv === "development") {
app.use(morgan("dev"));
} else {
app.use(
morgan("combined", {
stream: {
write: (message: string) => logger.info(message.trim())
}
})
);
}
// Health check endpoint (before rate limiting)
app.use("/api/health", healthRouter);
// Register application routes
registerRoutes(app);
// 404 handler for unknown routes
app.use(notFoundHandler);
// Global error handler - must be last
app.use(errorHandler);
// Start server
app.listen(config.port, () => {
logger.info(`Server ready on port ${config.port}`, {
nodeEnv: config.nodeEnv,
corsOrigin: config.corsOrigin
});
});

View File

@@ -0,0 +1,20 @@
import { Alert } from "../models/alert";
const alertStore: Alert[] = [
{
id: "al_001",
childId: "ch_001",
scheduleId: "sc_101",
activityId: "ac_500",
title: "Piscine demain 17:00",
triggerDateTime: new Date().toISOString(),
channel: "push",
status: "pending"
}
];
export const alertService = {
async listAlerts(): Promise<Alert[]> {
return alertStore;
}
};

View File

@@ -0,0 +1,102 @@
import { randomUUID } from "node:crypto";
import { Child, SchoolRegion } from "../models/child";
import { loadDB, saveDB } from "./file-db";
// Data is persisted in client.json. If file is empty, first read will be empty arrays.
type CreateChildInput = {
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: Child["avatar"];
schoolRegion?: SchoolRegion;
};
type UpdateChildInput = Partial<Omit<Child, "id" | "createdAt" | "deletedAt" | "avatar">> & {
avatar?: Child["avatar"] | null;
schoolRegion?: SchoolRegion;
};
export const childService = {
async listChildren(): Promise<Child[]> {
const db = loadDB();
return db.children;
},
async listArchivedChildren(): Promise<Child[]> {
const db = loadDB();
return db.archived;
},
async createChild(payload: CreateChildInput): Promise<Child> {
const child: Child = {
id: randomUUID(),
fullName: payload.fullName,
colorHex: payload.colorHex,
email: payload.email,
notes: payload.notes,
createdAt: new Date().toISOString(),
avatar: payload.avatar
};
const db = loadDB();
db.children.unshift(child);
saveDB(db);
return child;
},
async deleteChild(id: string): Promise<boolean> {
const db = loadDB();
const index = db.children.findIndex((item) => item.id === id);
if (index === -1) return false;
const [removed] = db.children.splice(index, 1);
removed.deletedAt = new Date().toISOString();
db.archived.unshift(removed);
saveDB(db);
return true;
},
async restoreChild(id: string): Promise<Child | null> {
const db = loadDB();
const index = db.archived.findIndex((item) => item.id === id);
if (index === -1) return null;
const [restored] = db.archived.splice(index, 1);
delete restored.deletedAt;
db.children.unshift(restored);
saveDB(db);
return restored;
},
async updateChild(id: string, payload: UpdateChildInput): Promise<Child | null> {
const db = loadDB();
const target = db.children.find((item) => item.id === id);
if (!target) {
return null;
}
if (payload.fullName !== undefined) {
target.fullName = payload.fullName;
}
if (payload.colorHex !== undefined) {
target.colorHex = payload.colorHex;
}
if (payload.email !== undefined) {
target.email = payload.email;
}
if (payload.notes !== undefined) {
target.notes = payload.notes;
}
if (payload.avatar === null) {
delete target.avatar;
} else if (payload.avatar !== undefined) {
target.avatar = payload.avatar;
}
if (payload.schoolRegion !== undefined) {
target.schoolRegion = payload.schoolRegion;
}
saveDB(db);
return target;
},
async permanentlyDeleteChild(id: string): Promise<boolean> {
const db = loadDB();
const index = db.archived.findIndex((item) => item.id === id);
if (index === -1) return false;
db.archived.splice(index, 1);
saveDB(db);
return true;
}
};

View File

@@ -0,0 +1,123 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Child } from "../models/child";
import { Parent } from "../models/parent";
import { GrandParent } from "../models/grandparent";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dataDir = path.join(__dirname, "../data");
const dbPath = path.join(dataDir, "client.json");
export type ClientDB = {
version: number;
children: Child[];
archived: Child[];
parents?: Parent[];
grandparents?: GrandParent[];
personalLeaves?: any[]; // Personal leaves for parents/grandparents
[key: string]: any; // Allow dynamic keys for extensibility
};
const defaultDB = (): ClientDB => ({ version: 3, children: [], archived: [], parents: [], grandparents: [] });
function ensureDir() {
fs.mkdirSync(dataDir, { recursive: true });
}
export function loadDB(): ClientDB {
try {
ensureDir();
if (!fs.existsSync(dbPath)) {
console.log("⚠️ client.json n'existe pas, création d'une base vide");
const db = defaultDB();
fs.writeFileSync(dbPath, JSON.stringify(db, null, 2), "utf8");
return db;
}
const raw = fs.readFileSync(dbPath, "utf8");
const parsed = JSON.parse(raw) as ClientDB;
// Basic shape guard + migration
if (!parsed.children || !parsed.archived) {
console.warn("⚠️ Structure de données invalide, réinitialisation");
// Créer une sauvegarde du fichier corrompu
const corruptedBackup = `${dbPath}.corrupted.${Date.now()}.json`;
fs.copyFileSync(dbPath, corruptedBackup);
console.log(`💾 Fichier corrompu sauvegardé: ${corruptedBackup}`);
return defaultDB();
}
if (!parsed.parents) {
parsed.parents = [];
}
if (!parsed.grandparents) {
parsed.grandparents = [];
parsed.version = Math.max(parsed.version ?? 1, 3);
fs.writeFileSync(dbPath, JSON.stringify(parsed, null, 2), "utf8");
}
return parsed;
} catch (error) {
console.error("❌ Erreur lors du chargement de client.json:", error);
// Tenter de restaurer depuis la dernière sauvegarde
const backupPath = `${dbPath}.backup`;
if (fs.existsSync(backupPath)) {
console.log("🔄 Tentative de restauration depuis backup...");
try {
const backupRaw = fs.readFileSync(backupPath, "utf8");
const backupData = JSON.parse(backupRaw) as ClientDB;
console.log("✅ Restauration réussie depuis backup");
return backupData;
} catch (backupError) {
console.error("❌ Impossible de restaurer depuis backup:", backupError);
}
}
return defaultDB();
}
}
export function saveDB(db: ClientDB) {
ensureDir();
// Créer une sauvegarde avant d'écrire (rotation de 5 backups)
if (fs.existsSync(dbPath)) {
try {
// Rotation des backups (garder les 5 derniers)
for (let i = 4; i > 0; i--) {
const oldBackup = `${dbPath}.backup.${i}`;
const newBackup = `${dbPath}.backup.${i + 1}`;
if (fs.existsSync(oldBackup)) {
fs.renameSync(oldBackup, newBackup);
}
}
// Créer le nouveau backup.1
fs.copyFileSync(dbPath, `${dbPath}.backup.1`);
// Copier aussi vers backup (dernier backup rapide)
fs.copyFileSync(dbPath, `${dbPath}.backup`);
} catch (backupError) {
console.warn("⚠️ Impossible de créer la sauvegarde:", backupError);
}
}
// Écrire atomiquement avec fichier temporaire
const tmp = `${dbPath}.tmp`;
try {
fs.writeFileSync(tmp, JSON.stringify(db, null, 2), "utf8");
fs.renameSync(tmp, dbPath);
console.log(`💾 Données sauvegardées (${db.children?.length || 0} enfants, ${db.parents?.length || 0} parents)`);
} catch (error) {
console.error("❌ Erreur lors de la sauvegarde:", error);
// Nettoyer le fichier temporaire si l'écriture a échoué
if (fs.existsSync(tmp)) {
fs.unlinkSync(tmp);
}
throw error;
}
}
export function withDB<T>(mutator: (db: ClientDB) => T): T {
const db = loadDB();
const result = mutator(db);
saveDB(db);
return result;
}

View File

@@ -0,0 +1,65 @@
import { randomUUID } from "node:crypto";
import { GrandParent } from "../models/grandparent";
import { loadDB, saveDB } from "./file-db";
type CreateGrandParentInput = {
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: GrandParent["avatar"];
};
type UpdateGrandParentInput = Partial<Omit<GrandParent, "id" | "createdAt" | "avatar">> & {
avatar?: GrandParent["avatar"] | null;
};
export const grandParentService = {
async listGrandParents(): Promise<GrandParent[]> {
const db = loadDB();
return db.grandparents ?? [];
},
async createGrandParent(payload: CreateGrandParentInput): Promise<GrandParent> {
const grandParent: GrandParent = {
id: randomUUID(),
fullName: payload.fullName,
colorHex: payload.colorHex,
email: payload.email,
notes: payload.notes,
avatar: payload.avatar,
createdAt: new Date().toISOString()
};
const db = loadDB();
if (!db.grandparents) db.grandparents = [];
db.grandparents.unshift(grandParent);
saveDB(db);
return grandParent;
},
async updateGrandParent(id: string, payload: UpdateGrandParentInput): Promise<GrandParent | null> {
const db = loadDB();
if (!db.grandparents) db.grandparents = [];
const target = db.grandparents.find((p) => p.id === id);
if (!target) return null;
if (payload.fullName !== undefined) target.fullName = payload.fullName;
if (payload.colorHex !== undefined) target.colorHex = payload.colorHex;
if (payload.email !== undefined) target.email = payload.email;
if (payload.notes !== undefined) target.notes = payload.notes;
if (payload.avatar === null) {
delete target.avatar;
} else if (payload.avatar !== undefined) {
target.avatar = payload.avatar;
}
saveDB(db);
return target;
},
async deleteGrandParent(id: string): Promise<boolean> {
const db = loadDB();
if (!db.grandparents) db.grandparents = [];
const idx = db.grandparents.findIndex((p) => p.id === id);
if (idx === -1) return false;
db.grandparents.splice(idx, 1);
saveDB(db);
return true;
}
};

View File

@@ -0,0 +1,372 @@
import { Holiday, HolidayType } from "../models/holiday";
import { SchoolRegion } from "../models/child";
/**
* Service pour récupérer les jours fériés et congés scolaires français
*/
// Données des congés scolaires 2024-2025
const SCHOOL_HOLIDAYS_2024_2025: Record<string, { start: string; end: string; zones: SchoolRegion[] }> = {
"Vacances de la Toussaint": {
start: "2024-10-19",
end: "2024-11-04",
zones: ["zone-a", "zone-b", "zone-c"]
},
"Vacances de Noël": {
start: "2024-12-21",
end: "2025-01-06",
zones: ["zone-a", "zone-b", "zone-c"]
},
"Vacances d'hiver - Zone A": {
start: "2025-02-08",
end: "2025-02-24",
zones: ["zone-a"]
},
"Vacances d'hiver - Zone B": {
start: "2025-02-15",
end: "2025-03-03",
zones: ["zone-b"]
},
"Vacances d'hiver - Zone C": {
start: "2025-02-22",
end: "2025-03-10",
zones: ["zone-c"]
},
"Vacances de printemps - Zone A": {
start: "2025-04-12",
end: "2025-04-28",
zones: ["zone-a"]
},
"Vacances de printemps - Zone B": {
start: "2025-04-05",
end: "2025-04-22",
zones: ["zone-b"]
},
"Vacances de printemps - Zone C": {
start: "2025-04-19",
end: "2025-05-05",
zones: ["zone-c"]
},
"Vacances d'été": {
start: "2025-07-05",
end: "2025-09-01",
zones: ["zone-a", "zone-b", "zone-c"]
},
// Monaco - Vacances scolaires 2024-2025 (Arrêté ministériel n° 2023-221)
"Vacances de la Toussaint Monaco": {
start: "2024-10-23",
end: "2024-11-04",
zones: ["monaco"]
},
"Vacances de Noël Monaco": {
start: "2024-12-20",
end: "2025-01-06",
zones: ["monaco"]
},
"Vacances d'hiver Monaco": {
start: "2025-02-07",
end: "2025-02-24",
zones: ["monaco"]
},
"Vacances de printemps Monaco": {
start: "2025-04-05",
end: "2025-04-22",
zones: ["monaco"]
},
"Vacances d'été Monaco": {
start: "2025-07-01",
end: "2025-09-08",
zones: ["monaco"]
}
};
// Données des congés scolaires 2025-2026
const SCHOOL_HOLIDAYS_2025_2026: Record<string, { start: string; end: string; zones: SchoolRegion[] }> = {
"Vacances de la Toussaint": {
start: "2025-10-18",
end: "2025-11-03",
zones: ["zone-a", "zone-b", "zone-c"]
},
"Vacances de Noël": {
start: "2025-12-20",
end: "2026-01-05",
zones: ["zone-a", "zone-b", "zone-c"]
},
"Vacances d'hiver - Zone A": {
start: "2026-02-07",
end: "2026-02-23",
zones: ["zone-a"]
},
"Vacances d'hiver - Zone B": {
start: "2026-02-21",
end: "2026-03-09",
zones: ["zone-b"]
},
"Vacances d'hiver - Zone C": {
start: "2026-02-14",
end: "2026-03-02",
zones: ["zone-c"]
},
"Vacances de printemps - Zone A": {
start: "2026-04-11",
end: "2026-04-27",
zones: ["zone-a"]
},
"Vacances de printemps - Zone B": {
start: "2026-04-18",
end: "2026-05-04",
zones: ["zone-b"]
},
"Vacances de printemps - Zone C": {
start: "2026-04-04",
end: "2026-04-20",
zones: ["zone-c"]
},
"Vacances d'été": {
start: "2026-07-04",
end: "2026-09-01",
zones: ["zone-a", "zone-b", "zone-c"]
},
// Monaco - Vacances scolaires 2025-2026
"Vacances de la Toussaint Monaco": {
start: "2025-10-23",
end: "2025-10-31",
zones: ["monaco"]
},
"Vacances de Noël Monaco": {
start: "2025-12-22",
end: "2026-01-02",
zones: ["monaco"]
},
"Vacances d'hiver Monaco": {
start: "2026-02-16",
end: "2026-02-27",
zones: ["monaco"]
},
"Vacances de printemps Monaco": {
start: "2026-04-13",
end: "2026-04-24",
zones: ["monaco"]
},
"Vacances Grand Prix Monaco": {
start: "2026-05-21",
end: "2026-05-25",
zones: ["monaco"]
},
"Vacances d'été Monaco": {
start: "2026-06-29",
end: "2026-09-04",
zones: ["monaco"]
}
};
// Jours fériés Monaco par année
const MONACO_PUBLIC_HOLIDAYS: Record<number, Record<string, string>> = {
2024: {
"Jour de l'an": "2024-01-01",
"Sainte Dévote": "2024-01-27",
"Lundi de Pâques": "2024-04-01",
"Fête du Travail": "2024-05-01",
"Ascension": "2024-05-09",
"Lundi de Pentecôte": "2024-05-20",
"Fête-Dieu": "2024-05-30",
"Assomption": "2024-08-15",
"Toussaint": "2024-11-01",
"Fête du Prince": "2024-11-19",
"Immaculée Conception": "2024-12-08",
"Noël": "2024-12-25"
},
2025: {
"Jour de l'an": "2025-01-01",
"Sainte Dévote": "2025-01-27",
"Lundi de Pâques": "2025-04-21",
"Fête du Travail": "2025-05-01",
"Ascension": "2025-05-29",
"Lundi de Pentecôte": "2025-06-09",
"Fête-Dieu": "2025-06-19",
"Assomption": "2025-08-15",
"Toussaint": "2025-11-01",
"Fête du Prince": "2025-11-19",
"Immaculée Conception": "2025-12-08",
"Noël": "2025-12-25"
},
2026: {
"Jour de l'an": "2026-01-01",
"Sainte Dévote": "2026-01-27",
"Lundi de Pâques": "2026-04-06",
"Fête du Travail": "2026-05-01",
"Ascension": "2026-05-14",
"Lundi de Pentecôte": "2026-05-25",
"Fête-Dieu": "2026-06-04",
"Assomption": "2026-08-15",
"Toussaint": "2026-11-01",
"Fête du Prince": "2026-11-19",
"Immaculée Conception": "2026-12-08",
"Noël": "2026-12-25"
}
};
// Jours fériés français par année
const PUBLIC_HOLIDAYS: Record<number, Record<string, string>> = {
2024: {
"Jour de l'an": "2024-01-01",
"Lundi de Pâques": "2024-04-01",
"Fête du Travail": "2024-05-01",
"Victoire 1945": "2024-05-08",
"Ascension": "2024-05-09",
"Lundi de Pentecôte": "2024-05-20",
"Fête Nationale": "2024-07-14",
"Assomption": "2024-08-15",
"Toussaint": "2024-11-01",
"Armistice 1918": "2024-11-11",
"Noël": "2024-12-25"
},
2025: {
"Jour de l'an": "2025-01-01",
"Lundi de Pâques": "2025-04-21",
"Fête du Travail": "2025-05-01",
"Victoire 1945": "2025-05-08",
"Ascension": "2025-05-29",
"Lundi de Pentecôte": "2025-06-09",
"Fête Nationale": "2025-07-14",
"Assomption": "2025-08-15",
"Toussaint": "2025-11-01",
"Armistice 1918": "2025-11-11",
"Noël": "2025-12-25"
},
2026: {
"Jour de l'an": "2026-01-01",
"Lundi de Pâques": "2026-04-06",
"Fête du Travail": "2026-05-01",
"Victoire 1945": "2026-05-08",
"Ascension": "2026-05-14",
"Lundi de Pentecôte": "2026-05-25",
"Fête Nationale": "2026-07-14",
"Assomption": "2026-08-15",
"Toussaint": "2026-11-01",
"Armistice 1918": "2026-11-11",
"Noël": "2026-12-25"
}
};
let holidayIdCounter = 1;
function generateId(): string {
return `holiday_${Date.now()}_${holidayIdCounter++}`;
}
/**
* Récupère les jours fériés de Monaco pour une année
*/
export function getMonacoPublicHolidays(year?: number): Holiday[] {
const targetYear = year ?? new Date().getFullYear();
const holidays = MONACO_PUBLIC_HOLIDAYS[targetYear] ?? MONACO_PUBLIC_HOLIDAYS[2025];
return Object.entries(holidays).map(([title, date]) => ({
id: generateId(),
title,
startDate: date,
endDate: date,
type: "public" as HolidayType,
description: "Jour férié à Monaco",
zones: ["monaco"]
}));
}
/**
* Récupère tous les jours fériés français pour une année
*/
export function getPublicHolidays(year?: number, region?: SchoolRegion): Holiday[] {
const targetYear = year ?? new Date().getFullYear();
// Si Monaco est demandé, retourner les jours fériés monégasques
if (region === "monaco") {
return getMonacoPublicHolidays(year);
}
// Sinon, retourner les jours fériés français
const holidays = PUBLIC_HOLIDAYS[targetYear] ?? PUBLIC_HOLIDAYS[2025];
return Object.entries(holidays).map(([title, date]) => ({
id: generateId(),
title,
startDate: date,
endDate: date,
type: "public" as HolidayType,
description: "Jour férié en France"
}));
}
/**
* Récupère les congés scolaires pour une zone et une année civile
* Charge TOUTES les années scolaires qui touchent l'année civile demandée
*/
export function getSchoolHolidays(region?: SchoolRegion, year?: number): Holiday[] {
const targetYear = year ?? new Date().getFullYear();
const allHolidays: Holiday[] = [];
// Mapper les années scolaires disponibles
const schoolYearData: Record<string, typeof SCHOOL_HOLIDAYS_2024_2025> = {
'2024-2025': SCHOOL_HOLIDAYS_2024_2025,
'2025-2026': SCHOOL_HOLIDAYS_2025_2026
};
// Pour chaque année scolaire disponible, vérifier si elle contient des vacances pour l'année demandée
for (const [yearKey, holidaysData] of Object.entries(schoolYearData)) {
const holidays = Object.entries(holidaysData)
.filter(([_, holiday]) => {
// Filtrer par région si spécifiée
if (region && !holiday.zones.includes(region)) {
return false;
}
// Vérifier si les vacances tombent dans l'année civile demandée
const startYear = new Date(holiday.start).getFullYear();
const endYear = new Date(holiday.end).getFullYear();
return startYear === targetYear || endYear === targetYear;
})
.map(([title, holiday]) => ({
id: generateId(),
title,
startDate: holiday.start,
endDate: holiday.end,
type: "school" as HolidayType,
zones: holiday.zones,
description: `Vacances scolaires`
}));
allHolidays.push(...holidays);
}
// Trier par date de début
return allHolidays.sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
}
/**
* Récupère tous les jours fériés et congés scolaires
*/
export function getAllHolidays(region?: SchoolRegion, year?: number): Holiday[] {
const publicHolidays = getPublicHolidays(year, region);
const schoolHolidays = getSchoolHolidays(region, year);
return [...publicHolidays, ...schoolHolidays].sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
}
/**
* Vérifie si une date est un jour férié ou en congé scolaire
*/
export function isHoliday(date: string, region?: SchoolRegion): boolean {
const holidays = getAllHolidays(region);
const targetDate = new Date(date);
return holidays.some(holiday => {
const start = new Date(holiday.startDate);
const end = new Date(holiday.endDate);
return targetDate >= start && targetDate <= end;
});
}

View File

@@ -0,0 +1,64 @@
import { randomUUID } from "node:crypto";
import { Parent } from "../models/parent";
import { loadDB, saveDB } from "./file-db";
type CreateParentInput = {
fullName: string;
colorHex: string;
email?: string;
notes?: string;
avatar?: Parent["avatar"];
};
type UpdateParentInput = Partial<Omit<Parent, "id" | "createdAt" | "avatar">> & {
avatar?: Parent["avatar"] | null;
};
export const parentService = {
async listParents(): Promise<Parent[]> {
const db = loadDB();
return db.parents ?? [];
},
async createParent(payload: CreateParentInput): Promise<Parent> {
const parent: Parent = {
id: randomUUID(),
fullName: payload.fullName,
colorHex: payload.colorHex,
email: payload.email,
notes: payload.notes,
avatar: payload.avatar,
createdAt: new Date().toISOString()
};
const db = loadDB();
if (!db.parents) db.parents = [];
db.parents.unshift(parent);
saveDB(db);
return parent;
},
async updateParent(id: string, payload: UpdateParentInput): Promise<Parent | null> {
const db = loadDB();
if (!db.parents) db.parents = [];
const target = db.parents.find((p) => p.id === id);
if (!target) return null;
if (payload.fullName !== undefined) target.fullName = payload.fullName;
if (payload.colorHex !== undefined) target.colorHex = payload.colorHex;
if (payload.email !== undefined) target.email = payload.email;
if (payload.notes !== undefined) target.notes = payload.notes;
if (payload.avatar === null) {
delete target.avatar;
} else if (payload.avatar !== undefined) {
target.avatar = payload.avatar;
}
saveDB(db);
return target;
},
async deleteParent(id: string): Promise<boolean> {
const db = loadDB();
if (!db.parents) db.parents = [];
const idx = db.parents.findIndex((p) => p.id === id);
if (idx === -1) return false;
db.parents.splice(idx, 1);
saveDB(db);
return true;
}
};

View File

@@ -0,0 +1,132 @@
import { PersonalLeave } from "../models/personal-leave";
import { loadDB, saveDB } from "./file-db";
const DB_KEY = "personalLeaves";
let leaveIdCounter = 1;
function generateId(): string {
return `leave_${Date.now()}_${leaveIdCounter++}`;
}
/**
* Récupère tous les congés personnels
*/
export async function listPersonalLeaves(): Promise<PersonalLeave[]> {
const db = loadDB();
return (db[DB_KEY] as PersonalLeave[]) ?? [];
}
/**
* Récupère les congés personnels d'un profil
*/
export async function getPersonalLeavesByProfile(profileId: string): Promise<PersonalLeave[]> {
const allLeaves = await listPersonalLeaves();
return allLeaves.filter(leave => leave.profileId === profileId);
}
/**
* Récupère un congé personnel par son ID
*/
export async function getPersonalLeaveById(id: string): Promise<PersonalLeave | null> {
const allLeaves = await listPersonalLeaves();
return allLeaves.find(leave => leave.id === id) ?? null;
}
/**
* Crée un nouveau congé personnel
*/
export async function createPersonalLeave(
data: Omit<PersonalLeave, "id" | "createdAt">
): Promise<PersonalLeave> {
const db = loadDB();
const leaves = (db[DB_KEY] as PersonalLeave[]) ?? [];
const newLeave: PersonalLeave = {
...data,
id: generateId(),
createdAt: new Date().toISOString(),
source: data.source ?? "manual"
};
leaves.push(newLeave);
db[DB_KEY] = leaves;
saveDB(db);
return newLeave;
}
/**
* Met à jour un congé personnel
*/
export async function updatePersonalLeave(
id: string,
updates: Partial<Omit<PersonalLeave, "id" | "profileId" | "createdAt">>
): Promise<PersonalLeave | null> {
const db = loadDB();
const leaves = (db[DB_KEY] as PersonalLeave[]) ?? [];
const index = leaves.findIndex(leave => leave.id === id);
if (index === -1) {
return null;
}
leaves[index] = {
...leaves[index],
...updates
};
db[DB_KEY] = leaves;
saveDB(db);
return leaves[index];
}
/**
* Supprime un congé personnel
*/
export async function deletePersonalLeave(id: string): Promise<boolean> {
const db = loadDB();
const leaves = (db[DB_KEY] as PersonalLeave[]) ?? [];
const index = leaves.findIndex(leave => leave.id === id);
if (index === -1) {
return false;
}
leaves.splice(index, 1);
db[DB_KEY] = leaves;
saveDB(db);
return true;
}
/**
* Supprime tous les congés d'un profil
*/
export async function deletePersonalLeavesByProfile(profileId: string): Promise<number> {
const db = loadDB();
const leaves = (db[DB_KEY] as PersonalLeave[]) ?? [];
const remaining = leaves.filter(leave => leave.profileId !== profileId);
const deletedCount = leaves.length - remaining.length;
db[DB_KEY] = remaining;
saveDB(db);
return deletedCount;
}
/**
* Vérifie si une date est en congé personnel pour un profil
*/
export async function isOnLeave(profileId: string, date: string): Promise<boolean> {
const leaves = await getPersonalLeavesByProfile(profileId);
const targetDate = new Date(date);
return leaves.some(leave => {
const start = new Date(leave.startDate);
const end = new Date(leave.endDate);
return targetDate >= start && targetDate <= end;
});
}

View File

@@ -0,0 +1,146 @@
import { randomUUID } from "node:crypto";
import { Schedule } from "../models/schedule";
import { Activity } from "../models/activity";
const scheduleStore: Schedule[] = [];
type CreateScheduleInput = {
childId: string;
periodStart: string;
periodEnd: string;
activities: Omit<Activity, "id">[];
sourceFileUrl?: string;
sourceFileName?: string;
sourceMimeType?: string;
exportCsvUrl?: string;
};
export const scheduleService = {
async createSchedule(payload: CreateScheduleInput): Promise<Schedule> {
const schedule: Schedule = {
id: randomUUID(),
childId: payload.childId,
periodStart: payload.periodStart,
periodEnd: payload.periodEnd,
activities: payload.activities.map((activity) => ({
...activity,
id: randomUUID()
})),
status: payload.activities.length > 0 ? "ready" : "processing",
sourceFileUrl: payload.sourceFileUrl,
sourceFileName: payload.sourceFileName,
sourceMimeType: payload.sourceMimeType,
exportCsvUrl: payload.exportCsvUrl,
createdAt: new Date().toISOString()
};
scheduleStore.push(schedule);
return schedule;
},
async getSchedule(id: string): Promise<Schedule | undefined> {
return scheduleStore.find((item) => item.id === id);
},
async listSchedules(): Promise<Schedule[]> {
return [...scheduleStore];
},
async listActivitiesForDate(dateISO: string, childId?: string): Promise<Array<{ childId: string; activities: Activity[] }>> {
const targetDate = new Date(dateISO).toISOString().slice(0, 10);
const items = scheduleStore.filter((s) => {
if (childId && s.childId !== childId) return false;
// match if date within schedule period
const within = targetDate >= s.periodStart && targetDate <= s.periodEnd;
return within;
});
const perChild: Record<string, Activity[]> = {};
for (const s of items) {
for (const a of s.activities) {
const startDay = new Date(a.startDateTime).toISOString().slice(0, 10);
const endDay = new Date(a.endDateTime).toISOString().slice(0, 10);
if (targetDate >= startDay && targetDate <= endDay) {
if (!perChild[s.childId]) perChild[s.childId] = [];
perChild[s.childId].push(a);
}
}
}
return Object.entries(perChild).map(([cid, acts]) => ({ childId: cid, activities: acts }));
},
async listActivitiesForWeek(weekStartISO: string, childId?: string): Promise<Array<{ childId: string; days: Record<string, Activity[]> }>> {
const start = new Date(weekStartISO);
const startDay = start.toISOString().slice(0, 10);
const end = new Date(start);
end.setDate(start.getDate() + 6);
const endDay = end.toISOString().slice(0, 10);
const items = scheduleStore.filter((s) => {
if (childId && s.childId !== childId) return false;
// Overlaps range
return !(s.periodEnd < startDay || s.periodStart > endDay);
});
const perChild: Record<string, Record<string, Activity[]>> = {};
for (const s of items) {
for (const a of s.activities) {
const aStart = new Date(a.startDateTime).toISOString().slice(0, 10);
const aEnd = new Date(a.endDateTime).toISOString().slice(0, 10);
// compute overlap with week range by days
let d = new Date(start);
for (let i = 0; i < 7; i++) {
const dISO = d.toISOString().slice(0, 10);
if (dISO >= aStart && dISO <= aEnd) {
if (!perChild[s.childId]) perChild[s.childId] = {};
if (!perChild[s.childId][dISO]) perChild[s.childId][dISO] = [];
perChild[s.childId][dISO].push(a);
}
d.setDate(d.getDate() + 1);
}
}
}
return Object.entries(perChild).map(([cid, days]) => ({ childId: cid, days }));
},
async listActivitiesForMonth(month: string): Promise<Array<{ date: string; items: Array<{ childId: string; activity: Activity }> }>> {
// month format YYYY-MM
const [yearStr, monthStr] = month.split("-");
const year = Number(yearStr);
const m = Number(monthStr) - 1; // JS month 0-11
const first = new Date(Date.UTC(year, m, 1));
const last = new Date(Date.UTC(year, m + 1, 0));
const startDay = first.toISOString().slice(0, 10);
const endDay = last.toISOString().slice(0, 10);
const items: Array<{ date: string; items: Array<{ childId: string; activity: Activity }> }> = [];
// Map for quick grouping
const byDate: Record<string, Array<{ childId: string; activity: Activity }>> = {};
for (const s of scheduleStore) {
// overlap schedule with month
if (s.periodEnd < startDay || s.periodStart > endDay) continue;
for (const a of s.activities) {
const aStart = new Date(a.startDateTime);
const aEnd = new Date(a.endDateTime);
// iterate across days for multi-day events
let d = new Date(first);
while (d <= last) {
const dISO = d.toISOString().slice(0, 10);
const dTime = new Date(dISO + "T00:00:00.000Z");
if (dISO >= s.periodStart && dISO <= s.periodEnd && dTime >= new Date(aStart.toISOString().slice(0,10)) && dTime <= new Date(aEnd.toISOString().slice(0,10))) {
if (!byDate[dISO]) byDate[dISO] = [];
byDate[dISO].push({ childId: s.childId, activity: a });
}
d.setUTCDate(d.getUTCDate() + 1);
}
}
}
for (let d = new Date(first); d <= last; d.setUTCDate(d.getUTCDate() + 1)) {
const dISO = d.toISOString().slice(0, 10);
items.push({ date: dISO, items: byDate[dISO] ?? [] });
}
return items;
}
};

View File

@@ -0,0 +1,58 @@
import { loadConfig } from "../config/env";
import { logger } from "../utils/logger";
/**
* SECURITY: This service now reads secrets from environment variables only.
* Never store secrets in files or databases in plain text.
*
* For production, use a proper secrets management solution like:
* - Azure Key Vault
* - AWS Secrets Manager
* - HashiCorp Vault
* - Kubernetes Secrets
*/
type Secrets = {
openaiApiKey?: string;
model?: string;
};
export const secretStore = {
/**
* Get secrets from environment variables
* This is a read-only operation - secrets should be set via environment
*/
async get(): Promise<Secrets> {
const config = loadConfig();
if (!config.openaiApiKey) {
logger.warn("OPENAI_API_KEY not set in environment variables");
}
return {
openaiApiKey: config.openaiApiKey,
model: config.openaiModel
};
},
/**
* DEPRECATED: Setting secrets is no longer supported.
* Secrets should be configured via environment variables only.
* This method is kept for backwards compatibility but logs a warning.
*/
async set(partial: Secrets): Promise<void> {
logger.warn(
"secretStore.set() is deprecated. Please set secrets via environment variables instead.",
{ attemptedKeys: Object.keys(partial) }
);
// For backwards compatibility during migration, log what was attempted
if (partial.openaiApiKey) {
logger.info("To set OpenAI API key, use environment variable: OPENAI_API_KEY");
}
if (partial.model) {
logger.info("To set OpenAI model, use environment variable: OPENAI_MODEL");
}
}
};

View File

@@ -0,0 +1,46 @@
import winston from "winston";
const logLevel = process.env.LOG_LEVEL || "info";
const isDevelopment = process.env.NODE_ENV !== "production";
export const logger = winston.createLogger({
level: logLevel,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.splat(),
isDevelopment
? winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : "";
return `${timestamp} [${level}]: ${message} ${metaStr}`;
})
)
: winston.format.json()
),
transports: [
new winston.transports.Console({
stderrLevels: ["error"]
})
]
});
// Add file transports in production
if (!isDevelopment) {
logger.add(
new winston.transports.File({
filename: "logs/error.log",
level: "error",
maxsize: 5242880, // 5MB
maxFiles: 5
})
);
logger.add(
new winston.transports.File({
filename: "logs/combined.log",
maxsize: 5242880,
maxFiles: 5
})
);
}

17
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ES2020",
"moduleResolution": "Node",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules"]
}

20
backend/vitest.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: [
"node_modules/",
"dist/",
"**/*.d.ts",
"**/*.config.*",
"**/*.test.ts",
"**/*.spec.ts"
]
}
}
});