From 2b3128ce4a7a4321ee7e3bebde0741e182c68541 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 10 Apr 2026 17:40:23 +0200 Subject: [PATCH] =?UTF-8?q?fix(api):=20inscription=20parent=20=E2=80=94=20?= =?UTF-8?q?body=2015mb,=20uploads=20persistants,=20409=20si=20unique=20PG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NestExpressApplication + useBodyParser json/urlencoded 15mb - UPLOAD_PHOTOS_DIR: résolution chemin absolu/relatif + doc .env.example / proxy - docker-compose: volume backend_uploads + UPLOAD_PHOTOS_DIR par défaut - Map erreur PostgreSQL 23505 → ConflictException (parent + AM) - Logs Nest si échec SMTP après inscription (email déjà géré) Made-with: Cursor --- backend/.env.example | 7 ++- backend/src/main.ts | 10 ++++- backend/src/routes/auth/auth.service.ts | 59 +++++++++++++++++++++---- docker-compose.yml | 5 +++ 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 94f09fa..cfd6a65 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,8 +22,13 @@ JWT_EXPIRATION_TIME=7d # Environnement NODE_ENV=development -# Photos (inscription). Defaut : ./uploads/photos sous le backend. Docker : UPLOAD_PHOTOS_DIR=/app/uploads/photos +# Photos inscription (fichiers écrits depuis base64). Préférer un chemin ABSOLU. +# Local : laisser vide → ./uploads/photos (relatif au cwd du processus). +# Docker (docker-compose) : UPLOAD_PHOTOS_DIR=/app/uploads/photos + volume nommé (voir docker-compose.yml). # UPLOAD_PHOTOS_DIR= +# +# Reverse proxy : si Nginx devant l’API, augmenter la taille du corps (ex. client_max_body_size 16m;). +# Traefik en reverse proxy simple ne limite en général pas le corps ; si middleware buffering, prévoir ~16 Mo+. # Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front # LOG_API_REQUESTS=true diff --git a/backend/src/main.ts b/backend/src/main.ts index 6c62287..9d3faf5 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,4 +1,5 @@ import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module'; @@ -7,8 +8,13 @@ import { ValidationPipe } from '@nestjs/common'; import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor'; async function bootstrap() { - const app = await NestFactory.create(AppModule, - { logger: ['error', 'warn', 'log', 'debug', 'verbose'] }); + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn', 'log', 'debug', 'verbose'], + }); + + // Photos inscription (base64) : limite Express par défaut ~100 ko → 413/500 sans ceci + app.useBodyParser('json', { limit: '15mb' }); + app.useBodyParser('urlencoded', { extended: true, limit: '15mb' }); // Log de chaque appel API si LOG_API_REQUESTS=true (mode debug) app.useGlobalInterceptors(new LogRequestInterceptor()); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 1d51f69..f509413 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -1,12 +1,13 @@ import { ConflictException, Injectable, + Logger, NotFoundException, UnauthorizedException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { QueryFailedError, Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; @@ -32,6 +33,8 @@ import { MailService } from 'src/modules/mail/mail.service'; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( private readonly usersService: UserService, private readonly jwtService: JwtService, @@ -213,7 +216,16 @@ export class AuthService { const dateExpiration = new Date(); dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); - const resultat = await this.usersRepo.manager.transaction(async (manager) => { + let resultat: { + parent1: Users; + parent2: Users | null; + enfants: Children[]; + tokenCreationMdp: string; + tokenCoParent: string | null; + }; + + try { + resultat = await this.usersRepo.manager.transaction(async (manager) => { const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); const parent1 = manager.create(Users, { @@ -331,6 +343,14 @@ export class AuthService { tokenCoParent, }; }); + } catch (err) { + if (this.isPostgresUniqueViolation(err)) { + throw new ConflictException( + 'Un compte avec cet email existe déjà (contrainte unique en base).', + ); + } + throw err; + } const numeroDossier = resultat.parent1.numero_dossier ?? ''; @@ -341,7 +361,7 @@ export class AuthService { resultat.parent1.nom ?? '', numeroDossier, ); - if (resultat.parent2) { + if (resultat.parent2) { await this.mailService.sendParentRegistrationPendingEmail( resultat.parent2.email, resultat.parent2.prenom ?? '', @@ -350,7 +370,10 @@ export class AuthService { ); } } catch (err) { - console.error("[inscrireParentComplet] Échec envoi email de confirmation d'inscription", err); + this.logger.error( + "[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)", + err instanceof Error ? err.stack : String(err), + ); } return { @@ -428,7 +451,9 @@ export class AuthService { const dateConsentementPhoto = dto.consentement_photo ? new Date() : undefined; - const resultat = await this.usersRepo.manager.transaction(async (manager) => { + let resultat: { user: Users }; + try { + resultat = await this.usersRepo.manager.transaction(async (manager) => { const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); const user = manager.create(Users, { @@ -467,6 +492,12 @@ export class AuthService { return { user: userEnregistre }; }); + } catch (err) { + if (this.isPostgresUniqueViolation(err)) { + throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).'); + } + throw err; + } return { message: @@ -488,9 +519,12 @@ export class AuthService { const extension = correspondances[1]; const tamponImage = Buffer.from(correspondances[2], 'base64'); - const dossierUpload = - (process.env.UPLOAD_PHOTOS_DIR && process.env.UPLOAD_PHOTOS_DIR.trim()) || - path.join(process.cwd(), 'uploads', 'photos'); + const rawDir = process.env.UPLOAD_PHOTOS_DIR?.trim(); + const dossierUpload = rawDir + ? path.isAbsolute(rawDir) + ? rawDir + : path.resolve(process.cwd(), rawDir) + : path.join(process.cwd(), 'uploads', 'photos'); await fs.mkdir(dossierUpload, { recursive: true }); const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`; @@ -601,4 +635,13 @@ export class AuthService { token: user.token_reprise, }; } + + /** Violation unique PostgreSQL (ex. email déjà présent malgré course entre requêtes). */ + private isPostgresUniqueViolation(err: unknown): boolean { + if (!(err instanceof QueryFailedError)) { + return false; + } + const code = (err.driverError as { code?: string } | undefined)?.code; + return code === '23505'; + } } diff --git a/docker-compose.yml b/docker-compose.yml index a4fad4d..d5ff9c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,10 @@ services: NODE_ENV: ${NODE_ENV} LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false} CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY} + # Photos inscription (base64) — chemin absolu dans le conteneur (voir volume backend_uploads) + UPLOAD_PHOTOS_DIR: ${UPLOAD_PHOTOS_DIR:-/app/uploads/photos} + volumes: + - backend_uploads:/app/uploads/photos depends_on: - database labels: @@ -94,6 +98,7 @@ services: volumes: postgres_data: + backend_uploads: networks: ptitspas_network: