init import projet

This commit is contained in:
2026-05-03 21:53:59 +02:00
parent f3756fdf8d
commit f4795e538c
179 changed files with 37694 additions and 132 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
apps/*/node_modules
.git
.gitignore
.vscode
apps/*/.next
apps/*/dist
npm-debug.log
+12
View File
@@ -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
+14 -131
View File
@@ -1,138 +1,21 @@
# ---> Node # Dependencies
node_modules/
apps/*/node_modules/
# Build output
apps/*/dist/
apps/*/.next/
apps/*/coverage/
# Logs # Logs
logs
*.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Environment files
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
.env .env
.env.development.local apps/*/.env
.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.*
# OS / editor
.DS_Store
Thumbs.db
+16
View File
@@ -0,0 +1,16 @@
{
"servers": {
"framelinkFigma": {
"command": "cmd",
"args": [
"/c",
"npx",
"-y",
"figma-developer-mcp",
"--figma-api-key",
"${env:FIGMA_API_KEY}",
"--stdio"
]
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"prisma.pinToPrisma6": true
}
+125
View File
@@ -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 lexistant. Commence par vérifier l’état actuel (API + front), puis propose et implémente les changements avec build de validation.
```
+88 -1
View File
@@ -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":"<REFRESH_TOKEN>"}'
```
## 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 <JWT_TOKEN>" \
-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
```
+277
View File
@@ -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."
+19
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
coverage
.git
.env
npm-debug.log
+5
View File
@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+33
View File
@@ -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"]
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## 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).
+35
View File
@@ -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" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+83
View File
@@ -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"
}
}
+447
View File
@@ -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])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

+23
View File
@@ -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 {}
+33
View File
@@ -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);
}
}
+29
View File
@@ -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<string>('JWT_SECRET') ?? 'change_me_jwt_secret',
signOptions: {
expiresIn:
(configService.get<string>('JWT_EXPIRES_IN') as StringValue) ?? '1d',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
})
export class AuthModule {}
+179
View File
@@ -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<string>('REFRESH_TOKEN_SECRET') ??
'change_me_refresh_secret',
expiresIn:
(this.configService.get<string>(
'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<string>('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,
};
}
}
+13
View File
@@ -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;
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshTokenDto {
@IsString()
@MinLength(10)
refreshToken!: string;
}
+57
View File
@@ -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<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
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<string>('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');
}
}
}
+5
View File
@@ -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);
+35
View File
@@ -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<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const userRole = request.user?.role;
if (!userRole || !requiredRoles.includes(userRole)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
health() {
return { status: 'ok' };
}
}
@@ -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;
}
@@ -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;
}
+190
View File
@@ -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,
);
}
}
+12
View File
@@ -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 {}
+281
View File
@@ -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 };
}
}
+35
View File
@@ -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();
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -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[];
}
@@ -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[];
}
@@ -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);
}
}
@@ -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 {}
File diff suppressed because it is too large Load Diff
@@ -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);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+9
View File
@@ -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();
}
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class AssignProjectParticipantDto {
@IsString()
@MinLength(1)
userId!: string;
}
@@ -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;
}
@@ -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[];
}
@@ -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[];
}
@@ -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;
@@ -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,
);
}
}
+12
View File
@@ -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 {}
+670
View File
@@ -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);
}
}
@@ -0,0 +1,8 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class AssignUserProjectDto {
@IsOptional()
@IsString()
@MinLength(1)
projectId?: string;
}
+30
View File
@@ -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;
}
@@ -0,0 +1,9 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateMyProfileDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(40)
displayName?: string;
}
+26
View File
@@ -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;
}
+188
View File
@@ -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);
}
}
+12
View File
@@ -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 {}
+526
View File
@@ -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<string>('ADMIN_USERNAME');
const adminPassword = this.configService.get<string>('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,
};
}
}
+29
View File
@@ -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<App>;
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();
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -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
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
.git
.env
npm-debug.log
+41
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+30
View File
@@ -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"]
+36
View File
@@ -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.
+18
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: ["192.168.1.172"],
};
export default nextConfig;
+24
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Some files were not shown because too many files have changed in this diff Show More