init import projet
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
.env
|
||||
npm-debug.log
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY apps/api/package.json ./apps/api/package.json
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run prisma:generate -w apps/api
|
||||
RUN npm run build -w apps/api
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_PATH=/app/apps/api/node_modules
|
||||
ENV PORT=3000
|
||||
|
||||
COPY package*.json ./
|
||||
COPY apps/api/package.json ./apps/api/package.json
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
||||
|
||||
VOLUME ["/app/apps/api/uploads"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# During initial setup we use `prisma db push` at container start to bypass baseline migration issues.
|
||||
CMD ["npm", "run", "start:docker", "-w", "apps/api"]
|
||||
@@ -0,0 +1,98 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start:docker": "npx prisma db push && npx prisma generate && node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:push": "prisma db push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@prisma/client": "^6.13.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"multer": "^2.1.1",
|
||||
"prisma": "^6.13.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Get a free hosted Postgres database in seconds: `npx create-db`
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
FAMILY
|
||||
}
|
||||
|
||||
enum GameStatus {
|
||||
OPEN
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum PredictionCardType {
|
||||
LEGACY
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
enum PredictionValueType {
|
||||
NUMBER
|
||||
TEXT
|
||||
SELECT
|
||||
MULTI_TEXT
|
||||
DATE
|
||||
}
|
||||
|
||||
enum PredictionActivityType {
|
||||
CARD_CREATED
|
||||
CARD_UPDATED
|
||||
CARD_DELETED
|
||||
PREDICTION_CREATED
|
||||
PREDICTION_UPDATED
|
||||
OUTCOME_SET
|
||||
SCORE_SUGGESTED
|
||||
SCORE_VALIDATED
|
||||
GAME_CLOSED
|
||||
GAME_OPENED
|
||||
}
|
||||
|
||||
enum ParentType {
|
||||
PAPA
|
||||
MAMAN
|
||||
}
|
||||
|
||||
enum Trimester {
|
||||
T1
|
||||
T2
|
||||
T3
|
||||
DATATION
|
||||
}
|
||||
|
||||
enum ProjectStatus {
|
||||
DRAFT
|
||||
OPEN
|
||||
CLOSED
|
||||
FINALIZED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
displayName String?
|
||||
profileImageUrl String?
|
||||
profileBgColor String?
|
||||
passwordHash String
|
||||
refreshTokenHash String?
|
||||
workspaceId String?
|
||||
workspace Workspace? @relation("WorkspaceUsers", fields: [workspaceId], references: [id], onDelete: SetNull)
|
||||
role UserRole @default(FAMILY)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
ownedWorkspace Workspace? @relation("WorkspaceOwner")
|
||||
createdProjects Project[] @relation("ProjectsCreatedBy")
|
||||
projectMembership ProjectMembership?
|
||||
assignedMemberships ProjectMembership[] @relation("ProjectMembershipAssignedBy")
|
||||
|
||||
predictionCardsCreated PredictionCard[] @relation("PredictionCardsCreatedBy")
|
||||
predictionEntries PredictionEntry[] @relation("PredictionEntriesByUser")
|
||||
predictionHistoryEvents PredictionEntryHistory[] @relation("PredictionHistoryByUser")
|
||||
predictionOutcomesSet PredictionOutcome[] @relation("PredictionOutcomesSetBy")
|
||||
predictionScoresValidated PredictionFieldScore[] @relation("PredictionScoresValidatedBy")
|
||||
predictionActivities PredictionActivity[] @relation("PredictionActivitiesByUser")
|
||||
|
||||
@@index([workspaceId])
|
||||
}
|
||||
|
||||
model Workspace {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
ownerUserId String @unique
|
||||
ownerUser User @relation("WorkspaceOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[] @relation("WorkspaceUsers")
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
createdById String
|
||||
createdBy User @relation("ProjectsCreatedBy", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
clonedFromProjectId String?
|
||||
clonedFromProject Project? @relation("ProjectCloneSource", fields: [clonedFromProjectId], references: [id], onDelete: SetNull)
|
||||
clonedProjects Project[] @relation("ProjectCloneSource")
|
||||
name String
|
||||
description String?
|
||||
projectImageUrl String?
|
||||
projectBgColor String?
|
||||
status ProjectStatus @default(DRAFT)
|
||||
babyCount Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
babies ProjectBaby[]
|
||||
memberships ProjectMembership[]
|
||||
games PredictionGame[]
|
||||
cards PredictionCard[]
|
||||
entries PredictionEntry[]
|
||||
outcomes PredictionOutcome[]
|
||||
fieldScores PredictionFieldScore[]
|
||||
activities PredictionActivity[]
|
||||
parentIndices ParentIndices[]
|
||||
babyIndices BabyIndices[]
|
||||
|
||||
@@index([workspaceId, createdAt])
|
||||
@@index([createdById, createdAt])
|
||||
}
|
||||
|
||||
model ProjectBaby {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
babyIndex Int
|
||||
label String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([projectId, babyIndex])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
model ProjectMembership {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
assignedById String?
|
||||
assignedBy User? @relation("ProjectMembershipAssignedBy", fields: [assignedById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([projectId, userId])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
model PredictionGame {
|
||||
id String @id @default(cuid())
|
||||
projectId String?
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
title String @default("Pronostics bebe")
|
||||
status GameStatus @default(OPEN)
|
||||
closedAt DateTime?
|
||||
reopenedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
cards PredictionCard[]
|
||||
entries PredictionEntry[]
|
||||
activities PredictionActivity[]
|
||||
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
model PredictionCard {
|
||||
id String @id @default(cuid())
|
||||
gameId String @default("singleton")
|
||||
game PredictionGame @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
code String?
|
||||
title String
|
||||
description String?
|
||||
type PredictionCardType @default(CUSTOM)
|
||||
valueType PredictionValueType
|
||||
styleId Int @default(0)
|
||||
unit String?
|
||||
isActive Boolean @default(true)
|
||||
isDeletable Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
basePoints Int @default(0)
|
||||
createdById String
|
||||
createdBy User @relation("PredictionCardsCreatedBy", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
fields PredictionCardField[]
|
||||
options PredictionCardOption[]
|
||||
entries PredictionEntry[]
|
||||
outcomes PredictionOutcome[]
|
||||
activities PredictionActivity[]
|
||||
|
||||
@@unique([projectId, code])
|
||||
@@index([gameId, isActive, sortOrder])
|
||||
@@index([projectId, isActive, sortOrder])
|
||||
}
|
||||
|
||||
model PredictionCardField {
|
||||
id String @id @default(cuid())
|
||||
cardId String
|
||||
card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
sortOrder Int @default(0)
|
||||
points Int @default(0)
|
||||
isPrimary Boolean @default(false)
|
||||
isRequired Boolean @default(false)
|
||||
minNumber Float?
|
||||
maxNumber Float?
|
||||
stepNumber Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
values PredictionEntryValue[]
|
||||
outcomes PredictionOutcome[]
|
||||
scores PredictionFieldScore[]
|
||||
|
||||
@@unique([cardId, label])
|
||||
@@index([cardId, sortOrder])
|
||||
}
|
||||
|
||||
model PredictionCardOption {
|
||||
id String @id @default(cuid())
|
||||
cardId String
|
||||
card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
value String
|
||||
sortOrder Int @default(0)
|
||||
|
||||
@@unique([cardId, value])
|
||||
@@index([cardId, sortOrder])
|
||||
}
|
||||
|
||||
model PredictionEntry {
|
||||
id String @id @default(cuid())
|
||||
gameId String @default("singleton")
|
||||
game PredictionGame @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
userId String
|
||||
user User @relation("PredictionEntriesByUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
cardId String
|
||||
card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
selectedBabyIndex Int?
|
||||
totalPoints Int @default(0)
|
||||
isScored Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
values PredictionEntryValue[]
|
||||
history PredictionEntryHistory[]
|
||||
scores PredictionFieldScore[]
|
||||
activities PredictionActivity[]
|
||||
|
||||
@@unique([userId, cardId, selectedBabyIndex])
|
||||
@@index([cardId])
|
||||
@@index([userId])
|
||||
@@index([projectId, userId])
|
||||
}
|
||||
|
||||
model PredictionEntryValue {
|
||||
id String @id @default(cuid())
|
||||
entryId String
|
||||
entry PredictionEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
|
||||
fieldId String
|
||||
field PredictionCardField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
|
||||
valueText String?
|
||||
valueNumber Float?
|
||||
valueDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([entryId, fieldId])
|
||||
@@index([fieldId])
|
||||
}
|
||||
|
||||
model PredictionEntryHistory {
|
||||
id String @id @default(cuid())
|
||||
entryId String
|
||||
entry PredictionEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation("PredictionHistoryByUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
snapshot Json
|
||||
message String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([entryId, createdAt])
|
||||
}
|
||||
|
||||
model PredictionOutcome {
|
||||
id String @id @default(cuid())
|
||||
cardId String
|
||||
card PredictionCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
fieldId String
|
||||
field PredictionCardField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
|
||||
selectedBabyIndex Int?
|
||||
valueText String?
|
||||
valueNumber Float?
|
||||
valueDate DateTime?
|
||||
setById String
|
||||
setBy User @relation("PredictionOutcomesSetBy", fields: [setById], references: [id], onDelete: Restrict)
|
||||
setAt DateTime @default(now())
|
||||
|
||||
@@unique([cardId, fieldId, selectedBabyIndex])
|
||||
@@index([projectId])
|
||||
@@index([projectId, selectedBabyIndex])
|
||||
}
|
||||
|
||||
model PredictionFieldScore {
|
||||
id String @id @default(cuid())
|
||||
entryId String
|
||||
entry PredictionEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
fieldId String
|
||||
field PredictionCardField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
|
||||
suggestedPoints Int @default(0)
|
||||
awardedPoints Int?
|
||||
isValidated Boolean @default(false)
|
||||
note String?
|
||||
validatedById String?
|
||||
validatedBy User? @relation("PredictionScoresValidatedBy", fields: [validatedById], references: [id], onDelete: SetNull)
|
||||
validatedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([entryId, fieldId])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
model PredictionActivity {
|
||||
id String @id @default(cuid())
|
||||
gameId String @default("singleton")
|
||||
game PredictionGame @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
type PredictionActivityType
|
||||
userId String?
|
||||
user User? @relation("PredictionActivitiesByUser", fields: [userId], references: [id], onDelete: SetNull)
|
||||
cardId String?
|
||||
card PredictionCard? @relation(fields: [cardId], references: [id], onDelete: SetNull)
|
||||
entryId String?
|
||||
entry PredictionEntry? @relation(fields: [entryId], references: [id], onDelete: SetNull)
|
||||
message String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([projectId, createdAt])
|
||||
}
|
||||
|
||||
// ===== INDICES =====
|
||||
|
||||
model ParentIndices {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
parentType ParentType
|
||||
poids Float?
|
||||
taille Float?
|
||||
perimCranien Float?
|
||||
dateNaissance DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
photos ParentIndexPhoto[]
|
||||
|
||||
@@unique([projectId, parentType])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
model ParentIndexPhoto {
|
||||
id String @id @default(cuid())
|
||||
parentIndicesId String
|
||||
parentIndices ParentIndices @relation(fields: [parentIndicesId], references: [id], onDelete: Cascade)
|
||||
url String
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([parentIndicesId])
|
||||
}
|
||||
|
||||
model BabyIndices {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
babyIndex Int
|
||||
dpa DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
trimesters BabyTrimesterEntry[]
|
||||
|
||||
@@unique([projectId, babyIndex])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
model BabyTrimesterEntry {
|
||||
id String @id @default(cuid())
|
||||
babyIndicesId String
|
||||
babyIndices BabyIndices @relation(fields: [babyIndicesId], references: [id], onDelete: Cascade)
|
||||
trimester Trimester
|
||||
date DateTime?
|
||||
note String?
|
||||
poids Float?
|
||||
taille Float?
|
||||
perimCranien Float?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
photos BabyTrimesterPhoto[]
|
||||
|
||||
@@unique([babyIndicesId, trimester])
|
||||
@@index([babyIndicesId])
|
||||
}
|
||||
|
||||
model BabyTrimesterPhoto {
|
||||
id String @id @default(cuid())
|
||||
trimesterEntryId String
|
||||
trimesterEntry BabyTrimesterEntry @relation(fields: [trimesterEntryId], references: [id], onDelete: Cascade)
|
||||
url String
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([trimesterEntryId])
|
||||
}
|
||||
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 |
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { IndicesModule } from './indices/indices.module';
|
||||
import { PredictionsModule } from './predictions/predictions.module';
|
||||
import { ProjectsModule } from './projects/projects.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
ProjectsModule,
|
||||
PredictionsModule,
|
||||
IndicesModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import type { AuthenticatedRequest } from './jwt-auth.guard';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() loginDto: LoginDto) {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.authService.refresh(refreshTokenDto);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
logout(@Req() request: AuthenticatedRequest) {
|
||||
return this.authService.logout(request.user!.sub);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@Req() request: AuthenticatedRequest) {
|
||||
return this.authService.me(request.user!.sub);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import type { StringValue } from 'ms';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET') ?? 'change_me_jwt_secret',
|
||||
signOptions: {
|
||||
expiresIn:
|
||||
(configService.get<string>('JWT_EXPIRES_IN') as StringValue) ?? '1d',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtAuthGuard, RolesGuard],
|
||||
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { StringValue } from 'ms';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
private async issueTokens(user: {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
displayName: string | null;
|
||||
profileImageUrl: string | null;
|
||||
workspaceId: string | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
const membership = await this.prismaService.projectMembership.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { projectId: true },
|
||||
});
|
||||
|
||||
const currentProjectId = membership?.projectId ?? null;
|
||||
|
||||
const accessToken = await this.jwtService.signAsync({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
workspaceId: user.workspaceId,
|
||||
projectId: currentProjectId,
|
||||
});
|
||||
|
||||
const refreshToken = await this.jwtService.signAsync(
|
||||
{ sub: user.id, type: 'refresh' },
|
||||
{
|
||||
secret:
|
||||
this.configService.get<string>('REFRESH_TOKEN_SECRET') ??
|
||||
'change_me_refresh_secret',
|
||||
expiresIn:
|
||||
(this.configService.get<string>(
|
||||
'REFRESH_TOKEN_EXPIRES_IN',
|
||||
) as StringValue) ?? '30d',
|
||||
},
|
||||
);
|
||||
|
||||
const refreshTokenHash = await bcrypt.hash(refreshToken, 12);
|
||||
|
||||
await this.prismaService.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshTokenHash },
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
profileImageUrl: user.profileImageUrl,
|
||||
workspaceId: user.workspaceId,
|
||||
currentProjectId,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto) {
|
||||
const normalizedUsername = loginDto.username.trim();
|
||||
|
||||
const user = await this.prismaService.user.findUnique({
|
||||
where: { username: normalizedUsername },
|
||||
});
|
||||
|
||||
const caseInsensitiveUser = user ?? (await this.prismaService.user.findFirst({
|
||||
where: {
|
||||
username: {
|
||||
equals: normalizedUsername,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
if (!caseInsensitiveUser) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const passwordMatches = await bcrypt.compare(
|
||||
loginDto.password,
|
||||
caseInsensitiveUser.passwordHash,
|
||||
);
|
||||
|
||||
if (!passwordMatches) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
return this.issueTokens(caseInsensitiveUser);
|
||||
}
|
||||
|
||||
async refresh(refreshTokenDto: RefreshTokenDto) {
|
||||
const refreshSecret =
|
||||
this.configService.get<string>('REFRESH_TOKEN_SECRET') ??
|
||||
'change_me_refresh_secret';
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(refreshTokenDto.refreshToken, {
|
||||
secret: refreshSecret,
|
||||
});
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const user = await this.prismaService.user.findUnique({
|
||||
where: { id: payload.sub as string },
|
||||
});
|
||||
|
||||
if (!user || !user.refreshTokenHash) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const isRefreshTokenValid = await bcrypt.compare(
|
||||
refreshTokenDto.refreshToken,
|
||||
user.refreshTokenHash,
|
||||
);
|
||||
|
||||
if (!isRefreshTokenValid) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
return this.issueTokens(user);
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
async logout(userId: string) {
|
||||
await this.prismaService.user.updateMany({
|
||||
where: { id: userId },
|
||||
data: { refreshTokenHash: null },
|
||||
});
|
||||
|
||||
return { loggedOut: true };
|
||||
}
|
||||
|
||||
async me(userId: string) {
|
||||
const user = await this.prismaService.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
profileImageUrl: user.profileImageUrl,
|
||||
workspaceId: user.workspaceId,
|
||||
currentProjectId: (
|
||||
await this.prismaService.projectMembership.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { projectId: true },
|
||||
})
|
||||
)?.projectId ?? null,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(40)
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
export type AuthenticatedRequest = Request & {
|
||||
user?: {
|
||||
sub: string;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
workspaceId?: string | null;
|
||||
projectId?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing bearer token');
|
||||
}
|
||||
|
||||
const token = authHeader.slice('Bearer '.length);
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret:
|
||||
this.configService.get<string>('JWT_SECRET') ?? 'change_me_jwt_secret',
|
||||
});
|
||||
|
||||
request.user = {
|
||||
sub: payload.sub as string,
|
||||
username: payload.username as string,
|
||||
role: payload.role as UserRole,
|
||||
workspaceId: (payload.workspaceId as string | null | undefined) ?? null,
|
||||
projectId: (payload.projectId as string | null | undefined) ?? null,
|
||||
};
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { AuthenticatedRequest } from './jwt-auth.guard';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const userRole = request.user?.role;
|
||||
|
||||
if (!userRole || !requiredRoles.includes(userRole)) {
|
||||
throw new ForbiddenException('Insufficient role');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
health() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IsDateString, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class UpsertBabyIndicesDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dpa?: string;
|
||||
}
|
||||
|
||||
export class UpsertBabyTrimesterDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
date?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.1)
|
||||
@Max(15)
|
||||
poids?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.1)
|
||||
@Max(60)
|
||||
taille?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
perimCranien?: number;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsDateString, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class UpsertParentIndicesDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.5)
|
||||
@Max(8)
|
||||
poids?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(30)
|
||||
@Max(70)
|
||||
taille?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(20)
|
||||
@Max(45)
|
||||
perimCranien?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateNaissance?: string;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
MaxFileSizeValidator,
|
||||
Param,
|
||||
ParseFilePipe,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ParentType, Trimester, UserRole } from '@prisma/client';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import type { AuthenticatedRequest } from '../auth/jwt-auth.guard';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { IndicesService } from './indices.service';
|
||||
import { UpsertParentIndicesDto } from './dto/upsert-parent-indices.dto';
|
||||
import { UpsertBabyIndicesDto, UpsertBabyTrimesterDto } from './dto/upsert-baby-indices.dto';
|
||||
|
||||
const uploadsDir = join(process.cwd(), 'uploads');
|
||||
|
||||
const storage = diskStorage({
|
||||
destination: (_req, _file, callback) => {
|
||||
if (!existsSync(uploadsDir)) {
|
||||
mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
callback(null, uploadsDir);
|
||||
},
|
||||
filename: (_req, file, callback) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
callback(null, `indices-${uniqueSuffix}${extname(file.originalname)}`);
|
||||
},
|
||||
});
|
||||
|
||||
@Controller('projects/:projectId/indices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class IndicesController {
|
||||
constructor(private readonly indicesService: IndicesService) {}
|
||||
|
||||
@Get()
|
||||
getProjectIndices(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
) {
|
||||
return this.indicesService.getProjectIndices(request.user!.sub, projectId);
|
||||
}
|
||||
|
||||
@Put('parents/:parentType')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
upsertParentIndices(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('parentType') parentType: ParentType,
|
||||
@Body() dto: UpsertParentIndicesDto,
|
||||
) {
|
||||
return this.indicesService.upsertParentIndices(request.user!.sub, projectId, parentType, dto);
|
||||
}
|
||||
|
||||
@Post('parents/:parentType/photos')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileInterceptor('file', { storage }))
|
||||
addParentPhoto(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('parentType') parentType: ParentType,
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 })],
|
||||
fileIsRequired: true,
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
const allowed = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
||||
if (!allowed.includes(file.mimetype)) {
|
||||
throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.');
|
||||
}
|
||||
return this.indicesService.addParentPhoto(
|
||||
request.user!.sub,
|
||||
projectId,
|
||||
parentType,
|
||||
`/uploads/${file.filename}`,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('parents/:parentType/photos/:photoId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
deleteParentPhoto(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('parentType') parentType: ParentType,
|
||||
@Param('photoId') photoId: string,
|
||||
) {
|
||||
return this.indicesService.deleteParentPhoto(request.user!.sub, projectId, parentType, photoId);
|
||||
}
|
||||
|
||||
@Put('babies/:babyIndex')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
upsertBabyIndices(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('babyIndex', ParseIntPipe) babyIndex: number,
|
||||
@Body() dto: UpsertBabyIndicesDto,
|
||||
) {
|
||||
return this.indicesService.upsertBabyIndices(request.user!.sub, projectId, babyIndex, dto);
|
||||
}
|
||||
|
||||
@Put('babies/:babyIndex/trimesters/:trimester')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
upsertBabyTrimester(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('babyIndex', ParseIntPipe) babyIndex: number,
|
||||
@Param('trimester') trimester: Trimester,
|
||||
@Body() dto: UpsertBabyTrimesterDto,
|
||||
) {
|
||||
return this.indicesService.upsertBabyTrimester(
|
||||
request.user!.sub,
|
||||
projectId,
|
||||
babyIndex,
|
||||
trimester,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('babies/:babyIndex/trimesters/:trimester/photos')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileInterceptor('file', { storage }))
|
||||
addTrimesterPhoto(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('babyIndex', ParseIntPipe) babyIndex: number,
|
||||
@Param('trimester') trimester: Trimester,
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 })],
|
||||
fileIsRequired: true,
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
const allowed = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
||||
if (!allowed.includes(file.mimetype)) {
|
||||
throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.');
|
||||
}
|
||||
return this.indicesService.addTrimesterPhoto(
|
||||
request.user!.sub,
|
||||
projectId,
|
||||
babyIndex,
|
||||
trimester,
|
||||
`/uploads/${file.filename}`,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('babies/:babyIndex/trimesters/:trimester/photos/:photoId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
deleteTrimesterPhoto(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('babyIndex', ParseIntPipe) babyIndex: number,
|
||||
@Param('trimester') trimester: Trimester,
|
||||
@Param('photoId') photoId: string,
|
||||
) {
|
||||
return this.indicesService.deleteTrimesterPhoto(
|
||||
request.user!.sub,
|
||||
projectId,
|
||||
babyIndex,
|
||||
trimester,
|
||||
photoId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { IndicesController } from './indices.controller';
|
||||
import { IndicesService } from './indices.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [IndicesController],
|
||||
providers: [IndicesService],
|
||||
})
|
||||
export class IndicesModule {}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ParentType, Trimester, UserRole } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpsertParentIndicesDto } from './dto/upsert-parent-indices.dto';
|
||||
import { UpsertBabyIndicesDto, UpsertBabyTrimesterDto } from './dto/upsert-baby-indices.dto';
|
||||
|
||||
const MAX_PHOTOS_PER_SECTION = 5;
|
||||
|
||||
@Injectable()
|
||||
export class IndicesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private async assertAccess(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
options: { adminOnly?: boolean } = {},
|
||||
) {
|
||||
const [user, project, membership] = await Promise.all([
|
||||
this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, role: true, workspaceId: true },
|
||||
}),
|
||||
this.prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, workspaceId: true },
|
||||
}),
|
||||
this.prisma.projectMembership.findUnique({
|
||||
where: { userId },
|
||||
select: { projectId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||
if (!project) throw new NotFoundException('Projet introuvable');
|
||||
if (!user.workspaceId || user.workspaceId !== project.workspaceId) {
|
||||
throw new ForbiddenException('Acces refuse a ce projet');
|
||||
}
|
||||
|
||||
if (user.role === UserRole.ADMIN) return { user, isAdmin: true };
|
||||
if (options.adminOnly) throw new ForbiddenException('Action reservee aux admins');
|
||||
if (!membership || membership.projectId !== projectId) {
|
||||
throw new ForbiddenException('Vous n etes pas rattache a ce projet');
|
||||
}
|
||||
|
||||
return { user, isAdmin: false };
|
||||
}
|
||||
|
||||
async getProjectIndices(userId: string, projectId: string) {
|
||||
await this.assertAccess(userId, projectId);
|
||||
|
||||
const [parentIndices, babyIndices] = await Promise.all([
|
||||
this.prisma.parentIndices.findMany({
|
||||
where: { projectId },
|
||||
include: {
|
||||
photos: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
orderBy: { parentType: 'asc' },
|
||||
}),
|
||||
this.prisma.babyIndices.findMany({
|
||||
where: { projectId },
|
||||
include: {
|
||||
trimesters: {
|
||||
include: {
|
||||
photos: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
orderBy: { trimester: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { babyIndex: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { parentIndices, babyIndices };
|
||||
}
|
||||
|
||||
async upsertParentIndices(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
parentType: ParentType,
|
||||
dto: UpsertParentIndicesDto,
|
||||
) {
|
||||
await this.assertAccess(userId, projectId, { adminOnly: true });
|
||||
|
||||
return this.prisma.parentIndices.upsert({
|
||||
where: { projectId_parentType: { projectId, parentType } },
|
||||
create: {
|
||||
projectId,
|
||||
parentType,
|
||||
poids: dto.poids ?? null,
|
||||
taille: dto.taille ?? null,
|
||||
perimCranien: dto.perimCranien ?? null,
|
||||
dateNaissance: dto.dateNaissance ? new Date(dto.dateNaissance) : null,
|
||||
},
|
||||
update: {
|
||||
poids: dto.poids ?? null,
|
||||
taille: dto.taille ?? null,
|
||||
perimCranien: dto.perimCranien ?? null,
|
||||
dateNaissance: dto.dateNaissance ? new Date(dto.dateNaissance) : null,
|
||||
},
|
||||
include: { photos: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
async addParentPhoto(userId: string, projectId: string, parentType: ParentType, url: string) {
|
||||
await this.assertAccess(userId, projectId, { adminOnly: true });
|
||||
|
||||
const parentIndices = await this.prisma.parentIndices.upsert({
|
||||
where: { projectId_parentType: { projectId, parentType } },
|
||||
create: { projectId, parentType },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const count = await this.prisma.parentIndexPhoto.count({
|
||||
where: { parentIndicesId: parentIndices.id },
|
||||
});
|
||||
|
||||
if (count >= MAX_PHOTOS_PER_SECTION) {
|
||||
throw new BadRequestException(`Maximum ${MAX_PHOTOS_PER_SECTION} photos par section`);
|
||||
}
|
||||
|
||||
return this.prisma.parentIndexPhoto.create({
|
||||
data: { parentIndicesId: parentIndices.id, url, sortOrder: count },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteParentPhoto(userId: string, projectId: string, parentType: ParentType, photoId: string) {
|
||||
await this.assertAccess(userId, projectId, { adminOnly: true });
|
||||
|
||||
const photo = await this.prisma.parentIndexPhoto.findUnique({
|
||||
where: { id: photoId },
|
||||
include: { parentIndices: { select: { projectId: true, parentType: true } } },
|
||||
});
|
||||
|
||||
if (!photo || photo.parentIndices.projectId !== projectId || photo.parentIndices.parentType !== parentType) {
|
||||
throw new NotFoundException('Photo introuvable');
|
||||
}
|
||||
|
||||
await this.prisma.parentIndexPhoto.delete({ where: { id: photoId } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async upsertBabyIndices(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
babyIndex: number,
|
||||
dto: UpsertBabyIndicesDto,
|
||||
) {
|
||||
await this.assertAccess(userId, projectId, { adminOnly: true });
|
||||
|
||||
return this.prisma.babyIndices.upsert({
|
||||
where: { projectId_babyIndex: { projectId, babyIndex } },
|
||||
create: {
|
||||
projectId,
|
||||
babyIndex,
|
||||
dpa: dto.dpa ? new Date(dto.dpa) : null,
|
||||
},
|
||||
update: {
|
||||
dpa: dto.dpa ? new Date(dto.dpa) : null,
|
||||
},
|
||||
include: {
|
||||
trimesters: {
|
||||
include: { photos: { orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { trimester: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async upsertBabyTrimester(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
babyIndex: number,
|
||||
trimester: Trimester,
|
||||
dto: UpsertBabyTrimesterDto,
|
||||
) {
|
||||
await this.assertAccess(userId, projectId, { adminOnly: true });
|
||||
|
||||
const babyIndices = await this.prisma.babyIndices.upsert({
|
||||
where: { projectId_babyIndex: { projectId, babyIndex } },
|
||||
create: { projectId, babyIndex },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return this.prisma.babyTrimesterEntry.upsert({
|
||||
where: { babyIndicesId_trimester: { babyIndicesId: babyIndices.id, trimester } },
|
||||
create: {
|
||||
babyIndicesId: babyIndices.id,
|
||||
trimester,
|
||||
date: dto.date ? new Date(dto.date) : null,
|
||||
note: dto.note ?? null,
|
||||
poids: dto.poids ?? null,
|
||||
taille: dto.taille ?? null,
|
||||
perimCranien: dto.perimCranien ?? null,
|
||||
},
|
||||
update: {
|
||||
date: dto.date ? new Date(dto.date) : null,
|
||||
note: dto.note ?? null,
|
||||
poids: dto.poids ?? null,
|
||||
taille: dto.taille ?? null,
|
||||
perimCranien: dto.perimCranien ?? null,
|
||||
},
|
||||
include: { photos: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
async addTrimesterPhoto(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
babyIndex: number,
|
||||
trimester: Trimester,
|
||||
url: string,
|
||||
) {
|
||||
await this.assertAccess(userId, projectId, { adminOnly: true });
|
||||
|
||||
const babyIndices = await this.prisma.babyIndices.upsert({
|
||||
where: { projectId_babyIndex: { projectId, babyIndex } },
|
||||
create: { projectId, babyIndex },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const entry = await this.prisma.babyTrimesterEntry.upsert({
|
||||
where: { babyIndicesId_trimester: { babyIndicesId: babyIndices.id, trimester } },
|
||||
create: { babyIndicesId: babyIndices.id, trimester },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const count = await this.prisma.babyTrimesterPhoto.count({
|
||||
where: { trimesterEntryId: entry.id },
|
||||
});
|
||||
|
||||
if (count >= MAX_PHOTOS_PER_SECTION) {
|
||||
throw new BadRequestException(`Maximum ${MAX_PHOTOS_PER_SECTION} photos par trimestre`);
|
||||
}
|
||||
|
||||
return this.prisma.babyTrimesterPhoto.create({
|
||||
data: { trimesterEntryId: entry.id, url, sortOrder: count },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTrimesterPhoto(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
babyIndex: number,
|
||||
trimester: Trimester,
|
||||
photoId: string,
|
||||
) {
|
||||
await this.assertAccess(userId, projectId, { adminOnly: true });
|
||||
|
||||
const photo = await this.prisma.babyTrimesterPhoto.findUnique({
|
||||
where: { id: photoId },
|
||||
include: {
|
||||
trimesterEntry: {
|
||||
include: {
|
||||
babyIndices: { select: { projectId: true, babyIndex: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!photo ||
|
||||
photo.trimesterEntry.babyIndices.projectId !== projectId ||
|
||||
photo.trimesterEntry.babyIndices.babyIndex !== babyIndex ||
|
||||
photo.trimesterEntry.trimester !== trimester
|
||||
) {
|
||||
throw new NotFoundException('Photo introuvable');
|
||||
}
|
||||
|
||||
await this.prisma.babyTrimesterPhoto.delete({ where: { id: photoId } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import * as express from 'express';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const frontendUrls = process.env.FRONTEND_URL
|
||||
? process.env.FRONTEND_URL.split(',').map((url) => url.trim()).filter(Boolean)
|
||||
: ['http://localhost:3002'];
|
||||
const uploadsDir = join(process.cwd(), 'uploads');
|
||||
if (!existsSync(uploadsDir)) {
|
||||
mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
app.use('/uploads', express.static(uploadsDir));
|
||||
app.use('/default-avatars', express.static(join(process.cwd(), 'public', 'default-avatars')));
|
||||
app.use('/default-project-avatars', express.static(join(process.cwd(), 'public', 'default-project-avatars')));
|
||||
app.enableCors({
|
||||
origin: frontendUrls,
|
||||
credentials: true,
|
||||
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CardFieldDto {
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
points?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrimary?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRequired?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
minNumber?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
maxNumber?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
stepNumber?: number;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CardOptionDto {
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
value!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { PredictionCardType, PredictionValueType } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { CardFieldDto } from './card-field.dto';
|
||||
import { CardOptionDto } from './card-option.dto';
|
||||
|
||||
export class CreateCardDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
description?: string;
|
||||
|
||||
@IsEnum(PredictionCardType)
|
||||
type!: PredictionCardType;
|
||||
|
||||
@IsEnum(PredictionValueType)
|
||||
valueType!: PredictionValueType;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
styleId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
unit?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
basePoints?: number;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CardFieldDto)
|
||||
fields!: CardFieldDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CardOptionDto)
|
||||
options?: CardOptionDto[];
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class PredictionValueDto {
|
||||
@IsString()
|
||||
fieldId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
valueText?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
valueNumber?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
valueDate?: string;
|
||||
}
|
||||
|
||||
export class UpsertPredictionEntryDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
selectedBabyIndex?: number;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PredictionValueDto)
|
||||
values!: PredictionValueDto[];
|
||||
}
|
||||
|
||||
export class OutcomeValueDto {
|
||||
@IsString()
|
||||
fieldId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
valueText?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
valueNumber?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
valueDate?: string;
|
||||
}
|
||||
|
||||
export class SetOutcomesDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
selectedBabyIndex?: number;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OutcomeValueDto)
|
||||
values!: OutcomeValueDto[];
|
||||
}
|
||||
|
||||
export class ValidateScoreItemDto {
|
||||
@IsString()
|
||||
fieldId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
awardedPoints?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class ValidateScoresDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ValidateScoreItemDto)
|
||||
scores!: ValidateScoreItemDto[];
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { PredictionValueType } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { CardFieldDto } from './card-field.dto';
|
||||
import { CardOptionDto } from './card-option.dto';
|
||||
|
||||
export class UpdateCardDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(PredictionValueType)
|
||||
valueType?: PredictionValueType;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
styleId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
unit?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
basePoints?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CardFieldDto)
|
||||
fields?: CardFieldDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CardOptionDto)
|
||||
options?: CardOptionDto[];
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Put,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import type { AuthenticatedRequest } from '../auth/jwt-auth.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { CreateCardDto } from './dto/create-card.dto';
|
||||
import {
|
||||
SetOutcomesDto,
|
||||
UpsertPredictionEntryDto,
|
||||
ValidateScoresDto,
|
||||
} from './dto/prediction-value.dto';
|
||||
import { UpdateCardDto } from './dto/update-card.dto';
|
||||
import { PredictionsService } from './predictions.service';
|
||||
|
||||
@Controller('predictions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PredictionsController {
|
||||
constructor(private readonly predictionsService: PredictionsService) {}
|
||||
|
||||
@Get('cards')
|
||||
listCards() {
|
||||
return this.predictionsService.listCards(false);
|
||||
}
|
||||
|
||||
@Get('board')
|
||||
board(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query('selectedBabyIndex') selectedBabyIndex?: string,
|
||||
) {
|
||||
return this.predictionsService.getBoard(
|
||||
request.user!.sub,
|
||||
undefined,
|
||||
selectedBabyIndex ? Number(selectedBabyIndex) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
activity(@Req() request: AuthenticatedRequest) {
|
||||
return this.predictionsService.listActivity(request.user!.sub);
|
||||
}
|
||||
|
||||
@Get('scoreboard')
|
||||
scoreboard() {
|
||||
return this.predictionsService.getScoreboard();
|
||||
}
|
||||
|
||||
@Get('my-entries')
|
||||
myEntries(@Req() request: AuthenticatedRequest) {
|
||||
return this.predictionsService.listMyEntries(request.user!.sub);
|
||||
}
|
||||
|
||||
@Put('cards/:cardId/my-entry')
|
||||
upsertMyEntry(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('cardId') cardId: string,
|
||||
@Body() dto: UpsertPredictionEntryDto,
|
||||
) {
|
||||
return this.predictionsService.upsertMyPrediction(request.user!.sub, cardId, dto);
|
||||
}
|
||||
|
||||
@Post('game/close')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
closeGame(@Req() request: AuthenticatedRequest) {
|
||||
return this.predictionsService.closeGame(request.user!.sub);
|
||||
}
|
||||
|
||||
@Post('game/open')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
openGame(@Req() request: AuthenticatedRequest) {
|
||||
return this.predictionsService.openGame(request.user!.sub);
|
||||
}
|
||||
|
||||
@Post('game/finalize')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
finalizeGame(@Req() request: AuthenticatedRequest) {
|
||||
return this.predictionsService.finalizeGame(request.user!.sub);
|
||||
}
|
||||
|
||||
@Post('cards')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
createCard(@Req() request: AuthenticatedRequest, @Body() dto: CreateCardDto) {
|
||||
return this.predictionsService.createCard(request.user!.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('cards/:cardId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
updateCard(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('cardId') cardId: string,
|
||||
@Body() dto: UpdateCardDto,
|
||||
) {
|
||||
return this.predictionsService.updateCard(request.user!.sub, cardId, dto);
|
||||
}
|
||||
|
||||
@Delete('cards/:cardId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
deleteCard(@Req() request: AuthenticatedRequest, @Param('cardId') cardId: string) {
|
||||
return this.predictionsService.deleteCard(request.user!.sub, cardId);
|
||||
}
|
||||
|
||||
@Post('cards/:cardId/outcomes')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
setOutcomes(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('cardId') cardId: string,
|
||||
@Body() dto: SetOutcomesDto,
|
||||
) {
|
||||
return this.predictionsService.setCardOutcomes(request.user!.sub, cardId, dto);
|
||||
}
|
||||
|
||||
@Post('cards/:cardId/suggest-scores')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
suggestScores(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('cardId') cardId: string,
|
||||
@Query('selectedBabyIndex') selectedBabyIndex?: string,
|
||||
) {
|
||||
return this.predictionsService.suggestScoresForCard(
|
||||
request.user!.sub,
|
||||
cardId,
|
||||
undefined,
|
||||
selectedBabyIndex ? Number(selectedBabyIndex) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('entries/:entryId/scores')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
validateScores(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('entryId') entryId: string,
|
||||
@Body() dto: ValidateScoresDto,
|
||||
) {
|
||||
return this.predictionsService.validateScores(request.user!.sub, entryId, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ProjectPredictionsController } from './project-predictions.controller';
|
||||
import { PredictionsController } from './predictions.controller';
|
||||
import { PredictionsService } from './predictions.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [PredictionsController, ProjectPredictionsController],
|
||||
providers: [PredictionsService],
|
||||
exports: [PredictionsService],
|
||||
})
|
||||
export class PredictionsModule {}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class AssignProjectParticipantDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
userId!: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CloneProjectDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeParticipants?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ProjectStatus } from '@prisma/client';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { MAX_PROJECT_BABIES } from '../project-scope.constants';
|
||||
|
||||
export class CreateProjectDto {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
babyCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ProjectStatus)
|
||||
status?: ProjectStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(MAX_PROJECT_BABIES)
|
||||
@IsString({ each: true })
|
||||
babyLabels?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
enabledLegacyCardCodes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
participantUserIds?: string[];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ProjectStatus } from '@prisma/client';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { MAX_PROJECT_BABIES } from '../project-scope.constants';
|
||||
|
||||
export class UpdateProjectDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
babyCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ProjectStatus)
|
||||
status?: ProjectStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(MAX_PROJECT_BABIES)
|
||||
@IsString({ each: true })
|
||||
babyLabels?: string[];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DEFAULT_WORKSPACE_ID = 'default_workspace';
|
||||
export const DEFAULT_WORKSPACE_SLUG = 'default-workspace';
|
||||
export const DEFAULT_WORKSPACE_NAME = 'Espace principal';
|
||||
|
||||
export const DEFAULT_PROJECT_ID = 'default_project';
|
||||
export const DEFAULT_PROJECT_NAME = 'Concours principal';
|
||||
|
||||
export const MAX_WORKSPACE_ADMINS = 3;
|
||||
export const MAX_PROJECT_BABIES = 3;
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
MaxFileSizeValidator,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
ParseFilePipe,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import { existsSync, mkdirSync, readdirSync } from 'fs';
|
||||
import type { AuthenticatedRequest } from '../auth/jwt-auth.guard';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { AssignProjectParticipantDto } from './dto/assign-project-participant.dto';
|
||||
import { CloneProjectDto } from './dto/clone-project.dto';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
import { ProjectsService } from './projects.service';
|
||||
|
||||
const uploadsDir = join(process.cwd(), 'uploads');
|
||||
|
||||
const storage = diskStorage({
|
||||
destination: (_req, _file, callback) => {
|
||||
if (!existsSync(uploadsDir)) {
|
||||
mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
callback(null, uploadsDir);
|
||||
},
|
||||
filename: (_req, file, callback) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
callback(null, `project-${uniqueSuffix}${extname(file.originalname)}`);
|
||||
},
|
||||
});
|
||||
|
||||
@Controller('projects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProjectsController {
|
||||
private readonly defaultProjectAvatars: string[];
|
||||
|
||||
constructor(private readonly projectsService: ProjectsService) {
|
||||
const avatarsDir = join(process.cwd(), 'public', 'default-project-avatars');
|
||||
const imageExts = ['.png', '.jpg', '.jpeg', '.webp'];
|
||||
try {
|
||||
this.defaultProjectAvatars = readdirSync(avatarsDir)
|
||||
.filter((f) => imageExts.includes(extname(f).toLowerCase()))
|
||||
.sort()
|
||||
.map((f) => `/default-project-avatars/${f}`);
|
||||
} catch {
|
||||
this.defaultProjectAvatars = [];
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
listForUser(@Req() request: AuthenticatedRequest) {
|
||||
return this.projectsService.listForUser(request.user!.sub);
|
||||
}
|
||||
|
||||
@Get(':projectId')
|
||||
getById(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
|
||||
return this.projectsService.getByIdForUser(request.user!.sub, projectId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
create(@Req() request: AuthenticatedRequest, @Body() dto: CreateProjectDto) {
|
||||
return this.projectsService.create(request.user!.sub, dto);
|
||||
}
|
||||
|
||||
@Patch(':projectId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
update(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() dto: UpdateProjectDto,
|
||||
) {
|
||||
return this.projectsService.update(request.user!.sub, projectId, dto);
|
||||
}
|
||||
|
||||
@Post(':projectId/clone')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
clone(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() dto: CloneProjectDto,
|
||||
) {
|
||||
return this.projectsService.clone(request.user!.sub, projectId, dto);
|
||||
}
|
||||
|
||||
@Get(':projectId/participants')
|
||||
listParticipants(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
|
||||
return this.projectsService.listParticipants(request.user!.sub, projectId);
|
||||
}
|
||||
|
||||
@Post(':projectId/participants')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
addParticipant(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() dto: AssignProjectParticipantDto,
|
||||
) {
|
||||
return this.projectsService.addParticipant(request.user!.sub, projectId, dto);
|
||||
}
|
||||
|
||||
@Delete(':projectId/participants/:participantUserId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
removeParticipant(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('participantUserId') participantUserId: string,
|
||||
) {
|
||||
return this.projectsService.removeParticipant(
|
||||
request.user!.sub,
|
||||
projectId,
|
||||
participantUserId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':projectId/default-project-avatars')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
listDefaultProjectAvatars() {
|
||||
return this.defaultProjectAvatars;
|
||||
}
|
||||
|
||||
@Post(':projectId/photo')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileInterceptor('file', { storage }))
|
||||
uploadProjectPhoto(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() body: { bgColor?: string },
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 })],
|
||||
fileIsRequired: false,
|
||||
}),
|
||||
)
|
||||
file?: Express.Multer.File,
|
||||
) {
|
||||
if (file) {
|
||||
const allowedMimeTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||
throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!file && !body.bgColor) {
|
||||
throw new BadRequestException('Envoyez une image ou une couleur de fond.');
|
||||
}
|
||||
|
||||
return this.projectsService.updateProjectImage(
|
||||
request.user!.sub,
|
||||
projectId,
|
||||
file ? `/uploads/${file.filename}` : undefined,
|
||||
body.bgColor ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':projectId/photo')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
deleteProjectPhoto(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
) {
|
||||
return this.projectsService.updateProjectImage(request.user!.sub, projectId, null, null);
|
||||
}
|
||||
|
||||
@Patch(':projectId/avatar')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
setProjectDefaultAvatar(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() body: { avatarUrl: string; bgColor?: string },
|
||||
) {
|
||||
if (!body.avatarUrl || !this.defaultProjectAvatars.includes(body.avatarUrl)) {
|
||||
throw new BadRequestException('Avatar par defaut invalide.');
|
||||
}
|
||||
return this.projectsService.updateProjectImage(
|
||||
request.user!.sub,
|
||||
projectId,
|
||||
body.avatarUrl,
|
||||
body.bgColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ProjectsController } from './projects.controller';
|
||||
import { ProjectsService } from './projects.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ProjectsController],
|
||||
providers: [ProjectsService],
|
||||
exports: [ProjectsService],
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
@@ -0,0 +1,670 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { GameStatus, ProjectStatus, UserRole } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AssignProjectParticipantDto } from './dto/assign-project-participant.dto';
|
||||
import { CloneProjectDto } from './dto/clone-project.dto';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
import {
|
||||
DEFAULT_PROJECT_ID,
|
||||
DEFAULT_WORKSPACE_ID,
|
||||
DEFAULT_WORKSPACE_NAME,
|
||||
DEFAULT_WORKSPACE_SLUG,
|
||||
MAX_PROJECT_BABIES,
|
||||
} from './project-scope.constants';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService implements OnModuleInit {
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureDefaultWorkspace();
|
||||
}
|
||||
|
||||
private clampBabyCount(value?: number) {
|
||||
if (!value) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(MAX_PROJECT_BABIES, Math.max(1, Math.trunc(value)));
|
||||
}
|
||||
|
||||
private mapProjectStatusToGameStatus(status: ProjectStatus): GameStatus {
|
||||
if (status === ProjectStatus.CLOSED || status === ProjectStatus.FINALIZED) {
|
||||
return GameStatus.CLOSED;
|
||||
}
|
||||
|
||||
return GameStatus.OPEN;
|
||||
}
|
||||
|
||||
private normalizeSlug(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized || `workspace-${Date.now()}`;
|
||||
}
|
||||
|
||||
private async ensureWorkspaceForAdmin(userId: string) {
|
||||
const user = await this.prismaService.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, role: true, workspaceId: true, username: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('Utilisateur introuvable');
|
||||
}
|
||||
|
||||
if (user.role !== UserRole.ADMIN) {
|
||||
throw new ForbiddenException('Seuls les comptes admin peuvent creer des projets');
|
||||
}
|
||||
|
||||
if (user.workspaceId) {
|
||||
return user.workspaceId;
|
||||
}
|
||||
|
||||
const baseSlug = this.normalizeSlug(user.username);
|
||||
const workspace = await this.prismaService.workspace.create({
|
||||
data: {
|
||||
name: `Espace ${user.username}`,
|
||||
slug: `${baseSlug}-${Date.now()}`,
|
||||
ownerUserId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prismaService.user.update({
|
||||
where: { id: user.id },
|
||||
data: { workspaceId: workspace.id },
|
||||
});
|
||||
|
||||
return workspace.id;
|
||||
}
|
||||
|
||||
private async ensureProjectBabies(projectId: string, babyCount: number, babyLabels?: string[]) {
|
||||
const safeCount = this.clampBabyCount(babyCount);
|
||||
|
||||
for (let index = 1; index <= safeCount; index += 1) {
|
||||
await this.prismaService.projectBaby.upsert({
|
||||
where: {
|
||||
projectId_babyIndex: {
|
||||
projectId,
|
||||
babyIndex: index,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
label: babyLabels?.[index - 1] ?? `Bebe ${index}`,
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
babyIndex: index,
|
||||
label: babyLabels?.[index - 1] ?? `Bebe ${index}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.prismaService.projectBaby.deleteMany({
|
||||
where: {
|
||||
projectId,
|
||||
babyIndex: { gt: safeCount },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async assertProjectAccess(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
options?: { adminOnly?: boolean },
|
||||
) {
|
||||
const [user, project, membership] = await Promise.all([
|
||||
this.prismaService.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, role: true, workspaceId: true },
|
||||
}),
|
||||
this.prismaService.project.findUnique({
|
||||
where: { id: projectId },
|
||||
}),
|
||||
this.prismaService.projectMembership.findUnique({
|
||||
where: { userId },
|
||||
select: { id: true, projectId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('Utilisateur introuvable');
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException('Projet introuvable');
|
||||
}
|
||||
|
||||
if (!user.workspaceId || user.workspaceId !== project.workspaceId) {
|
||||
throw new ForbiddenException('Acces refuse a ce projet');
|
||||
}
|
||||
|
||||
if (user.role === UserRole.ADMIN) {
|
||||
return { user, project, isAdmin: true };
|
||||
}
|
||||
|
||||
if (options?.adminOnly) {
|
||||
throw new ForbiddenException('Action reservee aux admins');
|
||||
}
|
||||
|
||||
if (!membership || membership.projectId !== project.id) {
|
||||
throw new ForbiddenException('Vous n etes pas rattache a ce projet');
|
||||
}
|
||||
|
||||
return { user, project, isAdmin: false };
|
||||
}
|
||||
|
||||
private async copyCardsFromProject(params: {
|
||||
sourceProjectId: string;
|
||||
targetProjectId: string;
|
||||
targetGameId: string;
|
||||
actorUserId: string;
|
||||
filterLegacyCodes?: string[];
|
||||
}) {
|
||||
const sourceProjectFilter =
|
||||
params.sourceProjectId === DEFAULT_PROJECT_ID
|
||||
? { OR: [{ projectId: params.sourceProjectId }, { projectId: null }] }
|
||||
: { projectId: params.sourceProjectId };
|
||||
|
||||
const sourceCards = await this.prismaService.predictionCard.findMany({
|
||||
where: {
|
||||
...sourceProjectFilter,
|
||||
...(params.filterLegacyCodes?.length
|
||||
? {
|
||||
code: {
|
||||
in: params.filterLegacyCodes,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
fields: { orderBy: { sortOrder: 'asc' } },
|
||||
options: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
for (const sourceCard of sourceCards) {
|
||||
const newCard = await this.prismaService.predictionCard.create({
|
||||
data: {
|
||||
gameId: params.targetGameId,
|
||||
projectId: params.targetProjectId,
|
||||
code: sourceCard.code,
|
||||
title: sourceCard.title,
|
||||
description: sourceCard.description,
|
||||
type: sourceCard.type,
|
||||
valueType: sourceCard.valueType,
|
||||
styleId: sourceCard.styleId,
|
||||
unit: sourceCard.unit,
|
||||
isActive: sourceCard.isActive,
|
||||
isDeletable: sourceCard.isDeletable,
|
||||
sortOrder: sourceCard.sortOrder,
|
||||
basePoints: sourceCard.basePoints,
|
||||
createdById: params.actorUserId,
|
||||
},
|
||||
});
|
||||
|
||||
for (const field of sourceCard.fields) {
|
||||
await this.prismaService.predictionCardField.create({
|
||||
data: {
|
||||
cardId: newCard.id,
|
||||
label: field.label,
|
||||
sortOrder: field.sortOrder,
|
||||
points: field.points,
|
||||
isPrimary: field.isPrimary,
|
||||
isRequired: field.isRequired,
|
||||
minNumber: field.minNumber,
|
||||
maxNumber: field.maxNumber,
|
||||
stepNumber: field.stepNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const option of sourceCard.options) {
|
||||
await this.prismaService.predictionCardOption.create({
|
||||
data: {
|
||||
cardId: newCard.id,
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
sortOrder: option.sortOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assignParticipantToProjectInternal(
|
||||
actorUserId: string,
|
||||
projectId: string,
|
||||
dto: AssignProjectParticipantDto,
|
||||
) {
|
||||
const project = await this.prismaService.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, workspaceId: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException('Projet introuvable');
|
||||
}
|
||||
|
||||
const participant = await this.prismaService.user.findUnique({
|
||||
where: { id: dto.userId },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
workspaceId: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new NotFoundException('Compte participant introuvable');
|
||||
}
|
||||
|
||||
if (participant.role === UserRole.ADMIN) {
|
||||
throw new BadRequestException('Un admin ne peut pas etre rattache comme participant');
|
||||
}
|
||||
|
||||
if (participant.workspaceId && participant.workspaceId !== project.workspaceId) {
|
||||
throw new ForbiddenException('Ce participant depend d un autre espace');
|
||||
}
|
||||
|
||||
if (!participant.workspaceId) {
|
||||
await this.prismaService.user.update({
|
||||
where: { id: participant.id },
|
||||
data: { workspaceId: project.workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
const existingMembership = await this.prismaService.projectMembership.findUnique({
|
||||
where: { userId: participant.id },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
if (existingMembership.projectId !== projectId) {
|
||||
return this.prismaService.projectMembership.update({
|
||||
where: { userId: participant.id },
|
||||
data: {
|
||||
projectId,
|
||||
assignedById: actorUserId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return existingMembership;
|
||||
}
|
||||
|
||||
return this.prismaService.projectMembership.create({
|
||||
data: {
|
||||
projectId,
|
||||
userId: participant.id,
|
||||
assignedById: actorUserId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async ensureDefaultWorkspace() {
|
||||
const firstAdmin = await this.prismaService.user.findFirst({
|
||||
where: { role: UserRole.ADMIN },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
workspaceId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!firstAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prismaService.workspace.upsert({
|
||||
where: { id: DEFAULT_WORKSPACE_ID },
|
||||
update: {
|
||||
name: DEFAULT_WORKSPACE_NAME,
|
||||
slug: DEFAULT_WORKSPACE_SLUG,
|
||||
ownerUserId: firstAdmin.id,
|
||||
},
|
||||
create: {
|
||||
id: DEFAULT_WORKSPACE_ID,
|
||||
name: DEFAULT_WORKSPACE_NAME,
|
||||
slug: DEFAULT_WORKSPACE_SLUG,
|
||||
ownerUserId: firstAdmin.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (firstAdmin.workspaceId !== DEFAULT_WORKSPACE_ID) {
|
||||
await this.prismaService.user.update({
|
||||
where: { id: firstAdmin.id },
|
||||
data: { workspaceId: DEFAULT_WORKSPACE_ID },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async listForUser(userId: string) {
|
||||
const user = await this.prismaService.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, role: true, workspaceId: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('Utilisateur introuvable');
|
||||
}
|
||||
|
||||
if (!user.workspaceId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (user.role === UserRole.ADMIN) {
|
||||
return this.prismaService.project.findMany({
|
||||
where: { workspaceId: user.workspaceId },
|
||||
include: {
|
||||
babies: { orderBy: { babyIndex: 'asc' } },
|
||||
_count: {
|
||||
select: {
|
||||
memberships: true,
|
||||
cards: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
const memberships = await this.prismaService.projectMembership.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
babies: { orderBy: { babyIndex: 'asc' } },
|
||||
_count: {
|
||||
select: {
|
||||
memberships: true,
|
||||
cards: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return memberships.map((membership) => membership.project);
|
||||
}
|
||||
|
||||
async getByIdForUser(userId: string, projectId: string) {
|
||||
await this.assertProjectAccess(userId, projectId);
|
||||
|
||||
const project = await this.prismaService.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
babies: { orderBy: { babyIndex: 'asc' } },
|
||||
_count: {
|
||||
select: {
|
||||
memberships: true,
|
||||
cards: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException('Projet introuvable');
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
async create(adminUserId: string, dto: CreateProjectDto) {
|
||||
const workspaceId = await this.ensureWorkspaceForAdmin(adminUserId);
|
||||
const babyCount = this.clampBabyCount(dto.babyCount);
|
||||
|
||||
const project = await this.prismaService.project.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
createdById: adminUserId,
|
||||
name: dto.name.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
status: dto.status ?? ProjectStatus.OPEN,
|
||||
babyCount,
|
||||
},
|
||||
});
|
||||
|
||||
await this.ensureProjectBabies(project.id, babyCount, dto.babyLabels);
|
||||
|
||||
const game = await this.prismaService.predictionGame.create({
|
||||
data: {
|
||||
id: project.id,
|
||||
projectId: project.id,
|
||||
title: `Pronostics ${project.name}`,
|
||||
status: this.mapProjectStatusToGameStatus(project.status),
|
||||
},
|
||||
});
|
||||
|
||||
await this.copyCardsFromProject({
|
||||
sourceProjectId: DEFAULT_PROJECT_ID,
|
||||
targetProjectId: project.id,
|
||||
targetGameId: game.id,
|
||||
actorUserId: adminUserId,
|
||||
filterLegacyCodes: dto.enabledLegacyCardCodes,
|
||||
});
|
||||
|
||||
if (dto.participantUserIds?.length) {
|
||||
for (const participantUserId of dto.participantUserIds) {
|
||||
await this.assignParticipantToProjectInternal(adminUserId, project.id, {
|
||||
userId: participantUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.getByIdForUser(adminUserId, project.id);
|
||||
}
|
||||
|
||||
async update(adminUserId: string, projectId: string, dto: UpdateProjectDto) {
|
||||
const { project } = await this.assertProjectAccess(adminUserId, projectId, {
|
||||
adminOnly: true,
|
||||
});
|
||||
|
||||
const babyCount = dto.babyCount ? this.clampBabyCount(dto.babyCount) : project.babyCount;
|
||||
|
||||
const updatedProject = await this.prismaService.project.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
name: dto.name?.trim(),
|
||||
description: dto.description?.trim() ?? (dto.description === '' ? null : undefined),
|
||||
status: dto.status,
|
||||
babyCount,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.babyCount || dto.babyLabels) {
|
||||
await this.ensureProjectBabies(projectId, babyCount, dto.babyLabels);
|
||||
}
|
||||
|
||||
if (dto.status) {
|
||||
await this.prismaService.predictionGame.updateMany({
|
||||
where: { projectId },
|
||||
data: {
|
||||
status: this.mapProjectStatusToGameStatus(dto.status),
|
||||
closedAt: dto.status === ProjectStatus.CLOSED ? new Date() : null,
|
||||
reopenedAt:
|
||||
dto.status === ProjectStatus.OPEN || dto.status === ProjectStatus.DRAFT
|
||||
? new Date()
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getByIdForUser(adminUserId, updatedProject.id);
|
||||
}
|
||||
|
||||
async clone(adminUserId: string, sourceProjectId: string, dto: CloneProjectDto) {
|
||||
const { project: sourceProject } = await this.assertProjectAccess(
|
||||
adminUserId,
|
||||
sourceProjectId,
|
||||
{ adminOnly: true },
|
||||
);
|
||||
|
||||
const sourceBabies = await this.prismaService.projectBaby.findMany({
|
||||
where: { projectId: sourceProjectId },
|
||||
orderBy: { babyIndex: 'asc' },
|
||||
});
|
||||
|
||||
const cloneName = dto.name?.trim() || `${sourceProject.name} (copie)`;
|
||||
|
||||
const clonedProject = await this.prismaService.project.create({
|
||||
data: {
|
||||
workspaceId: sourceProject.workspaceId,
|
||||
createdById: adminUserId,
|
||||
clonedFromProjectId: sourceProject.id,
|
||||
name: cloneName,
|
||||
description: dto.description?.trim() || sourceProject.description,
|
||||
status: sourceProject.status,
|
||||
babyCount: sourceProject.babyCount,
|
||||
},
|
||||
});
|
||||
|
||||
await this.ensureProjectBabies(
|
||||
clonedProject.id,
|
||||
sourceProject.babyCount,
|
||||
sourceBabies.map((baby) => baby.label ?? `Bebe ${baby.babyIndex}`),
|
||||
);
|
||||
|
||||
const clonedGame = await this.prismaService.predictionGame.create({
|
||||
data: {
|
||||
id: clonedProject.id,
|
||||
projectId: clonedProject.id,
|
||||
title: `Pronostics ${cloneName}`,
|
||||
status: this.mapProjectStatusToGameStatus(clonedProject.status),
|
||||
},
|
||||
});
|
||||
|
||||
await this.copyCardsFromProject({
|
||||
sourceProjectId,
|
||||
targetProjectId: clonedProject.id,
|
||||
targetGameId: clonedGame.id,
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
|
||||
if (dto.includeParticipants) {
|
||||
const sourceMemberships = await this.prismaService.projectMembership.findMany({
|
||||
where: { projectId: sourceProjectId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
for (const membership of sourceMemberships) {
|
||||
await this.assignParticipantToProjectInternal(adminUserId, clonedProject.id, {
|
||||
userId: membership.userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.getByIdForUser(adminUserId, clonedProject.id);
|
||||
}
|
||||
|
||||
async listParticipants(userId: string, projectId: string) {
|
||||
await this.assertProjectAccess(userId, projectId);
|
||||
|
||||
return this.prismaService.projectMembership.findMany({
|
||||
where: { projectId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
profileImageUrl: true,
|
||||
profileBgColor: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async addParticipant(
|
||||
adminUserId: string,
|
||||
projectId: string,
|
||||
dto: AssignProjectParticipantDto,
|
||||
) {
|
||||
await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true });
|
||||
return this.assignParticipantToProjectInternal(adminUserId, projectId, dto);
|
||||
}
|
||||
|
||||
async removeParticipant(adminUserId: string, projectId: string, participantUserId: string) {
|
||||
await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true });
|
||||
|
||||
const result = await this.prismaService.projectMembership.deleteMany({
|
||||
where: {
|
||||
projectId,
|
||||
userId: participantUserId,
|
||||
},
|
||||
});
|
||||
|
||||
return { removed: result.count > 0 };
|
||||
}
|
||||
|
||||
async updateProjectImage(
|
||||
adminUserId: string,
|
||||
projectId: string,
|
||||
projectImageUrl?: string | null,
|
||||
projectBgColor?: string | null,
|
||||
) {
|
||||
await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true });
|
||||
|
||||
const data: { projectImageUrl?: string | null; projectBgColor?: string | null } = {};
|
||||
if (projectImageUrl !== undefined) data.projectImageUrl = projectImageUrl;
|
||||
if (projectBgColor !== undefined) data.projectBgColor = projectBgColor;
|
||||
|
||||
await this.prismaService.project.update({
|
||||
where: { id: projectId },
|
||||
data,
|
||||
});
|
||||
|
||||
return this.getByIdForUser(adminUserId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class AssignUserProjectDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
projectId?: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(40)
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(40)
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
projectId?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateMyProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(40)
|
||||
displayName?: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(40)
|
||||
username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(40)
|
||||
displayName?: string;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
MaxFileSizeValidator,
|
||||
Param,
|
||||
Patch,
|
||||
Req,
|
||||
Post,
|
||||
ParseFilePipe,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import { existsSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import type { AuthenticatedRequest } from '../auth/jwt-auth.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { AssignUserProjectDto } from './dto/assign-user-project.dto';
|
||||
import { UpdateMyProfileDto } from './dto/update-my-profile.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
const uploadsDir = join(process.cwd(), 'uploads');
|
||||
|
||||
const storage = diskStorage({
|
||||
destination: (_req, _file, callback) => {
|
||||
if (!existsSync(uploadsDir)) {
|
||||
mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
callback(null, uploadsDir);
|
||||
},
|
||||
filename: (_req, file, callback) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
callback(null, `profile-${uniqueSuffix}${extname(file.originalname)}`);
|
||||
},
|
||||
});
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
private readonly defaultAvatars: string[];
|
||||
|
||||
constructor(private readonly usersService: UsersService) {
|
||||
const avatarsDir = join(process.cwd(), 'public', 'default-avatars');
|
||||
const imageExts = ['.png', '.jpg', '.jpeg', '.webp'];
|
||||
try {
|
||||
this.defaultAvatars = readdirSync(avatarsDir)
|
||||
.filter((f) => imageExts.includes(extname(f).toLowerCase()))
|
||||
.sort()
|
||||
.map((f) => `/default-avatars/${f}`);
|
||||
} catch {
|
||||
this.defaultAvatars = [];
|
||||
}
|
||||
}
|
||||
|
||||
@Get('default-avatars')
|
||||
listDefaultAvatars() {
|
||||
return this.defaultAvatars;
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@Req() request: AuthenticatedRequest) {
|
||||
return this.usersService.getProfile(request.user!.sub);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
updateMe(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() updateMyProfileDto: UpdateMyProfileDto,
|
||||
) {
|
||||
return this.usersService.updateMyProfile(
|
||||
request.user!.sub,
|
||||
updateMyProfileDto.displayName,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('me/photo')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseInterceptors(FileInterceptor('file', { storage }))
|
||||
uploadPhoto(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() body: { bgColor?: string },
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
|
||||
],
|
||||
fileIsRequired: false,
|
||||
}),
|
||||
)
|
||||
file?: Express.Multer.File,
|
||||
) {
|
||||
if (file) {
|
||||
const allowedMimeTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||
throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.');
|
||||
}
|
||||
}
|
||||
|
||||
const profileImageUrl = file ? `/uploads/${file.filename}` : body.bgColor && !file ? undefined : undefined;
|
||||
const bgColor = body.bgColor ?? undefined;
|
||||
|
||||
if (!file && !bgColor) {
|
||||
throw new BadRequestException('Envoyez une image ou une couleur de fond.');
|
||||
}
|
||||
|
||||
return this.usersService.updateProfileImage(
|
||||
request.user!.sub,
|
||||
file ? `/uploads/${file.filename}` : undefined,
|
||||
bgColor,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('me/photo')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
deletePhoto(@Req() request: AuthenticatedRequest) {
|
||||
return this.usersService.updateProfileImage(request.user!.sub, null, null);
|
||||
}
|
||||
|
||||
@Patch('me/avatar')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
setDefaultAvatar(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() body: { avatarUrl: string; bgColor?: string },
|
||||
) {
|
||||
if (!body.avatarUrl || !this.defaultAvatars.includes(body.avatarUrl)) {
|
||||
throw new BadRequestException('Avatar par defaut invalide.');
|
||||
}
|
||||
return this.usersService.updateProfileImage(
|
||||
request.user!.sub,
|
||||
body.avatarUrl,
|
||||
body.bgColor,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
create(@Req() request: AuthenticatedRequest, @Body() createUserDto: CreateUserDto) {
|
||||
return this.usersService.create(createUserDto, request.user?.sub);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
list() {
|
||||
return this.usersService.list();
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
) {
|
||||
return this.usersService.updateById(id, updateUserDto, request.user?.sub);
|
||||
}
|
||||
|
||||
@Patch(':id/project')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
assignProject(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AssignUserProjectDto,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
) {
|
||||
return this.usersService.assignProjectById(id, dto.projectId, request.user?.sub);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
remove(@Param('id') id: string, @Req() request: AuthenticatedRequest) {
|
||||
return this.usersService.removeById(id, request.user?.sub);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@@ -0,0 +1,526 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { MAX_WORKSPACE_ADMINS } from '../projects/project-scope.constants';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureAdminUser();
|
||||
}
|
||||
|
||||
private async ensureAdminUser() {
|
||||
const adminUsername = this.configService.get<string>('ADMIN_USERNAME');
|
||||
const adminPassword = this.configService.get<string>('ADMIN_PASSWORD');
|
||||
|
||||
if (!adminUsername || !adminPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(adminPassword, 12);
|
||||
|
||||
await this.prismaService.user.upsert({
|
||||
where: { username: adminUsername },
|
||||
update: {
|
||||
passwordHash,
|
||||
displayName: adminUsername,
|
||||
role: UserRole.ADMIN,
|
||||
},
|
||||
create: {
|
||||
username: adminUsername,
|
||||
passwordHash,
|
||||
displayName: adminUsername,
|
||||
role: UserRole.ADMIN,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async enforceWorkspaceAdminLimit(workspaceId: string, excludedUserId?: string) {
|
||||
const adminCount = await this.prismaService.user.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
role: UserRole.ADMIN,
|
||||
...(excludedUserId
|
||||
? {
|
||||
id: {
|
||||
not: excludedUserId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (adminCount >= MAX_WORKSPACE_ADMINS) {
|
||||
throw new ForbiddenException(
|
||||
`Limite atteinte: maximum ${MAX_WORKSPACE_ADMINS} comptes admin par espace`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getActorOrThrow(actorUserId?: string) {
|
||||
if (!actorUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actor = await this.prismaService.user.findUnique({
|
||||
where: { id: actorUserId },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
workspaceId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!actor) {
|
||||
throw new NotFoundException('Compte admin introuvable');
|
||||
}
|
||||
|
||||
if (actor.role !== UserRole.ADMIN) {
|
||||
throw new ForbiddenException('Action reservee aux admins');
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
private async resolveCurrentProjectId(userId: string) {
|
||||
const membership = await this.prismaService.projectMembership.findUnique({
|
||||
where: { userId },
|
||||
select: { projectId: true },
|
||||
});
|
||||
|
||||
return membership?.projectId ?? null;
|
||||
}
|
||||
|
||||
private async assignUserToProject(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
actorUserId?: string,
|
||||
) {
|
||||
const [targetUser, targetProject, actor] = await Promise.all([
|
||||
this.prismaService.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
workspaceId: true,
|
||||
},
|
||||
}),
|
||||
this.prismaService.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
workspaceId: true,
|
||||
},
|
||||
}),
|
||||
this.getActorOrThrow(actorUserId),
|
||||
]);
|
||||
|
||||
if (!targetUser) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!targetProject) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
if (targetUser.role === UserRole.ADMIN) {
|
||||
throw new BadRequestException('Un admin ne peut pas etre affecte comme participant');
|
||||
}
|
||||
|
||||
if (actor) {
|
||||
if (!actor.workspaceId || actor.workspaceId !== targetProject.workspaceId) {
|
||||
throw new ForbiddenException('Ce projet appartient a un autre espace');
|
||||
}
|
||||
}
|
||||
|
||||
if (targetUser.workspaceId && targetUser.workspaceId !== targetProject.workspaceId) {
|
||||
throw new ForbiddenException('Cet utilisateur appartient deja a un autre espace');
|
||||
}
|
||||
|
||||
if (!targetUser.workspaceId) {
|
||||
await this.prismaService.user.update({
|
||||
where: { id: targetUser.id },
|
||||
data: { workspaceId: targetProject.workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
const existingMembership = await this.prismaService.projectMembership.findUnique({
|
||||
where: { userId: targetUser.id },
|
||||
select: { projectId: true },
|
||||
});
|
||||
|
||||
if (existingMembership && existingMembership.projectId !== targetProject.id) {
|
||||
await this.prismaService.projectMembership.update({
|
||||
where: { userId: targetUser.id },
|
||||
data: {
|
||||
projectId: targetProject.id,
|
||||
assignedById: actor?.id,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingMembership) {
|
||||
await this.prismaService.projectMembership.create({
|
||||
data: {
|
||||
projectId: targetProject.id,
|
||||
userId: targetUser.id,
|
||||
assignedById: actor?.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async create(createUserDto: CreateUserDto, actorUserId?: string) {
|
||||
const passwordHash = await bcrypt.hash(createUserDto.password, 12);
|
||||
const actor = await this.getActorOrThrow(actorUserId);
|
||||
const role = createUserDto.role ?? UserRole.FAMILY;
|
||||
|
||||
if (role === UserRole.ADMIN && actor?.workspaceId) {
|
||||
await this.enforceWorkspaceAdminLimit(actor.workspaceId);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.prismaService.user.create({
|
||||
data: {
|
||||
username: createUserDto.username,
|
||||
displayName: createUserDto.displayName,
|
||||
passwordHash,
|
||||
role,
|
||||
workspaceId: actor?.workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (createUserDto.projectId) {
|
||||
await this.assignUserToProject(user.id, createUserDto.projectId, actor?.id);
|
||||
}
|
||||
|
||||
const currentProjectId = await this.resolveCurrentProjectId(user.id);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
profileImageUrl: user.profileImageUrl,
|
||||
workspaceId: user.workspaceId,
|
||||
currentProjectId,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Username already exists');
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException('Could not create user');
|
||||
}
|
||||
}
|
||||
|
||||
async findByUsername(username: string) {
|
||||
return this.prismaService.user.findFirst({
|
||||
where: {
|
||||
username: {
|
||||
equals: username.trim(),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async list() {
|
||||
const users = await this.prismaService.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
profileImageUrl: true,
|
||||
workspaceId: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
projectMembership: {
|
||||
select: {
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
profileImageUrl: user.profileImageUrl,
|
||||
workspaceId: user.workspaceId,
|
||||
currentProjectId: user.projectMembership?.projectId ?? null,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateById(id: string, updateUserDto: UpdateUserDto, actorUserId?: string) {
|
||||
try {
|
||||
const actor = await this.getActorOrThrow(actorUserId);
|
||||
|
||||
const data: {
|
||||
username?: string;
|
||||
passwordHash?: string;
|
||||
role?: UserRole;
|
||||
displayName?: string;
|
||||
workspaceId?: string;
|
||||
} = {};
|
||||
|
||||
if (updateUserDto.username) {
|
||||
data.username = updateUserDto.username;
|
||||
}
|
||||
|
||||
if (updateUserDto.password) {
|
||||
data.passwordHash = await bcrypt.hash(updateUserDto.password, 12);
|
||||
}
|
||||
|
||||
if (updateUserDto.role) {
|
||||
data.role = updateUserDto.role;
|
||||
}
|
||||
|
||||
if (updateUserDto.displayName !== undefined) {
|
||||
data.displayName = updateUserDto.displayName;
|
||||
}
|
||||
|
||||
const existing = await this.prismaService.user.findUnique({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (actor && actor.workspaceId && existing.workspaceId && actor.workspaceId !== existing.workspaceId) {
|
||||
throw new ForbiddenException('Vous ne pouvez gerer que les comptes de votre espace');
|
||||
}
|
||||
|
||||
if (actor && actor.workspaceId && !existing.workspaceId) {
|
||||
data.workspaceId = actor.workspaceId;
|
||||
}
|
||||
|
||||
if (updateUserDto.role === UserRole.ADMIN) {
|
||||
const workspaceId = existing.workspaceId ?? actor?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new BadRequestException('Impossible de promouvoir en admin sans espace associe');
|
||||
}
|
||||
|
||||
data.workspaceId = workspaceId;
|
||||
|
||||
if (existing.role !== UserRole.ADMIN) {
|
||||
await this.enforceWorkspaceAdminLimit(workspaceId, existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (actorUserId && existing.id === actorUserId && updateUserDto.role === UserRole.FAMILY) {
|
||||
throw new ForbiddenException('You cannot demote yourself');
|
||||
}
|
||||
|
||||
const user = await this.prismaService.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
profileImageUrl: user.profileImageUrl,
|
||||
workspaceId: user.workspaceId,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Username already exists');
|
||||
}
|
||||
if (
|
||||
error instanceof ForbiddenException ||
|
||||
error instanceof InternalServerErrorException ||
|
||||
error instanceof NotFoundException
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new InternalServerErrorException('Could not update user');
|
||||
}
|
||||
}
|
||||
|
||||
async assignProjectById(userId: string, projectId: string | undefined, actorUserId?: string) {
|
||||
const actor = await this.getActorOrThrow(actorUserId);
|
||||
|
||||
const targetUser = await this.prismaService.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
workspaceId: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
profileImageUrl: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (actor && actor.workspaceId && targetUser.workspaceId && targetUser.workspaceId !== actor.workspaceId) {
|
||||
throw new ForbiddenException('Vous ne pouvez gerer que les comptes de votre espace');
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
await this.prismaService.projectMembership.deleteMany({
|
||||
where: { userId: targetUser.id },
|
||||
});
|
||||
|
||||
return {
|
||||
id: targetUser.id,
|
||||
username: targetUser.username,
|
||||
displayName: targetUser.displayName,
|
||||
profileImageUrl: targetUser.profileImageUrl,
|
||||
workspaceId: targetUser.workspaceId,
|
||||
currentProjectId: null,
|
||||
role: targetUser.role,
|
||||
createdAt: targetUser.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
await this.assignUserToProject(targetUser.id, projectId, actor?.id);
|
||||
const currentProjectId = await this.resolveCurrentProjectId(targetUser.id);
|
||||
|
||||
return {
|
||||
id: targetUser.id,
|
||||
username: targetUser.username,
|
||||
displayName: targetUser.displayName,
|
||||
profileImageUrl: targetUser.profileImageUrl,
|
||||
workspaceId: targetUser.workspaceId,
|
||||
currentProjectId,
|
||||
role: targetUser.role,
|
||||
createdAt: targetUser.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async removeById(id: string, actorUserId?: string) {
|
||||
if (actorUserId && id === actorUserId) {
|
||||
throw new ForbiddenException('You cannot delete your own account');
|
||||
}
|
||||
|
||||
try {
|
||||
const actor = await this.getActorOrThrow(actorUserId);
|
||||
const target = await this.prismaService.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
workspaceId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (actor && actor.workspaceId && target.workspaceId && actor.workspaceId !== target.workspaceId) {
|
||||
throw new ForbiddenException('Vous ne pouvez supprimer que des comptes de votre espace');
|
||||
}
|
||||
|
||||
await this.prismaService.user.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenException || error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
throw new InternalServerErrorException('Could not delete user');
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
const user = await this.prismaService.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
profileImageUrl: user.profileImageUrl,
|
||||
profileBgColor: user.profileBgColor,
|
||||
workspaceId: user.workspaceId,
|
||||
currentProjectId: await this.resolveCurrentProjectId(user.id),
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async updateMyProfile(userId: string, displayName?: string) {
|
||||
const user = await this.prismaService.user.update({
|
||||
where: { id: userId },
|
||||
data: { displayName },
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
profileImageUrl: user.profileImageUrl,
|
||||
profileBgColor: user.profileBgColor,
|
||||
workspaceId: user.workspaceId,
|
||||
currentProjectId: await this.resolveCurrentProjectId(user.id),
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async updateProfileImage(userId: string, profileImageUrl?: string | null, profileBgColor?: string | null) {
|
||||
const data: { profileImageUrl?: string | null; profileBgColor?: string | null } = {};
|
||||
if (profileImageUrl !== undefined) data.profileImageUrl = profileImageUrl;
|
||||
if (profileBgColor !== undefined) data.profileBgColor = profileBgColor;
|
||||
|
||||
const user = await this.prismaService.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
profileImageUrl: user.profileImageUrl,
|
||||
profileBgColor: user.profileBgColor,
|
||||
workspaceId: user.workspaceId,
|
||||
currentProjectId: await this.resolveCurrentProjectId(user.id),
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/health (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/health')
|
||||
.expect(200)
|
||||
.expect({ status: 'ok' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user