init import projet

This commit is contained in:
2026-05-03 21:58:59 +02:00
parent f3756fdf8d
commit 8d3df9bbbb
179 changed files with 37694 additions and 132 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
coverage
.git
.env
npm-debug.log
+5
View File
@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+33
View File
@@ -0,0 +1,33 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY apps/api/package.json ./apps/api/package.json
COPY apps/web/package.json ./apps/web/package.json
RUN npm ci
COPY . .
RUN npm run prisma:generate -w apps/api
RUN npm run build -w apps/api
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_PATH=/app/apps/api/node_modules
ENV PORT=3000
COPY package*.json ./
COPY apps/api/package.json ./apps/api/package.json
COPY apps/web/package.json ./apps/web/package.json
RUN npm ci --omit=dev
COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
VOLUME ["/app/apps/api/uploads"]
EXPOSE 3000
# During initial setup we use `prisma db push` at container start to bypass baseline migration issues.
CMD ["npm", "run", "start:docker", "-w", "apps/api"]
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start:docker": "npx prisma db push && npx prisma generate && node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1",
"@prisma/client": "^6.13.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"multer": "^2.1.1",
"prisma": "^6.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+447
View File
@@ -0,0 +1,447 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
ADMIN
FAMILY
}
enum GameStatus {
OPEN
CLOSED
}
enum PredictionCardType {
LEGACY
CUSTOM
}
enum PredictionValueType {
NUMBER
TEXT
SELECT
MULTI_TEXT
DATE
}
enum PredictionActivityType {
CARD_CREATED
CARD_UPDATED
CARD_DELETED
PREDICTION_CREATED
PREDICTION_UPDATED
OUTCOME_SET
SCORE_SUGGESTED
SCORE_VALIDATED
GAME_CLOSED
GAME_OPENED
}
enum ParentType {
PAPA
MAMAN
}
enum Trimester {
T1
T2
T3
DATATION
}
enum ProjectStatus {
DRAFT
OPEN
CLOSED
FINALIZED
}
model User {
id String @id @default(cuid())
username String @unique
displayName String?
profileImageUrl String?
profileBgColor String?
passwordHash String
refreshTokenHash String?
workspaceId String?
workspace Workspace? @relation("WorkspaceUsers", fields: [workspaceId], references: [id], onDelete: SetNull)
role UserRole @default(FAMILY)
createdAt DateTime @default(now())
ownedWorkspace Workspace? @relation("WorkspaceOwner")
createdProjects Project[] @relation("ProjectsCreatedBy")
projectMembership ProjectMembership?
assignedMemberships ProjectMembership[] @relation("ProjectMembershipAssignedBy")
predictionCardsCreated PredictionCard[] @relation("PredictionCardsCreatedBy")
predictionEntries PredictionEntry[] @relation("PredictionEntriesByUser")
predictionHistoryEvents PredictionEntryHistory[] @relation("PredictionHistoryByUser")
predictionOutcomesSet PredictionOutcome[] @relation("PredictionOutcomesSetBy")
predictionScoresValidated PredictionFieldScore[] @relation("PredictionScoresValidatedBy")
predictionActivities PredictionActivity[] @relation("PredictionActivitiesByUser")
@@index([workspaceId])
}
model Workspace {
id String @id @default(cuid())
slug String @unique
name String
ownerUserId String @unique
ownerUser User @relation("WorkspaceOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[] @relation("WorkspaceUsers")
projects Project[]
}
model Project {
id String @id @default(cuid())
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdById String
createdBy User @relation("ProjectsCreatedBy", fields: [createdById], references: [id], onDelete: Restrict)
clonedFromProjectId String?
clonedFromProject Project? @relation("ProjectCloneSource", fields: [clonedFromProjectId], references: [id], onDelete: SetNull)
clonedProjects Project[] @relation("ProjectCloneSource")
name String
description String?
projectImageUrl String?
projectBgColor String?
status ProjectStatus @default(DRAFT)
babyCount Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
babies ProjectBaby[]
memberships ProjectMembership[]
games PredictionGame[]
cards PredictionCard[]
entries PredictionEntry[]
outcomes PredictionOutcome[]
fieldScores PredictionFieldScore[]
activities PredictionActivity[]
parentIndices ParentIndices[]
babyIndices BabyIndices[]
@@index([workspaceId, createdAt])
@@index([createdById, createdAt])
}
model ProjectBaby {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
babyIndex Int
label String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([projectId, babyIndex])
@@index([projectId])
}
model ProjectMembership {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
assignedById String?
assignedBy User? @relation("ProjectMembershipAssignedBy", fields: [assignedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([projectId, userId])
@@index([projectId])
}
model PredictionGame {
id String @id @default(cuid())
projectId String?
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
title String @default("Pronostics bebe")
status GameStatus @default(OPEN)
closedAt DateTime?
reopenedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cards PredictionCard[]
entries PredictionEntry[]
activities PredictionActivity[]
@@index([projectId])
}
model PredictionCard {
id String @id @default(cuid())
gameId String @default("singleton")
game PredictionGame @relation(fields: [gameId], references: [id], onDelete: Cascade)
projectId String?
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
code String?
title String
description String?
type PredictionCardType @default(CUSTOM)
valueType PredictionValueType
styleId Int @default(0)
unit String?
isActive Boolean @default(true)
isDeletable Boolean @default(true)
sortOrder Int @default(0)
basePoints Int @default(0)
createdById String
createdBy User @relation("PredictionCardsCreatedBy", fields: [createdById], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields PredictionCardField[]
options PredictionCardOption[]
entries PredictionEntry[]
outcomes PredictionOutcome[]
activities PredictionActivity[]
@@unique([projectId, code])
@@index([gameId, isActive, sortOrder])
@@index([projectId, isActive, sortOrder])
}
model PredictionCardField {
id String @id @default(cuid())
cardId String
card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
label String
sortOrder Int @default(0)
points Int @default(0)
isPrimary Boolean @default(false)
isRequired Boolean @default(false)
minNumber Float?
maxNumber Float?
stepNumber Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
values PredictionEntryValue[]
outcomes PredictionOutcome[]
scores PredictionFieldScore[]
@@unique([cardId, label])
@@index([cardId, sortOrder])
}
model PredictionCardOption {
id String @id @default(cuid())
cardId String
card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
label String
value String
sortOrder Int @default(0)
@@unique([cardId, value])
@@index([cardId, sortOrder])
}
model PredictionEntry {
id String @id @default(cuid())
gameId String @default("singleton")
game PredictionGame @relation(fields: [gameId], references: [id], onDelete: Cascade)
projectId String?
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
userId String
user User @relation("PredictionEntriesByUser", fields: [userId], references: [id], onDelete: Cascade)
cardId String
card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
selectedBabyIndex Int?
totalPoints Int @default(0)
isScored Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
values PredictionEntryValue[]
history PredictionEntryHistory[]
scores PredictionFieldScore[]
activities PredictionActivity[]
@@unique([userId, cardId, selectedBabyIndex])
@@index([cardId])
@@index([userId])
@@index([projectId, userId])
}
model PredictionEntryValue {
id String @id @default(cuid())
entryId String
entry PredictionEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
fieldId String
field PredictionCardField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
valueText String?
valueNumber Float?
valueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([entryId, fieldId])
@@index([fieldId])
}
model PredictionEntryHistory {
id String @id @default(cuid())
entryId String
entry PredictionEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
userId String
user User @relation("PredictionHistoryByUser", fields: [userId], references: [id], onDelete: Cascade)
snapshot Json
message String?
createdAt DateTime @default(now())
@@index([entryId, createdAt])
}
model PredictionOutcome {
id String @id @default(cuid())
cardId String
card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
projectId String?
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
fieldId String
field PredictionCardField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
selectedBabyIndex Int?
valueText String?
valueNumber Float?
valueDate DateTime?
setById String
setBy User @relation("PredictionOutcomesSetBy", fields: [setById], references: [id], onDelete: Restrict)
setAt DateTime @default(now())
@@unique([cardId, fieldId, selectedBabyIndex])
@@index([projectId])
@@index([projectId, selectedBabyIndex])
}
model PredictionFieldScore {
id String @id @default(cuid())
entryId String
entry PredictionEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
projectId String?
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
fieldId String
field PredictionCardField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
suggestedPoints Int @default(0)
awardedPoints Int?
isValidated Boolean @default(false)
note String?
validatedById String?
validatedBy User? @relation("PredictionScoresValidatedBy", fields: [validatedById], references: [id], onDelete: SetNull)
validatedAt DateTime?
updatedAt DateTime @updatedAt
@@unique([entryId, fieldId])
@@index([projectId])
}
model PredictionActivity {
id String @id @default(cuid())
gameId String @default("singleton")
game PredictionGame @relation(fields: [gameId], references: [id], onDelete: Cascade)
projectId String?
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
type PredictionActivityType
userId String?
user User? @relation("PredictionActivitiesByUser", fields: [userId], references: [id], onDelete: SetNull)
cardId String?
card PredictionCard? @relation(fields: [cardId], references: [id], onDelete: SetNull)
entryId String?
entry PredictionEntry? @relation(fields: [entryId], references: [id], onDelete: SetNull)
message String
createdAt DateTime @default(now())
@@index([createdAt])
@@index([projectId, createdAt])
}
// ===== INDICES =====
model ParentIndices {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
parentType ParentType
poids Float?
taille Float?
perimCranien Float?
dateNaissance DateTime?
updatedAt DateTime @updatedAt
photos ParentIndexPhoto[]
@@unique([projectId, parentType])
@@index([projectId])
}
model ParentIndexPhoto {
id String @id @default(cuid())
parentIndicesId String
parentIndices ParentIndices @relation(fields: [parentIndicesId], references: [id], onDelete: Cascade)
url String
sortOrder Int @default(0)
createdAt DateTime @default(now())
@@index([parentIndicesId])
}
model BabyIndices {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
babyIndex Int
dpa DateTime?
updatedAt DateTime @updatedAt
trimesters BabyTrimesterEntry[]
@@unique([projectId, babyIndex])
@@index([projectId])
}
model BabyTrimesterEntry {
id String @id @default(cuid())
babyIndicesId String
babyIndices BabyIndices @relation(fields: [babyIndicesId], references: [id], onDelete: Cascade)
trimester Trimester
date DateTime?
note String?
poids Float?
taille Float?
perimCranien Float?
updatedAt DateTime @updatedAt
photos BabyTrimesterPhoto[]
@@unique([babyIndicesId, trimester])
@@index([babyIndicesId])
}
model BabyTrimesterPhoto {
id String @id @default(cuid())
trimesterEntryId String
trimesterEntry BabyTrimesterEntry @relation(fields: [trimesterEntryId], references: [id], onDelete: Cascade)
url String
sortOrder Int @default(0)
createdAt DateTime @default(now())
@@index([trimesterEntryId])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
import { HealthController } from './health.controller';
import { IndicesModule } from './indices/indices.module';
import { PredictionsModule } from './predictions/predictions.module';
import { ProjectsModule } from './projects/projects.module';
import { PrismaModule } from './prisma/prisma.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
UsersModule,
ProjectsModule,
PredictionsModule,
IndicesModule,
],
controllers: [HealthController],
})
export class AppModule {}
+33
View File
@@ -0,0 +1,33 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import type { AuthenticatedRequest } from './jwt-auth.guard';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@Post('refresh')
refresh(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refresh(refreshTokenDto);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
logout(@Req() request: AuthenticatedRequest) {
return this.authService.logout(request.user!.sub);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@Req() request: AuthenticatedRequest) {
return this.authService.me(request.user!.sub);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import type { StringValue } from 'ms';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RolesGuard } from './roles.guard';
@Module({
imports: [
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') ?? 'change_me_jwt_secret',
signOptions: {
expiresIn:
(configService.get<string>('JWT_EXPIRES_IN') as StringValue) ?? '1d',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
})
export class AuthModule {}
+179
View File
@@ -0,0 +1,179 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prismaService: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
private async issueTokens(user: {
id: string;
username: string;
role: string;
displayName: string | null;
profileImageUrl: string | null;
workspaceId: string | null;
createdAt: Date;
}) {
const membership = await this.prismaService.projectMembership.findUnique({
where: { userId: user.id },
select: { projectId: true },
});
const currentProjectId = membership?.projectId ?? null;
const accessToken = await this.jwtService.signAsync({
sub: user.id,
username: user.username,
role: user.role,
workspaceId: user.workspaceId,
projectId: currentProjectId,
});
const refreshToken = await this.jwtService.signAsync(
{ sub: user.id, type: 'refresh' },
{
secret:
this.configService.get<string>('REFRESH_TOKEN_SECRET') ??
'change_me_refresh_secret',
expiresIn:
(this.configService.get<string>(
'REFRESH_TOKEN_EXPIRES_IN',
) as StringValue) ?? '30d',
},
);
const refreshTokenHash = await bcrypt.hash(refreshToken, 12);
await this.prismaService.user.update({
where: { id: user.id },
data: { refreshTokenHash },
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
username: user.username,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
workspaceId: user.workspaceId,
currentProjectId,
role: user.role,
createdAt: user.createdAt,
},
};
}
async login(loginDto: LoginDto) {
const normalizedUsername = loginDto.username.trim();
const user = await this.prismaService.user.findUnique({
where: { username: normalizedUsername },
});
const caseInsensitiveUser = user ?? (await this.prismaService.user.findFirst({
where: {
username: {
equals: normalizedUsername,
mode: 'insensitive',
},
},
}));
if (!caseInsensitiveUser) {
throw new UnauthorizedException('Invalid credentials');
}
const passwordMatches = await bcrypt.compare(
loginDto.password,
caseInsensitiveUser.passwordHash,
);
if (!passwordMatches) {
throw new UnauthorizedException('Invalid credentials');
}
return this.issueTokens(caseInsensitiveUser);
}
async refresh(refreshTokenDto: RefreshTokenDto) {
const refreshSecret =
this.configService.get<string>('REFRESH_TOKEN_SECRET') ??
'change_me_refresh_secret';
try {
const payload = await this.jwtService.verifyAsync(refreshTokenDto.refreshToken, {
secret: refreshSecret,
});
if (payload.type !== 'refresh') {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.prismaService.user.findUnique({
where: { id: payload.sub as string },
});
if (!user || !user.refreshTokenHash) {
throw new UnauthorizedException('Invalid refresh token');
}
const isRefreshTokenValid = await bcrypt.compare(
refreshTokenDto.refreshToken,
user.refreshTokenHash,
);
if (!isRefreshTokenValid) {
throw new UnauthorizedException('Invalid refresh token');
}
return this.issueTokens(user);
} catch {
throw new UnauthorizedException('Invalid or expired refresh token');
}
}
async logout(userId: string) {
await this.prismaService.user.updateMany({
where: { id: userId },
data: { refreshTokenHash: null },
});
return { loggedOut: true };
}
async me(userId: string) {
const user = await this.prismaService.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException('User not found');
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
workspaceId: user.workspaceId,
currentProjectId: (
await this.prismaService.projectMembership.findUnique({
where: { userId: user.id },
select: { projectId: true },
})
)?.projectId ?? null,
role: user.role,
createdAt: user.createdAt,
};
}
}
+13
View File
@@ -0,0 +1,13 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
@MinLength(3)
@MaxLength(40)
username!: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password!: string;
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshTokenDto {
@IsString()
@MinLength(10)
refreshToken!: string;
}
+57
View File
@@ -0,0 +1,57 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { UserRole } from '@prisma/client';
export type AuthenticatedRequest = Request & {
user?: {
sub: string;
username: string;
role: UserRole;
workspaceId?: string | null;
projectId?: string | null;
};
};
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing bearer token');
}
const token = authHeader.slice('Bearer '.length);
try {
const payload = await this.jwtService.verifyAsync(token, {
secret:
this.configService.get<string>('JWT_SECRET') ?? 'change_me_jwt_secret',
});
request.user = {
sub: payload.sub as string,
username: payload.username as string,
role: payload.role as UserRole,
workspaceId: (payload.workspaceId as string | null | undefined) ?? null,
projectId: (payload.projectId as string | null | undefined) ?? null,
};
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
}
+5
View File
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+35
View File
@@ -0,0 +1,35 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '@prisma/client';
import { AuthenticatedRequest } from './jwt-auth.guard';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const userRole = request.user?.role;
if (!userRole || !requiredRoles.includes(userRole)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
health() {
return { status: 'ok' };
}
}
@@ -0,0 +1,35 @@
import { IsDateString, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class UpsertBabyIndicesDto {
@IsOptional()
@IsDateString()
dpa?: string;
}
export class UpsertBabyTrimesterDto {
@IsOptional()
@IsDateString()
date?: string;
@IsOptional()
@IsString()
note?: string;
@IsOptional()
@IsNumber()
@Min(0.1)
@Max(15)
poids?: number;
@IsOptional()
@IsNumber()
@Min(0.1)
@Max(60)
taille?: number;
@IsOptional()
@IsNumber()
@Min(1)
@Max(50)
perimCranien?: number;
}
@@ -0,0 +1,25 @@
import { IsDateString, IsNumber, IsOptional, Max, Min } from 'class-validator';
export class UpsertParentIndicesDto {
@IsOptional()
@IsNumber()
@Min(0.5)
@Max(8)
poids?: number;
@IsOptional()
@IsNumber()
@Min(30)
@Max(70)
taille?: number;
@IsOptional()
@IsNumber()
@Min(20)
@Max(45)
perimCranien?: number;
@IsOptional()
@IsDateString()
dateNaissance?: string;
}
+190
View File
@@ -0,0 +1,190 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
MaxFileSizeValidator,
Param,
ParseFilePipe,
ParseIntPipe,
Post,
Put,
Req,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ParentType, Trimester, UserRole } from '@prisma/client';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import type { AuthenticatedRequest } from '../auth/jwt-auth.guard';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { IndicesService } from './indices.service';
import { UpsertParentIndicesDto } from './dto/upsert-parent-indices.dto';
import { UpsertBabyIndicesDto, UpsertBabyTrimesterDto } from './dto/upsert-baby-indices.dto';
const uploadsDir = join(process.cwd(), 'uploads');
const storage = diskStorage({
destination: (_req, _file, callback) => {
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true });
}
callback(null, uploadsDir);
},
filename: (_req, file, callback) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
callback(null, `indices-${uniqueSuffix}${extname(file.originalname)}`);
},
});
@Controller('projects/:projectId/indices')
@UseGuards(JwtAuthGuard)
export class IndicesController {
constructor(private readonly indicesService: IndicesService) {}
@Get()
getProjectIndices(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
) {
return this.indicesService.getProjectIndices(request.user!.sub, projectId);
}
@Put('parents/:parentType')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
upsertParentIndices(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('parentType') parentType: ParentType,
@Body() dto: UpsertParentIndicesDto,
) {
return this.indicesService.upsertParentIndices(request.user!.sub, projectId, parentType, dto);
}
@Post('parents/:parentType/photos')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileInterceptor('file', { storage }))
addParentPhoto(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('parentType') parentType: ParentType,
@UploadedFile(
new ParseFilePipe({
validators: [new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 })],
fileIsRequired: true,
}),
)
file: Express.Multer.File,
) {
const allowed = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.');
}
return this.indicesService.addParentPhoto(
request.user!.sub,
projectId,
parentType,
`/uploads/${file.filename}`,
);
}
@Delete('parents/:parentType/photos/:photoId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
deleteParentPhoto(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('parentType') parentType: ParentType,
@Param('photoId') photoId: string,
) {
return this.indicesService.deleteParentPhoto(request.user!.sub, projectId, parentType, photoId);
}
@Put('babies/:babyIndex')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
upsertBabyIndices(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('babyIndex', ParseIntPipe) babyIndex: number,
@Body() dto: UpsertBabyIndicesDto,
) {
return this.indicesService.upsertBabyIndices(request.user!.sub, projectId, babyIndex, dto);
}
@Put('babies/:babyIndex/trimesters/:trimester')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
upsertBabyTrimester(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('babyIndex', ParseIntPipe) babyIndex: number,
@Param('trimester') trimester: Trimester,
@Body() dto: UpsertBabyTrimesterDto,
) {
return this.indicesService.upsertBabyTrimester(
request.user!.sub,
projectId,
babyIndex,
trimester,
dto,
);
}
@Post('babies/:babyIndex/trimesters/:trimester/photos')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileInterceptor('file', { storage }))
addTrimesterPhoto(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('babyIndex', ParseIntPipe) babyIndex: number,
@Param('trimester') trimester: Trimester,
@UploadedFile(
new ParseFilePipe({
validators: [new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 })],
fileIsRequired: true,
}),
)
file: Express.Multer.File,
) {
const allowed = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.');
}
return this.indicesService.addTrimesterPhoto(
request.user!.sub,
projectId,
babyIndex,
trimester,
`/uploads/${file.filename}`,
);
}
@Delete('babies/:babyIndex/trimesters/:trimester/photos/:photoId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
deleteTrimesterPhoto(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('babyIndex', ParseIntPipe) babyIndex: number,
@Param('trimester') trimester: Trimester,
@Param('photoId') photoId: string,
) {
return this.indicesService.deleteTrimesterPhoto(
request.user!.sub,
projectId,
babyIndex,
trimester,
photoId,
);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { IndicesController } from './indices.controller';
import { IndicesService } from './indices.service';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [IndicesController],
providers: [IndicesService],
})
export class IndicesModule {}
+281
View File
@@ -0,0 +1,281 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ParentType, Trimester, UserRole } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { UpsertParentIndicesDto } from './dto/upsert-parent-indices.dto';
import { UpsertBabyIndicesDto, UpsertBabyTrimesterDto } from './dto/upsert-baby-indices.dto';
const MAX_PHOTOS_PER_SECTION = 5;
@Injectable()
export class IndicesService {
constructor(private readonly prisma: PrismaService) {}
private async assertAccess(
userId: string,
projectId: string,
options: { adminOnly?: boolean } = {},
) {
const [user, project, membership] = await Promise.all([
this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, role: true, workspaceId: true },
}),
this.prisma.project.findUnique({
where: { id: projectId },
select: { id: true, workspaceId: true },
}),
this.prisma.projectMembership.findUnique({
where: { userId },
select: { projectId: true },
}),
]);
if (!user) throw new NotFoundException('Utilisateur introuvable');
if (!project) throw new NotFoundException('Projet introuvable');
if (!user.workspaceId || user.workspaceId !== project.workspaceId) {
throw new ForbiddenException('Acces refuse a ce projet');
}
if (user.role === UserRole.ADMIN) return { user, isAdmin: true };
if (options.adminOnly) throw new ForbiddenException('Action reservee aux admins');
if (!membership || membership.projectId !== projectId) {
throw new ForbiddenException('Vous n etes pas rattache a ce projet');
}
return { user, isAdmin: false };
}
async getProjectIndices(userId: string, projectId: string) {
await this.assertAccess(userId, projectId);
const [parentIndices, babyIndices] = await Promise.all([
this.prisma.parentIndices.findMany({
where: { projectId },
include: {
photos: { orderBy: { sortOrder: 'asc' } },
},
orderBy: { parentType: 'asc' },
}),
this.prisma.babyIndices.findMany({
where: { projectId },
include: {
trimesters: {
include: {
photos: { orderBy: { sortOrder: 'asc' } },
},
orderBy: { trimester: 'asc' },
},
},
orderBy: { babyIndex: 'asc' },
}),
]);
return { parentIndices, babyIndices };
}
async upsertParentIndices(
userId: string,
projectId: string,
parentType: ParentType,
dto: UpsertParentIndicesDto,
) {
await this.assertAccess(userId, projectId, { adminOnly: true });
return this.prisma.parentIndices.upsert({
where: { projectId_parentType: { projectId, parentType } },
create: {
projectId,
parentType,
poids: dto.poids ?? null,
taille: dto.taille ?? null,
perimCranien: dto.perimCranien ?? null,
dateNaissance: dto.dateNaissance ? new Date(dto.dateNaissance) : null,
},
update: {
poids: dto.poids ?? null,
taille: dto.taille ?? null,
perimCranien: dto.perimCranien ?? null,
dateNaissance: dto.dateNaissance ? new Date(dto.dateNaissance) : null,
},
include: { photos: { orderBy: { sortOrder: 'asc' } } },
});
}
async addParentPhoto(userId: string, projectId: string, parentType: ParentType, url: string) {
await this.assertAccess(userId, projectId, { adminOnly: true });
const parentIndices = await this.prisma.parentIndices.upsert({
where: { projectId_parentType: { projectId, parentType } },
create: { projectId, parentType },
update: {},
select: { id: true },
});
const count = await this.prisma.parentIndexPhoto.count({
where: { parentIndicesId: parentIndices.id },
});
if (count >= MAX_PHOTOS_PER_SECTION) {
throw new BadRequestException(`Maximum ${MAX_PHOTOS_PER_SECTION} photos par section`);
}
return this.prisma.parentIndexPhoto.create({
data: { parentIndicesId: parentIndices.id, url, sortOrder: count },
});
}
async deleteParentPhoto(userId: string, projectId: string, parentType: ParentType, photoId: string) {
await this.assertAccess(userId, projectId, { adminOnly: true });
const photo = await this.prisma.parentIndexPhoto.findUnique({
where: { id: photoId },
include: { parentIndices: { select: { projectId: true, parentType: true } } },
});
if (!photo || photo.parentIndices.projectId !== projectId || photo.parentIndices.parentType !== parentType) {
throw new NotFoundException('Photo introuvable');
}
await this.prisma.parentIndexPhoto.delete({ where: { id: photoId } });
return { deleted: true };
}
async upsertBabyIndices(
userId: string,
projectId: string,
babyIndex: number,
dto: UpsertBabyIndicesDto,
) {
await this.assertAccess(userId, projectId, { adminOnly: true });
return this.prisma.babyIndices.upsert({
where: { projectId_babyIndex: { projectId, babyIndex } },
create: {
projectId,
babyIndex,
dpa: dto.dpa ? new Date(dto.dpa) : null,
},
update: {
dpa: dto.dpa ? new Date(dto.dpa) : null,
},
include: {
trimesters: {
include: { photos: { orderBy: { sortOrder: 'asc' } } },
orderBy: { trimester: 'asc' },
},
},
});
}
async upsertBabyTrimester(
userId: string,
projectId: string,
babyIndex: number,
trimester: Trimester,
dto: UpsertBabyTrimesterDto,
) {
await this.assertAccess(userId, projectId, { adminOnly: true });
const babyIndices = await this.prisma.babyIndices.upsert({
where: { projectId_babyIndex: { projectId, babyIndex } },
create: { projectId, babyIndex },
update: {},
select: { id: true },
});
return this.prisma.babyTrimesterEntry.upsert({
where: { babyIndicesId_trimester: { babyIndicesId: babyIndices.id, trimester } },
create: {
babyIndicesId: babyIndices.id,
trimester,
date: dto.date ? new Date(dto.date) : null,
note: dto.note ?? null,
poids: dto.poids ?? null,
taille: dto.taille ?? null,
perimCranien: dto.perimCranien ?? null,
},
update: {
date: dto.date ? new Date(dto.date) : null,
note: dto.note ?? null,
poids: dto.poids ?? null,
taille: dto.taille ?? null,
perimCranien: dto.perimCranien ?? null,
},
include: { photos: { orderBy: { sortOrder: 'asc' } } },
});
}
async addTrimesterPhoto(
userId: string,
projectId: string,
babyIndex: number,
trimester: Trimester,
url: string,
) {
await this.assertAccess(userId, projectId, { adminOnly: true });
const babyIndices = await this.prisma.babyIndices.upsert({
where: { projectId_babyIndex: { projectId, babyIndex } },
create: { projectId, babyIndex },
update: {},
select: { id: true },
});
const entry = await this.prisma.babyTrimesterEntry.upsert({
where: { babyIndicesId_trimester: { babyIndicesId: babyIndices.id, trimester } },
create: { babyIndicesId: babyIndices.id, trimester },
update: {},
select: { id: true },
});
const count = await this.prisma.babyTrimesterPhoto.count({
where: { trimesterEntryId: entry.id },
});
if (count >= MAX_PHOTOS_PER_SECTION) {
throw new BadRequestException(`Maximum ${MAX_PHOTOS_PER_SECTION} photos par trimestre`);
}
return this.prisma.babyTrimesterPhoto.create({
data: { trimesterEntryId: entry.id, url, sortOrder: count },
});
}
async deleteTrimesterPhoto(
userId: string,
projectId: string,
babyIndex: number,
trimester: Trimester,
photoId: string,
) {
await this.assertAccess(userId, projectId, { adminOnly: true });
const photo = await this.prisma.babyTrimesterPhoto.findUnique({
where: { id: photoId },
include: {
trimesterEntry: {
include: {
babyIndices: { select: { projectId: true, babyIndex: true } },
},
},
},
});
if (
!photo ||
photo.trimesterEntry.babyIndices.projectId !== projectId ||
photo.trimesterEntry.babyIndices.babyIndex !== babyIndex ||
photo.trimesterEntry.trimester !== trimester
) {
throw new NotFoundException('Photo introuvable');
}
await this.prisma.babyTrimesterPhoto.delete({ where: { id: photoId } });
return { deleted: true };
}
}
+35
View File
@@ -0,0 +1,35 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import * as express from 'express';
import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const frontendUrls = process.env.FRONTEND_URL
? process.env.FRONTEND_URL.split(',').map((url) => url.trim()).filter(Boolean)
: ['http://localhost:3002'];
const uploadsDir = join(process.cwd(), 'uploads');
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true });
}
app.use('/uploads', express.static(uploadsDir));
app.use('/default-avatars', express.static(join(process.cwd(), 'public', 'default-avatars')));
app.use('/default-project-avatars', express.static(join(process.cwd(), 'public', 'default-project-avatars')));
app.enableCors({
origin: frontendUrls,
credentials: true,
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
@@ -0,0 +1,37 @@
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CardFieldDto {
@IsString()
@MaxLength(80)
label!: string;
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
@IsOptional()
@IsInt()
@Min(0)
points?: number;
@IsOptional()
@IsBoolean()
isPrimary?: boolean;
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@IsOptional()
@IsNumber()
minNumber?: number;
@IsOptional()
@IsNumber()
maxNumber?: number;
@IsOptional()
@IsNumber()
stepNumber?: number;
}
@@ -0,0 +1,16 @@
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CardOptionDto {
@IsString()
@MaxLength(80)
label!: string;
@IsString()
@MaxLength(80)
value!: string;
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
}
@@ -0,0 +1,61 @@
import { PredictionCardType, PredictionValueType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { CardFieldDto } from './card-field.dto';
import { CardOptionDto } from './card-option.dto';
export class CreateCardDto {
@IsString()
@MaxLength(120)
title!: string;
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
@IsEnum(PredictionCardType)
type!: PredictionCardType;
@IsEnum(PredictionValueType)
valueType!: PredictionValueType;
@IsOptional()
@IsInt()
styleId?: number;
@IsOptional()
@IsString()
@MaxLength(20)
unit?: string;
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
@IsOptional()
@IsInt()
@Min(0)
basePoints?: number;
@IsArray()
@ValidateNested({ each: true })
@Type(() => CardFieldDto)
fields!: CardFieldDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CardOptionDto)
options?: CardOptionDto[];
}
@@ -0,0 +1,94 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export class PredictionValueDto {
@IsString()
fieldId!: string;
@IsOptional()
@IsString()
@MaxLength(200)
valueText?: string;
@IsOptional()
@IsNumber()
valueNumber?: number;
@IsOptional()
@IsDateString()
valueDate?: string;
}
export class UpsertPredictionEntryDto {
@IsOptional()
@IsInt()
@Min(1)
selectedBabyIndex?: number;
@IsArray()
@ValidateNested({ each: true })
@Type(() => PredictionValueDto)
values!: PredictionValueDto[];
}
export class OutcomeValueDto {
@IsString()
fieldId!: string;
@IsOptional()
@IsString()
@MaxLength(200)
valueText?: string;
@IsOptional()
@IsNumber()
valueNumber?: number;
@IsOptional()
@IsDateString()
valueDate?: string;
}
export class SetOutcomesDto {
@IsOptional()
@IsInt()
@Min(1)
selectedBabyIndex?: number;
@IsArray()
@ValidateNested({ each: true })
@Type(() => OutcomeValueDto)
values!: OutcomeValueDto[];
}
export class ValidateScoreItemDto {
@IsString()
fieldId!: string;
@IsOptional()
@IsInt()
@Min(0)
awardedPoints?: number;
@IsOptional()
@IsString()
@MaxLength(300)
note?: string;
}
export class ValidateScoresDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ValidateScoreItemDto)
scores!: ValidateScoreItemDto[];
}
@@ -0,0 +1,66 @@
import { PredictionValueType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { CardFieldDto } from './card-field.dto';
import { CardOptionDto } from './card-option.dto';
export class UpdateCardDto {
@IsOptional()
@IsString()
@MaxLength(120)
title?: string;
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
@IsOptional()
@IsEnum(PredictionValueType)
valueType?: PredictionValueType;
@IsOptional()
@IsInt()
styleId?: number;
@IsOptional()
@IsString()
@MaxLength(20)
unit?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
@IsOptional()
@IsInt()
@Min(0)
basePoints?: number;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CardFieldDto)
fields?: CardFieldDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CardOptionDto)
options?: CardOptionDto[];
}
@@ -0,0 +1,157 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthenticatedRequest } from '../auth/jwt-auth.guard';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { CreateCardDto } from './dto/create-card.dto';
import {
SetOutcomesDto,
UpsertPredictionEntryDto,
ValidateScoresDto,
} from './dto/prediction-value.dto';
import { UpdateCardDto } from './dto/update-card.dto';
import { PredictionsService } from './predictions.service';
@Controller('predictions')
@UseGuards(JwtAuthGuard)
export class PredictionsController {
constructor(private readonly predictionsService: PredictionsService) {}
@Get('cards')
listCards() {
return this.predictionsService.listCards(false);
}
@Get('board')
board(
@Req() request: AuthenticatedRequest,
@Query('selectedBabyIndex') selectedBabyIndex?: string,
) {
return this.predictionsService.getBoard(
request.user!.sub,
undefined,
selectedBabyIndex ? Number(selectedBabyIndex) : undefined,
);
}
@Get('activity')
activity(@Req() request: AuthenticatedRequest) {
return this.predictionsService.listActivity(request.user!.sub);
}
@Get('scoreboard')
scoreboard() {
return this.predictionsService.getScoreboard();
}
@Get('my-entries')
myEntries(@Req() request: AuthenticatedRequest) {
return this.predictionsService.listMyEntries(request.user!.sub);
}
@Put('cards/:cardId/my-entry')
upsertMyEntry(
@Req() request: AuthenticatedRequest,
@Param('cardId') cardId: string,
@Body() dto: UpsertPredictionEntryDto,
) {
return this.predictionsService.upsertMyPrediction(request.user!.sub, cardId, dto);
}
@Post('game/close')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
closeGame(@Req() request: AuthenticatedRequest) {
return this.predictionsService.closeGame(request.user!.sub);
}
@Post('game/open')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
openGame(@Req() request: AuthenticatedRequest) {
return this.predictionsService.openGame(request.user!.sub);
}
@Post('game/finalize')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
finalizeGame(@Req() request: AuthenticatedRequest) {
return this.predictionsService.finalizeGame(request.user!.sub);
}
@Post('cards')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
createCard(@Req() request: AuthenticatedRequest, @Body() dto: CreateCardDto) {
return this.predictionsService.createCard(request.user!.sub, dto);
}
@Patch('cards/:cardId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
updateCard(
@Req() request: AuthenticatedRequest,
@Param('cardId') cardId: string,
@Body() dto: UpdateCardDto,
) {
return this.predictionsService.updateCard(request.user!.sub, cardId, dto);
}
@Delete('cards/:cardId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
deleteCard(@Req() request: AuthenticatedRequest, @Param('cardId') cardId: string) {
return this.predictionsService.deleteCard(request.user!.sub, cardId);
}
@Post('cards/:cardId/outcomes')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
setOutcomes(
@Req() request: AuthenticatedRequest,
@Param('cardId') cardId: string,
@Body() dto: SetOutcomesDto,
) {
return this.predictionsService.setCardOutcomes(request.user!.sub, cardId, dto);
}
@Post('cards/:cardId/suggest-scores')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
suggestScores(
@Req() request: AuthenticatedRequest,
@Param('cardId') cardId: string,
@Query('selectedBabyIndex') selectedBabyIndex?: string,
) {
return this.predictionsService.suggestScoresForCard(
request.user!.sub,
cardId,
undefined,
selectedBabyIndex ? Number(selectedBabyIndex) : undefined,
);
}
@Patch('entries/:entryId/scores')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
validateScores(
@Req() request: AuthenticatedRequest,
@Param('entryId') entryId: string,
@Body() dto: ValidateScoresDto,
) {
return this.predictionsService.validateScores(request.user!.sub, entryId, dto);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProjectPredictionsController } from './project-predictions.controller';
import { PredictionsController } from './predictions.controller';
import { PredictionsService } from './predictions.service';
@Module({
imports: [AuthModule],
controllers: [PredictionsController, ProjectPredictionsController],
providers: [PredictionsService],
exports: [PredictionsService],
})
export class PredictionsModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthenticatedRequest } from '../auth/jwt-auth.guard';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { CreateCardDto } from './dto/create-card.dto';
import {
SetOutcomesDto,
UpsertPredictionEntryDto,
ValidateScoresDto,
} from './dto/prediction-value.dto';
import { UpdateCardDto } from './dto/update-card.dto';
import { PredictionsService } from './predictions.service';
@Controller('projects/:projectId/predictions')
@UseGuards(JwtAuthGuard)
export class ProjectPredictionsController {
constructor(private readonly predictionsService: PredictionsService) {}
@Get('cards')
listCards(@Param('projectId') projectId: string) {
return this.predictionsService.listCards(false, projectId);
}
@Get('board')
board(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Query('selectedBabyIndex') selectedBabyIndex?: string,
) {
return this.predictionsService.getBoard(
request.user!.sub,
projectId,
selectedBabyIndex ? Number(selectedBabyIndex) : undefined,
);
}
@Get('activity')
activity(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
return this.predictionsService.listActivity(request.user!.sub, projectId);
}
@Get('scoreboard')
scoreboard(@Param('projectId') projectId: string) {
return this.predictionsService.getScoreboard(projectId);
}
@Get('my-entries')
myEntries(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
return this.predictionsService.listMyEntries(request.user!.sub, projectId);
}
@Put('cards/:cardId/my-entry')
upsertMyEntry(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('cardId') cardId: string,
@Body() dto: UpsertPredictionEntryDto,
) {
return this.predictionsService.upsertMyPrediction(request.user!.sub, cardId, dto, projectId);
}
@Post('game/close')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
closeGame(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
return this.predictionsService.closeGame(request.user!.sub, projectId);
}
@Post('game/open')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
openGame(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
return this.predictionsService.openGame(request.user!.sub, projectId);
}
@Post('game/finalize')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
finalizeGame(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
return this.predictionsService.finalizeGame(request.user!.sub, projectId);
}
@Post('cards')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
createCard(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Body() dto: CreateCardDto,
) {
return this.predictionsService.createCard(request.user!.sub, dto, projectId);
}
@Patch('cards/:cardId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
updateCard(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('cardId') cardId: string,
@Body() dto: UpdateCardDto,
) {
return this.predictionsService.updateCard(request.user!.sub, cardId, dto, projectId);
}
@Delete('cards/:cardId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
deleteCard(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('cardId') cardId: string,
) {
return this.predictionsService.deleteCard(request.user!.sub, cardId, projectId);
}
@Post('cards/:cardId/outcomes')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
setOutcomes(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('cardId') cardId: string,
@Body() dto: SetOutcomesDto,
) {
return this.predictionsService.setCardOutcomes(request.user!.sub, cardId, dto, projectId);
}
@Post('cards/:cardId/suggest-scores')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
suggestScores(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('cardId') cardId: string,
@Query('selectedBabyIndex') selectedBabyIndex?: string,
) {
return this.predictionsService.suggestScoresForCard(
request.user!.sub,
cardId,
projectId,
selectedBabyIndex ? Number(selectedBabyIndex) : undefined,
);
}
@Patch('entries/:entryId/scores')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
validateScores(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('entryId') entryId: string,
@Body() dto: ValidateScoresDto,
) {
return this.predictionsService.validateScores(request.user!.sub, entryId, dto, projectId);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+9
View File
@@ -0,0 +1,9 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class AssignProjectParticipantDto {
@IsString()
@MinLength(1)
userId!: string;
}
@@ -0,0 +1,18 @@
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CloneProjectDto {
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(400)
description?: string;
@IsOptional()
@IsBoolean()
includeParticipants?: boolean;
}
@@ -0,0 +1,54 @@
import { ProjectStatus } from '@prisma/client';
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { MAX_PROJECT_BABIES } from '../project-scope.constants';
export class CreateProjectDto {
@IsString()
@MinLength(3)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(400)
description?: string;
@IsOptional()
@IsInt()
@Min(1)
babyCount?: number;
@IsOptional()
@IsEnum(ProjectStatus)
status?: ProjectStatus;
@IsOptional()
@IsArray()
@ArrayUnique()
@ArrayMaxSize(MAX_PROJECT_BABIES)
@IsString({ each: true })
babyLabels?: string[];
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
enabledLegacyCardCodes?: string[];
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
participantUserIds?: string[];
}
@@ -0,0 +1,43 @@
import { ProjectStatus } from '@prisma/client';
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { MAX_PROJECT_BABIES } from '../project-scope.constants';
export class UpdateProjectDto {
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(400)
description?: string;
@IsOptional()
@IsInt()
@Min(1)
babyCount?: number;
@IsOptional()
@IsEnum(ProjectStatus)
status?: ProjectStatus;
@IsOptional()
@IsArray()
@ArrayUnique()
@ArrayMaxSize(MAX_PROJECT_BABIES)
@IsString({ each: true })
babyLabels?: string[];
}
@@ -0,0 +1,9 @@
export const DEFAULT_WORKSPACE_ID = 'default_workspace';
export const DEFAULT_WORKSPACE_SLUG = 'default-workspace';
export const DEFAULT_WORKSPACE_NAME = 'Espace principal';
export const DEFAULT_PROJECT_ID = 'default_project';
export const DEFAULT_PROJECT_NAME = 'Concours principal';
export const MAX_WORKSPACE_ADMINS = 3;
export const MAX_PROJECT_BABIES = 3;
@@ -0,0 +1,205 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
MaxFileSizeValidator,
Param,
Patch,
Post,
ParseFilePipe,
Req,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { existsSync, mkdirSync, readdirSync } from 'fs';
import type { AuthenticatedRequest } from '../auth/jwt-auth.guard';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { AssignProjectParticipantDto } from './dto/assign-project-participant.dto';
import { CloneProjectDto } from './dto/clone-project.dto';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { ProjectsService } from './projects.service';
const uploadsDir = join(process.cwd(), 'uploads');
const storage = diskStorage({
destination: (_req, _file, callback) => {
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true });
}
callback(null, uploadsDir);
},
filename: (_req, file, callback) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
callback(null, `project-${uniqueSuffix}${extname(file.originalname)}`);
},
});
@Controller('projects')
@UseGuards(JwtAuthGuard)
export class ProjectsController {
private readonly defaultProjectAvatars: string[];
constructor(private readonly projectsService: ProjectsService) {
const avatarsDir = join(process.cwd(), 'public', 'default-project-avatars');
const imageExts = ['.png', '.jpg', '.jpeg', '.webp'];
try {
this.defaultProjectAvatars = readdirSync(avatarsDir)
.filter((f) => imageExts.includes(extname(f).toLowerCase()))
.sort()
.map((f) => `/default-project-avatars/${f}`);
} catch {
this.defaultProjectAvatars = [];
}
}
@Get()
listForUser(@Req() request: AuthenticatedRequest) {
return this.projectsService.listForUser(request.user!.sub);
}
@Get(':projectId')
getById(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
return this.projectsService.getByIdForUser(request.user!.sub, projectId);
}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
create(@Req() request: AuthenticatedRequest, @Body() dto: CreateProjectDto) {
return this.projectsService.create(request.user!.sub, dto);
}
@Patch(':projectId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
update(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Body() dto: UpdateProjectDto,
) {
return this.projectsService.update(request.user!.sub, projectId, dto);
}
@Post(':projectId/clone')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
clone(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Body() dto: CloneProjectDto,
) {
return this.projectsService.clone(request.user!.sub, projectId, dto);
}
@Get(':projectId/participants')
listParticipants(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
return this.projectsService.listParticipants(request.user!.sub, projectId);
}
@Post(':projectId/participants')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
addParticipant(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Body() dto: AssignProjectParticipantDto,
) {
return this.projectsService.addParticipant(request.user!.sub, projectId, dto);
}
@Delete(':projectId/participants/:participantUserId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
removeParticipant(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Param('participantUserId') participantUserId: string,
) {
return this.projectsService.removeParticipant(
request.user!.sub,
projectId,
participantUserId,
);
}
@Get(':projectId/default-project-avatars')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
listDefaultProjectAvatars() {
return this.defaultProjectAvatars;
}
@Post(':projectId/photo')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileInterceptor('file', { storage }))
uploadProjectPhoto(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Body() body: { bgColor?: string },
@UploadedFile(
new ParseFilePipe({
validators: [new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 })],
fileIsRequired: false,
}),
)
file?: Express.Multer.File,
) {
if (file) {
const allowedMimeTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
if (!allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.');
}
}
if (!file && !body.bgColor) {
throw new BadRequestException('Envoyez une image ou une couleur de fond.');
}
return this.projectsService.updateProjectImage(
request.user!.sub,
projectId,
file ? `/uploads/${file.filename}` : undefined,
body.bgColor ?? undefined,
);
}
@Delete(':projectId/photo')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
deleteProjectPhoto(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
) {
return this.projectsService.updateProjectImage(request.user!.sub, projectId, null, null);
}
@Patch(':projectId/avatar')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
setProjectDefaultAvatar(
@Req() request: AuthenticatedRequest,
@Param('projectId') projectId: string,
@Body() body: { avatarUrl: string; bgColor?: string },
) {
if (!body.avatarUrl || !this.defaultProjectAvatars.includes(body.avatarUrl)) {
throw new BadRequestException('Avatar par defaut invalide.');
}
return this.projectsService.updateProjectImage(
request.user!.sub,
projectId,
body.avatarUrl,
body.bgColor,
);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProjectsController } from './projects.controller';
import { ProjectsService } from './projects.service';
@Module({
imports: [AuthModule],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
})
export class ProjectsModule {}
+670
View File
@@ -0,0 +1,670 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
OnModuleInit,
} from '@nestjs/common';
import { GameStatus, ProjectStatus, UserRole } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AssignProjectParticipantDto } from './dto/assign-project-participant.dto';
import { CloneProjectDto } from './dto/clone-project.dto';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import {
DEFAULT_PROJECT_ID,
DEFAULT_WORKSPACE_ID,
DEFAULT_WORKSPACE_NAME,
DEFAULT_WORKSPACE_SLUG,
MAX_PROJECT_BABIES,
} from './project-scope.constants';
@Injectable()
export class ProjectsService implements OnModuleInit {
constructor(private readonly prismaService: PrismaService) {}
async onModuleInit() {
await this.ensureDefaultWorkspace();
}
private clampBabyCount(value?: number) {
if (!value) {
return 1;
}
return Math.min(MAX_PROJECT_BABIES, Math.max(1, Math.trunc(value)));
}
private mapProjectStatusToGameStatus(status: ProjectStatus): GameStatus {
if (status === ProjectStatus.CLOSED || status === ProjectStatus.FINALIZED) {
return GameStatus.CLOSED;
}
return GameStatus.OPEN;
}
private normalizeSlug(value: string) {
const normalized = value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || `workspace-${Date.now()}`;
}
private async ensureWorkspaceForAdmin(userId: string) {
const user = await this.prismaService.user.findUnique({
where: { id: userId },
select: { id: true, role: true, workspaceId: true, username: true },
});
if (!user) {
throw new NotFoundException('Utilisateur introuvable');
}
if (user.role !== UserRole.ADMIN) {
throw new ForbiddenException('Seuls les comptes admin peuvent creer des projets');
}
if (user.workspaceId) {
return user.workspaceId;
}
const baseSlug = this.normalizeSlug(user.username);
const workspace = await this.prismaService.workspace.create({
data: {
name: `Espace ${user.username}`,
slug: `${baseSlug}-${Date.now()}`,
ownerUserId: user.id,
},
});
await this.prismaService.user.update({
where: { id: user.id },
data: { workspaceId: workspace.id },
});
return workspace.id;
}
private async ensureProjectBabies(projectId: string, babyCount: number, babyLabels?: string[]) {
const safeCount = this.clampBabyCount(babyCount);
for (let index = 1; index <= safeCount; index += 1) {
await this.prismaService.projectBaby.upsert({
where: {
projectId_babyIndex: {
projectId,
babyIndex: index,
},
},
update: {
label: babyLabels?.[index - 1] ?? `Bebe ${index}`,
},
create: {
projectId,
babyIndex: index,
label: babyLabels?.[index - 1] ?? `Bebe ${index}`,
},
});
}
await this.prismaService.projectBaby.deleteMany({
where: {
projectId,
babyIndex: { gt: safeCount },
},
});
}
private async assertProjectAccess(
userId: string,
projectId: string,
options?: { adminOnly?: boolean },
) {
const [user, project, membership] = await Promise.all([
this.prismaService.user.findUnique({
where: { id: userId },
select: { id: true, role: true, workspaceId: true },
}),
this.prismaService.project.findUnique({
where: { id: projectId },
}),
this.prismaService.projectMembership.findUnique({
where: { userId },
select: { id: true, projectId: true },
}),
]);
if (!user) {
throw new NotFoundException('Utilisateur introuvable');
}
if (!project) {
throw new NotFoundException('Projet introuvable');
}
if (!user.workspaceId || user.workspaceId !== project.workspaceId) {
throw new ForbiddenException('Acces refuse a ce projet');
}
if (user.role === UserRole.ADMIN) {
return { user, project, isAdmin: true };
}
if (options?.adminOnly) {
throw new ForbiddenException('Action reservee aux admins');
}
if (!membership || membership.projectId !== project.id) {
throw new ForbiddenException('Vous n etes pas rattache a ce projet');
}
return { user, project, isAdmin: false };
}
private async copyCardsFromProject(params: {
sourceProjectId: string;
targetProjectId: string;
targetGameId: string;
actorUserId: string;
filterLegacyCodes?: string[];
}) {
const sourceProjectFilter =
params.sourceProjectId === DEFAULT_PROJECT_ID
? { OR: [{ projectId: params.sourceProjectId }, { projectId: null }] }
: { projectId: params.sourceProjectId };
const sourceCards = await this.prismaService.predictionCard.findMany({
where: {
...sourceProjectFilter,
...(params.filterLegacyCodes?.length
? {
code: {
in: params.filterLegacyCodes,
},
}
: {}),
},
include: {
fields: { orderBy: { sortOrder: 'asc' } },
options: { orderBy: { sortOrder: 'asc' } },
},
orderBy: { sortOrder: 'asc' },
});
for (const sourceCard of sourceCards) {
const newCard = await this.prismaService.predictionCard.create({
data: {
gameId: params.targetGameId,
projectId: params.targetProjectId,
code: sourceCard.code,
title: sourceCard.title,
description: sourceCard.description,
type: sourceCard.type,
valueType: sourceCard.valueType,
styleId: sourceCard.styleId,
unit: sourceCard.unit,
isActive: sourceCard.isActive,
isDeletable: sourceCard.isDeletable,
sortOrder: sourceCard.sortOrder,
basePoints: sourceCard.basePoints,
createdById: params.actorUserId,
},
});
for (const field of sourceCard.fields) {
await this.prismaService.predictionCardField.create({
data: {
cardId: newCard.id,
label: field.label,
sortOrder: field.sortOrder,
points: field.points,
isPrimary: field.isPrimary,
isRequired: field.isRequired,
minNumber: field.minNumber,
maxNumber: field.maxNumber,
stepNumber: field.stepNumber,
},
});
}
for (const option of sourceCard.options) {
await this.prismaService.predictionCardOption.create({
data: {
cardId: newCard.id,
label: option.label,
value: option.value,
sortOrder: option.sortOrder,
},
});
}
}
}
private async assignParticipantToProjectInternal(
actorUserId: string,
projectId: string,
dto: AssignProjectParticipantDto,
) {
const project = await this.prismaService.project.findUnique({
where: { id: projectId },
select: { id: true, workspaceId: true },
});
if (!project) {
throw new NotFoundException('Projet introuvable');
}
const participant = await this.prismaService.user.findUnique({
where: { id: dto.userId },
select: {
id: true,
role: true,
workspaceId: true,
username: true,
displayName: true,
},
});
if (!participant) {
throw new NotFoundException('Compte participant introuvable');
}
if (participant.role === UserRole.ADMIN) {
throw new BadRequestException('Un admin ne peut pas etre rattache comme participant');
}
if (participant.workspaceId && participant.workspaceId !== project.workspaceId) {
throw new ForbiddenException('Ce participant depend d un autre espace');
}
if (!participant.workspaceId) {
await this.prismaService.user.update({
where: { id: participant.id },
data: { workspaceId: project.workspaceId },
});
}
const existingMembership = await this.prismaService.projectMembership.findUnique({
where: { userId: participant.id },
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
role: true,
},
},
},
});
if (existingMembership) {
if (existingMembership.projectId !== projectId) {
return this.prismaService.projectMembership.update({
where: { userId: participant.id },
data: {
projectId,
assignedById: actorUserId,
},
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
role: true,
},
},
},
});
}
return existingMembership;
}
return this.prismaService.projectMembership.create({
data: {
projectId,
userId: participant.id,
assignedById: actorUserId,
},
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
role: true,
},
},
},
});
}
async ensureDefaultWorkspace() {
const firstAdmin = await this.prismaService.user.findFirst({
where: { role: UserRole.ADMIN },
orderBy: { createdAt: 'asc' },
select: {
id: true,
workspaceId: true,
},
});
if (!firstAdmin) {
return;
}
await this.prismaService.workspace.upsert({
where: { id: DEFAULT_WORKSPACE_ID },
update: {
name: DEFAULT_WORKSPACE_NAME,
slug: DEFAULT_WORKSPACE_SLUG,
ownerUserId: firstAdmin.id,
},
create: {
id: DEFAULT_WORKSPACE_ID,
name: DEFAULT_WORKSPACE_NAME,
slug: DEFAULT_WORKSPACE_SLUG,
ownerUserId: firstAdmin.id,
},
});
if (firstAdmin.workspaceId !== DEFAULT_WORKSPACE_ID) {
await this.prismaService.user.update({
where: { id: firstAdmin.id },
data: { workspaceId: DEFAULT_WORKSPACE_ID },
});
}
}
async listForUser(userId: string) {
const user = await this.prismaService.user.findUnique({
where: { id: userId },
select: { id: true, role: true, workspaceId: true },
});
if (!user) {
throw new NotFoundException('Utilisateur introuvable');
}
if (!user.workspaceId) {
return [];
}
if (user.role === UserRole.ADMIN) {
return this.prismaService.project.findMany({
where: { workspaceId: user.workspaceId },
include: {
babies: { orderBy: { babyIndex: 'asc' } },
_count: {
select: {
memberships: true,
cards: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
const memberships = await this.prismaService.projectMembership.findMany({
where: { userId },
include: {
project: {
include: {
babies: { orderBy: { babyIndex: 'asc' } },
_count: {
select: {
memberships: true,
cards: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return memberships.map((membership) => membership.project);
}
async getByIdForUser(userId: string, projectId: string) {
await this.assertProjectAccess(userId, projectId);
const project = await this.prismaService.project.findUnique({
where: { id: projectId },
include: {
babies: { orderBy: { babyIndex: 'asc' } },
_count: {
select: {
memberships: true,
cards: true,
},
},
},
});
if (!project) {
throw new NotFoundException('Projet introuvable');
}
return project;
}
async create(adminUserId: string, dto: CreateProjectDto) {
const workspaceId = await this.ensureWorkspaceForAdmin(adminUserId);
const babyCount = this.clampBabyCount(dto.babyCount);
const project = await this.prismaService.project.create({
data: {
workspaceId,
createdById: adminUserId,
name: dto.name.trim(),
description: dto.description?.trim() || null,
status: dto.status ?? ProjectStatus.OPEN,
babyCount,
},
});
await this.ensureProjectBabies(project.id, babyCount, dto.babyLabels);
const game = await this.prismaService.predictionGame.create({
data: {
id: project.id,
projectId: project.id,
title: `Pronostics ${project.name}`,
status: this.mapProjectStatusToGameStatus(project.status),
},
});
await this.copyCardsFromProject({
sourceProjectId: DEFAULT_PROJECT_ID,
targetProjectId: project.id,
targetGameId: game.id,
actorUserId: adminUserId,
filterLegacyCodes: dto.enabledLegacyCardCodes,
});
if (dto.participantUserIds?.length) {
for (const participantUserId of dto.participantUserIds) {
await this.assignParticipantToProjectInternal(adminUserId, project.id, {
userId: participantUserId,
});
}
}
return this.getByIdForUser(adminUserId, project.id);
}
async update(adminUserId: string, projectId: string, dto: UpdateProjectDto) {
const { project } = await this.assertProjectAccess(adminUserId, projectId, {
adminOnly: true,
});
const babyCount = dto.babyCount ? this.clampBabyCount(dto.babyCount) : project.babyCount;
const updatedProject = await this.prismaService.project.update({
where: { id: projectId },
data: {
name: dto.name?.trim(),
description: dto.description?.trim() ?? (dto.description === '' ? null : undefined),
status: dto.status,
babyCount,
},
});
if (dto.babyCount || dto.babyLabels) {
await this.ensureProjectBabies(projectId, babyCount, dto.babyLabels);
}
if (dto.status) {
await this.prismaService.predictionGame.updateMany({
where: { projectId },
data: {
status: this.mapProjectStatusToGameStatus(dto.status),
closedAt: dto.status === ProjectStatus.CLOSED ? new Date() : null,
reopenedAt:
dto.status === ProjectStatus.OPEN || dto.status === ProjectStatus.DRAFT
? new Date()
: null,
},
});
}
return this.getByIdForUser(adminUserId, updatedProject.id);
}
async clone(adminUserId: string, sourceProjectId: string, dto: CloneProjectDto) {
const { project: sourceProject } = await this.assertProjectAccess(
adminUserId,
sourceProjectId,
{ adminOnly: true },
);
const sourceBabies = await this.prismaService.projectBaby.findMany({
where: { projectId: sourceProjectId },
orderBy: { babyIndex: 'asc' },
});
const cloneName = dto.name?.trim() || `${sourceProject.name} (copie)`;
const clonedProject = await this.prismaService.project.create({
data: {
workspaceId: sourceProject.workspaceId,
createdById: adminUserId,
clonedFromProjectId: sourceProject.id,
name: cloneName,
description: dto.description?.trim() || sourceProject.description,
status: sourceProject.status,
babyCount: sourceProject.babyCount,
},
});
await this.ensureProjectBabies(
clonedProject.id,
sourceProject.babyCount,
sourceBabies.map((baby) => baby.label ?? `Bebe ${baby.babyIndex}`),
);
const clonedGame = await this.prismaService.predictionGame.create({
data: {
id: clonedProject.id,
projectId: clonedProject.id,
title: `Pronostics ${cloneName}`,
status: this.mapProjectStatusToGameStatus(clonedProject.status),
},
});
await this.copyCardsFromProject({
sourceProjectId,
targetProjectId: clonedProject.id,
targetGameId: clonedGame.id,
actorUserId: adminUserId,
});
if (dto.includeParticipants) {
const sourceMemberships = await this.prismaService.projectMembership.findMany({
where: { projectId: sourceProjectId },
select: { userId: true },
});
for (const membership of sourceMemberships) {
await this.assignParticipantToProjectInternal(adminUserId, clonedProject.id, {
userId: membership.userId,
});
}
}
return this.getByIdForUser(adminUserId, clonedProject.id);
}
async listParticipants(userId: string, projectId: string) {
await this.assertProjectAccess(userId, projectId);
return this.prismaService.projectMembership.findMany({
where: { projectId },
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
profileImageUrl: true,
profileBgColor: true,
role: true,
createdAt: true,
},
},
},
orderBy: { createdAt: 'asc' },
});
}
async addParticipant(
adminUserId: string,
projectId: string,
dto: AssignProjectParticipantDto,
) {
await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true });
return this.assignParticipantToProjectInternal(adminUserId, projectId, dto);
}
async removeParticipant(adminUserId: string, projectId: string, participantUserId: string) {
await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true });
const result = await this.prismaService.projectMembership.deleteMany({
where: {
projectId,
userId: participantUserId,
},
});
return { removed: result.count > 0 };
}
async updateProjectImage(
adminUserId: string,
projectId: string,
projectImageUrl?: string | null,
projectBgColor?: string | null,
) {
await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true });
const data: { projectImageUrl?: string | null; projectBgColor?: string | null } = {};
if (projectImageUrl !== undefined) data.projectImageUrl = projectImageUrl;
if (projectBgColor !== undefined) data.projectBgColor = projectBgColor;
await this.prismaService.project.update({
where: { id: projectId },
data,
});
return this.getByIdForUser(adminUserId, projectId);
}
}
@@ -0,0 +1,8 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class AssignUserProjectDto {
@IsOptional()
@IsString()
@MinLength(1)
projectId?: string;
}
+30
View File
@@ -0,0 +1,30 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
import { UserRole } from '@prisma/client';
import { IsEnum, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(3)
@MaxLength(40)
username!: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password!: string;
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(40)
displayName?: string;
@IsOptional()
@IsString()
@MinLength(1)
projectId?: string;
}
@@ -0,0 +1,9 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateMyProfileDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(40)
displayName?: string;
}
+26
View File
@@ -0,0 +1,26 @@
import { UserRole } from '@prisma/client';
import { IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateUserDto {
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(40)
username?: string;
@IsOptional()
@IsString()
@MinLength(8)
@MaxLength(128)
password?: string;
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(40)
displayName?: string;
}
+188
View File
@@ -0,0 +1,188 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
MaxFileSizeValidator,
Param,
Patch,
Req,
Post,
ParseFilePipe,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { existsSync, mkdirSync, readdirSync } from 'fs';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthenticatedRequest } from '../auth/jwt-auth.guard';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { CreateUserDto } from './dto/create-user.dto';
import { AssignUserProjectDto } from './dto/assign-user-project.dto';
import { UpdateMyProfileDto } from './dto/update-my-profile.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
const uploadsDir = join(process.cwd(), 'uploads');
const storage = diskStorage({
destination: (_req, _file, callback) => {
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true });
}
callback(null, uploadsDir);
},
filename: (_req, file, callback) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
callback(null, `profile-${uniqueSuffix}${extname(file.originalname)}`);
},
});
@Controller('users')
export class UsersController {
private readonly defaultAvatars: string[];
constructor(private readonly usersService: UsersService) {
const avatarsDir = join(process.cwd(), 'public', 'default-avatars');
const imageExts = ['.png', '.jpg', '.jpeg', '.webp'];
try {
this.defaultAvatars = readdirSync(avatarsDir)
.filter((f) => imageExts.includes(extname(f).toLowerCase()))
.sort()
.map((f) => `/default-avatars/${f}`);
} catch {
this.defaultAvatars = [];
}
}
@Get('default-avatars')
listDefaultAvatars() {
return this.defaultAvatars;
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@Req() request: AuthenticatedRequest) {
return this.usersService.getProfile(request.user!.sub);
}
@Patch('me')
@UseGuards(JwtAuthGuard)
updateMe(
@Req() request: AuthenticatedRequest,
@Body() updateMyProfileDto: UpdateMyProfileDto,
) {
return this.usersService.updateMyProfile(
request.user!.sub,
updateMyProfileDto.displayName,
);
}
@Post('me/photo')
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileInterceptor('file', { storage }))
uploadPhoto(
@Req() request: AuthenticatedRequest,
@Body() body: { bgColor?: string },
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
],
fileIsRequired: false,
}),
)
file?: Express.Multer.File,
) {
if (file) {
const allowedMimeTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
if (!allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.');
}
}
const profileImageUrl = file ? `/uploads/${file.filename}` : body.bgColor && !file ? undefined : undefined;
const bgColor = body.bgColor ?? undefined;
if (!file && !bgColor) {
throw new BadRequestException('Envoyez une image ou une couleur de fond.');
}
return this.usersService.updateProfileImage(
request.user!.sub,
file ? `/uploads/${file.filename}` : undefined,
bgColor,
);
}
@Delete('me/photo')
@UseGuards(JwtAuthGuard)
deletePhoto(@Req() request: AuthenticatedRequest) {
return this.usersService.updateProfileImage(request.user!.sub, null, null);
}
@Patch('me/avatar')
@UseGuards(JwtAuthGuard)
setDefaultAvatar(
@Req() request: AuthenticatedRequest,
@Body() body: { avatarUrl: string; bgColor?: string },
) {
if (!body.avatarUrl || !this.defaultAvatars.includes(body.avatarUrl)) {
throw new BadRequestException('Avatar par defaut invalide.');
}
return this.usersService.updateProfileImage(
request.user!.sub,
body.avatarUrl,
body.bgColor,
);
}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
create(@Req() request: AuthenticatedRequest, @Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto, request.user?.sub);
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
list() {
return this.usersService.list();
}
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
@Req() request: AuthenticatedRequest,
) {
return this.usersService.updateById(id, updateUserDto, request.user?.sub);
}
@Patch(':id/project')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
assignProject(
@Param('id') id: string,
@Body() dto: AssignUserProjectDto,
@Req() request: AuthenticatedRequest,
) {
return this.usersService.assignProjectById(id, dto.projectId, request.user?.sub);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
remove(@Param('id') id: string, @Req() request: AuthenticatedRequest) {
return this.usersService.removeById(id, request.user?.sub);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [AuthModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+526
View File
@@ -0,0 +1,526 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
InternalServerErrorException,
NotFoundException,
OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UserRole } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { MAX_WORKSPACE_ADMINS } from '../projects/project-scope.constants';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService implements OnModuleInit {
constructor(
private readonly prismaService: PrismaService,
private readonly configService: ConfigService,
) {}
async onModuleInit() {
await this.ensureAdminUser();
}
private async ensureAdminUser() {
const adminUsername = this.configService.get<string>('ADMIN_USERNAME');
const adminPassword = this.configService.get<string>('ADMIN_PASSWORD');
if (!adminUsername || !adminPassword) {
return;
}
const passwordHash = await bcrypt.hash(adminPassword, 12);
await this.prismaService.user.upsert({
where: { username: adminUsername },
update: {
passwordHash,
displayName: adminUsername,
role: UserRole.ADMIN,
},
create: {
username: adminUsername,
passwordHash,
displayName: adminUsername,
role: UserRole.ADMIN,
},
});
}
private async enforceWorkspaceAdminLimit(workspaceId: string, excludedUserId?: string) {
const adminCount = await this.prismaService.user.count({
where: {
workspaceId,
role: UserRole.ADMIN,
...(excludedUserId
? {
id: {
not: excludedUserId,
},
}
: {}),
},
});
if (adminCount >= MAX_WORKSPACE_ADMINS) {
throw new ForbiddenException(
`Limite atteinte: maximum ${MAX_WORKSPACE_ADMINS} comptes admin par espace`,
);
}
}
private async getActorOrThrow(actorUserId?: string) {
if (!actorUserId) {
return null;
}
const actor = await this.prismaService.user.findUnique({
where: { id: actorUserId },
select: {
id: true,
role: true,
workspaceId: true,
},
});
if (!actor) {
throw new NotFoundException('Compte admin introuvable');
}
if (actor.role !== UserRole.ADMIN) {
throw new ForbiddenException('Action reservee aux admins');
}
return actor;
}
private async resolveCurrentProjectId(userId: string) {
const membership = await this.prismaService.projectMembership.findUnique({
where: { userId },
select: { projectId: true },
});
return membership?.projectId ?? null;
}
private async assignUserToProject(
userId: string,
projectId: string,
actorUserId?: string,
) {
const [targetUser, targetProject, actor] = await Promise.all([
this.prismaService.user.findUnique({
where: { id: userId },
select: {
id: true,
role: true,
workspaceId: true,
},
}),
this.prismaService.project.findUnique({
where: { id: projectId },
select: {
id: true,
workspaceId: true,
},
}),
this.getActorOrThrow(actorUserId),
]);
if (!targetUser) {
throw new NotFoundException('User not found');
}
if (!targetProject) {
throw new NotFoundException('Project not found');
}
if (targetUser.role === UserRole.ADMIN) {
throw new BadRequestException('Un admin ne peut pas etre affecte comme participant');
}
if (actor) {
if (!actor.workspaceId || actor.workspaceId !== targetProject.workspaceId) {
throw new ForbiddenException('Ce projet appartient a un autre espace');
}
}
if (targetUser.workspaceId && targetUser.workspaceId !== targetProject.workspaceId) {
throw new ForbiddenException('Cet utilisateur appartient deja a un autre espace');
}
if (!targetUser.workspaceId) {
await this.prismaService.user.update({
where: { id: targetUser.id },
data: { workspaceId: targetProject.workspaceId },
});
}
const existingMembership = await this.prismaService.projectMembership.findUnique({
where: { userId: targetUser.id },
select: { projectId: true },
});
if (existingMembership && existingMembership.projectId !== targetProject.id) {
await this.prismaService.projectMembership.update({
where: { userId: targetUser.id },
data: {
projectId: targetProject.id,
assignedById: actor?.id,
},
});
return;
}
if (!existingMembership) {
await this.prismaService.projectMembership.create({
data: {
projectId: targetProject.id,
userId: targetUser.id,
assignedById: actor?.id,
},
});
}
}
async create(createUserDto: CreateUserDto, actorUserId?: string) {
const passwordHash = await bcrypt.hash(createUserDto.password, 12);
const actor = await this.getActorOrThrow(actorUserId);
const role = createUserDto.role ?? UserRole.FAMILY;
if (role === UserRole.ADMIN && actor?.workspaceId) {
await this.enforceWorkspaceAdminLimit(actor.workspaceId);
}
try {
const user = await this.prismaService.user.create({
data: {
username: createUserDto.username,
displayName: createUserDto.displayName,
passwordHash,
role,
workspaceId: actor?.workspaceId,
},
});
if (createUserDto.projectId) {
await this.assignUserToProject(user.id, createUserDto.projectId, actor?.id);
}
const currentProjectId = await this.resolveCurrentProjectId(user.id);
return {
id: user.id,
username: user.username,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
workspaceId: user.workspaceId,
currentProjectId,
role: user.role,
createdAt: user.createdAt,
};
} catch (error: unknown) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'P2002'
) {
throw new ConflictException('Username already exists');
}
throw new InternalServerErrorException('Could not create user');
}
}
async findByUsername(username: string) {
return this.prismaService.user.findFirst({
where: {
username: {
equals: username.trim(),
mode: 'insensitive',
},
},
});
}
async list() {
const users = await this.prismaService.user.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
username: true,
displayName: true,
profileImageUrl: true,
workspaceId: true,
role: true,
createdAt: true,
projectMembership: {
select: {
projectId: true,
},
},
},
});
return users.map((user) => ({
id: user.id,
username: user.username,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
workspaceId: user.workspaceId,
currentProjectId: user.projectMembership?.projectId ?? null,
role: user.role,
createdAt: user.createdAt,
}));
}
async updateById(id: string, updateUserDto: UpdateUserDto, actorUserId?: string) {
try {
const actor = await this.getActorOrThrow(actorUserId);
const data: {
username?: string;
passwordHash?: string;
role?: UserRole;
displayName?: string;
workspaceId?: string;
} = {};
if (updateUserDto.username) {
data.username = updateUserDto.username;
}
if (updateUserDto.password) {
data.passwordHash = await bcrypt.hash(updateUserDto.password, 12);
}
if (updateUserDto.role) {
data.role = updateUserDto.role;
}
if (updateUserDto.displayName !== undefined) {
data.displayName = updateUserDto.displayName;
}
const existing = await this.prismaService.user.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException('User not found');
}
if (actor && actor.workspaceId && existing.workspaceId && actor.workspaceId !== existing.workspaceId) {
throw new ForbiddenException('Vous ne pouvez gerer que les comptes de votre espace');
}
if (actor && actor.workspaceId && !existing.workspaceId) {
data.workspaceId = actor.workspaceId;
}
if (updateUserDto.role === UserRole.ADMIN) {
const workspaceId = existing.workspaceId ?? actor?.workspaceId;
if (!workspaceId) {
throw new BadRequestException('Impossible de promouvoir en admin sans espace associe');
}
data.workspaceId = workspaceId;
if (existing.role !== UserRole.ADMIN) {
await this.enforceWorkspaceAdminLimit(workspaceId, existing.id);
}
}
if (actorUserId && existing.id === actorUserId && updateUserDto.role === UserRole.FAMILY) {
throw new ForbiddenException('You cannot demote yourself');
}
const user = await this.prismaService.user.update({
where: { id },
data,
});
return {
id: user.id,
username: user.username,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
workspaceId: user.workspaceId,
role: user.role,
createdAt: user.createdAt,
};
} catch (error: unknown) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'P2002'
) {
throw new ConflictException('Username already exists');
}
if (
error instanceof ForbiddenException ||
error instanceof InternalServerErrorException ||
error instanceof NotFoundException
) {
throw error;
}
throw new InternalServerErrorException('Could not update user');
}
}
async assignProjectById(userId: string, projectId: string | undefined, actorUserId?: string) {
const actor = await this.getActorOrThrow(actorUserId);
const targetUser = await this.prismaService.user.findUnique({
where: { id: userId },
select: {
id: true,
role: true,
workspaceId: true,
username: true,
displayName: true,
profileImageUrl: true,
createdAt: true,
},
});
if (!targetUser) {
throw new NotFoundException('User not found');
}
if (actor && actor.workspaceId && targetUser.workspaceId && targetUser.workspaceId !== actor.workspaceId) {
throw new ForbiddenException('Vous ne pouvez gerer que les comptes de votre espace');
}
if (!projectId) {
await this.prismaService.projectMembership.deleteMany({
where: { userId: targetUser.id },
});
return {
id: targetUser.id,
username: targetUser.username,
displayName: targetUser.displayName,
profileImageUrl: targetUser.profileImageUrl,
workspaceId: targetUser.workspaceId,
currentProjectId: null,
role: targetUser.role,
createdAt: targetUser.createdAt,
};
}
await this.assignUserToProject(targetUser.id, projectId, actor?.id);
const currentProjectId = await this.resolveCurrentProjectId(targetUser.id);
return {
id: targetUser.id,
username: targetUser.username,
displayName: targetUser.displayName,
profileImageUrl: targetUser.profileImageUrl,
workspaceId: targetUser.workspaceId,
currentProjectId,
role: targetUser.role,
createdAt: targetUser.createdAt,
};
}
async removeById(id: string, actorUserId?: string) {
if (actorUserId && id === actorUserId) {
throw new ForbiddenException('You cannot delete your own account');
}
try {
const actor = await this.getActorOrThrow(actorUserId);
const target = await this.prismaService.user.findUnique({
where: { id },
select: {
id: true,
workspaceId: true,
},
});
if (!target) {
throw new NotFoundException('User not found');
}
if (actor && actor.workspaceId && target.workspaceId && actor.workspaceId !== target.workspaceId) {
throw new ForbiddenException('Vous ne pouvez supprimer que des comptes de votre espace');
}
await this.prismaService.user.delete({ where: { id } });
return { deleted: true };
} catch (error) {
if (error instanceof ForbiddenException || error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException('Could not delete user');
}
}
async getProfile(userId: string) {
const user = await this.prismaService.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
profileBgColor: user.profileBgColor,
workspaceId: user.workspaceId,
currentProjectId: await this.resolveCurrentProjectId(user.id),
role: user.role,
createdAt: user.createdAt,
};
}
async updateMyProfile(userId: string, displayName?: string) {
const user = await this.prismaService.user.update({
where: { id: userId },
data: { displayName },
});
return {
id: user.id,
username: user.username,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
profileBgColor: user.profileBgColor,
workspaceId: user.workspaceId,
currentProjectId: await this.resolveCurrentProjectId(user.id),
role: user.role,
createdAt: user.createdAt,
};
}
async updateProfileImage(userId: string, profileImageUrl?: string | null, profileBgColor?: string | null) {
const data: { profileImageUrl?: string | null; profileBgColor?: string | null } = {};
if (profileImageUrl !== undefined) data.profileImageUrl = profileImageUrl;
if (profileBgColor !== undefined) data.profileBgColor = profileBgColor;
const user = await this.prismaService.user.update({
where: { id: userId },
data,
});
return {
id: user.id,
username: user.username,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
profileBgColor: user.profileBgColor,
workspaceId: user.workspaceId,
currentProjectId: await this.resolveCurrentProjectId(user.id),
role: user.role,
createdAt: user.createdAt,
};
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/health (GET)', () => {
return request(app.getHttpServer())
.get('/health')
.expect(200)
.expect({ status: 'ok' });
});
afterEach(async () => {
await app.close();
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}