init import projet
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
apps/*/node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
apps/*/.next
|
||||||
|
apps/*/dist
|
||||||
|
npm-debug.log
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"framelinkFigma": {
|
||||||
|
"command": "cmd",
|
||||||
|
"args": [
|
||||||
|
"/c",
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"figma-developer-mcp",
|
||||||
|
"--figma-api-key",
|
||||||
|
"${env:FIGMA_API_KEY}",
|
||||||
|
"--stdio"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"prisma.pinToPrisma6": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Project Context - Le Juste Poids
|
||||||
|
|
||||||
|
Ce fichier sert de handoff rapide pour reprendre le projet dans une nouvelle conversation Copilot.
|
||||||
|
|
||||||
|
Specification metier complete:
|
||||||
|
|
||||||
|
- `SPEC_FONCTIONNELLE.md`
|
||||||
|
|
||||||
|
## 1) Stack et structure
|
||||||
|
|
||||||
|
- Monorepo npm workspaces
|
||||||
|
- API: NestJS + Prisma + PostgreSQL
|
||||||
|
- Front: Next.js (App Router)
|
||||||
|
- DB locale: Docker Compose
|
||||||
|
|
||||||
|
Arborescence utile:
|
||||||
|
|
||||||
|
- `apps/api` - backend NestJS
|
||||||
|
- `apps/web` - frontend Next.js
|
||||||
|
- `docker-compose.yml` - stack locale (web/api/db)
|
||||||
|
|
||||||
|
## 2) Commandes utiles
|
||||||
|
|
||||||
|
Installer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Lancer toute la stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Build API + Web:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w apps/api ; npm run build -w apps/web
|
||||||
|
```
|
||||||
|
|
||||||
|
URLs locales attendues:
|
||||||
|
|
||||||
|
- Web: `http://localhost:3002`
|
||||||
|
- API: `http://localhost:3001`
|
||||||
|
- DB: `localhost:5432`
|
||||||
|
|
||||||
|
## 3) Auth et rôles
|
||||||
|
|
||||||
|
- Rôles: `ADMIN`, `FAMILY`
|
||||||
|
- Login JWT + refresh token
|
||||||
|
- `ADMIN` redirigé vers `/admin`
|
||||||
|
- `FAMILY` redirigé vers `/predictions`
|
||||||
|
|
||||||
|
## 4) Workflow concours (état actuel)
|
||||||
|
|
||||||
|
Le concours suit un flux en 3 phases:
|
||||||
|
|
||||||
|
1. `OPEN`
|
||||||
|
- Les participants peuvent saisir/modifier leurs pronostics.
|
||||||
|
- L'admin peut saisir/modifier les valeurs finales (outcomes).
|
||||||
|
|
||||||
|
2. `CLOSED` (après clôture)
|
||||||
|
- Les pronostics participants sont verrouillés.
|
||||||
|
- Les outcomes sont verrouillés.
|
||||||
|
- L'admin passe sur un wizard de scoring par catégorie:
|
||||||
|
- suggestions auto
|
||||||
|
- comparaison réponse finale vs réponses candidats
|
||||||
|
- validation des points étape par étape
|
||||||
|
|
||||||
|
3. `finalized = true` (validation définitive)
|
||||||
|
- Le concours est validé définitivement.
|
||||||
|
- Plus de reouverture possible.
|
||||||
|
- Côté participants, la page `/predictions` affiche:
|
||||||
|
- gagnant
|
||||||
|
- classement final
|
||||||
|
- rappel de "vos réponses"
|
||||||
|
|
||||||
|
## 5) Endpoints pronostics clés
|
||||||
|
|
||||||
|
Public authentifié:
|
||||||
|
|
||||||
|
- `GET /predictions/cards`
|
||||||
|
- `GET /predictions/board`
|
||||||
|
- `GET /predictions/activity`
|
||||||
|
- `GET /predictions/scoreboard`
|
||||||
|
- `GET /predictions/my-entries`
|
||||||
|
- `PUT /predictions/cards/:cardId/my-entry`
|
||||||
|
|
||||||
|
Admin:
|
||||||
|
|
||||||
|
- `POST /predictions/game/close`
|
||||||
|
- `POST /predictions/game/open`
|
||||||
|
- `POST /predictions/game/finalize`
|
||||||
|
- `POST /predictions/cards/:cardId/outcomes`
|
||||||
|
- `POST /predictions/cards/:cardId/suggest-scores`
|
||||||
|
- `PATCH /predictions/entries/:entryId/scores`
|
||||||
|
|
||||||
|
## 6) Fichiers clés à lire en priorité
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
- `apps/api/src/predictions/predictions.controller.ts`
|
||||||
|
- `apps/api/src/predictions/predictions.service.ts`
|
||||||
|
- `apps/api/prisma/schema.prisma`
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- `apps/web/src/app/admin/page.tsx`
|
||||||
|
- `apps/web/src/app/predictions/page.tsx`
|
||||||
|
- `apps/web/src/lib/predictions-client.ts`
|
||||||
|
- `apps/web/src/types/predictions.ts`
|
||||||
|
|
||||||
|
## 7) Vérifications rapides après modif
|
||||||
|
|
||||||
|
1. Build API + web.
|
||||||
|
2. Parcours manuel:
|
||||||
|
- Admin: outcomes en `OPEN`, puis clôture, wizard de scoring, finalisation.
|
||||||
|
- Family: après finalisation, vérifier affichage gagnant + vos réponses.
|
||||||
|
|
||||||
|
## 8) Prompt de reprise prêt à coller
|
||||||
|
|
||||||
|
```text
|
||||||
|
Contexte projet: monorepo Le Juste Poids (NestJS API + Next.js web + Postgres). Lis PROJECT_CONTEXT.md puis inspecte les fichiers clés listés. Je veux que tu poursuives le workflow de fin de concours sans casser l’existant. Commence par vérifier l’état actuel (API + front), puis propose et implémente les changements avec build de validation.
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
# Keep environment variables out of version control
|
||||||
|
.env
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](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).
|
||||||
@@ -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" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
@@ -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
|
||||||
@@ -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 -->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
allowedDevOrigins: ["192.168.1.172"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 787 KiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 787 KiB |