fix(api): inscription parent — body 15mb, uploads persistants, 409 si unique PG

- 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
This commit is contained in:
MARTIN Julien 2026-04-10 17:40:23 +02:00
parent 077a1ca3ed
commit 2b3128ce4a
4 changed files with 70 additions and 11 deletions

View File

@ -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 lAPI, 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

View File

@ -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<NestExpressApplication>(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());

View File

@ -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 ?? '';
@ -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';
}
}

View File

@ -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: