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