diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9739248 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +apps/*/node_modules +.git +.gitignore +.vscode +apps/*/.next +apps/*/dist +npm-debug.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c9555e7 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +POSTGRES_DB=le_juste_poid +POSTGRES_USER=le_juste_poid +POSTGRES_PASSWORD=change_me_db_password + +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change_me_admin_password +JWT_SECRET=change_me_jwt_secret +JWT_EXPIRES_IN=1d +REFRESH_TOKEN_SECRET=change_me_refresh_secret +REFRESH_TOKEN_EXPIRES_IN=30d + +WEB_PORT=3000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2309cc8..6058664 100644 --- a/.gitignore +++ b/.gitignore @@ -1,138 +1,21 @@ -# ---> Node +# Dependencies +node_modules/ +apps/*/node_modules/ + +# Build output +apps/*/dist/ +apps/*/.next/ +apps/*/coverage/ + # Logs -logs -*.log npm-debug.log* yarn-debug.log* yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files +# Environment files .env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +apps/*/.env +# OS / editor +.DS_Store +Thumbs.db diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..3faf509 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,16 @@ +{ + "servers": { + "framelinkFigma": { + "command": "cmd", + "args": [ + "/c", + "npx", + "-y", + "figma-developer-mcp", + "--figma-api-key", + "${env:FIGMA_API_KEY}", + "--stdio" + ] + } + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..384fd2d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "prisma.pinToPrisma6": true +} \ No newline at end of file diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md new file mode 100644 index 0000000..16c40ec --- /dev/null +++ b/PROJECT_CONTEXT.md @@ -0,0 +1,125 @@ +# Project Context - Le Juste Poids + +Ce fichier sert de handoff rapide pour reprendre le projet dans une nouvelle conversation Copilot. + +Specification metier complete: + +- `SPEC_FONCTIONNELLE.md` + +## 1) Stack et structure + +- Monorepo npm workspaces +- API: NestJS + Prisma + PostgreSQL +- Front: Next.js (App Router) +- DB locale: Docker Compose + +Arborescence utile: + +- `apps/api` - backend NestJS +- `apps/web` - frontend Next.js +- `docker-compose.yml` - stack locale (web/api/db) + +## 2) Commandes utiles + +Installer: + +```bash +npm install +``` + +Lancer toute la stack: + +```bash +docker compose up --build +``` + +Build API + Web: + +```bash +npm run build -w apps/api ; npm run build -w apps/web +``` + +URLs locales attendues: + +- Web: `http://localhost:3002` +- API: `http://localhost:3001` +- DB: `localhost:5432` + +## 3) Auth et rôles + +- Rôles: `ADMIN`, `FAMILY` +- Login JWT + refresh token +- `ADMIN` redirigé vers `/admin` +- `FAMILY` redirigé vers `/predictions` + +## 4) Workflow concours (état actuel) + +Le concours suit un flux en 3 phases: + +1. `OPEN` +- Les participants peuvent saisir/modifier leurs pronostics. +- L'admin peut saisir/modifier les valeurs finales (outcomes). + +2. `CLOSED` (après clôture) +- Les pronostics participants sont verrouillés. +- Les outcomes sont verrouillés. +- L'admin passe sur un wizard de scoring par catégorie: + - suggestions auto + - comparaison réponse finale vs réponses candidats + - validation des points étape par étape + +3. `finalized = true` (validation définitive) +- Le concours est validé définitivement. +- Plus de reouverture possible. +- Côté participants, la page `/predictions` affiche: + - gagnant + - classement final + - rappel de "vos réponses" + +## 5) Endpoints pronostics clés + +Public authentifié: + +- `GET /predictions/cards` +- `GET /predictions/board` +- `GET /predictions/activity` +- `GET /predictions/scoreboard` +- `GET /predictions/my-entries` +- `PUT /predictions/cards/:cardId/my-entry` + +Admin: + +- `POST /predictions/game/close` +- `POST /predictions/game/open` +- `POST /predictions/game/finalize` +- `POST /predictions/cards/:cardId/outcomes` +- `POST /predictions/cards/:cardId/suggest-scores` +- `PATCH /predictions/entries/:entryId/scores` + +## 6) Fichiers clés à lire en priorité + +Backend: + +- `apps/api/src/predictions/predictions.controller.ts` +- `apps/api/src/predictions/predictions.service.ts` +- `apps/api/prisma/schema.prisma` + +Frontend: + +- `apps/web/src/app/admin/page.tsx` +- `apps/web/src/app/predictions/page.tsx` +- `apps/web/src/lib/predictions-client.ts` +- `apps/web/src/types/predictions.ts` + +## 7) Vérifications rapides après modif + +1. Build API + web. +2. Parcours manuel: +- Admin: outcomes en `OPEN`, puis clôture, wizard de scoring, finalisation. +- Family: après finalisation, vérifier affichage gagnant + vos réponses. + +## 8) Prompt de reprise prêt à coller + +```text +Contexte projet: monorepo Le Juste Poids (NestJS API + Next.js web + Postgres). Lis PROJECT_CONTEXT.md puis inspecte les fichiers clés listés. Je veux que tu poursuives le workflow de fin de concours sans casser l’existant. Commence par vérifier l’état actuel (API + front), puis propose et implémente les changements avec build de validation. +``` diff --git a/README.md b/README.md index a610482..2ccfe0f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,89 @@ -# mybabyguess +# Le Juste Poids +Monorepo contenant: +- API NestJS (`apps/api`) +- Front NextJS (`apps/web`) +- Base PostgreSQL (via Docker Compose) + +## Demarrage rapide + +1. Installer les dependances + +```bash +npm install +``` + +2. Configurer l'environnement + +Le fichier `.env` est deja present a la racine pour Docker Compose. + +3. Lancer toute la stack + +```bash +docker compose up --build +``` + +Services: +- Front: http://localhost:3002 +- API: http://localhost:3001 +- DB: localhost:5432 + +## Authentification + +L'admin est un vrai compte en base de donnees. Au demarrage de l'API, un compte +admin est cree (ou mis a jour) avec: +- `ADMIN_USERNAME` +- `ADMIN_PASSWORD` + +Connexion JWT: + +```bash +curl -X POST http://localhost:3001/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"change_me_admin_password"}' +``` + +La reponse contient `accessToken` (JWT Bearer). +La reponse contient aussi `refreshToken` pour renouveler la session. + +Renouveler la session: + +```bash +curl -X POST http://localhost:3001/auth/refresh \ + -H "Content-Type: application/json" \ + -d '{"refreshToken":""}' +``` + +## API admin: gestion des comptes + +Routes protegees par JWT + role `ADMIN`: +- `POST /users` creer un compte +- `GET /users` lister les comptes +- `PATCH /users/:id` modifier username/password/role +- `DELETE /users/:id` supprimer un compte + +Routes utilisateur connecte (ADMIN ou FAMILY): +- `GET /users/me` recuperer son profil +- `PATCH /users/me` modifier son pseudo (`displayName`) +- `POST /users/me/photo` uploader sa photo de profil (multipart `file`, PNG/JPG/WEBP) + +Exemple creation d'utilisateur: + +```bash +curl -X POST http://localhost:3001/users \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"username":"maman","password":"motdepassefort","role":"FAMILY"}' +``` + +## Frontend + +- `/` : page de login utilisateur +- `/admin` : login admin + creation/liste/modification/suppression des comptes +- `/profile` : page profil pour les utilisateurs non-admin (pseudo + photo) + +Healthcheck API: + +```bash +curl http://localhost:3001/health +``` diff --git a/SPEC_FONCTIONNELLE.md b/SPEC_FONCTIONNELLE.md new file mode 100644 index 0000000..f03fe5c --- /dev/null +++ b/SPEC_FONCTIONNELLE.md @@ -0,0 +1,277 @@ +# Specification fonctionnelle - Le Juste Poids + +Ce document decrit la vision fonctionnelle du produit, les regles metier, les parcours utilisateurs et les contraintes comportementales du systeme. + +## 1. Objectif produit + +Le Juste Poids est une application familiale de concours de pronostics autour de la naissance d un bebe. + +Le produit permet: + +- de gerer des comptes (admin + participants famille) +- de collecter des pronostics par categorie +- de definir les resultats reels +- de calculer et valider les points +- d annoncer les gagnants une fois le concours finalise + +## 2. Personas et roles + +### 2.1 Admin + +Responsabilites: + +- cree et gere les comptes utilisateurs +- pilote le concours (ouverture, cloture, finalisation) +- renseigne les resultats finaux +- lance les suggestions automatiques de points +- valide les points categorie par categorie + +Acces principal: + +- page admin: `/admin` + +### 2.2 Participant famille + +Responsabilites: + +- saisit ses pronostics +- consulte historique, recap et resultat final + +Acces principal: + +- espace pronostics: `/predictions` + +## 3. Perimetre fonctionnel + +### 3.1 Gestion des comptes + +- login JWT + refresh token +- profil utilisateur (pseudo, photo) +- role ADMIN/FAMILY +- CRUD comptes reserve a ADMIN + +### 3.2 Gestion des cartes de pronostics + +Chaque carte represente une categorie de pronostic. + +Attributs metier importants: + +- type de carte: LEGACY ou CUSTOM +- type de valeur: NUMBER, TEXT, SELECT, MULTI_TEXT, DATE +- champs (field) avec points, caractere requis, bornes numeriques, pas +- options pour SELECT + +Cartes legacy pre-initialisees: + +- poids du bebe +- sexe +- prenoms +- perimetre cranien +- taille + +### 3.3 Saisie des pronostics participants + +- un participant a au plus une entree par carte +- mise a jour possible tant que le concours est ouvert +- seules les valeurs modifiees sont envoyees et historisees +- validation des champs requise cote API + +### 3.4 Saisie des resultats finaux (outcomes) + +- faite par admin +- modifiable uniquement pendant la phase OPEN +- verifiee a la cloture: tous les champs requis doivent etre remplis + +### 3.5 Notation et classement + +- suggestion automatique des points par categorie +- validation manuelle par admin +- calcul d un total par participant (somme des points de toutes les categories) + +### 3.6 Finalisation du concours + +- action explicite admin +- possible seulement apres notation complete +- verrouille definitivement le concours +- deplace la vue participant vers ecran gagnant + classement final + rappel des reponses personnelles + +## 4. Machine d etat du concours + +Etat principal dans `PredictionGame.status`: + +- OPEN +- CLOSED + +Etat fonctionnel derive: + +- finalized (booleen expose dans le board) + +Cycle de vie: + +1. OPEN +- pronostics autorises +- outcomes autorises + +2. CLOSED +- pronostics interdits +- outcomes interdits +- scoring autorise + +3. CLOSED + finalized = true +- concours definitivement valide +- reouverture interdite +- rescoring/intervention metier interdits + +## 5. Regles metier detaillees + +## 5.1 Regles de saisie pronostics + +- refus si concours non OPEN +- un champ doit appartenir a la carte cible +- NUMBER exige valueNumber +- DATE exige valueDate +- TEXT/MULTI_TEXT/SELECT exigent valueText +- SELECT doit etre dans la liste d options autorisees +- les champs requis doivent etre renseignes + +## 5.2 Regles de saisie outcomes + +- refus si concours non OPEN +- les champs requis de cartes actives doivent etre renseignes pour cloturer + +## 5.3 Regles de scoring auto + +Par champ et par participant: + +- NUMBER: points accordes au plus proche du resultat final +- egalite de distance: ex aequo, tous obtiennent les points +- TEXT/SELECT: egalite texte normalisee (trim + lowercase) +- DATE: egalite stricte de date serialisee +- MULTI_TEXT: correspondance normalisee sur les valeurs finales renseignees + +## 5.4 Regles de validation de score + +- validation score autorisee uniquement en CLOSED +- interdite si concours finalise +- un entry note est marque isScored=true +- totalPoints de l entry recalcule depuis les points valides + +## 5.5 Regles de finalisation + +Finalisation refusee si: + +- concours pas CLOSED +- concours deja finalise +- outcomes requis manquants +- aucune categorie active +- aucune participation +- au moins une entree non notee +- classement indisponible + +Si OK: + +- activity de finalisation enregistree +- finalized expose a true via le board + +## 6. Parcours UX principaux + +## 6.1 Parcours admin - avant cloture + +1. saisit/ajuste les outcomes via tuiles +2. enregistre les outcomes +3. cloture le concours + +## 6.2 Parcours admin - apres cloture + +1. wizard etape par categorie +2. visualise resultat final + reponses candidats +3. ajuste/valide points pre-remplis +4. passe categorie suivante +5. recap final des totaux +6. valide definitivement le concours + +## 6.3 Parcours participant + +Avant finalisation: + +- saisie pronostics + sauvegarde globale +- consultation recap/historique + +Apres finalisation: + +- bloc saisie retire +- ecran resultat final affiche par defaut: + - gagnant(s) + - classement final + - vos reponses + +## 7. Donnees metier principales + +Entites centrales: + +- PredictionGame +- PredictionCard +- PredictionCardField +- PredictionEntry +- PredictionEntryValue +- PredictionOutcome +- PredictionFieldScore +- PredictionActivity + +Points de verite: + +- scores valides stockes par field score +- total par entree stocke sur PredictionEntry.totalPoints +- total par participant derive par aggregation de toutes ses entries + +## 8. Journalisation et tracabilite + +- historique des saisies participant (entry history) +- flux activite global (prediction activity) +- evenement de finalisation marque par un message technique dedie + +## 9. Permissions et securite + +- endpoints proteges JWT +- operations admin protegees par RolesGuard +- controle metier aussi dans service (pas seulement front) + +## 10. API fonctionnelle de reference + +Participants: + +- GET `/predictions/cards` +- GET `/predictions/board` +- GET `/predictions/activity` +- GET `/predictions/scoreboard` +- GET `/predictions/my-entries` +- PUT `/predictions/cards/:cardId/my-entry` + +Admin: + +- POST `/predictions/game/close` +- POST `/predictions/game/open` +- POST `/predictions/game/finalize` +- POST `/predictions/cards/:cardId/outcomes` +- POST `/predictions/cards/:cardId/suggest-scores` +- PATCH `/predictions/entries/:entryId/scores` + +## 11. Comportements attendus (acceptance) + +- impossible de modifier un pronostic en CLOSED +- impossible de modifier outcomes en CLOSED +- impossible de cloturer sans outcomes requis +- impossible de finaliser si tous les participants ne sont pas notes +- impossible de reouvrir apres finalisation +- apres finalisation, un participant voit directement le resultat final (pas de saisie) + +## 12. Limites et points d attention + +- la finalisation est materialisee par un evenement d activite (marker), pas par un champ dedie en base +- la robustesse depend du bon maintien de ce marker dans le flux metier + +## 13. Reprise de conversation Copilot + +Prompt recommande: + +"Lis d abord PROJECT_CONTEXT.md et SPEC_FONCTIONNELLE.md. Ensuite controle que les ecrans admin/famille et les regles API correspondent exactement a la spec, puis propose les ecarts et corrige-les avec build de validation." diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..13de86b --- /dev/null +++ b/TODO.md @@ -0,0 +1,19 @@ +# TODO — Le Juste Poids + +## Avatar / Photo de profil + +### 1. Modifier la couleur de fond sans changer la photo +- **Problème** : Dans le modal "Modifier la photo", changer la couleur de fond ne met à jour que la preview de crop local — mais si aucune nouvelle image n'est sélectionnée, le bouton "Enregistrer" ne fait rien (la condition `if (!file && !selectedDefault) return` bloque). +- **Solution** : Permettre la sauvegarde de la `bgColor` seule via `PATCH /users/me/avatar` (ou un endpoint dédié). Si l'utilisateur n'a pas sélectionné de nouvelle image, envoyer uniquement la couleur + `avatarUrl` actuel. +- La preview dans le modal devrait aussi afficher la photo actuelle avec la nouvelle couleur en arrière-plan pour que ce soit live. + +### 2. Recadrer la photo existante +- **Question** : Actuellement, à chaque "Enregistrer", un nouveau fichier PNG est généré par le canvas et uploadé. La position/zoom de crop n'est **pas** sauvegardée en base — seule l'image résultante l'est. +- **Conséquence** : Pour recadrer différemment, il faudrait soit : + - (A) Re-uploader le fichier original depuis le client (le navigateur ne peut pas relire un fichier déjà uploadé) + - (B) Stocker l'image originale côté serveur (dans un champ `profileImageOriginalUrl`) et laisser le client la recharger pour recadrer + - **(Recommandé) Option B** : À l'upload, sauvegarder aussi l'original non-croppé. En mode "recadrage", charger l'original dans le canvas et permettre de repositionner/zoomer → sauvegarder un nouveau crop sans supprimer/re-uploader. +- **À implémenter** : + - API : champ `profileImageOriginalUrl` dans le schéma Prisma + - API : stocker l'original à l'upload (`POST /users/me/photo`) + - Frontend : bouton "Recadrer" → charge l'original dans le modal en mode crop directement diff --git a/apps/api/.dockerignore b/apps/api/.dockerignore new file mode 100644 index 0000000..ff55d1d --- /dev/null +++ b/apps/api/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +coverage +.git +.env +npm-debug.log \ No newline at end of file diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 0000000..9f62ec0 --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,5 @@ +node_modules +# Keep environment variables out of version control +.env + +/generated/prisma diff --git a/apps/api/.prettierrc b/apps/api/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/apps/api/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..6b55ec1 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,33 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +COPY apps/api/package.json ./apps/api/package.json +COPY apps/web/package.json ./apps/web/package.json +RUN npm ci + +COPY . . +RUN npm run prisma:generate -w apps/api +RUN npm run build -w apps/api + +FROM node:20-alpine AS runner + +WORKDIR /app +ENV NODE_PATH=/app/apps/api/node_modules +ENV PORT=3000 + +COPY package*.json ./ +COPY apps/api/package.json ./apps/api/package.json +COPY apps/web/package.json ./apps/web/package.json +RUN npm ci --omit=dev + +COPY --from=builder /app/apps/api/dist ./apps/api/dist +COPY --from=builder /app/apps/api/prisma ./apps/api/prisma + +VOLUME ["/app/apps/api/uploads"] + +EXPOSE 3000 + +# During initial setup we use `prisma db push` at container start to bypass baseline migration issues. +CMD ["npm", "run", "start:docker", "-w", "apps/api"] \ No newline at end of file diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..8f0f65f --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,98 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Discord +Backers on Open Collective +Sponsors on Open Collective + Donate us + Support us + Follow us on Twitter +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Project setup + +```bash +$ npm install +``` + +## Compile and run the project + +```bash +# development +$ npm run start + +# watch mode +$ npm run start:dev + +# production mode +$ npm run start:prod +``` + +## Run tests + +```bash +# unit tests +$ npm run test + +# e2e tests +$ npm run test:e2e + +# test coverage +$ npm run test:cov +``` + +## Deployment + +When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. + +If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: + +```bash +$ npm install -g @nestjs/mau +$ mau deploy +``` + +With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. + +## Resources + +Check out a few resources that may come in handy when working with NestJS: + +- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. +- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). +- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). +- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. +- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). +- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). +- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). +- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs new file mode 100644 index 0000000..4e9f827 --- /dev/null +++ b/apps/api/eslint.config.mjs @@ -0,0 +1,35 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['eslint.config.mjs'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + sourceType: 'commonjs', + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + "prettier/prettier": ["error", { endOfLine: "auto" }], + }, + }, +); diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..ac316b2 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,83 @@ +{ + "name": "api", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "start:docker": "npx prisma db push && npx prisma generate && node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma:generate": "prisma generate", + "prisma:push": "prisma db push" + }, + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.3", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/platform-express": "^11.0.1", + "@prisma/client": "^6.13.0", + "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "multer": "^2.1.1", + "prisma": "^6.13.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", + "@types/node": "^24.0.0", + "@types/supertest": "^7.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^17.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..645cea1 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,447 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Get a free hosted Postgres database in seconds: `npx create-db` + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum UserRole { + ADMIN + FAMILY +} + +enum GameStatus { + OPEN + CLOSED +} + +enum PredictionCardType { + LEGACY + CUSTOM +} + +enum PredictionValueType { + NUMBER + TEXT + SELECT + MULTI_TEXT + DATE +} + +enum PredictionActivityType { + CARD_CREATED + CARD_UPDATED + CARD_DELETED + PREDICTION_CREATED + PREDICTION_UPDATED + OUTCOME_SET + SCORE_SUGGESTED + SCORE_VALIDATED + GAME_CLOSED + GAME_OPENED +} + +enum ParentType { + PAPA + MAMAN +} + +enum Trimester { + T1 + T2 + T3 + DATATION +} + +enum ProjectStatus { + DRAFT + OPEN + CLOSED + FINALIZED +} + +model User { + id String @id @default(cuid()) + username String @unique + displayName String? + profileImageUrl String? + profileBgColor String? + passwordHash String + refreshTokenHash String? + workspaceId String? + workspace Workspace? @relation("WorkspaceUsers", fields: [workspaceId], references: [id], onDelete: SetNull) + role UserRole @default(FAMILY) + createdAt DateTime @default(now()) + + ownedWorkspace Workspace? @relation("WorkspaceOwner") + createdProjects Project[] @relation("ProjectsCreatedBy") + projectMembership ProjectMembership? + assignedMemberships ProjectMembership[] @relation("ProjectMembershipAssignedBy") + + predictionCardsCreated PredictionCard[] @relation("PredictionCardsCreatedBy") + predictionEntries PredictionEntry[] @relation("PredictionEntriesByUser") + predictionHistoryEvents PredictionEntryHistory[] @relation("PredictionHistoryByUser") + predictionOutcomesSet PredictionOutcome[] @relation("PredictionOutcomesSetBy") + predictionScoresValidated PredictionFieldScore[] @relation("PredictionScoresValidatedBy") + predictionActivities PredictionActivity[] @relation("PredictionActivitiesByUser") + + @@index([workspaceId]) +} + +model Workspace { + id String @id @default(cuid()) + slug String @unique + name String + ownerUserId String @unique + ownerUser User @relation("WorkspaceOwner", fields: [ownerUserId], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] @relation("WorkspaceUsers") + projects Project[] +} + +model Project { + id String @id @default(cuid()) + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdById String + createdBy User @relation("ProjectsCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + clonedFromProjectId String? + clonedFromProject Project? @relation("ProjectCloneSource", fields: [clonedFromProjectId], references: [id], onDelete: SetNull) + clonedProjects Project[] @relation("ProjectCloneSource") + name String + description String? + projectImageUrl String? + projectBgColor String? + status ProjectStatus @default(DRAFT) + babyCount Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + babies ProjectBaby[] + memberships ProjectMembership[] + games PredictionGame[] + cards PredictionCard[] + entries PredictionEntry[] + outcomes PredictionOutcome[] + fieldScores PredictionFieldScore[] + activities PredictionActivity[] + parentIndices ParentIndices[] + babyIndices BabyIndices[] + + @@index([workspaceId, createdAt]) + @@index([createdById, createdAt]) +} + +model ProjectBaby { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + babyIndex Int + label String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([projectId, babyIndex]) + @@index([projectId]) +} + +model ProjectMembership { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + assignedById String? + assignedBy User? @relation("ProjectMembershipAssignedBy", fields: [assignedById], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([projectId, userId]) + @@index([projectId]) +} + +model PredictionGame { + id String @id @default(cuid()) + projectId String? + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + title String @default("Pronostics bebe") + status GameStatus @default(OPEN) + closedAt DateTime? + reopenedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cards PredictionCard[] + entries PredictionEntry[] + activities PredictionActivity[] + + @@index([projectId]) +} + +model PredictionCard { + id String @id @default(cuid()) + gameId String @default("singleton") + game PredictionGame @relation(fields: [gameId], references: [id], onDelete: Cascade) + projectId String? + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + code String? + title String + description String? + type PredictionCardType @default(CUSTOM) + valueType PredictionValueType + styleId Int @default(0) + unit String? + isActive Boolean @default(true) + isDeletable Boolean @default(true) + sortOrder Int @default(0) + basePoints Int @default(0) + createdById String + createdBy User @relation("PredictionCardsCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + fields PredictionCardField[] + options PredictionCardOption[] + entries PredictionEntry[] + outcomes PredictionOutcome[] + activities PredictionActivity[] + + @@unique([projectId, code]) + @@index([gameId, isActive, sortOrder]) + @@index([projectId, isActive, sortOrder]) +} + +model PredictionCardField { + id String @id @default(cuid()) + cardId String + card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade) + label String + sortOrder Int @default(0) + points Int @default(0) + isPrimary Boolean @default(false) + isRequired Boolean @default(false) + minNumber Float? + maxNumber Float? + stepNumber Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + values PredictionEntryValue[] + outcomes PredictionOutcome[] + scores PredictionFieldScore[] + + @@unique([cardId, label]) + @@index([cardId, sortOrder]) +} + +model PredictionCardOption { + id String @id @default(cuid()) + cardId String + card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade) + label String + value String + sortOrder Int @default(0) + + @@unique([cardId, value]) + @@index([cardId, sortOrder]) +} + +model PredictionEntry { + id String @id @default(cuid()) + gameId String @default("singleton") + game PredictionGame @relation(fields: [gameId], references: [id], onDelete: Cascade) + projectId String? + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + userId String + user User @relation("PredictionEntriesByUser", fields: [userId], references: [id], onDelete: Cascade) + cardId String + card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade) + selectedBabyIndex Int? + totalPoints Int @default(0) + isScored Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + values PredictionEntryValue[] + history PredictionEntryHistory[] + scores PredictionFieldScore[] + activities PredictionActivity[] + + @@unique([userId, cardId, selectedBabyIndex]) + @@index([cardId]) + @@index([userId]) + @@index([projectId, userId]) +} + +model PredictionEntryValue { + id String @id @default(cuid()) + entryId String + entry PredictionEntry @relation(fields: [entryId], references: [id], onDelete: Cascade) + fieldId String + field PredictionCardField @relation(fields: [fieldId], references: [id], onDelete: Cascade) + valueText String? + valueNumber Float? + valueDate DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([entryId, fieldId]) + @@index([fieldId]) +} + +model PredictionEntryHistory { + id String @id @default(cuid()) + entryId String + entry PredictionEntry @relation(fields: [entryId], references: [id], onDelete: Cascade) + userId String + user User @relation("PredictionHistoryByUser", fields: [userId], references: [id], onDelete: Cascade) + snapshot Json + message String? + createdAt DateTime @default(now()) + + @@index([entryId, createdAt]) +} + +model PredictionOutcome { + id String @id @default(cuid()) + cardId String + card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade) + projectId String? + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + fieldId String + field PredictionCardField @relation(fields: [fieldId], references: [id], onDelete: Cascade) + selectedBabyIndex Int? + valueText String? + valueNumber Float? + valueDate DateTime? + setById String + setBy User @relation("PredictionOutcomesSetBy", fields: [setById], references: [id], onDelete: Restrict) + setAt DateTime @default(now()) + + @@unique([cardId, fieldId, selectedBabyIndex]) + @@index([projectId]) + @@index([projectId, selectedBabyIndex]) +} + +model PredictionFieldScore { + id String @id @default(cuid()) + entryId String + entry PredictionEntry @relation(fields: [entryId], references: [id], onDelete: Cascade) + projectId String? + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + fieldId String + field PredictionCardField @relation(fields: [fieldId], references: [id], onDelete: Cascade) + suggestedPoints Int @default(0) + awardedPoints Int? + isValidated Boolean @default(false) + note String? + validatedById String? + validatedBy User? @relation("PredictionScoresValidatedBy", fields: [validatedById], references: [id], onDelete: SetNull) + validatedAt DateTime? + updatedAt DateTime @updatedAt + + @@unique([entryId, fieldId]) + @@index([projectId]) +} + +model PredictionActivity { + id String @id @default(cuid()) + gameId String @default("singleton") + game PredictionGame @relation(fields: [gameId], references: [id], onDelete: Cascade) + projectId String? + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + type PredictionActivityType + userId String? + user User? @relation("PredictionActivitiesByUser", fields: [userId], references: [id], onDelete: SetNull) + cardId String? + card PredictionCard? @relation(fields: [cardId], references: [id], onDelete: SetNull) + entryId String? + entry PredictionEntry? @relation(fields: [entryId], references: [id], onDelete: SetNull) + message String + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@index([projectId, createdAt]) +} + +// ===== INDICES ===== + +model ParentIndices { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + parentType ParentType + poids Float? + taille Float? + perimCranien Float? + dateNaissance DateTime? + updatedAt DateTime @updatedAt + + photos ParentIndexPhoto[] + + @@unique([projectId, parentType]) + @@index([projectId]) +} + +model ParentIndexPhoto { + id String @id @default(cuid()) + parentIndicesId String + parentIndices ParentIndices @relation(fields: [parentIndicesId], references: [id], onDelete: Cascade) + url String + sortOrder Int @default(0) + createdAt DateTime @default(now()) + + @@index([parentIndicesId]) +} + +model BabyIndices { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + babyIndex Int + dpa DateTime? + updatedAt DateTime @updatedAt + + trimesters BabyTrimesterEntry[] + + @@unique([projectId, babyIndex]) + @@index([projectId]) +} + +model BabyTrimesterEntry { + id String @id @default(cuid()) + babyIndicesId String + babyIndices BabyIndices @relation(fields: [babyIndicesId], references: [id], onDelete: Cascade) + trimester Trimester + date DateTime? + note String? + poids Float? + taille Float? + perimCranien Float? + updatedAt DateTime @updatedAt + + photos BabyTrimesterPhoto[] + + @@unique([babyIndicesId, trimester]) + @@index([babyIndicesId]) +} + +model BabyTrimesterPhoto { + id String @id @default(cuid()) + trimesterEntryId String + trimesterEntry BabyTrimesterEntry @relation(fields: [trimesterEntryId], references: [id], onDelete: Cascade) + url String + sortOrder Int @default(0) + createdAt DateTime @default(now()) + + @@index([trimesterEntryId]) +} diff --git a/apps/api/public/default-avatars/bebe-balance.png b/apps/api/public/default-avatars/bebe-balance.png new file mode 100644 index 0000000..b6716fc Binary files /dev/null and b/apps/api/public/default-avatars/bebe-balance.png differ diff --git a/apps/api/public/default-avatars/bebe-fille.png b/apps/api/public/default-avatars/bebe-fille.png new file mode 100644 index 0000000..ddec045 Binary files /dev/null and b/apps/api/public/default-avatars/bebe-fille.png differ diff --git a/apps/api/public/default-avatars/bebe-garcon.png b/apps/api/public/default-avatars/bebe-garcon.png new file mode 100644 index 0000000..c15da3d Binary files /dev/null and b/apps/api/public/default-avatars/bebe-garcon.png differ diff --git a/apps/api/public/default-avatars/jouet-bebe.png b/apps/api/public/default-avatars/jouet-bebe.png new file mode 100644 index 0000000..4646bd7 Binary files /dev/null and b/apps/api/public/default-avatars/jouet-bebe.png differ diff --git a/apps/api/public/default-avatars/medaille.png b/apps/api/public/default-avatars/medaille.png new file mode 100644 index 0000000..10726c3 Binary files /dev/null and b/apps/api/public/default-avatars/medaille.png differ diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..07eac34 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AuthModule } from './auth/auth.module'; +import { HealthController } from './health.controller'; +import { IndicesModule } from './indices/indices.module'; +import { PredictionsModule } from './predictions/predictions.module'; +import { ProjectsModule } from './projects/projects.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { UsersModule } from './users/users.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + AuthModule, + UsersModule, + ProjectsModule, + PredictionsModule, + IndicesModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..a5085da --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { LoginDto } from './dto/login.dto'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import type { AuthenticatedRequest } from './jwt-auth.guard'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('login') + login(@Body() loginDto: LoginDto) { + return this.authService.login(loginDto); + } + + @Post('refresh') + refresh(@Body() refreshTokenDto: RefreshTokenDto) { + return this.authService.refresh(refreshTokenDto); + } + + @Post('logout') + @UseGuards(JwtAuthGuard) + logout(@Req() request: AuthenticatedRequest) { + return this.authService.logout(request.user!.sub); + } + + @Get('me') + @UseGuards(JwtAuthGuard) + me(@Req() request: AuthenticatedRequest) { + return this.authService.me(request.user!.sub); + } +} \ No newline at end of file diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..7f0f0c3 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import type { StringValue } from 'ms'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { RolesGuard } from './roles.guard'; + +@Module({ + imports: [ + ConfigModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET') ?? 'change_me_jwt_secret', + signOptions: { + expiresIn: + (configService.get('JWT_EXPIRES_IN') as StringValue) ?? '1d', + }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtAuthGuard, RolesGuard], + exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule], +}) +export class AuthModule {} \ No newline at end of file diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..9b3e938 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,179 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import type { StringValue } from 'ms'; +import * as bcrypt from 'bcrypt'; +import { PrismaService } from '../prisma/prisma.service'; +import { LoginDto } from './dto/login.dto'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; + +@Injectable() +export class AuthService { + constructor( + private readonly prismaService: PrismaService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + private async issueTokens(user: { + id: string; + username: string; + role: string; + displayName: string | null; + profileImageUrl: string | null; + workspaceId: string | null; + createdAt: Date; + }) { + const membership = await this.prismaService.projectMembership.findUnique({ + where: { userId: user.id }, + select: { projectId: true }, + }); + + const currentProjectId = membership?.projectId ?? null; + + const accessToken = await this.jwtService.signAsync({ + sub: user.id, + username: user.username, + role: user.role, + workspaceId: user.workspaceId, + projectId: currentProjectId, + }); + + const refreshToken = await this.jwtService.signAsync( + { sub: user.id, type: 'refresh' }, + { + secret: + this.configService.get('REFRESH_TOKEN_SECRET') ?? + 'change_me_refresh_secret', + expiresIn: + (this.configService.get( + 'REFRESH_TOKEN_EXPIRES_IN', + ) as StringValue) ?? '30d', + }, + ); + + const refreshTokenHash = await bcrypt.hash(refreshToken, 12); + + await this.prismaService.user.update({ + where: { id: user.id }, + data: { refreshTokenHash }, + }); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + displayName: user.displayName, + profileImageUrl: user.profileImageUrl, + workspaceId: user.workspaceId, + currentProjectId, + role: user.role, + createdAt: user.createdAt, + }, + }; + } + + async login(loginDto: LoginDto) { + const normalizedUsername = loginDto.username.trim(); + + const user = await this.prismaService.user.findUnique({ + where: { username: normalizedUsername }, + }); + + const caseInsensitiveUser = user ?? (await this.prismaService.user.findFirst({ + where: { + username: { + equals: normalizedUsername, + mode: 'insensitive', + }, + }, + })); + + if (!caseInsensitiveUser) { + throw new UnauthorizedException('Invalid credentials'); + } + + const passwordMatches = await bcrypt.compare( + loginDto.password, + caseInsensitiveUser.passwordHash, + ); + + if (!passwordMatches) { + throw new UnauthorizedException('Invalid credentials'); + } + + return this.issueTokens(caseInsensitiveUser); + } + + async refresh(refreshTokenDto: RefreshTokenDto) { + const refreshSecret = + this.configService.get('REFRESH_TOKEN_SECRET') ?? + 'change_me_refresh_secret'; + + try { + const payload = await this.jwtService.verifyAsync(refreshTokenDto.refreshToken, { + secret: refreshSecret, + }); + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid refresh token'); + } + + const user = await this.prismaService.user.findUnique({ + where: { id: payload.sub as string }, + }); + + if (!user || !user.refreshTokenHash) { + throw new UnauthorizedException('Invalid refresh token'); + } + + const isRefreshTokenValid = await bcrypt.compare( + refreshTokenDto.refreshToken, + user.refreshTokenHash, + ); + + if (!isRefreshTokenValid) { + throw new UnauthorizedException('Invalid refresh token'); + } + + return this.issueTokens(user); + } catch { + throw new UnauthorizedException('Invalid or expired refresh token'); + } + } + + async logout(userId: string) { + await this.prismaService.user.updateMany({ + where: { id: userId }, + data: { refreshTokenHash: null }, + }); + + return { loggedOut: true }; + } + + async me(userId: string) { + const user = await this.prismaService.user.findUnique({ where: { id: userId } }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + profileImageUrl: user.profileImageUrl, + workspaceId: user.workspaceId, + currentProjectId: ( + await this.prismaService.projectMembership.findUnique({ + where: { userId: user.id }, + select: { projectId: true }, + }) + )?.projectId ?? null, + role: user.role, + createdAt: user.createdAt, + }; + } +} \ No newline at end of file diff --git a/apps/api/src/auth/dto/login.dto.ts b/apps/api/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..daa72e7 --- /dev/null +++ b/apps/api/src/auth/dto/login.dto.ts @@ -0,0 +1,13 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class LoginDto { + @IsString() + @MinLength(3) + @MaxLength(40) + username!: string; + + @IsString() + @MinLength(8) + @MaxLength(128) + password!: string; +} \ No newline at end of file diff --git a/apps/api/src/auth/dto/refresh-token.dto.ts b/apps/api/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..b63a149 --- /dev/null +++ b/apps/api/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class RefreshTokenDto { + @IsString() + @MinLength(10) + refreshToken!: string; +} \ No newline at end of file diff --git a/apps/api/src/auth/jwt-auth.guard.ts b/apps/api/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..c11aec3 --- /dev/null +++ b/apps/api/src/auth/jwt-auth.guard.ts @@ -0,0 +1,57 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import { UserRole } from '@prisma/client'; + +export type AuthenticatedRequest = Request & { + user?: { + sub: string; + username: string; + role: UserRole; + workspaceId?: string | null; + projectId?: string | null; + }; +}; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing bearer token'); + } + + const token = authHeader.slice('Bearer '.length); + + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: + this.configService.get('JWT_SECRET') ?? 'change_me_jwt_secret', + }); + + request.user = { + sub: payload.sub as string, + username: payload.username as string, + role: payload.role as UserRole, + workspaceId: (payload.workspaceId as string | null | undefined) ?? null, + projectId: (payload.projectId as string | null | undefined) ?? null, + }; + return true; + } catch { + throw new UnauthorizedException('Invalid or expired token'); + } + } +} \ No newline at end of file diff --git a/apps/api/src/auth/roles.decorator.ts b/apps/api/src/auth/roles.decorator.ts new file mode 100644 index 0000000..c5b5018 --- /dev/null +++ b/apps/api/src/auth/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '@prisma/client'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); \ No newline at end of file diff --git a/apps/api/src/auth/roles.guard.ts b/apps/api/src/auth/roles.guard.ts new file mode 100644 index 0000000..edd6909 --- /dev/null +++ b/apps/api/src/auth/roles.guard.ts @@ -0,0 +1,35 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { UserRole } from '@prisma/client'; +import { AuthenticatedRequest } from './jwt-auth.guard'; +import { ROLES_KEY } from './roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const userRole = request.user?.role; + + if (!userRole || !requiredRoles.includes(userRole)) { + throw new ForbiddenException('Insufficient role'); + } + + return true; + } +} \ No newline at end of file diff --git a/apps/api/src/health.controller.ts b/apps/api/src/health.controller.ts new file mode 100644 index 0000000..1fe07f1 --- /dev/null +++ b/apps/api/src/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + health() { + return { status: 'ok' }; + } +} \ No newline at end of file diff --git a/apps/api/src/indices/dto/upsert-baby-indices.dto.ts b/apps/api/src/indices/dto/upsert-baby-indices.dto.ts new file mode 100644 index 0000000..9693b51 --- /dev/null +++ b/apps/api/src/indices/dto/upsert-baby-indices.dto.ts @@ -0,0 +1,35 @@ +import { IsDateString, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class UpsertBabyIndicesDto { + @IsOptional() + @IsDateString() + dpa?: string; +} + +export class UpsertBabyTrimesterDto { + @IsOptional() + @IsDateString() + date?: string; + + @IsOptional() + @IsString() + note?: string; + + @IsOptional() + @IsNumber() + @Min(0.1) + @Max(15) + poids?: number; + + @IsOptional() + @IsNumber() + @Min(0.1) + @Max(60) + taille?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(50) + perimCranien?: number; +} diff --git a/apps/api/src/indices/dto/upsert-parent-indices.dto.ts b/apps/api/src/indices/dto/upsert-parent-indices.dto.ts new file mode 100644 index 0000000..99fd4bb --- /dev/null +++ b/apps/api/src/indices/dto/upsert-parent-indices.dto.ts @@ -0,0 +1,25 @@ +import { IsDateString, IsNumber, IsOptional, Max, Min } from 'class-validator'; + +export class UpsertParentIndicesDto { + @IsOptional() + @IsNumber() + @Min(0.5) + @Max(8) + poids?: number; + + @IsOptional() + @IsNumber() + @Min(30) + @Max(70) + taille?: number; + + @IsOptional() + @IsNumber() + @Min(20) + @Max(45) + perimCranien?: number; + + @IsOptional() + @IsDateString() + dateNaissance?: string; +} diff --git a/apps/api/src/indices/indices.controller.ts b/apps/api/src/indices/indices.controller.ts new file mode 100644 index 0000000..1a712ff --- /dev/null +++ b/apps/api/src/indices/indices.controller.ts @@ -0,0 +1,190 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + MaxFileSizeValidator, + Param, + ParseFilePipe, + ParseIntPipe, + Post, + Put, + Req, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ParentType, Trimester, UserRole } from '@prisma/client'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { extname, join } from 'path'; +import { existsSync, mkdirSync } from 'fs'; +import type { AuthenticatedRequest } from '../auth/jwt-auth.guard'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Roles } from '../auth/roles.decorator'; +import { RolesGuard } from '../auth/roles.guard'; +import { IndicesService } from './indices.service'; +import { UpsertParentIndicesDto } from './dto/upsert-parent-indices.dto'; +import { UpsertBabyIndicesDto, UpsertBabyTrimesterDto } from './dto/upsert-baby-indices.dto'; + +const uploadsDir = join(process.cwd(), 'uploads'); + +const storage = diskStorage({ + destination: (_req, _file, callback) => { + if (!existsSync(uploadsDir)) { + mkdirSync(uploadsDir, { recursive: true }); + } + callback(null, uploadsDir); + }, + filename: (_req, file, callback) => { + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + callback(null, `indices-${uniqueSuffix}${extname(file.originalname)}`); + }, +}); + +@Controller('projects/:projectId/indices') +@UseGuards(JwtAuthGuard) +export class IndicesController { + constructor(private readonly indicesService: IndicesService) {} + + @Get() + getProjectIndices( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + ) { + return this.indicesService.getProjectIndices(request.user!.sub, projectId); + } + + @Put('parents/:parentType') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + upsertParentIndices( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('parentType') parentType: ParentType, + @Body() dto: UpsertParentIndicesDto, + ) { + return this.indicesService.upsertParentIndices(request.user!.sub, projectId, parentType, dto); + } + + @Post('parents/:parentType/photos') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @UseInterceptors(FileInterceptor('file', { storage })) + addParentPhoto( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('parentType') parentType: ParentType, + @UploadedFile( + new ParseFilePipe({ + validators: [new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 })], + fileIsRequired: true, + }), + ) + file: Express.Multer.File, + ) { + const allowed = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']; + if (!allowed.includes(file.mimetype)) { + throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.'); + } + return this.indicesService.addParentPhoto( + request.user!.sub, + projectId, + parentType, + `/uploads/${file.filename}`, + ); + } + + @Delete('parents/:parentType/photos/:photoId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + deleteParentPhoto( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('parentType') parentType: ParentType, + @Param('photoId') photoId: string, + ) { + return this.indicesService.deleteParentPhoto(request.user!.sub, projectId, parentType, photoId); + } + + @Put('babies/:babyIndex') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + upsertBabyIndices( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('babyIndex', ParseIntPipe) babyIndex: number, + @Body() dto: UpsertBabyIndicesDto, + ) { + return this.indicesService.upsertBabyIndices(request.user!.sub, projectId, babyIndex, dto); + } + + @Put('babies/:babyIndex/trimesters/:trimester') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + upsertBabyTrimester( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('babyIndex', ParseIntPipe) babyIndex: number, + @Param('trimester') trimester: Trimester, + @Body() dto: UpsertBabyTrimesterDto, + ) { + return this.indicesService.upsertBabyTrimester( + request.user!.sub, + projectId, + babyIndex, + trimester, + dto, + ); + } + + @Post('babies/:babyIndex/trimesters/:trimester/photos') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @UseInterceptors(FileInterceptor('file', { storage })) + addTrimesterPhoto( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('babyIndex', ParseIntPipe) babyIndex: number, + @Param('trimester') trimester: Trimester, + @UploadedFile( + new ParseFilePipe({ + validators: [new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 })], + fileIsRequired: true, + }), + ) + file: Express.Multer.File, + ) { + const allowed = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']; + if (!allowed.includes(file.mimetype)) { + throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.'); + } + return this.indicesService.addTrimesterPhoto( + request.user!.sub, + projectId, + babyIndex, + trimester, + `/uploads/${file.filename}`, + ); + } + + @Delete('babies/:babyIndex/trimesters/:trimester/photos/:photoId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + deleteTrimesterPhoto( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('babyIndex', ParseIntPipe) babyIndex: number, + @Param('trimester') trimester: Trimester, + @Param('photoId') photoId: string, + ) { + return this.indicesService.deleteTrimesterPhoto( + request.user!.sub, + projectId, + babyIndex, + trimester, + photoId, + ); + } +} diff --git a/apps/api/src/indices/indices.module.ts b/apps/api/src/indices/indices.module.ts new file mode 100644 index 0000000..6109059 --- /dev/null +++ b/apps/api/src/indices/indices.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { IndicesController } from './indices.controller'; +import { IndicesService } from './indices.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [IndicesController], + providers: [IndicesService], +}) +export class IndicesModule {} diff --git a/apps/api/src/indices/indices.service.ts b/apps/api/src/indices/indices.service.ts new file mode 100644 index 0000000..ead4097 --- /dev/null +++ b/apps/api/src/indices/indices.service.ts @@ -0,0 +1,281 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ParentType, Trimester, UserRole } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { UpsertParentIndicesDto } from './dto/upsert-parent-indices.dto'; +import { UpsertBabyIndicesDto, UpsertBabyTrimesterDto } from './dto/upsert-baby-indices.dto'; + +const MAX_PHOTOS_PER_SECTION = 5; + +@Injectable() +export class IndicesService { + constructor(private readonly prisma: PrismaService) {} + + private async assertAccess( + userId: string, + projectId: string, + options: { adminOnly?: boolean } = {}, + ) { + const [user, project, membership] = await Promise.all([ + this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true, workspaceId: true }, + }), + this.prisma.project.findUnique({ + where: { id: projectId }, + select: { id: true, workspaceId: true }, + }), + this.prisma.projectMembership.findUnique({ + where: { userId }, + select: { projectId: true }, + }), + ]); + + if (!user) throw new NotFoundException('Utilisateur introuvable'); + if (!project) throw new NotFoundException('Projet introuvable'); + if (!user.workspaceId || user.workspaceId !== project.workspaceId) { + throw new ForbiddenException('Acces refuse a ce projet'); + } + + if (user.role === UserRole.ADMIN) return { user, isAdmin: true }; + if (options.adminOnly) throw new ForbiddenException('Action reservee aux admins'); + if (!membership || membership.projectId !== projectId) { + throw new ForbiddenException('Vous n etes pas rattache a ce projet'); + } + + return { user, isAdmin: false }; + } + + async getProjectIndices(userId: string, projectId: string) { + await this.assertAccess(userId, projectId); + + const [parentIndices, babyIndices] = await Promise.all([ + this.prisma.parentIndices.findMany({ + where: { projectId }, + include: { + photos: { orderBy: { sortOrder: 'asc' } }, + }, + orderBy: { parentType: 'asc' }, + }), + this.prisma.babyIndices.findMany({ + where: { projectId }, + include: { + trimesters: { + include: { + photos: { orderBy: { sortOrder: 'asc' } }, + }, + orderBy: { trimester: 'asc' }, + }, + }, + orderBy: { babyIndex: 'asc' }, + }), + ]); + + return { parentIndices, babyIndices }; + } + + async upsertParentIndices( + userId: string, + projectId: string, + parentType: ParentType, + dto: UpsertParentIndicesDto, + ) { + await this.assertAccess(userId, projectId, { adminOnly: true }); + + return this.prisma.parentIndices.upsert({ + where: { projectId_parentType: { projectId, parentType } }, + create: { + projectId, + parentType, + poids: dto.poids ?? null, + taille: dto.taille ?? null, + perimCranien: dto.perimCranien ?? null, + dateNaissance: dto.dateNaissance ? new Date(dto.dateNaissance) : null, + }, + update: { + poids: dto.poids ?? null, + taille: dto.taille ?? null, + perimCranien: dto.perimCranien ?? null, + dateNaissance: dto.dateNaissance ? new Date(dto.dateNaissance) : null, + }, + include: { photos: { orderBy: { sortOrder: 'asc' } } }, + }); + } + + async addParentPhoto(userId: string, projectId: string, parentType: ParentType, url: string) { + await this.assertAccess(userId, projectId, { adminOnly: true }); + + const parentIndices = await this.prisma.parentIndices.upsert({ + where: { projectId_parentType: { projectId, parentType } }, + create: { projectId, parentType }, + update: {}, + select: { id: true }, + }); + + const count = await this.prisma.parentIndexPhoto.count({ + where: { parentIndicesId: parentIndices.id }, + }); + + if (count >= MAX_PHOTOS_PER_SECTION) { + throw new BadRequestException(`Maximum ${MAX_PHOTOS_PER_SECTION} photos par section`); + } + + return this.prisma.parentIndexPhoto.create({ + data: { parentIndicesId: parentIndices.id, url, sortOrder: count }, + }); + } + + async deleteParentPhoto(userId: string, projectId: string, parentType: ParentType, photoId: string) { + await this.assertAccess(userId, projectId, { adminOnly: true }); + + const photo = await this.prisma.parentIndexPhoto.findUnique({ + where: { id: photoId }, + include: { parentIndices: { select: { projectId: true, parentType: true } } }, + }); + + if (!photo || photo.parentIndices.projectId !== projectId || photo.parentIndices.parentType !== parentType) { + throw new NotFoundException('Photo introuvable'); + } + + await this.prisma.parentIndexPhoto.delete({ where: { id: photoId } }); + return { deleted: true }; + } + + async upsertBabyIndices( + userId: string, + projectId: string, + babyIndex: number, + dto: UpsertBabyIndicesDto, + ) { + await this.assertAccess(userId, projectId, { adminOnly: true }); + + return this.prisma.babyIndices.upsert({ + where: { projectId_babyIndex: { projectId, babyIndex } }, + create: { + projectId, + babyIndex, + dpa: dto.dpa ? new Date(dto.dpa) : null, + }, + update: { + dpa: dto.dpa ? new Date(dto.dpa) : null, + }, + include: { + trimesters: { + include: { photos: { orderBy: { sortOrder: 'asc' } } }, + orderBy: { trimester: 'asc' }, + }, + }, + }); + } + + async upsertBabyTrimester( + userId: string, + projectId: string, + babyIndex: number, + trimester: Trimester, + dto: UpsertBabyTrimesterDto, + ) { + await this.assertAccess(userId, projectId, { adminOnly: true }); + + const babyIndices = await this.prisma.babyIndices.upsert({ + where: { projectId_babyIndex: { projectId, babyIndex } }, + create: { projectId, babyIndex }, + update: {}, + select: { id: true }, + }); + + return this.prisma.babyTrimesterEntry.upsert({ + where: { babyIndicesId_trimester: { babyIndicesId: babyIndices.id, trimester } }, + create: { + babyIndicesId: babyIndices.id, + trimester, + date: dto.date ? new Date(dto.date) : null, + note: dto.note ?? null, + poids: dto.poids ?? null, + taille: dto.taille ?? null, + perimCranien: dto.perimCranien ?? null, + }, + update: { + date: dto.date ? new Date(dto.date) : null, + note: dto.note ?? null, + poids: dto.poids ?? null, + taille: dto.taille ?? null, + perimCranien: dto.perimCranien ?? null, + }, + include: { photos: { orderBy: { sortOrder: 'asc' } } }, + }); + } + + async addTrimesterPhoto( + userId: string, + projectId: string, + babyIndex: number, + trimester: Trimester, + url: string, + ) { + await this.assertAccess(userId, projectId, { adminOnly: true }); + + const babyIndices = await this.prisma.babyIndices.upsert({ + where: { projectId_babyIndex: { projectId, babyIndex } }, + create: { projectId, babyIndex }, + update: {}, + select: { id: true }, + }); + + const entry = await this.prisma.babyTrimesterEntry.upsert({ + where: { babyIndicesId_trimester: { babyIndicesId: babyIndices.id, trimester } }, + create: { babyIndicesId: babyIndices.id, trimester }, + update: {}, + select: { id: true }, + }); + + const count = await this.prisma.babyTrimesterPhoto.count({ + where: { trimesterEntryId: entry.id }, + }); + + if (count >= MAX_PHOTOS_PER_SECTION) { + throw new BadRequestException(`Maximum ${MAX_PHOTOS_PER_SECTION} photos par trimestre`); + } + + return this.prisma.babyTrimesterPhoto.create({ + data: { trimesterEntryId: entry.id, url, sortOrder: count }, + }); + } + + async deleteTrimesterPhoto( + userId: string, + projectId: string, + babyIndex: number, + trimester: Trimester, + photoId: string, + ) { + await this.assertAccess(userId, projectId, { adminOnly: true }); + + const photo = await this.prisma.babyTrimesterPhoto.findUnique({ + where: { id: photoId }, + include: { + trimesterEntry: { + include: { + babyIndices: { select: { projectId: true, babyIndex: true } }, + }, + }, + }, + }); + + if ( + !photo || + photo.trimesterEntry.babyIndices.projectId !== projectId || + photo.trimesterEntry.babyIndices.babyIndex !== babyIndex || + photo.trimesterEntry.trimester !== trimester + ) { + throw new NotFoundException('Photo introuvable'); + } + + await this.prisma.babyTrimesterPhoto.delete({ where: { id: photoId } }); + return { deleted: true }; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..477dfb8 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,35 @@ +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import * as express from 'express'; +import { existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const frontendUrls = process.env.FRONTEND_URL + ? process.env.FRONTEND_URL.split(',').map((url) => url.trim()).filter(Boolean) + : ['http://localhost:3002']; + const uploadsDir = join(process.cwd(), 'uploads'); + if (!existsSync(uploadsDir)) { + mkdirSync(uploadsDir, { recursive: true }); + } + + app.use('/uploads', express.static(uploadsDir)); + app.use('/default-avatars', express.static(join(process.cwd(), 'public', 'default-avatars'))); + app.use('/default-project-avatars', express.static(join(process.cwd(), 'public', 'default-project-avatars'))); + app.enableCors({ + origin: frontendUrls, + credentials: true, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], + }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.listen(process.env.PORT ?? 3000); +} +bootstrap(); diff --git a/apps/api/src/predictions/dto/card-field.dto.ts b/apps/api/src/predictions/dto/card-field.dto.ts new file mode 100644 index 0000000..8855cc8 --- /dev/null +++ b/apps/api/src/predictions/dto/card-field.dto.ts @@ -0,0 +1,37 @@ +import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class CardFieldDto { + @IsString() + @MaxLength(80) + label!: string; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @IsOptional() + @IsInt() + @Min(0) + points?: number; + + @IsOptional() + @IsBoolean() + isPrimary?: boolean; + + @IsOptional() + @IsBoolean() + isRequired?: boolean; + + @IsOptional() + @IsNumber() + minNumber?: number; + + @IsOptional() + @IsNumber() + maxNumber?: number; + + @IsOptional() + @IsNumber() + stepNumber?: number; +} diff --git a/apps/api/src/predictions/dto/card-option.dto.ts b/apps/api/src/predictions/dto/card-option.dto.ts new file mode 100644 index 0000000..8bd8f50 --- /dev/null +++ b/apps/api/src/predictions/dto/card-option.dto.ts @@ -0,0 +1,16 @@ +import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class CardOptionDto { + @IsString() + @MaxLength(80) + label!: string; + + @IsString() + @MaxLength(80) + value!: string; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/apps/api/src/predictions/dto/create-card.dto.ts b/apps/api/src/predictions/dto/create-card.dto.ts new file mode 100644 index 0000000..8a5d4ad --- /dev/null +++ b/apps/api/src/predictions/dto/create-card.dto.ts @@ -0,0 +1,61 @@ +import { PredictionCardType, PredictionValueType } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; +import { CardFieldDto } from './card-field.dto'; +import { CardOptionDto } from './card-option.dto'; + +export class CreateCardDto { + @IsString() + @MaxLength(120) + title!: string; + + @IsOptional() + @IsString() + @MaxLength(300) + description?: string; + + @IsEnum(PredictionCardType) + type!: PredictionCardType; + + @IsEnum(PredictionValueType) + valueType!: PredictionValueType; + + @IsOptional() + @IsInt() + styleId?: number; + + @IsOptional() + @IsString() + @MaxLength(20) + unit?: string; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @IsOptional() + @IsInt() + @Min(0) + basePoints?: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CardFieldDto) + fields!: CardFieldDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CardOptionDto) + options?: CardOptionDto[]; +} diff --git a/apps/api/src/predictions/dto/prediction-value.dto.ts b/apps/api/src/predictions/dto/prediction-value.dto.ts new file mode 100644 index 0000000..3587b9d --- /dev/null +++ b/apps/api/src/predictions/dto/prediction-value.dto.ts @@ -0,0 +1,94 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +export class PredictionValueDto { + @IsString() + fieldId!: string; + + @IsOptional() + @IsString() + @MaxLength(200) + valueText?: string; + + @IsOptional() + @IsNumber() + valueNumber?: number; + + @IsOptional() + @IsDateString() + valueDate?: string; +} + +export class UpsertPredictionEntryDto { + @IsOptional() + @IsInt() + @Min(1) + selectedBabyIndex?: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PredictionValueDto) + values!: PredictionValueDto[]; +} + +export class OutcomeValueDto { + @IsString() + fieldId!: string; + + @IsOptional() + @IsString() + @MaxLength(200) + valueText?: string; + + @IsOptional() + @IsNumber() + valueNumber?: number; + + @IsOptional() + @IsDateString() + valueDate?: string; +} + +export class SetOutcomesDto { + @IsOptional() + @IsInt() + @Min(1) + selectedBabyIndex?: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OutcomeValueDto) + values!: OutcomeValueDto[]; +} + +export class ValidateScoreItemDto { + @IsString() + fieldId!: string; + + @IsOptional() + @IsInt() + @Min(0) + awardedPoints?: number; + + @IsOptional() + @IsString() + @MaxLength(300) + note?: string; +} + +export class ValidateScoresDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ValidateScoreItemDto) + scores!: ValidateScoreItemDto[]; +} diff --git a/apps/api/src/predictions/dto/update-card.dto.ts b/apps/api/src/predictions/dto/update-card.dto.ts new file mode 100644 index 0000000..8085ef3 --- /dev/null +++ b/apps/api/src/predictions/dto/update-card.dto.ts @@ -0,0 +1,66 @@ +import { PredictionValueType } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; +import { CardFieldDto } from './card-field.dto'; +import { CardOptionDto } from './card-option.dto'; + +export class UpdateCardDto { + @IsOptional() + @IsString() + @MaxLength(120) + title?: string; + + @IsOptional() + @IsString() + @MaxLength(300) + description?: string; + + @IsOptional() + @IsEnum(PredictionValueType) + valueType?: PredictionValueType; + + @IsOptional() + @IsInt() + styleId?: number; + + @IsOptional() + @IsString() + @MaxLength(20) + unit?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @IsOptional() + @IsInt() + @Min(0) + basePoints?: number; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CardFieldDto) + fields?: CardFieldDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CardOptionDto) + options?: CardOptionDto[]; +} diff --git a/apps/api/src/predictions/predictions.controller.ts b/apps/api/src/predictions/predictions.controller.ts new file mode 100644 index 0000000..81fb645 --- /dev/null +++ b/apps/api/src/predictions/predictions.controller.ts @@ -0,0 +1,157 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Put, + Req, + UseGuards, +} from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { AuthenticatedRequest } from '../auth/jwt-auth.guard'; +import { Roles } from '../auth/roles.decorator'; +import { RolesGuard } from '../auth/roles.guard'; +import { CreateCardDto } from './dto/create-card.dto'; +import { + SetOutcomesDto, + UpsertPredictionEntryDto, + ValidateScoresDto, +} from './dto/prediction-value.dto'; +import { UpdateCardDto } from './dto/update-card.dto'; +import { PredictionsService } from './predictions.service'; + +@Controller('predictions') +@UseGuards(JwtAuthGuard) +export class PredictionsController { + constructor(private readonly predictionsService: PredictionsService) {} + + @Get('cards') + listCards() { + return this.predictionsService.listCards(false); + } + + @Get('board') + board( + @Req() request: AuthenticatedRequest, + @Query('selectedBabyIndex') selectedBabyIndex?: string, + ) { + return this.predictionsService.getBoard( + request.user!.sub, + undefined, + selectedBabyIndex ? Number(selectedBabyIndex) : undefined, + ); + } + + @Get('activity') + activity(@Req() request: AuthenticatedRequest) { + return this.predictionsService.listActivity(request.user!.sub); + } + + @Get('scoreboard') + scoreboard() { + return this.predictionsService.getScoreboard(); + } + + @Get('my-entries') + myEntries(@Req() request: AuthenticatedRequest) { + return this.predictionsService.listMyEntries(request.user!.sub); + } + + @Put('cards/:cardId/my-entry') + upsertMyEntry( + @Req() request: AuthenticatedRequest, + @Param('cardId') cardId: string, + @Body() dto: UpsertPredictionEntryDto, + ) { + return this.predictionsService.upsertMyPrediction(request.user!.sub, cardId, dto); + } + + @Post('game/close') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + closeGame(@Req() request: AuthenticatedRequest) { + return this.predictionsService.closeGame(request.user!.sub); + } + + @Post('game/open') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + openGame(@Req() request: AuthenticatedRequest) { + return this.predictionsService.openGame(request.user!.sub); + } + + @Post('game/finalize') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + finalizeGame(@Req() request: AuthenticatedRequest) { + return this.predictionsService.finalizeGame(request.user!.sub); + } + + @Post('cards') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + createCard(@Req() request: AuthenticatedRequest, @Body() dto: CreateCardDto) { + return this.predictionsService.createCard(request.user!.sub, dto); + } + + @Patch('cards/:cardId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + updateCard( + @Req() request: AuthenticatedRequest, + @Param('cardId') cardId: string, + @Body() dto: UpdateCardDto, + ) { + return this.predictionsService.updateCard(request.user!.sub, cardId, dto); + } + + @Delete('cards/:cardId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + deleteCard(@Req() request: AuthenticatedRequest, @Param('cardId') cardId: string) { + return this.predictionsService.deleteCard(request.user!.sub, cardId); + } + + @Post('cards/:cardId/outcomes') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + setOutcomes( + @Req() request: AuthenticatedRequest, + @Param('cardId') cardId: string, + @Body() dto: SetOutcomesDto, + ) { + return this.predictionsService.setCardOutcomes(request.user!.sub, cardId, dto); + } + + @Post('cards/:cardId/suggest-scores') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + suggestScores( + @Req() request: AuthenticatedRequest, + @Param('cardId') cardId: string, + @Query('selectedBabyIndex') selectedBabyIndex?: string, + ) { + return this.predictionsService.suggestScoresForCard( + request.user!.sub, + cardId, + undefined, + selectedBabyIndex ? Number(selectedBabyIndex) : undefined, + ); + } + + @Patch('entries/:entryId/scores') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + validateScores( + @Req() request: AuthenticatedRequest, + @Param('entryId') entryId: string, + @Body() dto: ValidateScoresDto, + ) { + return this.predictionsService.validateScores(request.user!.sub, entryId, dto); + } +} diff --git a/apps/api/src/predictions/predictions.module.ts b/apps/api/src/predictions/predictions.module.ts new file mode 100644 index 0000000..7132c8a --- /dev/null +++ b/apps/api/src/predictions/predictions.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ProjectPredictionsController } from './project-predictions.controller'; +import { PredictionsController } from './predictions.controller'; +import { PredictionsService } from './predictions.service'; + +@Module({ + imports: [AuthModule], + controllers: [PredictionsController, ProjectPredictionsController], + providers: [PredictionsService], + exports: [PredictionsService], +}) +export class PredictionsModule {} diff --git a/apps/api/src/predictions/predictions.service.ts b/apps/api/src/predictions/predictions.service.ts new file mode 100644 index 0000000..f209c92 --- /dev/null +++ b/apps/api/src/predictions/predictions.service.ts @@ -0,0 +1,1758 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { + PredictionActivityType, + PredictionCard, + PredictionCardType, + PredictionValueType, + UserRole, +} from '@prisma/client'; +import { DEFAULT_PROJECT_ID } from '../projects/project-scope.constants'; +import { PrismaService } from '../prisma/prisma.service'; +import { CardFieldDto } from './dto/card-field.dto'; +import { CardOptionDto } from './dto/card-option.dto'; +import { CreateCardDto } from './dto/create-card.dto'; +import { + SetOutcomesDto, + UpsertPredictionEntryDto, + ValidateScoresDto, +} from './dto/prediction-value.dto'; +import { UpdateCardDto } from './dto/update-card.dto'; + +@Injectable() +export class PredictionsService implements OnModuleInit { + private readonly contestFinalizedMarker = '[CONTEST_FINALIZED]'; + + constructor(private readonly prismaService: PrismaService) {} + + private resolveProjectId(projectId?: string) { + return projectId ?? DEFAULT_PROJECT_ID; + } + + private buildProjectScopeFilter(projectId: string) { + if (projectId === DEFAULT_PROJECT_ID) { + return { + OR: [{ projectId: DEFAULT_PROJECT_ID }, { projectId: null }], + }; + } + + return { projectId }; + } + + private normalizeSelectedBabyIndex(selectedBabyIndex?: number) { + if (!selectedBabyIndex || Number.isNaN(selectedBabyIndex)) { + return 1; + } + + return Math.max(1, Math.trunc(selectedBabyIndex)); + } + + private buildBabyScopeFilter(selectedBabyIndex: number) { + const normalized = this.normalizeSelectedBabyIndex(selectedBabyIndex); + + if (normalized === 1) { + return { + OR: [{ selectedBabyIndex: 1 }, { selectedBabyIndex: null }], + }; + } + + return { selectedBabyIndex: normalized }; + } + + private isCardInProjectScope(cardProjectId: string | null, projectId: string) { + if (projectId === DEFAULT_PROJECT_ID) { + return cardProjectId == null || cardProjectId === DEFAULT_PROJECT_ID; + } + + return cardProjectId === projectId; + } + + private async assertProjectAccess(userId: string, projectId: string, adminOnly = false) { + const [user, project, membership] = await Promise.all([ + this.prismaService.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true, workspaceId: true }, + }), + this.prismaService.project.findUnique({ + where: { id: projectId }, + select: { id: true, workspaceId: true }, + }), + this.prismaService.projectMembership.findUnique({ + where: { userId }, + select: { projectId: true }, + }), + ]); + + if (!user) { + throw new NotFoundException('Utilisateur introuvable'); + } + + if (!project) { + if (projectId === DEFAULT_PROJECT_ID) { + return; + } + throw new NotFoundException('Projet introuvable'); + } + + if (user.workspaceId && user.workspaceId !== project.workspaceId) { + throw new ForbiddenException('Acces refuse a ce projet'); + } + + if (user.role === UserRole.ADMIN) { + return; + } + + if (adminOnly) { + throw new ForbiddenException('Action reservee aux admins'); + } + + if (membership?.projectId === projectId) { + return; + } + + if (projectId === DEFAULT_PROJECT_ID) { + return; + } + + throw new ForbiddenException('Vous n etes pas rattache a ce projet'); + } + + private async getGameForProject(projectId: string) { + if (projectId === DEFAULT_PROJECT_ID) { + const defaultProject = await this.prismaService.project.findUnique({ + where: { id: DEFAULT_PROJECT_ID }, + select: { id: true }, + }); + + return this.prismaService.predictionGame.upsert({ + where: { id: 'singleton' }, + update: { + projectId: defaultProject ? DEFAULT_PROJECT_ID : null, + }, + create: { + id: 'singleton', + ...(defaultProject ? { projectId: DEFAULT_PROJECT_ID } : {}), + }, + }); + } + + const existing = await this.prismaService.predictionGame.findFirst({ + where: { projectId }, + }); + + if (existing) { + return existing; + } + + const project = await this.prismaService.project.findUnique({ + where: { id: projectId }, + select: { + id: true, + name: true, + status: true, + }, + }); + + if (!project) { + throw new NotFoundException('Projet introuvable'); + } + + return this.prismaService.predictionGame.create({ + data: { + id: projectId, + projectId, + title: `Pronostics ${project.name}`, + status: project.status === 'CLOSED' || project.status === 'FINALIZED' ? 'CLOSED' : 'OPEN', + }, + }); + } + + async onModuleInit() { + await this.ensureGame(); + await this.ensureLegacyCards(); + } + + private async ensureGame(projectId = DEFAULT_PROJECT_ID) { + await this.getGameForProject(projectId); + } + + private async ensureLegacyCards() { + const admin = await this.prismaService.user.findFirst({ + where: { role: UserRole.ADMIN }, + orderBy: { createdAt: 'asc' }, + }); + + if (!admin) { + return; + } + + await this.upsertLegacyCard(admin.id, { + code: 'legacy_weight', + title: 'Poids du bebe', + description: 'Pronostic du poids de naissance', + basePoints: 100, + styleId: 1, + valueType: PredictionValueType.NUMBER, + unit: 'kg', + sortOrder: 1, + fields: [ + { + label: 'Poids', + points: 100, + isRequired: true, + minNumber: 1.5, + maxNumber: 5.995, + stepNumber: 0.005, + }, + ], + options: [], + }); + + await this.upsertLegacyCard(admin.id, { + code: 'legacy_sex', + title: 'Sexe du bebe', + description: 'Pronostic du sexe du bebe', + styleId: 2, + valueType: PredictionValueType.SELECT, + sortOrder: 2, + fields: [{ label: 'Sexe', points: 20, isRequired: true }], + options: [ + { label: 'Fille', value: 'fille' }, + { label: 'Garcon', value: 'garçon' }, + ], + }); + + await this.upsertLegacyCard(admin.id, { + code: 'legacy_names', + title: 'Prenoms du bebe', + description: 'Jusqua 6 propositions de prenoms', + styleId: 3, + valueType: PredictionValueType.MULTI_TEXT, + sortOrder: 3, + fields: [ + { label: 'Prenom principal', points: 30, isPrimary: true, isRequired: true }, + { label: 'Prenom secondaire 1', points: 15 }, + { label: 'Prenom secondaire 2', points: 15 }, + { label: 'Prenom secondaire 3', points: 15 }, + { label: 'Prenom secondaire 4', points: 15 }, + { label: 'Prenom secondaire 5', points: 15 }, + ], + options: [], + }); + + await this.upsertLegacyCard(admin.id, { + code: 'legacy_head', + title: 'Perimetre cranien', + description: 'Pronostic du perimetre cranien', + styleId: 4, + valueType: PredictionValueType.NUMBER, + unit: 'cm', + sortOrder: 4, + fields: [ + { + label: 'Perimetre cranien', + points: 15, + isRequired: true, + minNumber: 30, + maxNumber: 45, + stepNumber: 0.1, + }, + ], + options: [], + }); + + await this.upsertLegacyCard(admin.id, { + code: 'legacy_height', + title: 'Taille du bebe', + description: 'Pronostic de la taille a la naissance', + styleId: 5, + valueType: PredictionValueType.NUMBER, + unit: 'cm', + sortOrder: 5, + fields: [ + { + label: 'Taille', + points: 15, + isRequired: true, + minNumber: 40, + maxNumber: 65, + stepNumber: 0.1, + }, + ], + options: [], + }); + } + + private async upsertLegacyCard( + userId: string, + payload: { + code: string; + title: string; + description: string; + basePoints?: number; + styleId: number; + valueType: PredictionValueType; + unit?: string; + sortOrder: number; + fields: CardFieldDto[]; + options: CardOptionDto[]; + }, + ) { + const existingCards = await this.prismaService.predictionCard.findMany({ + where: { code: payload.code }, + orderBy: [{ projectId: 'asc' }, { createdAt: 'asc' }], + }); + + const cardsToSync = await Promise.all( + existingCards.map((card) => + this.prismaService.predictionCard.update({ + where: { id: card.id }, + data: { + projectId: card.projectId === DEFAULT_PROJECT_ID ? null : card.projectId, + title: payload.title, + description: payload.description, + basePoints: payload.basePoints ?? 0, + type: PredictionCardType.LEGACY, + valueType: payload.valueType, + styleId: payload.styleId, + unit: payload.unit, + sortOrder: payload.sortOrder, + isActive: true, + isDeletable: false, + }, + }), + ), + ); + + if (!cardsToSync.some((card) => card.projectId == null)) { + cardsToSync.push( + await this.prismaService.predictionCard.create({ + data: { + projectId: null, + code: payload.code, + title: payload.title, + description: payload.description, + basePoints: payload.basePoints ?? 0, + type: PredictionCardType.LEGACY, + valueType: payload.valueType, + styleId: payload.styleId, + unit: payload.unit, + sortOrder: payload.sortOrder, + isActive: true, + isDeletable: false, + createdById: userId, + }, + }), + ); + } + + for (const card of cardsToSync) { + for (const field of payload.fields) { + await this.prismaService.predictionCardField.upsert({ + where: { + cardId_label: { + cardId: card.id, + label: field.label, + }, + }, + update: { + sortOrder: field.sortOrder ?? 0, + points: field.points ?? 0, + isPrimary: field.isPrimary ?? false, + isRequired: field.isRequired ?? false, + minNumber: field.minNumber, + maxNumber: field.maxNumber, + stepNumber: field.stepNumber, + }, + create: { + cardId: card.id, + label: field.label, + sortOrder: field.sortOrder ?? 0, + points: field.points ?? 0, + isPrimary: field.isPrimary ?? false, + isRequired: field.isRequired ?? false, + minNumber: field.minNumber, + maxNumber: field.maxNumber, + stepNumber: field.stepNumber, + }, + }); + } + + for (const option of payload.options) { + await this.prismaService.predictionCardOption.upsert({ + where: { + cardId_value: { + cardId: card.id, + value: option.value, + }, + }, + update: { + label: option.label, + sortOrder: option.sortOrder ?? 0, + }, + create: { + cardId: card.id, + label: option.label, + value: option.value, + sortOrder: option.sortOrder ?? 0, + }, + }); + } + } + } + + private normalizeText(input?: string | null) { + return (input ?? '').trim().toLowerCase(); + } + + private normalizeDateOnly(value?: string | Date | null) { + if (!value) { + return null; + } + + if (typeof value === 'string') { + return value.slice(0, 10); + } + + return value.toISOString().slice(0, 10); + } + + private async isContestFinalizedSinceLastOpen(projectId = DEFAULT_PROJECT_ID) { + const scopeFilter = this.buildProjectScopeFilter(projectId); + + const [latestOpenActivity, latestFinalizationActivity] = await Promise.all([ + this.prismaService.predictionActivity.findFirst({ + where: { ...scopeFilter, type: PredictionActivityType.GAME_OPENED }, + orderBy: { createdAt: 'desc' }, + }), + this.prismaService.predictionActivity.findFirst({ + where: { + ...scopeFilter, + type: PredictionActivityType.SCORE_VALIDATED, + message: { startsWith: this.contestFinalizedMarker }, + }, + orderBy: { createdAt: 'desc' }, + }), + ]); + + if (!latestFinalizationActivity) { + return false; + } + + if (!latestOpenActivity) { + return true; + } + + return latestFinalizationActivity.createdAt > latestOpenActivity.createdAt; + } + + private hasOutcomeValue( + outcome: + | { + valueText: string | null; + valueNumber: number | null; + valueDate: Date | null; + } + | undefined, + valueType: PredictionValueType, + ) { + if (!outcome) { + return false; + } + + if (valueType === PredictionValueType.NUMBER) { + return outcome.valueNumber != null; + } + + if (valueType === PredictionValueType.DATE) { + return outcome.valueDate != null; + } + + return (outcome.valueText ?? '').trim().length > 0; + } + + private async assertRequiredOutcomesAreFilled(projectId = DEFAULT_PROJECT_ID) { + const scopeFilter = this.buildProjectScopeFilter(projectId); + + const projectBabyCount = + projectId === DEFAULT_PROJECT_ID + ? 1 + : ( + await this.prismaService.project.findUnique({ + where: { id: projectId }, + select: { babyCount: true }, + }) + )?.babyCount ?? 1; + + const cards = await this.prismaService.predictionCard.findMany({ + where: { ...scopeFilter, isActive: true }, + include: { + fields: { orderBy: { sortOrder: 'asc' } }, + outcomes: true, + }, + orderBy: { sortOrder: 'asc' }, + }); + + const missing: string[] = []; + + for (const card of cards) { + const outcomesByFieldAndBaby = new Map( + card.outcomes.map((outcome) => [ + `${outcome.fieldId}::${this.normalizeSelectedBabyIndex(outcome.selectedBabyIndex ?? 1)}`, + outcome, + ]), + ); + + for (const field of card.fields) { + if (!field.isRequired) { + continue; + } + + for (let babyIndex = 1; babyIndex <= Math.max(1, projectBabyCount); babyIndex += 1) { + const outcome = outcomesByFieldAndBaby.get(`${field.id}::${babyIndex}`); + if (!this.hasOutcomeValue(outcome, card.valueType)) { + missing.push(`${card.title} / ${field.label} / Bebe ${babyIndex}`); + } + } + } + } + + if (missing.length > 0) { + throw new BadRequestException( + `Resultats finaux manquants pour: ${missing.join(', ')}`, + ); + } + + return cards; + } + + private hasValueForType( + value: + | { + valueText: string | null; + valueNumber: number | null; + valueDate: string | null; + } + | undefined, + valueType: PredictionValueType, + ) { + if (!value) { + return false; + } + + if (valueType === PredictionValueType.NUMBER) { + return value.valueNumber != null; + } + + if (valueType === PredictionValueType.DATE) { + return value.valueDate != null; + } + + return (value.valueText ?? '').trim().length > 0; + } + + private formatValueForMessage( + value: { valueText: string | null; valueNumber: number | null; valueDate: string | null }, + ) { + if (value.valueNumber != null) { + return `${value.valueNumber}`; + } + + if (value.valueText && value.valueText.trim().length > 0) { + return value.valueText.trim(); + } + + if (value.valueDate) { + return value.valueDate; + } + + return '(vide)'; + } + + private areEntryValuesEqual( + left: { valueText: string | null; valueNumber: number | null; valueDate: string | null }, + right: { valueText: string | null; valueNumber: number | null; valueDate: string | null }, + ) { + return ( + this.normalizeText(left.valueText) === this.normalizeText(right.valueText) && + left.valueNumber === right.valueNumber && + this.normalizeDateOnly(left.valueDate) === this.normalizeDateOnly(right.valueDate) + ); + } + + private async createActivity(payload: { + projectId?: string; + userId?: string; + cardId?: string; + entryId?: string; + type: PredictionActivityType; + message: string; + }) { + const scopedProjectId = this.resolveProjectId(payload.projectId); + const game = await this.getGameForProject(scopedProjectId); + + await this.prismaService.predictionActivity.create({ + data: { + gameId: game.id, + projectId: scopedProjectId, + type: payload.type, + userId: payload.userId, + cardId: payload.cardId, + entryId: payload.entryId, + message: payload.message, + }, + }); + } + + async closeGame(userId: string, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId, true); + await this.assertRequiredOutcomesAreFilled(scopedProjectId); + + const game = await this.getGameForProject(scopedProjectId); + + await this.prismaService.predictionGame.update({ + where: { id: game.id }, + data: { status: 'CLOSED', closedAt: new Date() }, + }); + + await this.createActivity({ + projectId: scopedProjectId, + userId, + type: PredictionActivityType.GAME_CLOSED, + message: 'Le jeu de pronostics a ete cloture', + }); + + return { status: 'CLOSED' }; + } + + async openGame(userId: string, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId, true); + + const game = await this.getGameForProject(scopedProjectId); + + if (!game) { + throw new NotFoundException('Game not found'); + } + + const isFinalized = await this.isContestFinalizedSinceLastOpen(scopedProjectId); + if (isFinalized) { + throw new ForbiddenException('Le concours a deja ete valide et ne peut plus etre reouvert'); + } + + await this.prismaService.predictionGame.update({ + where: { id: game.id }, + data: { status: 'OPEN', reopenedAt: new Date() }, + }); + + await this.createActivity({ + projectId: scopedProjectId, + userId, + type: PredictionActivityType.GAME_OPENED, + message: 'Le jeu de pronostics est de nouveau ouvert', + }); + + return { status: 'OPEN' }; + } + + async createCard(userId: string, createCardDto: CreateCardDto, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId, true); + + if (!createCardDto.fields?.length) { + throw new BadRequestException('A card must contain at least one field'); + } + + const game = await this.getGameForProject(scopedProjectId); + + const card = await this.prismaService.predictionCard.create({ + data: { + gameId: game.id, + projectId: scopedProjectId, + title: createCardDto.title, + description: createCardDto.description, + type: createCardDto.type, + valueType: createCardDto.valueType, + styleId: createCardDto.styleId ?? 0, + unit: createCardDto.unit, + sortOrder: createCardDto.sortOrder ?? 0, + basePoints: createCardDto.basePoints ?? 0, + isActive: true, + isDeletable: createCardDto.type !== PredictionCardType.LEGACY, + createdById: userId, + }, + }); + + for (const field of createCardDto.fields) { + await this.prismaService.predictionCardField.create({ + data: { + cardId: card.id, + label: field.label, + sortOrder: field.sortOrder ?? 0, + points: field.points ?? 0, + isPrimary: field.isPrimary ?? false, + isRequired: field.isRequired ?? false, + minNumber: field.minNumber, + maxNumber: field.maxNumber, + stepNumber: field.stepNumber, + }, + }); + } + + for (const option of createCardDto.options ?? []) { + await this.prismaService.predictionCardOption.create({ + data: { + cardId: card.id, + label: option.label, + value: option.value, + sortOrder: option.sortOrder ?? 0, + }, + }); + } + + await this.createActivity({ + projectId: scopedProjectId, + userId, + cardId: card.id, + type: PredictionActivityType.CARD_CREATED, + message: `Nouvelle carte creee: ${card.title}`, + }); + + return this.getCard(card.id, scopedProjectId); + } + + async updateCard(userId: string, cardId: string, updateCardDto: UpdateCardDto, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId, true); + + const card = await this.prismaService.predictionCard.findUnique({ + where: { id: cardId }, + }); + + if (!card || !this.isCardInProjectScope(card.projectId, scopedProjectId)) { + throw new NotFoundException('Card not found'); + } + + if (card.type === PredictionCardType.LEGACY && updateCardDto.valueType) { + throw new ForbiddenException('Legacy card type cannot be changed'); + } + + await this.prismaService.predictionCard.update({ + where: { id: cardId }, + data: { + title: updateCardDto.title, + description: updateCardDto.description, + valueType: updateCardDto.valueType, + styleId: updateCardDto.styleId, + unit: updateCardDto.unit, + sortOrder: updateCardDto.sortOrder, + basePoints: updateCardDto.basePoints, + isActive: updateCardDto.isActive, + }, + }); + + if (updateCardDto.fields) { + await this.prismaService.predictionCardField.deleteMany({ where: { cardId } }); + for (const field of updateCardDto.fields) { + await this.prismaService.predictionCardField.create({ + data: { + cardId, + label: field.label, + sortOrder: field.sortOrder ?? 0, + points: field.points ?? 0, + isPrimary: field.isPrimary ?? false, + isRequired: field.isRequired ?? false, + minNumber: field.minNumber, + maxNumber: field.maxNumber, + stepNumber: field.stepNumber, + }, + }); + } + } + + if (updateCardDto.options) { + await this.prismaService.predictionCardOption.deleteMany({ where: { cardId } }); + for (const option of updateCardDto.options) { + await this.prismaService.predictionCardOption.create({ + data: { + cardId, + label: option.label, + value: option.value, + sortOrder: option.sortOrder ?? 0, + }, + }); + } + } + + await this.createActivity({ + projectId: scopedProjectId, + userId, + cardId, + type: PredictionActivityType.CARD_UPDATED, + message: `Carte mise a jour: ${updateCardDto.title ?? card.title}`, + }); + + return this.getCard(cardId, scopedProjectId); + } + + async deleteCard(userId: string, cardId: string, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId, true); + + const card = await this.prismaService.predictionCard.findUnique({ + where: { id: cardId }, + }); + + if (!card || !this.isCardInProjectScope(card.projectId, scopedProjectId)) { + throw new NotFoundException('Card not found'); + } + + if (!card.isDeletable) { + throw new ForbiddenException('Legacy cards cannot be deleted'); + } + + await this.prismaService.predictionCard.delete({ where: { id: cardId } }); + + await this.createActivity({ + projectId: scopedProjectId, + userId, + type: PredictionActivityType.CARD_DELETED, + message: `Carte supprimee: ${card.title}`, + }); + + return { deleted: true }; + } + + async listCards(includeInactive = false, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + const scopeFilter = this.buildProjectScopeFilter(scopedProjectId); + + return this.prismaService.predictionCard.findMany({ + where: includeInactive ? scopeFilter : { ...scopeFilter, isActive: true }, + include: { + fields: { orderBy: { sortOrder: 'asc' } }, + options: { orderBy: { sortOrder: 'asc' } }, + }, + orderBy: { sortOrder: 'asc' }, + }); + } + + async getCard(cardId: string, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + + const card = await this.prismaService.predictionCard.findUnique({ + where: { id: cardId }, + include: { + fields: { orderBy: { sortOrder: 'asc' } }, + options: { orderBy: { sortOrder: 'asc' } }, + }, + }); + + if (!card) { + throw new NotFoundException('Card not found'); + } + + if (!this.isCardInProjectScope(card.projectId, scopedProjectId)) { + throw new NotFoundException('Card not found'); + } + + return card; + } + + async upsertMyPrediction( + userId: string, + cardId: string, + dto: UpsertPredictionEntryDto, + projectId?: string, + ) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId); + + const game = await this.getGameForProject(scopedProjectId); + + if (!game || game.status !== 'OPEN') { + throw new ForbiddenException('The game is closed'); + } + + const card = await this.getCard(cardId, scopedProjectId); + + if (!card.isActive) { + throw new NotFoundException('Card not found'); + } + + const fieldById = new Map(card.fields.map((field) => [field.id, field])); + + const selectedBabyIndex = dto.selectedBabyIndex ?? 1; + const scopeFilter = this.buildProjectScopeFilter(scopedProjectId); + const babyScopeFilter = + selectedBabyIndex === 1 + ? { + OR: [{ selectedBabyIndex }, { selectedBabyIndex: null }], + } + : { selectedBabyIndex }; + + const existing = await this.prismaService.predictionEntry.findFirst({ + where: { + userId, + cardId, + AND: [scopeFilter, babyScopeFilter], + }, + include: { values: true }, + }); + + if (!dto.values.length) { + throw new BadRequestException('At least one value is required'); + } + + const incomingByField = new Map< + string, + { valueText: string | null; valueNumber: number | null; valueDate: string | null } + >(); + + for (const value of dto.values) { + const field = fieldById.get(value.fieldId); + if (!field) { + throw new BadRequestException('Unknown field for this card'); + } + + const normalizedText = value.valueText?.trim() ?? ''; + + if (card.valueType === PredictionValueType.NUMBER && value.valueNumber == null) { + throw new BadRequestException(`Field ${field.label} requires a number value`); + } + + if (card.valueType === PredictionValueType.DATE && !value.valueDate) { + throw new BadRequestException(`Field ${field.label} requires a date value`); + } + + if (card.valueType === PredictionValueType.SELECT && normalizedText.length > 0) { + const allowed = card.options.some( + (option) => this.normalizeText(option.value) === this.normalizeText(normalizedText), + ); + if (!allowed) { + throw new BadRequestException(`Value ${normalizedText} is not in allowed options`); + } + } + + incomingByField.set(value.fieldId, { + valueText: + card.valueType === PredictionValueType.TEXT || + card.valueType === PredictionValueType.MULTI_TEXT || + card.valueType === PredictionValueType.SELECT + ? normalizedText || null + : value.valueText ?? null, + valueNumber: value.valueNumber ?? null, + valueDate: this.normalizeDateOnly(value.valueDate), + }); + } + + const mergedByField = new Map< + string, + { valueText: string | null; valueNumber: number | null; valueDate: string | null } + >(); + + for (const current of existing?.values ?? []) { + mergedByField.set(current.fieldId, { + valueText: current.valueText, + valueNumber: current.valueNumber, + valueDate: this.normalizeDateOnly(current.valueDate), + }); + } + + for (const [fieldId, incoming] of incomingByField.entries()) { + mergedByField.set(fieldId, incoming); + } + + for (const field of card.fields) { + if (!field.isRequired) { + continue; + } + + const merged = mergedByField.get(field.id); + if (!this.hasValueForType(merged, card.valueType)) { + throw new BadRequestException(`Field ${field.label} requires a value`); + } + } + + type ChangedValue = { + fieldId: string; + previous: { valueText: string | null; valueNumber: number | null; valueDate: string | null } | null; + next: { valueText: string | null; valueNumber: number | null; valueDate: string | null }; + }; + + const changedValues: ChangedValue[] = dto.values.reduce((accumulator, value) => { + const normalizedText = value.valueText?.trim() ?? ''; + const next = { + valueText: + card.valueType === PredictionValueType.TEXT || + card.valueType === PredictionValueType.MULTI_TEXT || + card.valueType === PredictionValueType.SELECT + ? normalizedText || null + : value.valueText ?? null, + valueNumber: value.valueNumber ?? null, + valueDate: this.normalizeDateOnly(value.valueDate), + }; + + const previousRaw = existing?.values.find((item) => item.fieldId === value.fieldId); + const previous = previousRaw + ? { + valueText: previousRaw.valueText, + valueNumber: previousRaw.valueNumber, + valueDate: this.normalizeDateOnly(previousRaw.valueDate), + } + : null; + + if (!previous) { + accumulator.push({ + fieldId: value.fieldId, + previous: null, + next, + }); + return accumulator; + } + + if (this.areEntryValuesEqual(previous, next)) { + return accumulator; + } + + accumulator.push({ + fieldId: value.fieldId, + previous, + next, + }); + + return accumulator; + }, []); + + if (existing && changedValues.length === 0) { + return this.prismaService.predictionEntry.findUnique({ + where: { id: existing.id }, + include: { + card: true, + values: { include: { field: true } }, + }, + }); + } + + const entry = existing + ? await this.prismaService.predictionEntry.update({ + where: { id: existing.id }, + data: { + gameId: game.id, + projectId: scopedProjectId, + selectedBabyIndex, + values: { + upsert: changedValues.map((value) => ({ + where: { + entryId_fieldId: { + entryId: existing.id, + fieldId: value.fieldId, + }, + }, + update: { + valueText: value.next.valueText, + valueNumber: value.next.valueNumber, + valueDate: value.next.valueDate ? new Date(value.next.valueDate) : null, + }, + create: { + fieldId: value.fieldId, + valueText: value.next.valueText, + valueNumber: value.next.valueNumber, + valueDate: value.next.valueDate ? new Date(value.next.valueDate) : undefined, + }, + })), + }, + }, + }) + : await this.prismaService.predictionEntry.create({ + data: { + userId, + cardId, + gameId: game.id, + projectId: scopedProjectId, + selectedBabyIndex, + values: { + create: dto.values.map((value) => ({ + fieldId: value.fieldId, + valueText: value.valueText ?? null, + valueNumber: value.valueNumber ?? null, + valueDate: value.valueDate ? new Date(value.valueDate) : undefined, + })), + }, + }, + }); + + const user = await this.prismaService.user.findUnique({ where: { id: userId } }); + const actorName = user?.displayName ?? user?.username ?? 'Membre'; + + const fieldLabelById = new Map(card.fields.map((field) => [field.id, field.label])); + const changedSummary = (existing ? changedValues : dto.values.map((value) => ({ + fieldId: value.fieldId, + next: { + valueText: value.valueText ?? null, + valueNumber: value.valueNumber ?? null, + valueDate: this.normalizeDateOnly(value.valueDate), + }, + }))) + .map((item) => `${fieldLabelById.get(item.fieldId) ?? item.fieldId}: ${this.formatValueForMessage(item.next)}`) + .join(' | '); + + await this.prismaService.predictionEntryHistory.create({ + data: { + entryId: entry.id, + userId, + snapshot: (existing ? changedValues : dto.values.map((value) => ({ + fieldId: value.fieldId, + next: { + valueText: value.valueText ?? null, + valueNumber: value.valueNumber ?? null, + valueDate: this.normalizeDateOnly(value.valueDate), + }, + }))).map((item) => ({ + fieldId: item.fieldId, + valueText: item.next.valueText, + valueNumber: item.next.valueNumber, + valueDate: item.next.valueDate, + })), + message: existing + ? `${actorName} a mis a jour ${card.title}: ${changedSummary}` + : `${actorName} a cree son pronostic sur ${card.title}`, + }, + }); + + await this.createActivity({ + projectId: scopedProjectId, + userId, + entryId: entry.id, + cardId, + type: existing + ? PredictionActivityType.PREDICTION_UPDATED + : PredictionActivityType.PREDICTION_CREATED, + message: existing + ? `${actorName} a change ${card.title}: ${changedSummary}` + : `${actorName} a pronostique ${card.title}`, + }); + + return this.prismaService.predictionEntry.findUnique({ + where: { id: entry.id }, + include: { + card: true, + values: { include: { field: true } }, + }, + }); + } + + async listMyEntries(userId: string, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId); + + return this.prismaService.predictionEntry.findMany({ + where: { + userId, + ...this.buildProjectScopeFilter(scopedProjectId), + }, + include: { + card: true, + values: { include: { field: true } }, + history: { orderBy: { createdAt: 'desc' }, take: 20 }, + scores: { include: { field: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async listActivity(userId: string, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId); + + return this.prismaService.predictionActivity.findMany({ + where: this.buildProjectScopeFilter(scopedProjectId), + include: { + user: true, + card: true, + entry: { + include: { + values: { + include: { field: true }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 200, + }); + } + + async setCardOutcomes(userId: string, cardId: string, dto: SetOutcomesDto, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId, true); + const selectedBabyIndex = this.normalizeSelectedBabyIndex(dto.selectedBabyIndex); + + const game = await this.getGameForProject(scopedProjectId); + + if (!game || game.status !== 'OPEN') { + throw new ForbiddenException('Les resultats finaux sont verrouilles apres la cloture du concours'); + } + + const card = await this.getCard(cardId, scopedProjectId); + const fieldById = new Set(card.fields.map((field) => field.id)); + + for (const value of dto.values) { + if (!fieldById.has(value.fieldId)) { + throw new BadRequestException('Unknown field in outcomes'); + } + + const existingOutcome = await this.prismaService.predictionOutcome.findFirst({ + where: { + cardId, + fieldId: value.fieldId, + ...this.buildProjectScopeFilter(scopedProjectId), + ...this.buildBabyScopeFilter(selectedBabyIndex), + }, + }); + + if (existingOutcome) { + await this.prismaService.predictionOutcome.update({ + where: { id: existingOutcome.id }, + data: { + projectId: scopedProjectId, + selectedBabyIndex, + valueText: value.valueText, + valueNumber: value.valueNumber, + valueDate: value.valueDate ? new Date(value.valueDate) : null, + setById: userId, + setAt: new Date(), + }, + }); + continue; + } + + await this.prismaService.predictionOutcome.create({ + data: { + projectId: scopedProjectId, + cardId, + fieldId: value.fieldId, + selectedBabyIndex, + valueText: value.valueText, + valueNumber: value.valueNumber, + valueDate: value.valueDate ? new Date(value.valueDate) : undefined, + setById: userId, + }, + }); + } + + await this.createActivity({ + projectId: scopedProjectId, + userId, + cardId, + type: PredictionActivityType.OUTCOME_SET, + message: `Resultat reel defini pour ${card.title} (Bebe ${selectedBabyIndex})`, + }); + + return this.prismaService.predictionOutcome.findMany({ + where: { + cardId, + ...this.buildProjectScopeFilter(scopedProjectId), + ...this.buildBabyScopeFilter(selectedBabyIndex), + }, + include: { field: true }, + }); + } + + async suggestScoresForCard( + userId: string, + cardId: string, + projectId?: string, + selectedBabyIndex?: number, + ) { + const scopedProjectId = this.resolveProjectId(projectId); + const normalizedBabyIndex = this.normalizeSelectedBabyIndex(selectedBabyIndex); + await this.assertProjectAccess(userId, scopedProjectId, true); + + const game = await this.getGameForProject(scopedProjectId); + + if (!game || game.status !== 'CLOSED') { + throw new ForbiddenException('Le concours doit etre cloture pour lancer le calcul des points'); + } + + const isFinalized = await this.isContestFinalizedSinceLastOpen(scopedProjectId); + if (isFinalized) { + throw new ForbiddenException('Le concours est deja valide'); + } + + const card = await this.prismaService.predictionCard.findFirst({ + where: { + id: cardId, + ...this.buildProjectScopeFilter(scopedProjectId), + }, + include: { + fields: true, + outcomes: { + where: this.buildBabyScopeFilter(normalizedBabyIndex), + }, + entries: { + where: this.buildBabyScopeFilter(normalizedBabyIndex), + include: { + values: true, + user: true, + }, + }, + }, + }); + + if (!card) { + throw new NotFoundException('Card not found'); + } + + const outcomesByField = new Map(card.outcomes.map((outcome) => [outcome.fieldId, outcome])); + const allOutcomeTexts = card.outcomes + .map((outcome) => this.normalizeText(outcome.valueText)) + .filter((value) => value.length > 0); + + const nearestDiffByField = new Map(); + if (card.valueType === PredictionValueType.NUMBER) { + for (const field of card.fields) { + const outcome = outcomesByField.get(field.id); + if (!outcome || outcome.valueNumber == null) { + continue; + } + + const distances = card.entries + .map((entry) => entry.values.find((value) => value.fieldId === field.id)?.valueNumber) + .filter((value): value is number => value != null) + .map((value) => Math.abs(value - outcome.valueNumber!)); + + if (distances.length === 0) { + continue; + } + + nearestDiffByField.set(field.id, Math.min(...distances)); + } + } + + for (const entry of card.entries) { + for (const field of card.fields) { + const predictedValue = entry.values.find((value) => value.fieldId === field.id); + const outcome = outcomesByField.get(field.id); + + let suggestedPoints = 0; + + if (predictedValue && outcome) { + if (card.valueType === PredictionValueType.NUMBER) { + const predicted = predictedValue.valueNumber; + const actual = outcome.valueNumber; + const nearestDiff = nearestDiffByField.get(field.id); + if (predicted != null && actual != null && nearestDiff != null) { + const diff = Math.abs(predicted - actual); + if (Math.abs(diff - nearestDiff) < 0.0000001) { + suggestedPoints = field.points; + } + } + } + + if ( + card.valueType === PredictionValueType.TEXT || + card.valueType === PredictionValueType.SELECT + ) { + if ( + this.normalizeText(predictedValue.valueText) === + this.normalizeText(outcome.valueText) + ) { + suggestedPoints = field.points; + } + } + + if (card.valueType === PredictionValueType.DATE) { + const predicted = predictedValue.valueDate?.toISOString(); + const actual = outcome.valueDate?.toISOString(); + if (predicted && actual && predicted === actual) { + suggestedPoints = field.points; + } + } + } + + if (card.valueType === PredictionValueType.MULTI_TEXT && predictedValue?.valueText) { + const normalized = this.normalizeText(predictedValue.valueText); + + if (field.isPrimary) { + if (allOutcomeTexts.includes(normalized)) { + suggestedPoints = field.points; + } + } else if (allOutcomeTexts.includes(normalized)) { + suggestedPoints = field.points; + } + } + + await this.prismaService.predictionFieldScore.upsert({ + where: { + entryId_fieldId: { + entryId: entry.id, + fieldId: field.id, + }, + }, + update: { + projectId: scopedProjectId, + suggestedPoints, + }, + create: { + projectId: scopedProjectId, + entryId: entry.id, + fieldId: field.id, + suggestedPoints, + }, + }); + } + } + + await this.createActivity({ + projectId: scopedProjectId, + userId, + cardId, + type: PredictionActivityType.SCORE_SUGGESTED, + message: `Suggestion de score calculee pour ${card.title} (Bebe ${normalizedBabyIndex})`, + }); + + return this.prismaService.predictionEntry.findMany({ + where: { + cardId, + ...this.buildProjectScopeFilter(scopedProjectId), + ...this.buildBabyScopeFilter(normalizedBabyIndex), + }, + include: { + user: true, + scores: { include: { field: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async validateScores(userId: string, entryId: string, dto: ValidateScoresDto, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId, true); + + const game = await this.getGameForProject(scopedProjectId); + + if (!game || game.status !== 'CLOSED') { + throw new ForbiddenException('Le concours doit etre cloture pour valider des points'); + } + + const isFinalized = await this.isContestFinalizedSinceLastOpen(scopedProjectId); + if (isFinalized) { + throw new ForbiddenException('Le concours est deja valide'); + } + + const entry = await this.prismaService.predictionEntry.findFirst({ + where: { + id: entryId, + ...this.buildProjectScopeFilter(scopedProjectId), + }, + }); + + if (!entry) { + throw new NotFoundException('Entry not found'); + } + + for (const score of dto.scores) { + await this.prismaService.predictionFieldScore.upsert({ + where: { + entryId_fieldId: { + entryId, + fieldId: score.fieldId, + }, + }, + update: { + projectId: scopedProjectId, + awardedPoints: score.awardedPoints, + isValidated: true, + validatedById: userId, + validatedAt: new Date(), + note: score.note, + }, + create: { + projectId: scopedProjectId, + entryId, + fieldId: score.fieldId, + suggestedPoints: 0, + awardedPoints: score.awardedPoints, + isValidated: true, + validatedById: userId, + validatedAt: new Date(), + note: score.note, + }, + }); + } + + const scores = await this.prismaService.predictionFieldScore.findMany({ + where: { entryId }, + }); + + const totalPoints = scores.reduce( + (total, score) => total + (score.awardedPoints ?? 0), + 0, + ); + + const updatedEntry = await this.prismaService.predictionEntry.update({ + where: { id: entryId }, + data: { + totalPoints, + isScored: true, + }, + }); + + await this.createActivity({ + projectId: scopedProjectId, + userId, + entryId, + cardId: entry.cardId, + type: PredictionActivityType.SCORE_VALIDATED, + message: 'Score valide manuellement', + }); + + return updatedEntry; + } + + async finalizeGame(userId: string, projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + await this.assertProjectAccess(userId, scopedProjectId, true); + + const game = await this.getGameForProject(scopedProjectId); + + if (!game || game.status !== 'CLOSED') { + throw new ForbiddenException('Le concours doit etre cloture avant la validation finale'); + } + + const alreadyFinalized = await this.isContestFinalizedSinceLastOpen(scopedProjectId); + if (alreadyFinalized) { + throw new BadRequestException('Le concours est deja valide'); + } + + const cards = await this.assertRequiredOutcomesAreFilled(scopedProjectId); + const activeCardIds = cards.map((card) => card.id); + + if (activeCardIds.length === 0) { + throw new BadRequestException('Aucune categorie active a valider'); + } + + const entries = await this.prismaService.predictionEntry.findMany({ + where: { + cardId: { in: activeCardIds }, + ...this.buildProjectScopeFilter(scopedProjectId), + }, + select: { + id: true, + isScored: true, + }, + }); + + if (entries.length === 0) { + throw new BadRequestException('Aucun pronostic participant a valider'); + } + + const remainingToScore = entries.filter((entry) => !entry.isScored).length; + if (remainingToScore > 0) { + throw new BadRequestException( + `Validation impossible: ${remainingToScore} pronostic(s) n'ont pas encore ete notes`, + ); + } + + const scoreboard = await this.getScoreboard(scopedProjectId); + if (scoreboard.length === 0) { + throw new BadRequestException('Classement indisponible'); + } + + const winnerPoints = scoreboard[0].totalPoints; + const winners = scoreboard.filter((participant) => participant.totalPoints === winnerPoints); + + await this.createActivity({ + projectId: scopedProjectId, + userId, + type: PredictionActivityType.SCORE_VALIDATED, + message: `${this.contestFinalizedMarker} Gagnant(s): ${winners + .map((winner) => winner.displayName ?? winner.username) + .join(', ')} (${winnerPoints} pts)`, + }); + + return { + finalized: true, + winners, + scoreboard, + }; + } + + async getBoard(userId: string, projectId?: string, selectedBabyIndex?: number) { + const scopedProjectId = this.resolveProjectId(projectId); + const normalizedSelectedBabyIndex = + selectedBabyIndex == null + ? undefined + : this.normalizeSelectedBabyIndex(selectedBabyIndex); + await this.assertProjectAccess(userId, scopedProjectId); + + const gamePromise = + scopedProjectId === DEFAULT_PROJECT_ID + ? this.prismaService.predictionGame.findUnique({ where: { id: 'singleton' } }) + : this.prismaService.predictionGame.findFirst({ where: { projectId: scopedProjectId } }); + + const [game, isFinalized] = await Promise.all([ + gamePromise, + this.isContestFinalizedSinceLastOpen(scopedProjectId), + ]); + + const scopeFilter = this.buildProjectScopeFilter(scopedProjectId); + + const cards = await this.prismaService.predictionCard.findMany({ + where: { + isActive: true, + ...scopeFilter, + }, + include: { + fields: { orderBy: { sortOrder: 'asc' } }, + options: { orderBy: { sortOrder: 'asc' } }, + outcomes: { + ...(normalizedSelectedBabyIndex != null + ? { + where: this.buildBabyScopeFilter(normalizedSelectedBabyIndex), + } + : {}), + include: { field: true }, + orderBy: { field: { sortOrder: 'asc' } }, + }, + entries: { + ...(normalizedSelectedBabyIndex != null + ? { + where: this.buildBabyScopeFilter(normalizedSelectedBabyIndex), + } + : {}), + include: { + user: true, + values: { include: { field: true } }, + }, + }, + }, + orderBy: { sortOrder: 'asc' }, + }); + + const byCard = cards.map((card) => { + const values = card.entries.flatMap((entry) => entry.values); + + let averageNumber: number | null = null; + if (card.valueType === PredictionValueType.NUMBER) { + const numbers = values + .map((value) => value.valueNumber) + .filter((value): value is number => value != null); + if (numbers.length > 0) { + averageNumber = numbers.reduce((total, value) => total + value, 0) / numbers.length; + } + } + + const textCounts = new Map(); + for (const value of values) { + if (!value.valueText) { + continue; + } + const normalized = this.normalizeText(value.valueText); + if (!normalized) { + continue; + } + textCounts.set(normalized, (textCounts.get(normalized) ?? 0) + 1); + } + + const topTexts = Array.from(textCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([value, count]) => ({ value, count })); + + return { + id: card.id, + title: card.title, + type: card.valueType, + unit: card.unit, + styleId: card.styleId, + totalPredictions: card.entries.length, + averageNumber, + topTexts, + outcomes: card.outcomes.map((outcome) => ({ + fieldId: outcome.fieldId, + fieldLabel: outcome.field.label, + selectedBabyIndex: outcome.selectedBabyIndex, + valueText: outcome.valueText, + valueNumber: outcome.valueNumber, + valueDate: outcome.valueDate, + })), + entries: card.entries.map((entry) => ({ + entryId: entry.id, + userId: entry.userId, + selectedBabyIndex: entry.selectedBabyIndex, + username: entry.user.username, + displayName: entry.user.displayName, + profileImageUrl: entry.user.profileImageUrl, + profileBgColor: entry.user.profileBgColor, + isScored: entry.isScored, + totalPoints: entry.totalPoints, + values: entry.values.map((value) => ({ + fieldId: value.fieldId, + fieldLabel: value.field.label, + valueText: value.valueText, + valueNumber: value.valueNumber, + valueDate: value.valueDate, + })), + updatedAt: entry.updatedAt, + })), + }; + }); + + return { + game: game + ? { + ...game, + finalized: game.status === 'CLOSED' && isFinalized, + } + : null, + cards: byCard, + }; + } + + async getScoreboard(projectId?: string) { + const scopedProjectId = this.resolveProjectId(projectId); + + const entries = await this.prismaService.predictionEntry.findMany({ + where: this.buildProjectScopeFilter(scopedProjectId), + include: { user: true }, + }); + + const map = new Map< + string, + { + userId: string; + username: string; + displayName: string | null; + profileImageUrl: string | null; + profileBgColor: string | null; + totalPoints: number; + } + >(); + + for (const entry of entries) { + const existing = map.get(entry.userId); + if (!existing) { + map.set(entry.userId, { + userId: entry.userId, + username: entry.user.username, + displayName: entry.user.displayName, + profileImageUrl: entry.user.profileImageUrl, + profileBgColor: entry.user.profileBgColor, + totalPoints: entry.totalPoints, + }); + continue; + } + existing.totalPoints += entry.totalPoints; + } + + return Array.from(map.values()).sort((a, b) => b.totalPoints - a.totalPoints); + } +} diff --git a/apps/api/src/predictions/project-predictions.controller.ts b/apps/api/src/predictions/project-predictions.controller.ts new file mode 100644 index 0000000..554300d --- /dev/null +++ b/apps/api/src/predictions/project-predictions.controller.ts @@ -0,0 +1,171 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Put, + Req, + UseGuards, +} from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { AuthenticatedRequest } from '../auth/jwt-auth.guard'; +import { Roles } from '../auth/roles.decorator'; +import { RolesGuard } from '../auth/roles.guard'; +import { CreateCardDto } from './dto/create-card.dto'; +import { + SetOutcomesDto, + UpsertPredictionEntryDto, + ValidateScoresDto, +} from './dto/prediction-value.dto'; +import { UpdateCardDto } from './dto/update-card.dto'; +import { PredictionsService } from './predictions.service'; + +@Controller('projects/:projectId/predictions') +@UseGuards(JwtAuthGuard) +export class ProjectPredictionsController { + constructor(private readonly predictionsService: PredictionsService) {} + + @Get('cards') + listCards(@Param('projectId') projectId: string) { + return this.predictionsService.listCards(false, projectId); + } + + @Get('board') + board( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Query('selectedBabyIndex') selectedBabyIndex?: string, + ) { + return this.predictionsService.getBoard( + request.user!.sub, + projectId, + selectedBabyIndex ? Number(selectedBabyIndex) : undefined, + ); + } + + @Get('activity') + activity(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) { + return this.predictionsService.listActivity(request.user!.sub, projectId); + } + + @Get('scoreboard') + scoreboard(@Param('projectId') projectId: string) { + return this.predictionsService.getScoreboard(projectId); + } + + @Get('my-entries') + myEntries(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) { + return this.predictionsService.listMyEntries(request.user!.sub, projectId); + } + + @Put('cards/:cardId/my-entry') + upsertMyEntry( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('cardId') cardId: string, + @Body() dto: UpsertPredictionEntryDto, + ) { + return this.predictionsService.upsertMyPrediction(request.user!.sub, cardId, dto, projectId); + } + + @Post('game/close') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + closeGame(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) { + return this.predictionsService.closeGame(request.user!.sub, projectId); + } + + @Post('game/open') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + openGame(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) { + return this.predictionsService.openGame(request.user!.sub, projectId); + } + + @Post('game/finalize') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + finalizeGame(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) { + return this.predictionsService.finalizeGame(request.user!.sub, projectId); + } + + @Post('cards') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + createCard( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Body() dto: CreateCardDto, + ) { + return this.predictionsService.createCard(request.user!.sub, dto, projectId); + } + + @Patch('cards/:cardId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + updateCard( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('cardId') cardId: string, + @Body() dto: UpdateCardDto, + ) { + return this.predictionsService.updateCard(request.user!.sub, cardId, dto, projectId); + } + + @Delete('cards/:cardId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + deleteCard( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('cardId') cardId: string, + ) { + return this.predictionsService.deleteCard(request.user!.sub, cardId, projectId); + } + + @Post('cards/:cardId/outcomes') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + setOutcomes( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('cardId') cardId: string, + @Body() dto: SetOutcomesDto, + ) { + return this.predictionsService.setCardOutcomes(request.user!.sub, cardId, dto, projectId); + } + + @Post('cards/:cardId/suggest-scores') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + suggestScores( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('cardId') cardId: string, + @Query('selectedBabyIndex') selectedBabyIndex?: string, + ) { + return this.predictionsService.suggestScoresForCard( + request.user!.sub, + cardId, + projectId, + selectedBabyIndex ? Number(selectedBabyIndex) : undefined, + ); + } + + @Patch('entries/:entryId/scores') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + validateScores( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('entryId') entryId: string, + @Body() dto: ValidateScoresDto, + ) { + return this.predictionsService.validateScores(request.user!.sub, entryId, dto, projectId); + } +} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..0d0faf8 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} \ No newline at end of file diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..a53ce76 --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -0,0 +1,9 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit { + async onModuleInit() { + await this.$connect(); + } +} \ No newline at end of file diff --git a/apps/api/src/projects/dto/assign-project-participant.dto.ts b/apps/api/src/projects/dto/assign-project-participant.dto.ts new file mode 100644 index 0000000..46bfb18 --- /dev/null +++ b/apps/api/src/projects/dto/assign-project-participant.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class AssignProjectParticipantDto { + @IsString() + @MinLength(1) + userId!: string; +} diff --git a/apps/api/src/projects/dto/clone-project.dto.ts b/apps/api/src/projects/dto/clone-project.dto.ts new file mode 100644 index 0000000..65f7b67 --- /dev/null +++ b/apps/api/src/projects/dto/clone-project.dto.ts @@ -0,0 +1,18 @@ +import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CloneProjectDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(120) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(400) + description?: string; + + @IsOptional() + @IsBoolean() + includeParticipants?: boolean; +} diff --git a/apps/api/src/projects/dto/create-project.dto.ts b/apps/api/src/projects/dto/create-project.dto.ts new file mode 100644 index 0000000..cda9873 --- /dev/null +++ b/apps/api/src/projects/dto/create-project.dto.ts @@ -0,0 +1,54 @@ +import { ProjectStatus } from '@prisma/client'; +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsEnum, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { MAX_PROJECT_BABIES } from '../project-scope.constants'; + +export class CreateProjectDto { + @IsString() + @MinLength(3) + @MaxLength(120) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(400) + description?: string; + + @IsOptional() + @IsInt() + @Min(1) + babyCount?: number; + + @IsOptional() + @IsEnum(ProjectStatus) + status?: ProjectStatus; + + @IsOptional() + @IsArray() + @ArrayUnique() + @ArrayMaxSize(MAX_PROJECT_BABIES) + @IsString({ each: true }) + babyLabels?: string[]; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsString({ each: true }) + enabledLegacyCardCodes?: string[]; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsString({ each: true }) + participantUserIds?: string[]; +} diff --git a/apps/api/src/projects/dto/update-project.dto.ts b/apps/api/src/projects/dto/update-project.dto.ts new file mode 100644 index 0000000..5a19497 --- /dev/null +++ b/apps/api/src/projects/dto/update-project.dto.ts @@ -0,0 +1,43 @@ +import { ProjectStatus } from '@prisma/client'; +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsEnum, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { MAX_PROJECT_BABIES } from '../project-scope.constants'; + +export class UpdateProjectDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(120) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(400) + description?: string; + + @IsOptional() + @IsInt() + @Min(1) + babyCount?: number; + + @IsOptional() + @IsEnum(ProjectStatus) + status?: ProjectStatus; + + @IsOptional() + @IsArray() + @ArrayUnique() + @ArrayMaxSize(MAX_PROJECT_BABIES) + @IsString({ each: true }) + babyLabels?: string[]; +} diff --git a/apps/api/src/projects/project-scope.constants.ts b/apps/api/src/projects/project-scope.constants.ts new file mode 100644 index 0000000..da3e49b --- /dev/null +++ b/apps/api/src/projects/project-scope.constants.ts @@ -0,0 +1,9 @@ +export const DEFAULT_WORKSPACE_ID = 'default_workspace'; +export const DEFAULT_WORKSPACE_SLUG = 'default-workspace'; +export const DEFAULT_WORKSPACE_NAME = 'Espace principal'; + +export const DEFAULT_PROJECT_ID = 'default_project'; +export const DEFAULT_PROJECT_NAME = 'Concours principal'; + +export const MAX_WORKSPACE_ADMINS = 3; +export const MAX_PROJECT_BABIES = 3; diff --git a/apps/api/src/projects/projects.controller.ts b/apps/api/src/projects/projects.controller.ts new file mode 100644 index 0000000..2857479 --- /dev/null +++ b/apps/api/src/projects/projects.controller.ts @@ -0,0 +1,205 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + MaxFileSizeValidator, + Param, + Patch, + Post, + ParseFilePipe, + Req, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { extname, join } from 'path'; +import { existsSync, mkdirSync, readdirSync } from 'fs'; +import type { AuthenticatedRequest } from '../auth/jwt-auth.guard'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Roles } from '../auth/roles.decorator'; +import { RolesGuard } from '../auth/roles.guard'; +import { AssignProjectParticipantDto } from './dto/assign-project-participant.dto'; +import { CloneProjectDto } from './dto/clone-project.dto'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectDto } from './dto/update-project.dto'; +import { ProjectsService } from './projects.service'; + +const uploadsDir = join(process.cwd(), 'uploads'); + +const storage = diskStorage({ + destination: (_req, _file, callback) => { + if (!existsSync(uploadsDir)) { + mkdirSync(uploadsDir, { recursive: true }); + } + callback(null, uploadsDir); + }, + filename: (_req, file, callback) => { + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + callback(null, `project-${uniqueSuffix}${extname(file.originalname)}`); + }, +}); + +@Controller('projects') +@UseGuards(JwtAuthGuard) +export class ProjectsController { + private readonly defaultProjectAvatars: string[]; + + constructor(private readonly projectsService: ProjectsService) { + const avatarsDir = join(process.cwd(), 'public', 'default-project-avatars'); + const imageExts = ['.png', '.jpg', '.jpeg', '.webp']; + try { + this.defaultProjectAvatars = readdirSync(avatarsDir) + .filter((f) => imageExts.includes(extname(f).toLowerCase())) + .sort() + .map((f) => `/default-project-avatars/${f}`); + } catch { + this.defaultProjectAvatars = []; + } + } + + @Get() + listForUser(@Req() request: AuthenticatedRequest) { + return this.projectsService.listForUser(request.user!.sub); + } + + @Get(':projectId') + getById(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) { + return this.projectsService.getByIdForUser(request.user!.sub, projectId); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + create(@Req() request: AuthenticatedRequest, @Body() dto: CreateProjectDto) { + return this.projectsService.create(request.user!.sub, dto); + } + + @Patch(':projectId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + update( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Body() dto: UpdateProjectDto, + ) { + return this.projectsService.update(request.user!.sub, projectId, dto); + } + + @Post(':projectId/clone') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + clone( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Body() dto: CloneProjectDto, + ) { + return this.projectsService.clone(request.user!.sub, projectId, dto); + } + + @Get(':projectId/participants') + listParticipants(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) { + return this.projectsService.listParticipants(request.user!.sub, projectId); + } + + @Post(':projectId/participants') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + addParticipant( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Body() dto: AssignProjectParticipantDto, + ) { + return this.projectsService.addParticipant(request.user!.sub, projectId, dto); + } + + @Delete(':projectId/participants/:participantUserId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + removeParticipant( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('participantUserId') participantUserId: string, + ) { + return this.projectsService.removeParticipant( + request.user!.sub, + projectId, + participantUserId, + ); + } + + @Get(':projectId/default-project-avatars') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + listDefaultProjectAvatars() { + return this.defaultProjectAvatars; + } + + @Post(':projectId/photo') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @UseInterceptors(FileInterceptor('file', { storage })) + uploadProjectPhoto( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Body() body: { bgColor?: string }, + @UploadedFile( + new ParseFilePipe({ + validators: [new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 })], + fileIsRequired: false, + }), + ) + file?: Express.Multer.File, + ) { + if (file) { + const allowedMimeTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']; + if (!allowedMimeTypes.includes(file.mimetype)) { + throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.'); + } + } + + if (!file && !body.bgColor) { + throw new BadRequestException('Envoyez une image ou une couleur de fond.'); + } + + return this.projectsService.updateProjectImage( + request.user!.sub, + projectId, + file ? `/uploads/${file.filename}` : undefined, + body.bgColor ?? undefined, + ); + } + + @Delete(':projectId/photo') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + deleteProjectPhoto( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + ) { + return this.projectsService.updateProjectImage(request.user!.sub, projectId, null, null); + } + + @Patch(':projectId/avatar') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + setProjectDefaultAvatar( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Body() body: { avatarUrl: string; bgColor?: string }, + ) { + if (!body.avatarUrl || !this.defaultProjectAvatars.includes(body.avatarUrl)) { + throw new BadRequestException('Avatar par defaut invalide.'); + } + return this.projectsService.updateProjectImage( + request.user!.sub, + projectId, + body.avatarUrl, + body.bgColor, + ); + } +} diff --git a/apps/api/src/projects/projects.module.ts b/apps/api/src/projects/projects.module.ts new file mode 100644 index 0000000..c2b6364 --- /dev/null +++ b/apps/api/src/projects/projects.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ProjectsController } from './projects.controller'; +import { ProjectsService } from './projects.service'; + +@Module({ + imports: [AuthModule], + controllers: [ProjectsController], + providers: [ProjectsService], + exports: [ProjectsService], +}) +export class ProjectsModule {} diff --git a/apps/api/src/projects/projects.service.ts b/apps/api/src/projects/projects.service.ts new file mode 100644 index 0000000..7702dbe --- /dev/null +++ b/apps/api/src/projects/projects.service.ts @@ -0,0 +1,670 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { GameStatus, ProjectStatus, UserRole } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { AssignProjectParticipantDto } from './dto/assign-project-participant.dto'; +import { CloneProjectDto } from './dto/clone-project.dto'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectDto } from './dto/update-project.dto'; +import { + DEFAULT_PROJECT_ID, + DEFAULT_WORKSPACE_ID, + DEFAULT_WORKSPACE_NAME, + DEFAULT_WORKSPACE_SLUG, + MAX_PROJECT_BABIES, +} from './project-scope.constants'; + +@Injectable() +export class ProjectsService implements OnModuleInit { + constructor(private readonly prismaService: PrismaService) {} + + async onModuleInit() { + await this.ensureDefaultWorkspace(); + } + + private clampBabyCount(value?: number) { + if (!value) { + return 1; + } + + return Math.min(MAX_PROJECT_BABIES, Math.max(1, Math.trunc(value))); + } + + private mapProjectStatusToGameStatus(status: ProjectStatus): GameStatus { + if (status === ProjectStatus.CLOSED || status === ProjectStatus.FINALIZED) { + return GameStatus.CLOSED; + } + + return GameStatus.OPEN; + } + + private normalizeSlug(value: string) { + const normalized = value + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized || `workspace-${Date.now()}`; + } + + private async ensureWorkspaceForAdmin(userId: string) { + const user = await this.prismaService.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true, workspaceId: true, username: true }, + }); + + if (!user) { + throw new NotFoundException('Utilisateur introuvable'); + } + + if (user.role !== UserRole.ADMIN) { + throw new ForbiddenException('Seuls les comptes admin peuvent creer des projets'); + } + + if (user.workspaceId) { + return user.workspaceId; + } + + const baseSlug = this.normalizeSlug(user.username); + const workspace = await this.prismaService.workspace.create({ + data: { + name: `Espace ${user.username}`, + slug: `${baseSlug}-${Date.now()}`, + ownerUserId: user.id, + }, + }); + + await this.prismaService.user.update({ + where: { id: user.id }, + data: { workspaceId: workspace.id }, + }); + + return workspace.id; + } + + private async ensureProjectBabies(projectId: string, babyCount: number, babyLabels?: string[]) { + const safeCount = this.clampBabyCount(babyCount); + + for (let index = 1; index <= safeCount; index += 1) { + await this.prismaService.projectBaby.upsert({ + where: { + projectId_babyIndex: { + projectId, + babyIndex: index, + }, + }, + update: { + label: babyLabels?.[index - 1] ?? `Bebe ${index}`, + }, + create: { + projectId, + babyIndex: index, + label: babyLabels?.[index - 1] ?? `Bebe ${index}`, + }, + }); + } + + await this.prismaService.projectBaby.deleteMany({ + where: { + projectId, + babyIndex: { gt: safeCount }, + }, + }); + } + + private async assertProjectAccess( + userId: string, + projectId: string, + options?: { adminOnly?: boolean }, + ) { + const [user, project, membership] = await Promise.all([ + this.prismaService.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true, workspaceId: true }, + }), + this.prismaService.project.findUnique({ + where: { id: projectId }, + }), + this.prismaService.projectMembership.findUnique({ + where: { userId }, + select: { id: true, projectId: true }, + }), + ]); + + if (!user) { + throw new NotFoundException('Utilisateur introuvable'); + } + + if (!project) { + throw new NotFoundException('Projet introuvable'); + } + + if (!user.workspaceId || user.workspaceId !== project.workspaceId) { + throw new ForbiddenException('Acces refuse a ce projet'); + } + + if (user.role === UserRole.ADMIN) { + return { user, project, isAdmin: true }; + } + + if (options?.adminOnly) { + throw new ForbiddenException('Action reservee aux admins'); + } + + if (!membership || membership.projectId !== project.id) { + throw new ForbiddenException('Vous n etes pas rattache a ce projet'); + } + + return { user, project, isAdmin: false }; + } + + private async copyCardsFromProject(params: { + sourceProjectId: string; + targetProjectId: string; + targetGameId: string; + actorUserId: string; + filterLegacyCodes?: string[]; + }) { + const sourceProjectFilter = + params.sourceProjectId === DEFAULT_PROJECT_ID + ? { OR: [{ projectId: params.sourceProjectId }, { projectId: null }] } + : { projectId: params.sourceProjectId }; + + const sourceCards = await this.prismaService.predictionCard.findMany({ + where: { + ...sourceProjectFilter, + ...(params.filterLegacyCodes?.length + ? { + code: { + in: params.filterLegacyCodes, + }, + } + : {}), + }, + include: { + fields: { orderBy: { sortOrder: 'asc' } }, + options: { orderBy: { sortOrder: 'asc' } }, + }, + orderBy: { sortOrder: 'asc' }, + }); + + for (const sourceCard of sourceCards) { + const newCard = await this.prismaService.predictionCard.create({ + data: { + gameId: params.targetGameId, + projectId: params.targetProjectId, + code: sourceCard.code, + title: sourceCard.title, + description: sourceCard.description, + type: sourceCard.type, + valueType: sourceCard.valueType, + styleId: sourceCard.styleId, + unit: sourceCard.unit, + isActive: sourceCard.isActive, + isDeletable: sourceCard.isDeletable, + sortOrder: sourceCard.sortOrder, + basePoints: sourceCard.basePoints, + createdById: params.actorUserId, + }, + }); + + for (const field of sourceCard.fields) { + await this.prismaService.predictionCardField.create({ + data: { + cardId: newCard.id, + label: field.label, + sortOrder: field.sortOrder, + points: field.points, + isPrimary: field.isPrimary, + isRequired: field.isRequired, + minNumber: field.minNumber, + maxNumber: field.maxNumber, + stepNumber: field.stepNumber, + }, + }); + } + + for (const option of sourceCard.options) { + await this.prismaService.predictionCardOption.create({ + data: { + cardId: newCard.id, + label: option.label, + value: option.value, + sortOrder: option.sortOrder, + }, + }); + } + } + } + + private async assignParticipantToProjectInternal( + actorUserId: string, + projectId: string, + dto: AssignProjectParticipantDto, + ) { + const project = await this.prismaService.project.findUnique({ + where: { id: projectId }, + select: { id: true, workspaceId: true }, + }); + + if (!project) { + throw new NotFoundException('Projet introuvable'); + } + + const participant = await this.prismaService.user.findUnique({ + where: { id: dto.userId }, + select: { + id: true, + role: true, + workspaceId: true, + username: true, + displayName: true, + }, + }); + + if (!participant) { + throw new NotFoundException('Compte participant introuvable'); + } + + if (participant.role === UserRole.ADMIN) { + throw new BadRequestException('Un admin ne peut pas etre rattache comme participant'); + } + + if (participant.workspaceId && participant.workspaceId !== project.workspaceId) { + throw new ForbiddenException('Ce participant depend d un autre espace'); + } + + if (!participant.workspaceId) { + await this.prismaService.user.update({ + where: { id: participant.id }, + data: { workspaceId: project.workspaceId }, + }); + } + + const existingMembership = await this.prismaService.projectMembership.findUnique({ + where: { userId: participant.id }, + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + role: true, + }, + }, + }, + }); + + if (existingMembership) { + if (existingMembership.projectId !== projectId) { + return this.prismaService.projectMembership.update({ + where: { userId: participant.id }, + data: { + projectId, + assignedById: actorUserId, + }, + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + role: true, + }, + }, + }, + }); + } + + return existingMembership; + } + + return this.prismaService.projectMembership.create({ + data: { + projectId, + userId: participant.id, + assignedById: actorUserId, + }, + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + role: true, + }, + }, + }, + }); + } + + async ensureDefaultWorkspace() { + const firstAdmin = await this.prismaService.user.findFirst({ + where: { role: UserRole.ADMIN }, + orderBy: { createdAt: 'asc' }, + select: { + id: true, + workspaceId: true, + }, + }); + + if (!firstAdmin) { + return; + } + + await this.prismaService.workspace.upsert({ + where: { id: DEFAULT_WORKSPACE_ID }, + update: { + name: DEFAULT_WORKSPACE_NAME, + slug: DEFAULT_WORKSPACE_SLUG, + ownerUserId: firstAdmin.id, + }, + create: { + id: DEFAULT_WORKSPACE_ID, + name: DEFAULT_WORKSPACE_NAME, + slug: DEFAULT_WORKSPACE_SLUG, + ownerUserId: firstAdmin.id, + }, + }); + + if (firstAdmin.workspaceId !== DEFAULT_WORKSPACE_ID) { + await this.prismaService.user.update({ + where: { id: firstAdmin.id }, + data: { workspaceId: DEFAULT_WORKSPACE_ID }, + }); + } + } + + async listForUser(userId: string) { + const user = await this.prismaService.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true, workspaceId: true }, + }); + + if (!user) { + throw new NotFoundException('Utilisateur introuvable'); + } + + if (!user.workspaceId) { + return []; + } + + if (user.role === UserRole.ADMIN) { + return this.prismaService.project.findMany({ + where: { workspaceId: user.workspaceId }, + include: { + babies: { orderBy: { babyIndex: 'asc' } }, + _count: { + select: { + memberships: true, + cards: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + const memberships = await this.prismaService.projectMembership.findMany({ + where: { userId }, + include: { + project: { + include: { + babies: { orderBy: { babyIndex: 'asc' } }, + _count: { + select: { + memberships: true, + cards: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return memberships.map((membership) => membership.project); + } + + async getByIdForUser(userId: string, projectId: string) { + await this.assertProjectAccess(userId, projectId); + + const project = await this.prismaService.project.findUnique({ + where: { id: projectId }, + include: { + babies: { orderBy: { babyIndex: 'asc' } }, + _count: { + select: { + memberships: true, + cards: true, + }, + }, + }, + }); + + if (!project) { + throw new NotFoundException('Projet introuvable'); + } + + return project; + } + + async create(adminUserId: string, dto: CreateProjectDto) { + const workspaceId = await this.ensureWorkspaceForAdmin(adminUserId); + const babyCount = this.clampBabyCount(dto.babyCount); + + const project = await this.prismaService.project.create({ + data: { + workspaceId, + createdById: adminUserId, + name: dto.name.trim(), + description: dto.description?.trim() || null, + status: dto.status ?? ProjectStatus.OPEN, + babyCount, + }, + }); + + await this.ensureProjectBabies(project.id, babyCount, dto.babyLabels); + + const game = await this.prismaService.predictionGame.create({ + data: { + id: project.id, + projectId: project.id, + title: `Pronostics ${project.name}`, + status: this.mapProjectStatusToGameStatus(project.status), + }, + }); + + await this.copyCardsFromProject({ + sourceProjectId: DEFAULT_PROJECT_ID, + targetProjectId: project.id, + targetGameId: game.id, + actorUserId: adminUserId, + filterLegacyCodes: dto.enabledLegacyCardCodes, + }); + + if (dto.participantUserIds?.length) { + for (const participantUserId of dto.participantUserIds) { + await this.assignParticipantToProjectInternal(adminUserId, project.id, { + userId: participantUserId, + }); + } + } + + return this.getByIdForUser(adminUserId, project.id); + } + + async update(adminUserId: string, projectId: string, dto: UpdateProjectDto) { + const { project } = await this.assertProjectAccess(adminUserId, projectId, { + adminOnly: true, + }); + + const babyCount = dto.babyCount ? this.clampBabyCount(dto.babyCount) : project.babyCount; + + const updatedProject = await this.prismaService.project.update({ + where: { id: projectId }, + data: { + name: dto.name?.trim(), + description: dto.description?.trim() ?? (dto.description === '' ? null : undefined), + status: dto.status, + babyCount, + }, + }); + + if (dto.babyCount || dto.babyLabels) { + await this.ensureProjectBabies(projectId, babyCount, dto.babyLabels); + } + + if (dto.status) { + await this.prismaService.predictionGame.updateMany({ + where: { projectId }, + data: { + status: this.mapProjectStatusToGameStatus(dto.status), + closedAt: dto.status === ProjectStatus.CLOSED ? new Date() : null, + reopenedAt: + dto.status === ProjectStatus.OPEN || dto.status === ProjectStatus.DRAFT + ? new Date() + : null, + }, + }); + } + + return this.getByIdForUser(adminUserId, updatedProject.id); + } + + async clone(adminUserId: string, sourceProjectId: string, dto: CloneProjectDto) { + const { project: sourceProject } = await this.assertProjectAccess( + adminUserId, + sourceProjectId, + { adminOnly: true }, + ); + + const sourceBabies = await this.prismaService.projectBaby.findMany({ + where: { projectId: sourceProjectId }, + orderBy: { babyIndex: 'asc' }, + }); + + const cloneName = dto.name?.trim() || `${sourceProject.name} (copie)`; + + const clonedProject = await this.prismaService.project.create({ + data: { + workspaceId: sourceProject.workspaceId, + createdById: adminUserId, + clonedFromProjectId: sourceProject.id, + name: cloneName, + description: dto.description?.trim() || sourceProject.description, + status: sourceProject.status, + babyCount: sourceProject.babyCount, + }, + }); + + await this.ensureProjectBabies( + clonedProject.id, + sourceProject.babyCount, + sourceBabies.map((baby) => baby.label ?? `Bebe ${baby.babyIndex}`), + ); + + const clonedGame = await this.prismaService.predictionGame.create({ + data: { + id: clonedProject.id, + projectId: clonedProject.id, + title: `Pronostics ${cloneName}`, + status: this.mapProjectStatusToGameStatus(clonedProject.status), + }, + }); + + await this.copyCardsFromProject({ + sourceProjectId, + targetProjectId: clonedProject.id, + targetGameId: clonedGame.id, + actorUserId: adminUserId, + }); + + if (dto.includeParticipants) { + const sourceMemberships = await this.prismaService.projectMembership.findMany({ + where: { projectId: sourceProjectId }, + select: { userId: true }, + }); + + for (const membership of sourceMemberships) { + await this.assignParticipantToProjectInternal(adminUserId, clonedProject.id, { + userId: membership.userId, + }); + } + } + + return this.getByIdForUser(adminUserId, clonedProject.id); + } + + async listParticipants(userId: string, projectId: string) { + await this.assertProjectAccess(userId, projectId); + + return this.prismaService.projectMembership.findMany({ + where: { projectId }, + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + profileImageUrl: true, + profileBgColor: true, + role: true, + createdAt: true, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + } + + async addParticipant( + adminUserId: string, + projectId: string, + dto: AssignProjectParticipantDto, + ) { + await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true }); + return this.assignParticipantToProjectInternal(adminUserId, projectId, dto); + } + + async removeParticipant(adminUserId: string, projectId: string, participantUserId: string) { + await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true }); + + const result = await this.prismaService.projectMembership.deleteMany({ + where: { + projectId, + userId: participantUserId, + }, + }); + + return { removed: result.count > 0 }; + } + + async updateProjectImage( + adminUserId: string, + projectId: string, + projectImageUrl?: string | null, + projectBgColor?: string | null, + ) { + await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true }); + + const data: { projectImageUrl?: string | null; projectBgColor?: string | null } = {}; + if (projectImageUrl !== undefined) data.projectImageUrl = projectImageUrl; + if (projectBgColor !== undefined) data.projectBgColor = projectBgColor; + + await this.prismaService.project.update({ + where: { id: projectId }, + data, + }); + + return this.getByIdForUser(adminUserId, projectId); + } +} diff --git a/apps/api/src/users/dto/assign-user-project.dto.ts b/apps/api/src/users/dto/assign-user-project.dto.ts new file mode 100644 index 0000000..f95b22d --- /dev/null +++ b/apps/api/src/users/dto/assign-user-project.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString, MinLength } from 'class-validator'; + +export class AssignUserProjectDto { + @IsOptional() + @IsString() + @MinLength(1) + projectId?: string; +} diff --git a/apps/api/src/users/dto/create-user.dto.ts b/apps/api/src/users/dto/create-user.dto.ts new file mode 100644 index 0000000..119280c --- /dev/null +++ b/apps/api/src/users/dto/create-user.dto.ts @@ -0,0 +1,30 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; +import { UserRole } from '@prisma/client'; +import { IsEnum, IsOptional } from 'class-validator'; + +export class CreateUserDto { + @IsString() + @MinLength(3) + @MaxLength(40) + username!: string; + + @IsString() + @MinLength(8) + @MaxLength(128) + password!: string; + + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(40) + displayName?: string; + + @IsOptional() + @IsString() + @MinLength(1) + projectId?: string; +} \ No newline at end of file diff --git a/apps/api/src/users/dto/update-my-profile.dto.ts b/apps/api/src/users/dto/update-my-profile.dto.ts new file mode 100644 index 0000000..fda5a69 --- /dev/null +++ b/apps/api/src/users/dto/update-my-profile.dto.ts @@ -0,0 +1,9 @@ +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpdateMyProfileDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(40) + displayName?: string; +} diff --git a/apps/api/src/users/dto/update-user.dto.ts b/apps/api/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..c6a0efe --- /dev/null +++ b/apps/api/src/users/dto/update-user.dto.ts @@ -0,0 +1,26 @@ +import { UserRole } from '@prisma/client'; +import { IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpdateUserDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(40) + username?: string; + + @IsOptional() + @IsString() + @MinLength(8) + @MaxLength(128) + password?: string; + + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(40) + displayName?: string; +} \ No newline at end of file diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..03d67ed --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,188 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + MaxFileSizeValidator, + Param, + Patch, + Req, + Post, + ParseFilePipe, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { extname, join } from 'path'; +import { existsSync, mkdirSync, readdirSync } from 'fs'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { AuthenticatedRequest } from '../auth/jwt-auth.guard'; +import { Roles } from '../auth/roles.decorator'; +import { RolesGuard } from '../auth/roles.guard'; +import { CreateUserDto } from './dto/create-user.dto'; +import { AssignUserProjectDto } from './dto/assign-user-project.dto'; +import { UpdateMyProfileDto } from './dto/update-my-profile.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UsersService } from './users.service'; + +const uploadsDir = join(process.cwd(), 'uploads'); + +const storage = diskStorage({ + destination: (_req, _file, callback) => { + if (!existsSync(uploadsDir)) { + mkdirSync(uploadsDir, { recursive: true }); + } + callback(null, uploadsDir); + }, + filename: (_req, file, callback) => { + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + callback(null, `profile-${uniqueSuffix}${extname(file.originalname)}`); + }, +}); + +@Controller('users') +export class UsersController { + private readonly defaultAvatars: string[]; + + constructor(private readonly usersService: UsersService) { + const avatarsDir = join(process.cwd(), 'public', 'default-avatars'); + const imageExts = ['.png', '.jpg', '.jpeg', '.webp']; + try { + this.defaultAvatars = readdirSync(avatarsDir) + .filter((f) => imageExts.includes(extname(f).toLowerCase())) + .sort() + .map((f) => `/default-avatars/${f}`); + } catch { + this.defaultAvatars = []; + } + } + + @Get('default-avatars') + listDefaultAvatars() { + return this.defaultAvatars; + } + + @Get('me') + @UseGuards(JwtAuthGuard) + me(@Req() request: AuthenticatedRequest) { + return this.usersService.getProfile(request.user!.sub); + } + + @Patch('me') + @UseGuards(JwtAuthGuard) + updateMe( + @Req() request: AuthenticatedRequest, + @Body() updateMyProfileDto: UpdateMyProfileDto, + ) { + return this.usersService.updateMyProfile( + request.user!.sub, + updateMyProfileDto.displayName, + ); + } + + @Post('me/photo') + @UseGuards(JwtAuthGuard) + @UseInterceptors(FileInterceptor('file', { storage })) + uploadPhoto( + @Req() request: AuthenticatedRequest, + @Body() body: { bgColor?: string }, + @UploadedFile( + new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), + ], + fileIsRequired: false, + }), + ) + file?: Express.Multer.File, + ) { + if (file) { + const allowedMimeTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']; + if (!allowedMimeTypes.includes(file.mimetype)) { + throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.'); + } + } + + const profileImageUrl = file ? `/uploads/${file.filename}` : body.bgColor && !file ? undefined : undefined; + const bgColor = body.bgColor ?? undefined; + + if (!file && !bgColor) { + throw new BadRequestException('Envoyez une image ou une couleur de fond.'); + } + + return this.usersService.updateProfileImage( + request.user!.sub, + file ? `/uploads/${file.filename}` : undefined, + bgColor, + ); + } + + @Delete('me/photo') + @UseGuards(JwtAuthGuard) + deletePhoto(@Req() request: AuthenticatedRequest) { + return this.usersService.updateProfileImage(request.user!.sub, null, null); + } + + @Patch('me/avatar') + @UseGuards(JwtAuthGuard) + setDefaultAvatar( + @Req() request: AuthenticatedRequest, + @Body() body: { avatarUrl: string; bgColor?: string }, + ) { + if (!body.avatarUrl || !this.defaultAvatars.includes(body.avatarUrl)) { + throw new BadRequestException('Avatar par defaut invalide.'); + } + return this.usersService.updateProfileImage( + request.user!.sub, + body.avatarUrl, + body.bgColor, + ); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + create(@Req() request: AuthenticatedRequest, @Body() createUserDto: CreateUserDto) { + return this.usersService.create(createUserDto, request.user?.sub); + } + + @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + list() { + return this.usersService.list(); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + update( + @Param('id') id: string, + @Body() updateUserDto: UpdateUserDto, + @Req() request: AuthenticatedRequest, + ) { + return this.usersService.updateById(id, updateUserDto, request.user?.sub); + } + + @Patch(':id/project') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + assignProject( + @Param('id') id: string, + @Body() dto: AssignUserProjectDto, + @Req() request: AuthenticatedRequest, + ) { + return this.usersService.assignProjectById(id, dto.projectId, request.user?.sub); + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + remove(@Param('id') id: string, @Req() request: AuthenticatedRequest) { + return this.usersService.removeById(id, request.user?.sub); + } +} \ No newline at end of file diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..cfb62c8 --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + imports: [AuthModule], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} \ No newline at end of file diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..6ffc18f --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,526 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { UserRole } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; +import { MAX_WORKSPACE_ADMINS } from '../projects/project-scope.constants'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; + +@Injectable() +export class UsersService implements OnModuleInit { + constructor( + private readonly prismaService: PrismaService, + private readonly configService: ConfigService, + ) {} + + async onModuleInit() { + await this.ensureAdminUser(); + } + + private async ensureAdminUser() { + const adminUsername = this.configService.get('ADMIN_USERNAME'); + const adminPassword = this.configService.get('ADMIN_PASSWORD'); + + if (!adminUsername || !adminPassword) { + return; + } + + const passwordHash = await bcrypt.hash(adminPassword, 12); + + await this.prismaService.user.upsert({ + where: { username: adminUsername }, + update: { + passwordHash, + displayName: adminUsername, + role: UserRole.ADMIN, + }, + create: { + username: adminUsername, + passwordHash, + displayName: adminUsername, + role: UserRole.ADMIN, + }, + }); + } + + private async enforceWorkspaceAdminLimit(workspaceId: string, excludedUserId?: string) { + const adminCount = await this.prismaService.user.count({ + where: { + workspaceId, + role: UserRole.ADMIN, + ...(excludedUserId + ? { + id: { + not: excludedUserId, + }, + } + : {}), + }, + }); + + if (adminCount >= MAX_WORKSPACE_ADMINS) { + throw new ForbiddenException( + `Limite atteinte: maximum ${MAX_WORKSPACE_ADMINS} comptes admin par espace`, + ); + } + } + + private async getActorOrThrow(actorUserId?: string) { + if (!actorUserId) { + return null; + } + + const actor = await this.prismaService.user.findUnique({ + where: { id: actorUserId }, + select: { + id: true, + role: true, + workspaceId: true, + }, + }); + + if (!actor) { + throw new NotFoundException('Compte admin introuvable'); + } + + if (actor.role !== UserRole.ADMIN) { + throw new ForbiddenException('Action reservee aux admins'); + } + + return actor; + } + + private async resolveCurrentProjectId(userId: string) { + const membership = await this.prismaService.projectMembership.findUnique({ + where: { userId }, + select: { projectId: true }, + }); + + return membership?.projectId ?? null; + } + + private async assignUserToProject( + userId: string, + projectId: string, + actorUserId?: string, + ) { + const [targetUser, targetProject, actor] = await Promise.all([ + this.prismaService.user.findUnique({ + where: { id: userId }, + select: { + id: true, + role: true, + workspaceId: true, + }, + }), + this.prismaService.project.findUnique({ + where: { id: projectId }, + select: { + id: true, + workspaceId: true, + }, + }), + this.getActorOrThrow(actorUserId), + ]); + + if (!targetUser) { + throw new NotFoundException('User not found'); + } + + if (!targetProject) { + throw new NotFoundException('Project not found'); + } + + if (targetUser.role === UserRole.ADMIN) { + throw new BadRequestException('Un admin ne peut pas etre affecte comme participant'); + } + + if (actor) { + if (!actor.workspaceId || actor.workspaceId !== targetProject.workspaceId) { + throw new ForbiddenException('Ce projet appartient a un autre espace'); + } + } + + if (targetUser.workspaceId && targetUser.workspaceId !== targetProject.workspaceId) { + throw new ForbiddenException('Cet utilisateur appartient deja a un autre espace'); + } + + if (!targetUser.workspaceId) { + await this.prismaService.user.update({ + where: { id: targetUser.id }, + data: { workspaceId: targetProject.workspaceId }, + }); + } + + const existingMembership = await this.prismaService.projectMembership.findUnique({ + where: { userId: targetUser.id }, + select: { projectId: true }, + }); + + if (existingMembership && existingMembership.projectId !== targetProject.id) { + await this.prismaService.projectMembership.update({ + where: { userId: targetUser.id }, + data: { + projectId: targetProject.id, + assignedById: actor?.id, + }, + }); + return; + } + + if (!existingMembership) { + await this.prismaService.projectMembership.create({ + data: { + projectId: targetProject.id, + userId: targetUser.id, + assignedById: actor?.id, + }, + }); + } + } + + async create(createUserDto: CreateUserDto, actorUserId?: string) { + const passwordHash = await bcrypt.hash(createUserDto.password, 12); + const actor = await this.getActorOrThrow(actorUserId); + const role = createUserDto.role ?? UserRole.FAMILY; + + if (role === UserRole.ADMIN && actor?.workspaceId) { + await this.enforceWorkspaceAdminLimit(actor.workspaceId); + } + + try { + const user = await this.prismaService.user.create({ + data: { + username: createUserDto.username, + displayName: createUserDto.displayName, + passwordHash, + role, + workspaceId: actor?.workspaceId, + }, + }); + + if (createUserDto.projectId) { + await this.assignUserToProject(user.id, createUserDto.projectId, actor?.id); + } + + const currentProjectId = await this.resolveCurrentProjectId(user.id); + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + profileImageUrl: user.profileImageUrl, + workspaceId: user.workspaceId, + currentProjectId, + role: user.role, + createdAt: user.createdAt, + }; + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'P2002' + ) { + throw new ConflictException('Username already exists'); + } + + throw new InternalServerErrorException('Could not create user'); + } + } + + async findByUsername(username: string) { + return this.prismaService.user.findFirst({ + where: { + username: { + equals: username.trim(), + mode: 'insensitive', + }, + }, + }); + } + + async list() { + const users = await this.prismaService.user.findMany({ + orderBy: { createdAt: 'desc' }, + select: { + id: true, + username: true, + displayName: true, + profileImageUrl: true, + workspaceId: true, + role: true, + createdAt: true, + projectMembership: { + select: { + projectId: true, + }, + }, + }, + }); + + return users.map((user) => ({ + id: user.id, + username: user.username, + displayName: user.displayName, + profileImageUrl: user.profileImageUrl, + workspaceId: user.workspaceId, + currentProjectId: user.projectMembership?.projectId ?? null, + role: user.role, + createdAt: user.createdAt, + })); + } + + async updateById(id: string, updateUserDto: UpdateUserDto, actorUserId?: string) { + try { + const actor = await this.getActorOrThrow(actorUserId); + + const data: { + username?: string; + passwordHash?: string; + role?: UserRole; + displayName?: string; + workspaceId?: string; + } = {}; + + if (updateUserDto.username) { + data.username = updateUserDto.username; + } + + if (updateUserDto.password) { + data.passwordHash = await bcrypt.hash(updateUserDto.password, 12); + } + + if (updateUserDto.role) { + data.role = updateUserDto.role; + } + + if (updateUserDto.displayName !== undefined) { + data.displayName = updateUserDto.displayName; + } + + const existing = await this.prismaService.user.findUnique({ where: { id } }); + + if (!existing) { + throw new NotFoundException('User not found'); + } + + if (actor && actor.workspaceId && existing.workspaceId && actor.workspaceId !== existing.workspaceId) { + throw new ForbiddenException('Vous ne pouvez gerer que les comptes de votre espace'); + } + + if (actor && actor.workspaceId && !existing.workspaceId) { + data.workspaceId = actor.workspaceId; + } + + if (updateUserDto.role === UserRole.ADMIN) { + const workspaceId = existing.workspaceId ?? actor?.workspaceId; + if (!workspaceId) { + throw new BadRequestException('Impossible de promouvoir en admin sans espace associe'); + } + + data.workspaceId = workspaceId; + + if (existing.role !== UserRole.ADMIN) { + await this.enforceWorkspaceAdminLimit(workspaceId, existing.id); + } + } + + if (actorUserId && existing.id === actorUserId && updateUserDto.role === UserRole.FAMILY) { + throw new ForbiddenException('You cannot demote yourself'); + } + + const user = await this.prismaService.user.update({ + where: { id }, + data, + }); + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + profileImageUrl: user.profileImageUrl, + workspaceId: user.workspaceId, + role: user.role, + createdAt: user.createdAt, + }; + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'P2002' + ) { + throw new ConflictException('Username already exists'); + } + if ( + error instanceof ForbiddenException || + error instanceof InternalServerErrorException || + error instanceof NotFoundException + ) { + throw error; + } + throw new InternalServerErrorException('Could not update user'); + } + } + + async assignProjectById(userId: string, projectId: string | undefined, actorUserId?: string) { + const actor = await this.getActorOrThrow(actorUserId); + + const targetUser = await this.prismaService.user.findUnique({ + where: { id: userId }, + select: { + id: true, + role: true, + workspaceId: true, + username: true, + displayName: true, + profileImageUrl: true, + createdAt: true, + }, + }); + + if (!targetUser) { + throw new NotFoundException('User not found'); + } + + if (actor && actor.workspaceId && targetUser.workspaceId && targetUser.workspaceId !== actor.workspaceId) { + throw new ForbiddenException('Vous ne pouvez gerer que les comptes de votre espace'); + } + + if (!projectId) { + await this.prismaService.projectMembership.deleteMany({ + where: { userId: targetUser.id }, + }); + + return { + id: targetUser.id, + username: targetUser.username, + displayName: targetUser.displayName, + profileImageUrl: targetUser.profileImageUrl, + workspaceId: targetUser.workspaceId, + currentProjectId: null, + role: targetUser.role, + createdAt: targetUser.createdAt, + }; + } + + await this.assignUserToProject(targetUser.id, projectId, actor?.id); + const currentProjectId = await this.resolveCurrentProjectId(targetUser.id); + + return { + id: targetUser.id, + username: targetUser.username, + displayName: targetUser.displayName, + profileImageUrl: targetUser.profileImageUrl, + workspaceId: targetUser.workspaceId, + currentProjectId, + role: targetUser.role, + createdAt: targetUser.createdAt, + }; + } + + async removeById(id: string, actorUserId?: string) { + if (actorUserId && id === actorUserId) { + throw new ForbiddenException('You cannot delete your own account'); + } + + try { + const actor = await this.getActorOrThrow(actorUserId); + const target = await this.prismaService.user.findUnique({ + where: { id }, + select: { + id: true, + workspaceId: true, + }, + }); + + if (!target) { + throw new NotFoundException('User not found'); + } + + if (actor && actor.workspaceId && target.workspaceId && actor.workspaceId !== target.workspaceId) { + throw new ForbiddenException('Vous ne pouvez supprimer que des comptes de votre espace'); + } + + await this.prismaService.user.delete({ where: { id } }); + return { deleted: true }; + } catch (error) { + if (error instanceof ForbiddenException || error instanceof NotFoundException) { + throw error; + } + throw new InternalServerErrorException('Could not delete user'); + } + } + + async getProfile(userId: string) { + const user = await this.prismaService.user.findUnique({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + profileImageUrl: user.profileImageUrl, + profileBgColor: user.profileBgColor, + workspaceId: user.workspaceId, + currentProjectId: await this.resolveCurrentProjectId(user.id), + role: user.role, + createdAt: user.createdAt, + }; + } + + async updateMyProfile(userId: string, displayName?: string) { + const user = await this.prismaService.user.update({ + where: { id: userId }, + data: { displayName }, + }); + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + profileImageUrl: user.profileImageUrl, + profileBgColor: user.profileBgColor, + workspaceId: user.workspaceId, + currentProjectId: await this.resolveCurrentProjectId(user.id), + role: user.role, + createdAt: user.createdAt, + }; + } + + async updateProfileImage(userId: string, profileImageUrl?: string | null, profileBgColor?: string | null) { + const data: { profileImageUrl?: string | null; profileBgColor?: string | null } = {}; + if (profileImageUrl !== undefined) data.profileImageUrl = profileImageUrl; + if (profileBgColor !== undefined) data.profileBgColor = profileBgColor; + + const user = await this.prismaService.user.update({ + where: { id: userId }, + data, + }); + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + profileImageUrl: user.profileImageUrl, + profileBgColor: user.profileBgColor, + workspaceId: user.workspaceId, + currentProjectId: await this.resolveCurrentProjectId(user.id), + role: user.role, + createdAt: user.createdAt, + }; + } +} \ No newline at end of file diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts new file mode 100644 index 0000000..c794389 --- /dev/null +++ b/apps/api/test/app.e2e-spec.ts @@ -0,0 +1,29 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/health (GET)', () => { + return request(app.getHttpServer()) + .get('/health') + .expect(200) + .expect({ status: 'ok' }); + }); + + afterEach(async () => { + await app.close(); + }); +}); diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/apps/api/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..aba29b0 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "noFallthroughCasesInSwitch": false + } +} diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..d5abb4c --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +.env +npm-debug.log \ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/apps/web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..02424f4 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +COPY apps/api/package.json ./apps/api/package.json +COPY apps/web/package.json ./apps/web/package.json +RUN npm ci + +COPY . . +RUN npm run build -w apps/web + +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +COPY package*.json ./ +COPY apps/api/package.json ./apps/api/package.json +COPY apps/web/package.json ./apps/web/package.json +RUN npm ci --omit=dev + +COPY --from=builder /app/apps/web/.next ./apps/web/.next +COPY --from=builder /app/apps/web/public ./apps/web/public +COPY --from=builder /app/apps/web/next.config.ts ./apps/web/next.config.ts + +EXPOSE 3000 + +CMD ["npm", "run", "start", "-w", "apps/web", "--", "-H", "0.0.0.0", "-p", "3000"] \ No newline at end of file diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..acff8fa --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + allowedDevOrigins: ["192.168.1.172"], +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..d50357a --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --hostname 0.0.0.0", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "next": "16.2.3", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.3", + "typescript": "^5" + } +} diff --git a/apps/web/public/depotARanger/baniere-deco-droite-haut.png b/apps/web/public/depotARanger/baniere-deco-droite-haut.png new file mode 100644 index 0000000..1883856 Binary files /dev/null and b/apps/web/public/depotARanger/baniere-deco-droite-haut.png differ diff --git a/apps/web/public/depotARanger/baniere-deco-gauche-haut.png b/apps/web/public/depotARanger/baniere-deco-gauche-haut.png new file mode 100644 index 0000000..2343248 Binary files /dev/null and b/apps/web/public/depotARanger/baniere-deco-gauche-haut.png differ diff --git a/apps/web/public/depotARanger/baniere-familleDroite.png b/apps/web/public/depotARanger/baniere-familleDroite.png new file mode 100644 index 0000000..858993b Binary files /dev/null and b/apps/web/public/depotARanger/baniere-familleDroite.png differ diff --git a/apps/web/public/depotARanger/baniere-familleGauche.png b/apps/web/public/depotARanger/baniere-familleGauche.png new file mode 100644 index 0000000..b04f43e Binary files /dev/null and b/apps/web/public/depotARanger/baniere-familleGauche.png differ diff --git a/apps/web/public/depotARanger/bebe-sur-un-nuage-girl.png b/apps/web/public/depotARanger/bebe-sur-un-nuage-girl.png new file mode 100644 index 0000000..723a416 Binary files /dev/null and b/apps/web/public/depotARanger/bebe-sur-un-nuage-girl.png differ diff --git a/apps/web/public/depotARanger/bebe-sur-un-nuage.png b/apps/web/public/depotARanger/bebe-sur-un-nuage.png new file mode 100644 index 0000000..7ce4ec4 Binary files /dev/null and b/apps/web/public/depotARanger/bebe-sur-un-nuage.png differ diff --git a/apps/web/public/depotARanger/bebe-sur-une-balance-girl.png b/apps/web/public/depotARanger/bebe-sur-une-balance-girl.png new file mode 100644 index 0000000..48f7054 Binary files /dev/null and b/apps/web/public/depotARanger/bebe-sur-une-balance-girl.png differ diff --git a/apps/web/public/depotARanger/bebe-sur-une-balance.png b/apps/web/public/depotARanger/bebe-sur-une-balance.png new file mode 100644 index 0000000..b6716fc Binary files /dev/null and b/apps/web/public/depotARanger/bebe-sur-une-balance.png differ diff --git a/apps/web/public/depotARanger/boutton-choix-fille.png b/apps/web/public/depotARanger/boutton-choix-fille.png new file mode 100644 index 0000000..92e8f9d Binary files /dev/null and b/apps/web/public/depotARanger/boutton-choix-fille.png differ diff --git a/apps/web/public/depotARanger/boutton-choix-garcon.png b/apps/web/public/depotARanger/boutton-choix-garcon.png new file mode 100644 index 0000000..5fa1e97 Binary files /dev/null and b/apps/web/public/depotARanger/boutton-choix-garcon.png differ diff --git a/apps/web/public/depotARanger/deocration-coeur-double-etoile.png b/apps/web/public/depotARanger/deocration-coeur-double-etoile.png new file mode 100644 index 0000000..6948c1c Binary files /dev/null and b/apps/web/public/depotARanger/deocration-coeur-double-etoile.png differ diff --git a/apps/web/public/depotARanger/jouet-bebe-fait-du-bruit.png b/apps/web/public/depotARanger/jouet-bebe-fait-du-bruit.png new file mode 100644 index 0000000..4646bd7 Binary files /dev/null and b/apps/web/public/depotARanger/jouet-bebe-fait-du-bruit.png differ diff --git a/apps/web/public/depotARanger/medaille.png b/apps/web/public/depotARanger/medaille.png new file mode 100644 index 0000000..10726c3 Binary files /dev/null and b/apps/web/public/depotARanger/medaille.png differ diff --git a/apps/web/public/depotARanger/perimetre-cranien.png b/apps/web/public/depotARanger/perimetre-cranien.png new file mode 100644 index 0000000..a2d8df3 Binary files /dev/null and b/apps/web/public/depotARanger/perimetre-cranien.png differ diff --git a/apps/web/public/depotARanger/regle.png b/apps/web/public/depotARanger/regle.png new file mode 100644 index 0000000..91bedf7 Binary files /dev/null and b/apps/web/public/depotARanger/regle.png differ diff --git a/apps/web/public/depotARanger/reglette-enroulé.png b/apps/web/public/depotARanger/reglette-enroulé.png new file mode 100644 index 0000000..071a387 Binary files /dev/null and b/apps/web/public/depotARanger/reglette-enroulé.png differ diff --git a/apps/web/public/depotARanger/taille-baby.png b/apps/web/public/depotARanger/taille-baby.png new file mode 100644 index 0000000..fcc0181 Binary files /dev/null and b/apps/web/public/depotARanger/taille-baby.png differ diff --git a/apps/web/public/depotARanger/tete-bebe-fille.png b/apps/web/public/depotARanger/tete-bebe-fille.png new file mode 100644 index 0000000..ddec045 Binary files /dev/null and b/apps/web/public/depotARanger/tete-bebe-fille.png differ diff --git a/apps/web/public/depotARanger/tete-bebe-garcon.png b/apps/web/public/depotARanger/tete-bebe-garcon.png new file mode 100644 index 0000000..c15da3d Binary files /dev/null and b/apps/web/public/depotARanger/tete-bebe-garcon.png differ diff --git a/apps/web/public/file.svg b/apps/web/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/apps/web/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/globe.svg b/apps/web/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/apps/web/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/images/banners/famille-droite.png b/apps/web/public/images/banners/famille-droite.png new file mode 100644 index 0000000..858993b Binary files /dev/null and b/apps/web/public/images/banners/famille-droite.png differ diff --git a/apps/web/public/images/banners/famille-gauche.png b/apps/web/public/images/banners/famille-gauche.png new file mode 100644 index 0000000..b04f43e Binary files /dev/null and b/apps/web/public/images/banners/famille-gauche.png differ diff --git a/apps/web/public/images/banners/hero-wave.svg b/apps/web/public/images/banners/hero-wave.svg new file mode 100644 index 0000000..d2b7453 --- /dev/null +++ b/apps/web/public/images/banners/hero-wave.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/images/icons/logo.png b/apps/web/public/images/icons/logo.png new file mode 100644 index 0000000..7ce4ec4 Binary files /dev/null and b/apps/web/public/images/icons/logo.png differ diff --git a/apps/web/public/images/pictos/bebe-balance.png b/apps/web/public/images/pictos/bebe-balance.png new file mode 100644 index 0000000..b6716fc Binary files /dev/null and b/apps/web/public/images/pictos/bebe-balance.png differ diff --git a/apps/web/public/images/pictos/bebe-fille.png b/apps/web/public/images/pictos/bebe-fille.png new file mode 100644 index 0000000..ddec045 Binary files /dev/null and b/apps/web/public/images/pictos/bebe-fille.png differ diff --git a/apps/web/public/images/pictos/bebe-garcon.png b/apps/web/public/images/pictos/bebe-garcon.png new file mode 100644 index 0000000..c15da3d Binary files /dev/null and b/apps/web/public/images/pictos/bebe-garcon.png differ diff --git a/apps/web/public/images/pictos/choix-fille.png b/apps/web/public/images/pictos/choix-fille.png new file mode 100644 index 0000000..92e8f9d Binary files /dev/null and b/apps/web/public/images/pictos/choix-fille.png differ diff --git a/apps/web/public/images/pictos/choix-garcon.png b/apps/web/public/images/pictos/choix-garcon.png new file mode 100644 index 0000000..5fa1e97 Binary files /dev/null and b/apps/web/public/images/pictos/choix-garcon.png differ diff --git a/apps/web/public/images/pictos/deco-coeur-etoile.png b/apps/web/public/images/pictos/deco-coeur-etoile.png new file mode 100644 index 0000000..6948c1c Binary files /dev/null and b/apps/web/public/images/pictos/deco-coeur-etoile.png differ diff --git a/apps/web/public/images/pictos/jouet-bebe.png b/apps/web/public/images/pictos/jouet-bebe.png new file mode 100644 index 0000000..4646bd7 Binary files /dev/null and b/apps/web/public/images/pictos/jouet-bebe.png differ diff --git a/apps/web/public/images/pictos/medaille.png b/apps/web/public/images/pictos/medaille.png new file mode 100644 index 0000000..10726c3 Binary files /dev/null and b/apps/web/public/images/pictos/medaille.png differ diff --git a/apps/web/public/images/pictos/regle.png b/apps/web/public/images/pictos/regle.png new file mode 100644 index 0000000..91bedf7 Binary files /dev/null and b/apps/web/public/images/pictos/regle.png differ diff --git a/apps/web/public/images/pictos/reglette.png b/apps/web/public/images/pictos/reglette.png new file mode 100644 index 0000000..071a387 Binary files /dev/null and b/apps/web/public/images/pictos/reglette.png differ diff --git a/apps/web/public/images/pictos/weight-backdrop-figma b/apps/web/public/images/pictos/weight-backdrop-figma new file mode 100644 index 0000000..7f39036 --- /dev/null +++ b/apps/web/public/images/pictos/weight-backdrop-figma @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/images/pictos/weight-backdrop-figma-girl.svg b/apps/web/public/images/pictos/weight-backdrop-figma-girl.svg new file mode 100644 index 0000000..daa8a5c --- /dev/null +++ b/apps/web/public/images/pictos/weight-backdrop-figma-girl.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/images/pictos/weight-backdrop-figma.svg b/apps/web/public/images/pictos/weight-backdrop-figma.svg new file mode 100644 index 0000000..24c6c87 --- /dev/null +++ b/apps/web/public/images/pictos/weight-backdrop-figma.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/next.svg b/apps/web/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/apps/web/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/vercel.svg b/apps/web/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/apps/web/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/window.svg b/apps/web/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/apps/web/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/app/admin/page.module.css b/apps/web/src/app/admin/page.module.css new file mode 100644 index 0000000..2db032f --- /dev/null +++ b/apps/web/src/app/admin/page.module.css @@ -0,0 +1,389 @@ +.page { + display: grid; + gap: 0.75rem; +} + +.header { + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; +} + +.header h1 { + color: var(--ink-0); + font-size: 1.3rem; + font-weight: 800; +} + +.actions { + display: flex; + gap: 0.5rem; +} + +.projectPanel, +.loginPanel, +.createPanel, +.tablePanel { + padding: 1rem; + display: grid; + gap: 0.8rem; +} + +.adminTabs { + padding: 0.55rem; + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.adminTab { + border: 1px solid var(--border); + border-radius: var(--radius-btn); + background: var(--panel-warm); + color: var(--ink-1); + padding: 0.5rem 0.75rem; + font-weight: 700; + cursor: pointer; +} + +.adminTabActive { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +.projectPanelHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.8rem; +} + +.projectList { + display: grid; + gap: 0.6rem; +} + +.projectItem { + border: 1px solid var(--border); + border-radius: 12px; + background: #f8fbf3; + padding: 0.75rem; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 0.6rem; +} + +.predictionPanel { + padding: 1.25rem; + display: grid; + gap: 1rem; +} + +.predictionPanel h2 { + font-size: 1.35rem; + font-weight: 800; + color: var(--ink-0); +} + +.predictionPanel h3 { + font-size: 1rem; + font-weight: 700; + color: var(--ink-1); + padding-top: 0.5rem; + border-top: 1px solid var(--border-light); +} + +.formGrid, +.createForm { + display: grid; + gap: 0.7rem; +} + +.message { + border: 1px solid var(--border); + border-radius: 10px; + background: #eef3e3; + padding: 0.65rem 0.75rem; + color: var(--ink-1); +} + +.tableWrapper { + overflow-x: auto; +} + +.tablePanel table { + width: 100%; + border-collapse: collapse; + min-width: 760px; +} + +.tablePanel th, +.tablePanel td { + border-bottom: 1px solid var(--border); + text-align: left; + padding: 0.6rem; + vertical-align: top; +} + +.tablePanel th { + color: var(--ink-1); +} + +.rowActions { + display: grid; + grid-template-columns: repeat(2, max-content); + gap: 0.45rem; + align-items: center; +} + +.rowActions input { + grid-column: 1 / -1; + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.55rem 0.65rem; +} + +.predictionActions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; + justify-content: flex-start; +} + +.statusPill { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.35rem 0.7rem; + background: var(--accent-soft); + border-color: var(--accent); + color: var(--ink-1); + font-weight: 700; +} + +.predictionGrid { + display: grid; + gap: 0.7rem; +} + +.helpText { + color: var(--muted); +} + +.wizardPanel { + border: 1px solid var(--border); + border-radius: 12px; + background: #f8fbf3; + padding: 0.8rem; + display: grid; + gap: 0.75rem; +} + +.wizardHeader { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5rem; + align-items: center; +} + +.stepPill { + border: 1px solid var(--border); + border-radius: 999px; + background: #fff; + color: var(--ink-1); + padding: 0.32rem 0.7rem; + font-weight: 700; + font-size: 0.84rem; +} + +.outcomeSummary { + border: 1px solid var(--border); + border-radius: 10px; + background: #ffffff; + padding: 0.65rem; + display: grid; + gap: 0.35rem; +} + +.candidateList { + display: grid; + gap: 0.65rem; +} + +.candidateCard { + border: 1px solid var(--border); + border-radius: 12px; + background: #ffffff; + padding: 0.7rem; + display: grid; + gap: 0.55rem; +} + +.candidateHeader { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.45rem; + align-items: center; +} + +.candidateMeta { + color: var(--muted); + font-size: 0.9rem; +} + +.scoreInputs { + display: grid; + gap: 0.55rem; +} + +.scoreInputRow { + display: grid; + gap: 0.3rem; +} + +.scoreInputRow input { + max-width: 220px; + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.45rem 0.55rem; +} + +.wizardActions { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + align-items: center; +} + +.outcomesForm { + border: 1px solid var(--border); + border-radius: 12px; + background: #f8fbf3; + padding: 0.75rem; + display: grid; + gap: 0.65rem; +} + +.scoringList { + display: grid; + gap: 0.6rem; +} + +.babyColumnsGrid { + display: grid; + gap: 1rem; + margin: 0.5rem 0; +} + +.babyColumn { + display: grid; + gap: 0.6rem; + align-content: start; +} + +.babyColumnHeader { + text-align: center; + font-size: 1rem; + font-weight: 700; + color: var(--ink-1); + padding: 0.4rem 0; + border-bottom: 2px solid var(--primary); +} + +.scoringCard { + border: 1px solid var(--border); + border-radius: 12px; + background: #f8fbf3; + padding: 0.7rem; + display: grid; + gap: 0.55rem; +} + +.scoringRow { + display: grid; + gap: 0.3rem; +} + +.scoringRow input { + max-width: 180px; + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.45rem 0.55rem; +} + +@media (max-width: 760px) { + .header { + flex-direction: column; + align-items: flex-start; + } + + .projectPanelHeader { + flex-direction: column; + } + + .adminTabs { + flex-direction: column; + } + + .projectItem { + align-items: flex-start; + } + + .predictionActions { + flex-direction: column; + align-items: flex-start; + } + + .wizardHeader { + align-items: flex-start; + } + + .candidateHeader, + .wizardActions { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 520px) { + .predictionPanel { + padding: 1rem 0.9rem; + gap: 0.85rem; + } + + .predictionActions { + flex-direction: column; + align-items: stretch; + } + + .predictionActions .btn { + width: 100%; + text-align: center; + } + + .babyColumnsGrid { + grid-template-columns: 1fr !important; + } + + .wizardHeader { + flex-direction: column; + align-items: flex-start; + } + + .wizardActions { + flex-direction: column; + align-items: stretch; + } + + .wizardActions .btn { + width: 100%; + text-align: center; + } +} diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx new file mode 100644 index 0000000..7adcf6a --- /dev/null +++ b/apps/web/src/app/admin/page.tsx @@ -0,0 +1,1653 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ProjectSwitcher } from "@/features/predictions/components/project-switcher"; +import { FieldRendererRegistry } from "@/features/predictions/components/fields/field-renderer-registry"; +import { WeightDoubleSlider } from "@/features/predictions/components/fields/weight-double-slider"; +import predictionStyles from "@/features/predictions/styles/predictions.module.css"; +import { + closePredictionGame, + finalizePredictionGame, + getPredictionBoard, + getPredictionCards, + getPredictionScoreboard, + openPredictionGame, + setPredictionOutcomes, + suggestPredictionScores, + validatePredictionScores, +} from "@/lib/predictions-client"; +import { + authenticatedFetch, + clearSession, + getCurrentProjectId, + getApiUrl, + loadSession, + logoutSession, + type AuthUser, + type UserRole, +} from "@/lib/auth"; +import { getMyProjects } from "@/lib/projects-client"; +import { ProjectPhotoModal } from "@/features/predictions/components/project-photo-modal"; +import type { + PredictionBoardCard, + PredictionBoardResponse, + PredictionCard, + PredictionDraftValue, + PredictionScoringEntry, + PredictionScoreboardItem, +} from "@/types/predictions"; +import type { ProjectSummary } from "@/types/projects"; +import styles from "./page.module.css"; + +type UserListItem = { + id: string; + username: string; + displayName: string | null; + profileImageUrl: string | null; + workspaceId?: string | null; + currentProjectId?: string | null; + role: UserRole; + createdAt: string; +}; + +type OutcomeDraftValue = { valueText: string; valueNumber: string; valueDate: string }; +type OutcomeDraft = Record>; +type ScoreDraft = Record>; +type ScoreDraftByCard = Record; +type ScoresByCard = Record; +type ScoresByBabyCard = ScoresByCard; +type ScoreDraftByBabyCard = ScoreDraftByCard; +function babyCardKey(babyIndex: number, cardId: string) { + return `${babyIndex}::${cardId}`; +} + +type AdminTab = "projects" | "users" | "final"; + +function formatDate(value: string) { + return new Date(value).toLocaleDateString("fr-FR"); +} + +function formatRawValue( + value: { valueText: string | null; valueNumber: number | null; valueDate: string | null }, + unit?: string | null, +) { + if (value.valueNumber != null) { + return `${value.valueNumber}${unit ? ` ${unit}` : ""}`; + } + + if (value.valueText && value.valueText.trim().length > 0) { + return value.valueText; + } + + if (value.valueDate) { + return value.valueDate.slice(0, 10); + } + + return "-"; +} + +function formatLabeledValue( + label: string, + value: { valueText: string | null; valueNumber: number | null; valueDate: string | null }, + unit?: string | null, +) { + return `${label}: ${formatRawValue(value, unit)}`; +} + +function buildOutcomeDraftKey(cardId: string, babyIndex: number) { + return `${cardId}::${babyIndex}`; +} + +function initializeOutcomeDraft( + cards: PredictionCard[], + board: PredictionBoardResponse | null, + babyIndex: number, +) { + const boardByCardId = new Map((board?.cards ?? []).map((card) => [card.id, card])); + const draft: OutcomeDraft = {}; + + for (const card of cards) { + const key = buildOutcomeDraftKey(card.id, babyIndex); + const boardCard = boardByCardId.get(card.id); + const outcomesByField = new Map((boardCard?.outcomes ?? []).map((outcome) => [outcome.fieldId, outcome])); + + draft[key] = {}; + for (const field of card.fields) { + const outcome = outcomesByField.get(field.id); + draft[key][field.id] = { + valueText: outcome?.valueText ?? "", + valueNumber: outcome?.valueNumber != null ? `${outcome.valueNumber}` : "", + valueDate: outcome?.valueDate ? outcome.valueDate.slice(0, 10) : "", + }; + } + } + + return draft; +} + +function getOutcomeDraftValues( + card: PredictionCard, + outcomeDraft: OutcomeDraft, + babyIndex: number, +): PredictionDraftValue[] { + const cardDraft = outcomeDraft[buildOutcomeDraftKey(card.id, babyIndex)] ?? {}; + return card.fields.map((field) => { + const draftValue = cardDraft[field.id] ?? { valueText: "", valueNumber: "", valueDate: "" }; + const parsedNumber = Number(draftValue.valueNumber); + + return { + fieldId: field.id, + valueText: draftValue.valueText, + valueNumber: + draftValue.valueNumber.trim().length === 0 || Number.isNaN(parsedNumber) + ? null + : parsedNumber, + valueDate: draftValue.valueDate, + }; + }); +} + +function createScoreDraft(entries: PredictionScoringEntry[]) { + return entries.reduce((accumulator, entry) => { + accumulator[entry.id] = entry.scores.reduce>((scoreAccumulator, score) => { + scoreAccumulator[score.fieldId] = `${score.awardedPoints ?? score.suggestedPoints}`; + return scoreAccumulator; + }, {}); + return accumulator; + }, {}); +} + +function parsePointsInput(rawValue: string | undefined, fallback: number) { + const parsed = Number(rawValue); + if (Number.isNaN(parsed) || parsed < 0) { + return fallback; + } + return Math.round(parsed); +} + +export default function AdminPage() { + const router = useRouter(); + const [currentUser, setCurrentUser] = useState(null); + const [projects, setProjects] = useState([]); + const [activeProjectId, setActiveProjectId] = useState(getCurrentProjectId()); + const [activeAdminTab, setActiveAdminTab] = useState("projects"); + const [photoModalProjectId, setPhotoModalProjectId] = useState(null); + + const [users, setUsers] = useState([]); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(false); + + const [newUsername, setNewUsername] = useState(""); + const [newDisplayName, setNewDisplayName] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newRole, setNewRole] = useState("FAMILY"); + const [newProjectId, setNewProjectId] = useState(""); + + const [editingUserId, setEditingUserId] = useState(null); + const [editUsername, setEditUsername] = useState(""); + const [editDisplayName, setEditDisplayName] = useState(""); + const [editPassword, setEditPassword] = useState(""); + const [editRole, setEditRole] = useState("FAMILY"); + + const [predictionCards, setPredictionCards] = useState([]); + const [boardsByBaby, setBoardsByBaby] = useState>({}); + const [scoreboard, setScoreboard] = useState([]); + const [outcomeDraft, setOutcomeDraft] = useState({}); + const [scoresByBabyCard, setScoresByBabyCard] = useState({}); + const [scoreDraftByBabyCard, setScoreDraftByBabyCard] = useState({}); + const [wizardStepIndex, setWizardStepIndex] = useState(0); + const [predictionMessage, setPredictionMessage] = useState(""); + + const apiUrl = getApiUrl(); + const isAdmin = currentUser?.role === "ADMIN"; + + const primaryBoard = boardsByBaby[1] ?? null; + const isGameOpen = primaryBoard?.game?.status === "OPEN"; + const isGameClosed = primaryBoard?.game?.status === "CLOSED"; + const isFinalized = Boolean(primaryBoard?.game?.finalized); + + const totalWorkflowSteps = predictionCards.length + 1; + const isRecapStep = isGameClosed && !isFinalized && wizardStepIndex >= predictionCards.length; + + const currentStepCard = useMemo( + () => (!isRecapStep ? predictionCards[wizardStepIndex] ?? null : null), + [isRecapStep, predictionCards, wizardStepIndex], + ); + + const currentBoardCard = useMemo(() => { + if (!currentStepCard) { + return null; + } + + return primaryBoard?.cards.find((card) => card.id === currentStepCard.id) ?? null; + }, [currentStepCard, primaryBoard?.cards]); + + const activeProjectBabyCount = useMemo(() => { + const project = projects.find((item) => item.id === activeProjectId); + return Math.max(1, project?.babyCount ?? 1); + }, [activeProjectId, projects]); + + const currentScoringEntriesByBaby = useMemo(() => { + if (!currentStepCard) { + return {}; + } + + const result: Record = {}; + for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) { + result[babyIdx] = scoresByBabyCard[babyCardKey(babyIdx, currentStepCard.id)] ?? []; + } + return result; + }, [currentStepCard, scoresByBabyCard, activeProjectBabyCount]); + + const currentScoreDraftByBaby = useMemo(() => { + if (!currentStepCard) { + return {}; + } + + const result: Record = {}; + for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) { + result[babyIdx] = scoreDraftByBabyCard[babyCardKey(babyIdx, currentStepCard.id)] ?? {}; + } + return result; + }, [currentStepCard, scoreDraftByBabyCard, activeProjectBabyCount]); + + const totalsByUserId = useMemo(() => { + return new Map(scoreboard.map((item) => [item.userId, item.totalPoints])); + }, [scoreboard]); + + const winnerSummary = useMemo(() => { + if (scoreboard.length === 0) { + return null; + } + + const maxPoints = scoreboard[0].totalPoints; + const winners = scoreboard.filter((item) => item.totalPoints === maxPoints); + return { + points: maxPoints, + winners, + }; + }, [scoreboard]); + + const remainingEntriesToScore = useMemo(() => { + let total = 0; + for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) { + const board = boardsByBaby[babyIdx]; + if (!board) continue; + for (const card of board.cards) { + total += card.entries.filter((entry) => !entry.isScored).length; + } + } + return total; + }, [boardsByBaby, activeProjectBabyCount]); + + const canFinalizeContest = isGameClosed && !isFinalized && remainingEntriesToScore === 0 && predictionCards.length > 0; + + const activeProjectName = useMemo(() => { + return projects.find((project) => project.id === activeProjectId)?.name ?? null; + }, [activeProjectId, projects]); + + const getBabyLabel = useCallback((idx: number): string => { + const project = projects.find((p) => p.id === activeProjectId); + return project?.babies?.find((b) => b.babyIndex === idx)?.label ?? `Bébé ${idx}`; + }, [activeProjectId, projects]); + + const handleProjectChanged = useCallback((projectId: string) => { + setActiveProjectId((current) => (current === projectId ? current : projectId)); + setPredictionMessage(""); + }, []); + + useEffect(() => { + const session = loadSession(); + + if (!session) { + router.replace("/"); + return; + } + + if (session.user.role !== "ADMIN") { + router.replace("/predictions"); + return; + } + + setCurrentUser(session.user); + setActiveProjectId(session.user.currentProjectId ?? null); + }, [router]); + + useEffect(() => { + setWizardStepIndex((current) => Math.min(current, predictionCards.length)); + }, [predictionCards.length]); + + const loadUsers = async () => { + const response = await authenticatedFetch(apiUrl, "/users"); + const payload = (await response.json()) as UserListItem[] | { message?: string }; + + if (!response.ok || !Array.isArray(payload)) { + throw new Error((payload as { message?: string }).message ?? "Impossible de charger les utilisateurs"); + } + + setUsers(payload); + }; + + const loadProjects = async () => { + const payload = await getMyProjects(); + setProjects(payload); + + if (payload.length === 0) { + setNewProjectId(""); + setActiveProjectId(null); + return null; + } + + const currentProjectId = getCurrentProjectId(); + const selectedProject = payload.find((project) => project.id === currentProjectId) ?? payload[0]; + + if (selectedProject) { + setActiveProjectId(selectedProject.id); + setNewProjectId((current) => current || selectedProject.id); + return selectedProject.id; + } + + return null; + }; + + const loadPredictionAdminData = async (projectId?: string | null) => { + const scopedProjectId = projectId ?? getCurrentProjectId(); + + if (!scopedProjectId) { + setPredictionCards([]); + setBoardsByBaby({}); + setScoreboard([]); + setOutcomeDraft({}); + setScoresByBabyCard({}); + setScoreDraftByBabyCard({}); + setWizardStepIndex(0); + return; + } + + const project = projects.find((p) => p.id === scopedProjectId); + const babyCount = Math.max(1, project?.babyCount ?? 1); + + const boardPromises = Array.from({ length: babyCount }, (_, i) => + getPredictionBoard(scopedProjectId, i + 1), + ); + + const [cards, nextScoreboard, ...boards] = await Promise.all([ + getPredictionCards(scopedProjectId), + getPredictionScoreboard(scopedProjectId), + ...boardPromises, + ]); + + setPredictionCards(cards); + setScoreboard(nextScoreboard); + + const newBoardsByBaby: Record = {}; + const allOutcomeDrafts: OutcomeDraft = {}; + + for (let i = 0; i < boards.length; i++) { + const babyIdx = i + 1; + newBoardsByBaby[babyIdx] = boards[i]; + const draft = initializeOutcomeDraft(cards, boards[i], babyIdx); + Object.assign(allOutcomeDrafts, draft); + } + + setBoardsByBaby(newBoardsByBaby); + setOutcomeDraft((current) => ({ ...current, ...allOutcomeDrafts })); + }; + + useEffect(() => { + if (!isAdmin) { + return; + } + + Promise.all([loadUsers(), loadProjects()]) + .then(async ([, selectedProjectId]) => { + await loadPredictionAdminData(selectedProjectId); + }) + .catch((error: unknown) => { + if (error instanceof Error && error.message.includes("Session manquante")) { + clearSession(); + setCurrentUser(null); + router.replace("/"); + } + setMessage("Impossible de charger les donnees admin"); + }); + }, [isAdmin, activeProjectId, router]); + + useEffect(() => { + setScoresByBabyCard({}); + setScoreDraftByBabyCard({}); + setWizardStepIndex(0); + }, [primaryBoard?.game?.status, primaryBoard?.game?.finalized]); + + useEffect(() => { + if (!isGameClosed || isFinalized || isRecapStep || !currentStepCard) { + return; + } + + const missingBabies: number[] = []; + for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) { + const key = babyCardKey(babyIdx, currentStepCard.id); + if (!Object.prototype.hasOwnProperty.call(scoresByBabyCard, key)) { + missingBabies.push(babyIdx); + } + } + + if (missingBabies.length === 0) { + return; + } + + setLoading(true); + setPredictionMessage(""); + + Promise.all( + missingBabies.map((babyIdx) => + suggestPredictionScores(currentStepCard.id, activeProjectId, babyIdx).then((entries) => ({ + babyIdx, + entries, + })), + ), + ) + .then((results) => { + setScoresByBabyCard((current) => { + const next = { ...current }; + for (const { babyIdx, entries } of results) { + next[babyCardKey(babyIdx, currentStepCard.id)] = entries; + } + return next; + }); + setScoreDraftByBabyCard((current) => { + const next = { ...current }; + for (const { babyIdx, entries } of results) { + next[babyCardKey(babyIdx, currentStepCard.id)] = createScoreDraft(entries); + } + return next; + }); + }) + .catch((error) => { + setPredictionMessage(error instanceof Error ? error.message : "Erreur de chargement de l'etape"); + }) + .finally(() => { + setLoading(false); + }); + }, [ + activeProjectBabyCount, + activeProjectId, + currentStepCard, + isFinalized, + isGameClosed, + isRecapStep, + scoresByBabyCard, + ]); + + const onCreateUser = async (event: FormEvent) => { + event.preventDefault(); + setLoading(true); + setMessage(""); + + try { + const response = await authenticatedFetch(apiUrl, "/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: newUsername, + displayName: newDisplayName || undefined, + password: newPassword, + role: newRole, + projectId: newRole === "FAMILY" ? newProjectId || undefined : undefined, + }), + }); + + const payload = (await response.json()) as { message?: string }; + + if (!response.ok) { + setMessage(payload.message ?? "Creation impossible"); + return; + } + + setNewUsername(""); + setNewDisplayName(""); + setNewPassword(""); + setNewRole("FAMILY"); + setNewProjectId((current) => current || projects[0]?.id || ""); + setMessage("Compte cree"); + await loadUsers(); + } catch { + setMessage("Erreur reseau pendant la creation"); + } finally { + setLoading(false); + } + }; + + const startEdit = (user: UserListItem) => { + setEditingUserId(user.id); + setEditUsername(user.username); + setEditDisplayName(user.displayName ?? ""); + setEditPassword(""); + setEditRole(user.role); + }; + + const cancelEdit = () => { + setEditingUserId(null); + setEditUsername(""); + setEditDisplayName(""); + setEditPassword(""); + setEditRole("FAMILY"); + }; + + const onSaveEdit = async (userId: string) => { + setLoading(true); + setMessage(""); + + const body: { + username?: string; + displayName?: string; + password?: string; + role?: UserRole; + } = {}; + if (editUsername.trim()) body.username = editUsername.trim(); + if (editDisplayName.trim().length >= 2) { + body.displayName = editDisplayName.trim(); + } + if (editPassword.trim()) body.password = editPassword; + body.role = editRole; + + try { + const response = await authenticatedFetch(apiUrl, `/users/${userId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const payload = (await response.json()) as { message?: string }; + + if (!response.ok) { + setMessage(payload.message ?? "Mise a jour impossible"); + return; + } + + setMessage("Compte mis a jour"); + cancelEdit(); + await loadUsers(); + } catch { + setMessage("Erreur reseau pendant la mise a jour"); + } finally { + setLoading(false); + } + }; + + const onDeleteUser = async (userId: string) => { + setLoading(true); + setMessage(""); + + try { + const response = await authenticatedFetch(apiUrl, `/users/${userId}`, { + method: "DELETE", + }); + + const payload = (await response.json()) as { message?: string }; + + if (!response.ok) { + setMessage(payload.message ?? "Suppression impossible"); + return; + } + + setMessage("Compte supprime"); + await loadUsers(); + } catch { + setMessage("Erreur reseau pendant la suppression"); + } finally { + setLoading(false); + } + }; + + const onAssignUserProject = async (userId: string, projectId: string) => { + setLoading(true); + setMessage(""); + + try { + const response = await authenticatedFetch(apiUrl, `/users/${userId}/project`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectId: projectId || undefined, + }), + }); + + const payload = (await response.json()) as { message?: string }; + + if (!response.ok) { + setMessage(payload.message ?? "Affectation de projet impossible"); + return; + } + + setMessage("Projet utilisateur mis a jour"); + await loadUsers(); + } catch { + setMessage("Erreur reseau pendant l'affectation projet"); + } finally { + setLoading(false); + } + }; + + const patchOutcomeDraft = ( + cardId: string, + babyIndex: number, + fieldId: string, + patch: Partial, + ) => { + const key = buildOutcomeDraftKey(cardId, babyIndex); + + setOutcomeDraft((current) => ({ + ...current, + [key]: { + ...(current[key] ?? {}), + [fieldId]: { + ...(current[key]?.[fieldId] ?? { + valueText: "", + valueNumber: "", + valueDate: "", + }), + ...patch, + }, + }, + })); + }; + + const onToggleGame = async () => { + if (!primaryBoard?.game) { + return; + } + + if (primaryBoard.game.status === "CLOSED" && isFinalized) { + setPredictionMessage("Concours deja valide: reouverture de La Grande Revelation impossible."); + return; + } + + setPredictionMessage(""); + setLoading(true); + + try { + if (primaryBoard.game.status === "OPEN") { + await closePredictionGame(activeProjectId); + setPredictionMessage("La Grande Revelation est cloturee. Passage en mode notation par etapes."); + } else { + await openPredictionGame(activeProjectId); + setPredictionMessage("La Grande Revelation est reouverte. Tu peux encore corriger les valeurs finales."); + } + + await loadPredictionAdminData(); + } catch (error) { + setPredictionMessage(error instanceof Error ? error.message : "Erreur de changement d'etat du concours"); + } finally { + setLoading(false); + } + }; + + const onSaveOutcomes = async () => { + if (!isGameOpen) { + setPredictionMessage("La saisie des valeurs finales est fermee."); + return; + } + + if (predictionCards.length === 0) { + setPredictionMessage("Aucune categorie disponible."); + return; + } + + setLoading(true); + setPredictionMessage(""); + + let submittedCards = 0; + + try { + for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) { + for (const card of predictionCards) { + const cardDraft = outcomeDraft[buildOutcomeDraftKey(card.id, babyIdx)] ?? {}; + const values = card.fields + .map((field) => { + const draft = cardDraft[field.id] ?? { valueText: "", valueNumber: "", valueDate: "" }; + + if (card.valueType === "NUMBER") { + const parsed = Number(draft.valueNumber); + if (Number.isNaN(parsed)) { + return null; + } + return { fieldId: field.id, valueNumber: parsed }; + } + + if (card.valueType === "DATE") { + if (!draft.valueDate) { + return null; + } + return { fieldId: field.id, valueDate: draft.valueDate }; + } + + if (!draft.valueText.trim()) { + return null; + } + + return { fieldId: field.id, valueText: draft.valueText.trim() }; + }) + .filter((value): value is NonNullable => value != null); + + if (values.length === 0) { + continue; + } + + await setPredictionOutcomes(card.id, { selectedBabyIndex: babyIdx, values }, activeProjectId); + submittedCards += 1; + } + } + + if (submittedCards === 0) { + setPredictionMessage("Aucune valeur finale detectee a enregistrer."); + } else { + setPredictionMessage(`${submittedCards} valeur(s) de resultats enregistree(s) pour ${activeProjectBabyCount} bebe(s).`); + } + + await loadPredictionAdminData(); + } catch (error) { + setPredictionMessage(error instanceof Error ? error.message : "Erreur pendant l'enregistrement des resultats"); + } finally { + setLoading(false); + } + }; + + const onRefreshStepSuggestions = async () => { + if (!currentStepCard) { + return; + } + + setLoading(true); + setPredictionMessage(""); + + try { + const results = await Promise.all( + Array.from({ length: activeProjectBabyCount }, (_, i) => i + 1).map((babyIdx) => + suggestPredictionScores(currentStepCard.id, activeProjectId, babyIdx).then((entries) => ({ + babyIdx, + entries, + })), + ), + ); + + setScoresByBabyCard((current) => { + const next = { ...current }; + for (const { babyIdx, entries } of results) { + next[babyCardKey(babyIdx, currentStepCard.id)] = entries; + } + return next; + }); + setScoreDraftByBabyCard((current) => { + const next = { ...current }; + for (const { babyIdx, entries } of results) { + next[babyCardKey(babyIdx, currentStepCard.id)] = createScoreDraft(entries); + } + return next; + }); + setPredictionMessage(`Suggestions de points recalculees pour ${currentStepCard.title}.`); + } catch (error) { + setPredictionMessage(error instanceof Error ? error.message : "Erreur pendant le recalcul des points"); + } finally { + setLoading(false); + } + }; + + const onScoreChange = (babyIdx: number, cardId: string, entryId: string, fieldId: string, value: string) => { + const key = babyCardKey(babyIdx, cardId); + setScoreDraftByBabyCard((current) => ({ + ...current, + [key]: { + ...(current[key] ?? {}), + [entryId]: { + ...(current[key]?.[entryId] ?? {}), + [fieldId]: value, + }, + }, + })); + }; + + const getStepPointsTotal = (babyIdx: number, entry: PredictionScoringEntry) => { + if (!currentStepCard) { + return 0; + } + + const key = babyCardKey(babyIdx, currentStepCard.id); + const draft = scoreDraftByBabyCard[key]?.[entry.id] ?? {}; + + return entry.scores.reduce((total, score) => { + return total + parsePointsInput(draft[score.fieldId], score.awardedPoints ?? score.suggestedPoints); + }, 0); + }; + + const onValidateCurrentStep = async () => { + if (!currentStepCard) { + return; + } + + let hasEntries = false; + for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) { + const entries = scoresByBabyCard[babyCardKey(babyIdx, currentStepCard.id)] ?? []; + if (entries.length > 0) { + hasEntries = true; + break; + } + } + + if (!hasEntries) { + setPredictionMessage("Aucun participant a noter sur cette categorie."); + setWizardStepIndex((current) => Math.min(current + 1, predictionCards.length)); + return; + } + + setLoading(true); + setPredictionMessage(""); + + try { + for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) { + const key = babyCardKey(babyIdx, currentStepCard.id); + const entries = scoresByBabyCard[key] ?? []; + + for (const entry of entries) { + const draftByField = scoreDraftByBabyCard[key]?.[entry.id] ?? {}; + const scores = entry.scores.map((score) => ({ + fieldId: score.fieldId, + awardedPoints: parsePointsInput(draftByField[score.fieldId], score.awardedPoints ?? score.suggestedPoints), + })); + + await validatePredictionScores(entry.id, { scores }); + } + } + + setScoresByBabyCard((current) => { + const next = { ...current }; + for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) { + delete next[babyCardKey(babyIdx, currentStepCard.id)]; + } + return next; + }); + setScoreDraftByBabyCard((current) => { + const next = { ...current }; + for (let babyIdx = 1; babyIdx <= activeProjectBabyCount; babyIdx++) { + delete next[babyCardKey(babyIdx, currentStepCard.id)]; + } + return next; + }); + + await loadPredictionAdminData(); + setPredictionMessage(`Categorie ${currentStepCard.title} validee pour ${activeProjectBabyCount} bebe(s).`); + setWizardStepIndex((current) => Math.min(current + 1, predictionCards.length)); + } catch (error) { + setPredictionMessage(error instanceof Error ? error.message : "Erreur pendant la validation des points"); + } finally { + setLoading(false); + } + }; + + const onFinalizeContest = async () => { + setLoading(true); + setPredictionMessage(""); + + try { + const result = await finalizePredictionGame(activeProjectId); + const names = result.winners + .map((winner) => winner.displayName ?? winner.username) + .join(" / "); + setPredictionMessage(`Concours valide. Gagnant(s): ${names}.`); + await loadPredictionAdminData(); + } catch (error) { + setPredictionMessage(error instanceof Error ? error.message : "Erreur pendant la validation finale du concours"); + } finally { + setLoading(false); + } + }; + + const logout = async () => { + await logoutSession(apiUrl); + setCurrentUser(null); + setUsers([]); + setPredictionCards([]); + setBoardsByBaby({}); + setScoreboard([]); + setMessage("Deconnecte"); + window.location.replace("/"); + }; + + const renderOutcomeCard = (card: PredictionCard, babyIdx: number, boardCard: PredictionBoardCard | null) => { + const draftValues = getOutcomeDraftValues(card, outcomeDraft, babyIdx); + const weightField = card.code === "legacy_weight" ? card.fields[0] : null; + const weightValue = + weightField == null + ? null + : draftValues.find((value) => value.fieldId === weightField.id)?.valueNumber ?? null; + + return ( +
+
+
+

{card.title}

+ {card.description ?

{card.description}

: null} +
+ Resultat final +
+ +
+ {weightField ? ( + { + patchOutcomeDraft(card.id, babyIdx, weightField.id, { + valueNumber: nextValueKg.toFixed(3), + }); + }} + /> + ) : ( + { + patchOutcomeDraft(card.id, babyIdx, fieldId, { valueText: value }); + }} + setNumber={(fieldId, value) => { + patchOutcomeDraft(card.id, babyIdx, fieldId, { valueNumber: value == null ? "" : `${value}` }); + }} + setDate={(fieldId, value) => { + patchOutcomeDraft(card.id, babyIdx, fieldId, { valueDate: value }); + }} + /> + )} +

+ Valeur actuellement enregistree: {boardCard?.outcomes.length + ? boardCard.outcomes + .map((outcome) => + formatLabeledValue(outcome.fieldLabel, { + valueText: outcome.valueText, + valueNumber: outcome.valueNumber, + valueDate: outcome.valueDate, + }, card.unit), + ) + .join(" | ") + : "Aucune valeur encore sauvegardee"} +

+
+
+ ); + }; + + return ( +
+
+
+

Le Juste Poids / Admin

+

Gestion des comptes famille

+
+
+ + Mon profil + + {currentUser ? ( + + ) : null} +
+
+ + {!currentUser || !isAdmin ? ( +
+

Redirection vers la page de connexion...

+
+ ) : ( + <> +
+ + + +
+ + {activeAdminTab === "projects" ? ( +
+
+
+

Gestion des projets

+

+ Cree, clone et selectionne un projet. Ouvre ensuite La Grande Revelation dans l onglet dedie. +

+
+ + Nouveau projet + +
+ + {projects.length === 0 ? ( +

+ Aucun projet pour le moment. Commence par creer le premier projet via le wizard. +

+ ) : ( +
+ {projects.map((project) => ( +
+
+ {project.name} +

+ {project.babyCount} bebe{project.babyCount > 1 ? "s" : ""} • {project.status} +

+
+
+ + Configurer les indices + + + +
+
+ ))} +
+ )} + + {photoModalProjectId ? (() => { + const modalProject = projects.find((p) => p.id === photoModalProjectId); + return modalProject ? ( + { + setProjects((prev) => prev.map((p) => p.id === updated.id ? updated : p)); + }} + onClose={() => setPhotoModalProjectId(null)} + /> + ) : null; + })() : null} +
+ ) : null} + + {activeAdminTab === "users" ? ( + <> +
+

Creer un compte

+
+
+ + setNewUsername(event.target.value)} + /> +
+
+ + setNewDisplayName(event.target.value)} + /> +
+
+ + setNewPassword(event.target.value)} + /> +
+
+ + +
+ {newRole === "FAMILY" ? ( +
+ + +
+ ) : null} + +
+ {message ?

{message}

: null} +
+ +
+

Comptes existants

+
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + +
UsernamePseudoRoleProjetCree leActions
+ {editingUserId === user.id ? ( + setEditUsername(event.target.value)} + /> + ) : ( + user.username + )} + + {editingUserId === user.id ? ( + setEditDisplayName(event.target.value)} + /> + ) : ( + user.displayName ?? "-" + )} + + {editingUserId === user.id ? ( + + ) : ( + user.role + )} + + {user.role === "FAMILY" ? ( + + ) : ( + Admin scope espace + )} + {formatDate(user.createdAt)} +
+ {editingUserId === user.id ? ( + <> + setEditPassword(event.target.value)} + /> + + + + ) : ( + <> + + + + )} +
+
+
+
+ + ) : null} + + {activeAdminTab === "final" ? ( +
+

La Grande Revelation

+ + {!activeProjectId ? ( +

+ Selectionne ou cree d abord un projet pour activer La Grande Revelation. +

+ ) : ( + <> +

+ Projet actif: {activeProjectName ?? "Projet selectionne"} + {activeProjectBabyCount > 1 ? ` — ${activeProjectBabyCount} bebes` : ""} +

+

+ Une fois bebe arrive et ses caracteristiques connues, renseigne ici les resultats finaux. + Ensuite, cloture le concours pour lancer la notation et decouvrir qui a gagne. +

+ + )} + + {!activeProjectId ? null : ( + <> +
+ + Ouvrir les indices de ce projet + +
+ +
+ + Etat: {isGameOpen ? "OUVERT" : isFinalized ? "CLOTURE ET VALIDE" : "CLOTURE"} + + +
+ + {isGameOpen ? ( + <> +

1. Saisie des valeurs finales

+

+ Renseigne les resultats finaux pour {activeProjectBabyCount > 1 ? "chaque bebe" : "le bebe"}. +

+ + {activeProjectBabyCount > 1 ? ( +
+ {Array.from({ length: activeProjectBabyCount }, (_, i) => i + 1).map((babyIdx) => ( +
+

{getBabyLabel(babyIdx)}

+ {predictionCards.map((card) => renderOutcomeCard( + card, + babyIdx, + boardsByBaby[babyIdx]?.cards.find((c) => c.id === card.id) ?? null, + ))} +
+ ))} +
+ ) : ( +
+ {predictionCards.map((card) => renderOutcomeCard( + card, + 1, + boardsByBaby[1]?.cards.find((c) => c.id === card.id) ?? null, + ))} +
+ )} + +
+ +
+ + ) : null} + + {isGameClosed && !isFinalized ? ( +
+
+

2. Notation par categorie

+ + Etape {Math.min(wizardStepIndex + 1, totalWorkflowSteps)} / {totalWorkflowSteps} + +
+ + {!isRecapStep && currentStepCard ? ( + <> +
+ Categorie: {currentStepCard.title} + {Array.from({ length: activeProjectBabyCount }, (_, i) => i + 1).map((babyIdx) => { + const board = boardsByBaby[babyIdx]; + const boardCard = board?.cards.find((c) => c.id === currentStepCard.id); + return ( +

+ {activeProjectBabyCount > 1 ? `${getBabyLabel(babyIdx)}: ` : "Resultat final: "} + {boardCard?.outcomes.length + ? boardCard.outcomes + .map((outcome) => + formatLabeledValue(outcome.fieldLabel, { + valueText: outcome.valueText, + valueNumber: outcome.valueNumber, + valueDate: outcome.valueDate, + }, currentStepCard.unit), + ) + .join(" | ") + : "Aucun resultat final sauvegarde"} +

+ ); + })} +
+ +
+ +
+ + {activeProjectBabyCount > 1 ? ( +
+ {Array.from({ length: activeProjectBabyCount }, (_, i) => i + 1).map((babyIdx) => { + const entries = currentScoringEntriesByBaby[babyIdx] ?? []; + const board = boardsByBaby[babyIdx]; + const boardCard = board?.cards.find((c) => c.id === currentStepCard.id); + const draft = currentScoreDraftByBaby[babyIdx] ?? {}; + + return ( +
+

{getBabyLabel(babyIdx)}

+ {entries.length === 0 ? ( +

Aucun participant pour ce bebe.

+ ) : ( +
+ {entries.map((entry) => { + const boardEntry = boardCard?.entries.find((item) => item.entryId === entry.id) ?? null; + const participantName = entry.user.displayName ?? entry.user.username; + const currentTotal = totalsByUserId.get(entry.userId) ?? 0; + + return ( +
+
+ {participantName} + Total: {currentTotal} pts +
+

+ {boardEntry?.values.length + ? boardEntry.values + .map((value) => + formatLabeledValue(value.fieldLabel, { + valueText: value.valueText, + valueNumber: value.valueNumber, + valueDate: value.valueDate, + }, currentStepCard.unit), + ) + .join(" | ") + : "Aucune reponse"} +

+
+ {entry.scores.map((score) => ( + + ))} +
+

Points: {getStepPointsTotal(babyIdx, entry)}

+
+ ); + })} +
+ )} +
+ ); + })} +
+ ) : ( + <> + {(currentScoringEntriesByBaby[1] ?? []).length === 0 ? ( +

Aucun participant sur cette categorie.

+ ) : ( +
+ {(currentScoringEntriesByBaby[1] ?? []).map((entry) => { + const boardEntry = currentBoardCard?.entries.find((item) => item.entryId === entry.id) ?? null; + const participantName = entry.user.displayName ?? entry.user.username; + const currentTotal = totalsByUserId.get(entry.userId) ?? 0; + const draft = currentScoreDraftByBaby[1] ?? {}; + + return ( +
+
+ {participantName} + Total actuel: {currentTotal} pts +
+

+ Pronostic: {boardEntry?.values.length + ? boardEntry.values + .map((value) => + formatLabeledValue(value.fieldLabel, { + valueText: value.valueText, + valueNumber: value.valueNumber, + valueDate: value.valueDate, + }, currentStepCard.unit), + ) + .join(" | ") + : "Aucune reponse"} +

+
+ {entry.scores.map((score) => ( + + ))} +
+

Points sur cette categorie: {getStepPointsTotal(1, entry)}

+
+ ); + })} +
+ )} + + )} + +
+ + + +
+ + ) : ( + <> +

3. Recap final des points

+

+ Les points sont cumules entre les bebes. Si un participant est le plus proche pour les {activeProjectBabyCount} bebes, il gagne les points x{activeProjectBabyCount}. +

+

Pronostics restants a noter: {remainingEntriesToScore}

+ + {winnerSummary ? ( +
+ + Gagnant(s) provisoire(s): {winnerSummary.winners + .map((winner) => winner.displayName ?? winner.username) + .join(" / ")} + +

Score total: {winnerSummary.points} points (cumul sur {activeProjectBabyCount} bebe{activeProjectBabyCount > 1 ? "s" : ""})

+
+ ) : ( +

Pas encore de classement disponible.

+ )} + + + + + + + + + + + {scoreboard.map((item, index) => ( + + + + + + ))} + +
#ParticipantTotal cumule
{index + 1}{item.displayName ?? item.username}{item.totalPoints}
+ +
+ + +
+ {!canFinalizeContest ? ( +

+ Valide tous les pronostics de chaque categorie{activeProjectBabyCount > 1 ? " pour chaque bebe" : ""} avant la validation finale. +

+ ) : null} + + )} +
+ ) : null} + + {isFinalized ? ( +
+

Concours valide

+ {winnerSummary ? ( +
+ + Gagnant(s): {winnerSummary.winners + .map((winner) => winner.displayName ?? winner.username) + .join(" / ")} + +

{winnerSummary.points} points (cumul {activeProjectBabyCount} bebe{activeProjectBabyCount > 1 ? "s" : ""})

+
+ ) : null} + + + + + + + + + + {scoreboard.map((item, index) => ( + + + + + + ))} + +
#ParticipantTotal cumule
{index + 1}{item.displayName ?? item.username}{item.totalPoints}
+
+ ) : null} + + {predictionMessage ?

{predictionMessage}

: null} + + )} +
+ ) : null} + + )} +
+ ); +} diff --git a/apps/web/src/app/admin/projects/[projectId]/indices/page.module.css b/apps/web/src/app/admin/projects/[projectId]/indices/page.module.css new file mode 100644 index 0000000..57559cd --- /dev/null +++ b/apps/web/src/app/admin/projects/[projectId]/indices/page.module.css @@ -0,0 +1,5 @@ +.photoStepGrid { + display: flex; + flex-direction: column; + gap: 0.75rem; +} diff --git a/apps/web/src/app/admin/projects/[projectId]/indices/page.tsx b/apps/web/src/app/admin/projects/[projectId]/indices/page.tsx new file mode 100644 index 0000000..495d6e5 --- /dev/null +++ b/apps/web/src/app/admin/projects/[projectId]/indices/page.tsx @@ -0,0 +1,594 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { clearSession, getApiUrl, loadSession } from "@/lib/auth"; +import { getMyProjects } from "@/lib/projects-client"; +import { + deleteParentPhoto, + deleteTrimesterPhoto, + getProjectIndices, + uploadParentPhoto, + uploadTrimesterPhoto, + upsertBabyIndices, + upsertBabyTrimester, + upsertParentIndices, +} from "@/lib/indices-client"; +import type { + BabyIndices, + ParentIndices, + ParentType, + ProjectIndicesResponse, + Trimester, +} from "@/types/indices"; +import type { ProjectSummary } from "@/types/projects"; +import { PhotoField } from "@/features/indices/components/PhotoField"; +import styles from "./page.module.css"; +import wizardStyles from "../../new/page.module.css"; + +const TRIMESTERS: Trimester[] = ["DATATION", "T1", "T2", "T3"]; +const TRIMESTER_LABELS: Record = { + DATATION: "Datation (1ère échographie)", + T1: "1er trimestre", + T2: "2ème trimestre", + T3: "3ème trimestre", +}; + +type ParentDraft = { + poids: string; + taille: string; + perimCranien: string; + dateNaissance: string; +}; + +type TrimesterDraft = { + date: string; + note: string; + poids: string; + taille: string; + perimCranien: string; +}; + +function emptyParentDraft(): ParentDraft { + return { poids: "", taille: "", perimCranien: "", dateNaissance: "" }; +} + +function emptyTrimesterDraft(): TrimesterDraft { + return { date: "", note: "", poids: "", taille: "", perimCranien: "" }; +} + +function parentToForm(p: ParentIndices | undefined): ParentDraft { + if (!p) return emptyParentDraft(); + return { + poids: p.poids != null ? String(p.poids) : "", + taille: p.taille != null ? String(p.taille) : "", + perimCranien: p.perimCranien != null ? String(p.perimCranien) : "", + dateNaissance: p.dateNaissance ? p.dateNaissance.slice(0, 10) : "", + }; +} + +function babyTrimesterToForm(baby: BabyIndices | undefined, tri: Trimester): TrimesterDraft { + const entry = baby?.trimesters.find((t) => t.trimester === tri); + if (!entry) return emptyTrimesterDraft(); + return { + date: entry.date ? entry.date.slice(0, 10) : "", + note: entry.note ?? "", + poids: entry.poids != null ? String(entry.poids) : "", + taille: entry.taille != null ? String(entry.taille) : "", + perimCranien: entry.perimCranien != null ? String(entry.perimCranien) : "", + }; +} + +function parseOptionalFloat(val: string): number | undefined { + const n = parseFloat(val); + return Number.isNaN(n) ? undefined : n; +} + +/* ─────────────────────────── Page ─────────────────────────── */ + +export default function AdminIndicesPage() { + const params = useParams<{ projectId: string }>(); + const projectId = params.projectId; + const router = useRouter(); + + const [ready, setReady] = useState(false); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(""); + const [stepIndex, setStepIndex] = useState(0); + + const [project, setProject] = useState(null); + const [indices, setIndices] = useState(null); + + // Papa + const [papaDraft, setPapaDraft] = useState(emptyParentDraft()); + const [papaPhotos, setPapaPhotos] = useState>([]); + + // Maman + const [mamanDraft, setManamDraft] = useState(emptyParentDraft()); + const [mamanPhotos, setManamPhotos] = useState>([]); + + // Bébés — datation + trimestres + DPA + const [babyDpas, setBabyDpas] = useState>({}); + const [trimesterDrafts, setTrimesterDrafts] = useState>({}); + const [trimesterPhotos, setTrimesterPhotos] = useState>>({}); + + const apiBaseUrl = getApiUrl(); + + const babyCount = project?.babyCount ?? 1; + + // Steps: 0=papa mesures, 1=papa photos, 2=maman mesures, 3=maman photos, + // puis par bébé: 4+4n=datation(+DPA), 4+4n+1=T1, 4+4n+2=T2, 4+4n+3=T3 + // Dernier step = recap + const totalBabySteps = babyCount * 4; + const totalSteps = 4 + totalBabySteps + 1; // +1 recap + const recapStepIndex = totalSteps - 1; + + const getBabyLabel = useCallback( + (idx: number) => project?.babies?.find((b) => b.babyIndex === idx)?.label ?? `Bébé ${idx}`, + [project], + ); + + const getBabyStepInfo = (step: number): { babyIndex: number; trimester: Trimester } | null => { + if (step < 4 || step >= 4 + totalBabySteps) return null; + const offset = step - 4; + const babyIndex = Math.floor(offset / 4) + 1; + const triIndex = offset % 4; + return { babyIndex, trimester: TRIMESTERS[triIndex] }; + }; + + const triKey = (babyIndex: number, tri: Trimester) => `${babyIndex}-${tri}`; + + /* ─── Init ─── */ + useEffect(() => { + const session = loadSession(); + if (!session) { router.replace("/"); return; } + if (session.user.role !== "ADMIN") { router.replace("/predictions"); return; } + + Promise.all([getMyProjects(), getProjectIndices(projectId)]) + .then(([projects, idx]) => { + const proj = projects.find((p) => p.id === projectId) ?? null; + setProject(proj); + setIndices(idx); + + // Init papa + const papa = idx.parentIndices.find((p) => p.parentType === "PAPA"); + setPapaDraft(parentToForm(papa)); + setPapaPhotos(papa?.photos.map((ph) => ({ id: ph.id, url: ph.url })) ?? []); + + // Init maman + const maman = idx.parentIndices.find((p) => p.parentType === "MAMAN"); + setManamDraft(parentToForm(maman)); + setManamPhotos(maman?.photos.map((ph) => ({ id: ph.id, url: ph.url })) ?? []); + + // Init bébés + const dpas: Record = {}; + const triDrafts: Record = {}; + const triPh: Record> = {}; + const bc = proj?.babyCount ?? 1; + for (let i = 1; i <= bc; i++) { + const baby = idx.babyIndices.find((b) => b.babyIndex === i); + dpas[i] = baby?.dpa ? baby.dpa.slice(0, 10) : ""; + for (const tri of TRIMESTERS) { + triDrafts[triKey(i, tri)] = babyTrimesterToForm(baby, tri); + const existingTri = baby?.trimesters.find((t) => t.trimester === tri); + triPh[triKey(i, tri)] = existingTri?.photos.map((ph) => ({ id: ph.id, url: ph.url })) ?? []; + } + } + setBabyDpas(dpas); + setTrimesterDrafts(triDrafts); + setTrimesterPhotos(triPh); + }) + .catch((err) => { + setMessage(err instanceof Error ? err.message : "Erreur de chargement"); + }) + .finally(() => setReady(true)); + }, [projectId, router]); + + /* ─── Save current step ─── */ + const saveCurrentStep = useCallback(async () => { + setLoading(true); + setMessage(""); + try { + if (stepIndex === 0) { + await upsertParentIndices(projectId, "PAPA", { + poids: parseOptionalFloat(papaDraft.poids), + taille: parseOptionalFloat(papaDraft.taille), + perimCranien: parseOptionalFloat(papaDraft.perimCranien), + dateNaissance: papaDraft.dateNaissance || null, + }); + } else if (stepIndex === 2) { + await upsertParentIndices(projectId, "MAMAN", { + poids: parseOptionalFloat(mamanDraft.poids), + taille: parseOptionalFloat(mamanDraft.taille), + perimCranien: parseOptionalFloat(mamanDraft.perimCranien), + dateNaissance: mamanDraft.dateNaissance || null, + }); + } else { + const babyStep = getBabyStepInfo(stepIndex); + if (babyStep) { + const { babyIndex, trimester } = babyStep; + const triOffset = stepIndex - 4 - (babyIndex - 1) * 4; + if (triOffset === 0) { + // Datation step: DPA + données de la première échographie + await upsertBabyIndices(projectId, babyIndex, { dpa: babyDpas[babyIndex] || null }); + } + const draft = trimesterDrafts[triKey(babyIndex, trimester)] ?? emptyTrimesterDraft(); + await upsertBabyTrimester(projectId, babyIndex, trimester, { + date: draft.date || null, + note: draft.note || null, + poids: parseOptionalFloat(draft.poids), + taille: parseOptionalFloat(draft.taille), + perimCranien: parseOptionalFloat(draft.perimCranien), + }); + } + } + } catch (err) { + if (err instanceof Error && err.message.includes("Session")) { + clearSession(); + router.replace("/"); + return false; + } + setMessage(err instanceof Error ? err.message : "Erreur de sauvegarde"); + return false; + } finally { + setLoading(false); + } + return true; + }, [stepIndex, projectId, papaDraft, mamanDraft, babyDpas, trimesterDrafts, getBabyStepInfo, router]); + + const goNext = async () => { + if (stepIndex === recapStepIndex) return; + const photoStep = stepIndex === 1 || stepIndex === 3; + let ok = true; + if (!photoStep) { + ok = (await saveCurrentStep()) !== false; + } + if (ok) setStepIndex((s) => s + 1); + }; + + const goBack = () => setStepIndex((s) => Math.max(0, s - 1)); + + /* ─── Photo upload ─── */ + const handleParentPhotosAdd = async (parentType: ParentType, files: File[]) => { + setLoading(true); + try { + for (const file of files) { + const photo = await uploadParentPhoto(projectId, parentType, file); + if (parentType === "PAPA") { + setPapaPhotos((prev) => [...prev, { id: photo.id, url: photo.url }]); + } else { + setManamPhotos((prev) => [...prev, { id: photo.id, url: photo.url }]); + } + } + } catch (err) { + setMessage(err instanceof Error ? err.message : "Erreur upload"); + } finally { + setLoading(false); + } + }; + + const handleTrimesterPhotosAdd = async (babyIndex: number, trimester: Trimester, files: File[]) => { + setLoading(true); + try { + for (const file of files) { + const photo = await uploadTrimesterPhoto(projectId, babyIndex, trimester, file); + const k = triKey(babyIndex, trimester); + setTrimesterPhotos((prev) => ({ ...prev, [k]: [...(prev[k] ?? []), { id: photo.id, url: photo.url }] })); + } + } catch (err) { + setMessage(err instanceof Error ? err.message : "Erreur upload"); + } finally { + setLoading(false); + } + }; + + const removeParentPhoto = async (parentType: ParentType, photoId: string) => { + setLoading(true); + try { + await deleteParentPhoto(projectId, parentType, photoId); + if (parentType === "PAPA") { + setPapaPhotos((prev) => prev.filter((p) => p.id !== photoId)); + } else { + setManamPhotos((prev) => prev.filter((p) => p.id !== photoId)); + } + } catch (err) { + setMessage(err instanceof Error ? err.message : "Erreur suppression"); + } finally { + setLoading(false); + } + }; + + const removeTrimesterPhoto = async (babyIndex: number, trimester: Trimester, photoId: string) => { + setLoading(true); + try { + await deleteTrimesterPhoto(projectId, babyIndex, trimester, photoId); + const k = triKey(babyIndex, trimester); + setTrimesterPhotos((prev) => ({ ...prev, [k]: (prev[k] ?? []).filter((p) => p.id !== photoId) })); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Erreur suppression"); + } finally { + setLoading(false); + } + }; + + /* ─── Helpers render ─── */ + const renderParentForm = (parentType: ParentType, draft: ParentDraft, setDraft: (d: ParentDraft) => void) => { + const label = parentType === "PAPA" ? "Papa" : "Maman"; + return ( +
+

+ Renseigne les informations physiques de naissance de {label}. Tous les champs sont optionnels. +

+
+ + setDraft({ ...draft, poids: e.target.value })} + /> +
+
+ + setDraft({ ...draft, taille: e.target.value })} + /> +
+
+ + setDraft({ ...draft, perimCranien: e.target.value })} + /> +
+
+ + setDraft({ ...draft, dateNaissance: e.target.value })} + /> +
+
+ ); + }; + + const renderPhotoStep = ( + parentType: ParentType, + photos: Array<{ id: string; url: string }>, + label: string, + ) => ( +
+

+ Ajoute jusqu'à 5 photos pour {label} (échographies, portraits...). +

+ void handleParentPhotosAdd(parentType, files)} + onRemove={(id) => void removeParentPhoto(parentType, id)} + /> +
+ ); + + const renderTrimesterStep = (babyIndex: number, trimester: Trimester) => { + const k = triKey(babyIndex, trimester); + const draft = trimesterDrafts[k] ?? emptyTrimesterDraft(); + const photos = trimesterPhotos[k] ?? []; + const triOffset = (stepIndex - 4) % 4; + const isDpaStep = triOffset === 0; + + return ( +
+ {isDpaStep && ( +
+ + setBabyDpas((prev) => ({ ...prev, [babyIndex]: e.target.value }))} + /> +

Optionnel — peut être renseignée plus tard.

+
+ )} + +

+ {TRIMESTER_LABELS[trimester]} — données d'échographie pour {getBabyLabel(babyIndex)}. Tous les champs sont optionnels. +

+ +
+ + setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, date: e.target.value } }))} + /> +
+
+ + setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, poids: e.target.value } }))} + /> +
+
+ + setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, taille: e.target.value } }))} + /> +
+
+ + setTrimesterDrafts((prev) => ({ ...prev, [k]: { ...draft, perimCranien: e.target.value } }))} + /> +
+
+ +