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:
21
backend/.env.example
Normal file
21
backend/.env.example
Normal 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
22
backend/.eslintrc.cjs
Normal 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
7
backend/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 90,
|
||||
"semi": true
|
||||
}
|
||||
47
backend/package.json
Normal file
47
backend/package.json
Normal 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
32
backend/src/config/env.ts
Normal 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;
|
||||
};
|
||||
7
backend/src/controllers/alerts.ts
Normal file
7
backend/src/controllers/alerts.ts
Normal 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);
|
||||
};
|
||||
117
backend/src/controllers/children.ts
Normal file
117
backend/src/controllers/children.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
82
backend/src/controllers/grandparents.ts
Normal file
82
backend/src/controllers/grandparents.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
||||
70
backend/src/controllers/holidays.ts
Normal file
70
backend/src/controllers/holidays.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
}
|
||||
81
backend/src/controllers/parents.ts
Normal file
81
backend/src/controllers/parents.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
156
backend/src/controllers/personal-leaves.ts
Normal file
156
backend/src/controllers/personal-leaves.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
}
|
||||
101
backend/src/controllers/schedules.ts
Normal file
101
backend/src/controllers/schedules.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
88
backend/src/data/client.json.backup.1
Normal file
88
backend/src/data/client.json.backup.1
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
88
backend/src/data/client.json.backup.2
Normal file
88
backend/src/data/client.json.backup.2
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
88
backend/src/data/client.json.backup.3
Normal file
88
backend/src/data/client.json.backup.3
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
88
backend/src/data/client.json.backup.4
Normal file
88
backend/src/data/client.json.backup.4
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
88
backend/src/data/client.json.backup.5
Normal file
88
backend/src/data/client.json.backup.5
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
93
backend/src/middleware/error-handler.ts
Normal file
93
backend/src/middleware/error-handler.ts
Normal 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);
|
||||
};
|
||||
223
backend/src/middleware/file-upload.ts
Normal file
223
backend/src/middleware/file-upload.ts
Normal 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 };
|
||||
94
backend/src/middleware/security.ts
Normal file
94
backend/src/middleware/security.ts
Normal 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();
|
||||
};
|
||||
11
backend/src/models/activity.ts
Normal file
11
backend/src/models/activity.ts
Normal 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;
|
||||
};
|
||||
10
backend/src/models/alert.ts
Normal file
10
backend/src/models/alert.ts
Normal 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";
|
||||
};
|
||||
27
backend/src/models/child.ts
Normal file
27
backend/src/models/child.ts
Normal 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;
|
||||
};
|
||||
14
backend/src/models/grandparent.ts
Normal file
14
backend/src/models/grandparent.ts
Normal 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;
|
||||
};
|
||||
|
||||
13
backend/src/models/holiday.ts
Normal file
13
backend/src/models/holiday.ts
Normal 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[];
|
||||
};
|
||||
13
backend/src/models/parent.ts
Normal file
13
backend/src/models/parent.ts
Normal 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;
|
||||
};
|
||||
11
backend/src/models/personal-leave.ts
Normal file
11
backend/src/models/personal-leave.ts
Normal 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;
|
||||
};
|
||||
15
backend/src/models/schedule.ts
Normal file
15
backend/src/models/schedule.ts
Normal 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;
|
||||
};
|
||||
6
backend/src/routes/alerts.ts
Normal file
6
backend/src/routes/alerts.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Router } from "express";
|
||||
import { listAlertsController } from "../controllers/alerts";
|
||||
|
||||
export const alertRouter = Router();
|
||||
|
||||
alertRouter.get("/", listAlertsController);
|
||||
179
backend/src/routes/calendar.ts
Normal file
179
backend/src/routes/calendar.ts
Normal 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();
|
||||
});
|
||||
20
backend/src/routes/children.ts
Normal file
20
backend/src/routes/children.ts
Normal 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);
|
||||
15
backend/src/routes/grandparents.ts
Normal file
15
backend/src/routes/grandparents.ts
Normal 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);
|
||||
|
||||
10
backend/src/routes/health.ts
Normal file
10
backend/src/routes/health.ts
Normal 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()
|
||||
});
|
||||
});
|
||||
31
backend/src/routes/holidays.ts
Normal file
31
backend/src/routes/holidays.ts
Normal 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;
|
||||
24
backend/src/routes/index.ts
Normal file
24
backend/src/routes/index.ts
Normal 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);
|
||||
};
|
||||
216
backend/src/routes/ingestion.ts
Normal file
216
backend/src/routes/ingestion.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
10
backend/src/routes/parents.ts
Normal file
10
backend/src/routes/parents.ts
Normal 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);
|
||||
|
||||
44
backend/src/routes/personal-leaves.ts
Normal file
44
backend/src/routes/personal-leaves.ts
Normal 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;
|
||||
18
backend/src/routes/schedules.ts
Normal file
18
backend/src/routes/schedules.ts
Normal 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);
|
||||
505
backend/src/routes/uploads.ts
Normal file
505
backend/src/routes/uploads.ts
Normal 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
81
backend/src/server.ts
Normal 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
|
||||
});
|
||||
});
|
||||
20
backend/src/services/alert-service.ts
Normal file
20
backend/src/services/alert-service.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
102
backend/src/services/child-service.ts
Normal file
102
backend/src/services/child-service.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
123
backend/src/services/file-db.ts
Normal file
123
backend/src/services/file-db.ts
Normal 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;
|
||||
}
|
||||
|
||||
65
backend/src/services/grandparent-service.ts
Normal file
65
backend/src/services/grandparent-service.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
|
||||
372
backend/src/services/holiday-service.ts
Normal file
372
backend/src/services/holiday-service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
64
backend/src/services/parent-service.ts
Normal file
64
backend/src/services/parent-service.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
132
backend/src/services/personal-leave-service.ts
Normal file
132
backend/src/services/personal-leave-service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
146
backend/src/services/schedule-service.ts
Normal file
146
backend/src/services/schedule-service.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
58
backend/src/services/secret-store.ts
Normal file
58
backend/src/services/secret-store.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
46
backend/src/utils/logger.ts
Normal file
46
backend/src/utils/logger.ts
Normal 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
17
backend/tsconfig.json
Normal 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
20
backend/vitest.config.ts
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user