diff --git a/backend/src/routes/enfants/dto/create_enfants.dto.ts b/backend/src/routes/enfants/dto/create_enfants.dto.ts index 7d69067..edfc5cb 100644 --- a/backend/src/routes/enfants/dto/create_enfants.dto.ts +++ b/backend/src/routes/enfants/dto/create_enfants.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsDateString, @@ -6,6 +6,7 @@ import { IsNotEmpty, IsOptional, IsString, + IsUUID, MaxLength, ValidateIf, } from 'class-validator'; @@ -63,4 +64,17 @@ export class CreateEnfantsDto { @ApiProperty({ default: false }) @IsBoolean() is_multiple: boolean; + + /** + * Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin). + * Ignoré / interdit en externe pour un PARENT (ticket #132). + */ + @ApiPropertyOptional({ + description: + 'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.', + format: 'uuid', + }) + @IsOptional() + @IsUUID('4') + parent_user_id?: string; } diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index 2579a45..5505704 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -1,20 +1,33 @@ import { Body, + CallHandler, Controller, Delete, + ExecutionContext, Get, + HttpCode, + HttpStatus, + Injectable, + NestInterceptor, Param, ParseUUIDPipe, Patch, Post, + UploadedFile, UseGuards, UseInterceptors, - UploadedFile, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { diskStorage } from 'multer'; import { extname } from 'path'; +import { Observable } from 'rxjs'; import { EnfantsService } from './enfants.service'; import { CreateEnfantsDto } from './dto/create_enfants.dto'; import { UpdateEnfantsDto } from './dto/update_enfants.dto'; @@ -24,6 +37,47 @@ import { AuthGuard } from 'src/common/guards/auth.guard'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RolesGuard } from 'src/common/guards/roles.guard'; +const photoMulterOptions = { + storage: diskStorage({ + destination: './uploads/photos', + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = extname(file.originalname); + cb(null, `enfant-${uniqueSuffix}${ext}`); + }, + }), + fileFilter: (req, file, cb) => { + if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { + return cb(new Error('Seules les images sont autorisées'), false); + } + cb(null, true); + }, + limits: { + fileSize: 5 * 1024 * 1024, + }, +}; + +/** + * Multer uniquement si Content-Type multipart (parcours parent). + * Les appels staff JSON (#132) passent sans interceptor fichier. + */ +@Injectable() +class OptionalEnfantPhotoInterceptor implements NestInterceptor { + private readonly multipart = new (FileInterceptor( + 'photo', + photoMulterOptions, + ))(); + + intercept(context: ExecutionContext, next: CallHandler): Observable | Promise> { + const req = context.switchToHttp().getRequest(); + const ct = String(req.headers['content-type'] ?? ''); + if (!ct.includes('multipart/form-data')) { + return next.handle(); + } + return this.multipart.intercept(context, next); + } +} + @ApiBearerAuth('access-token') @ApiTags('Enfants') @UseGuards(AuthGuard, RolesGuard) @@ -31,30 +85,22 @@ import { RolesGuard } from 'src/common/guards/roles.guard'; export class EnfantsController { constructor(private readonly enfantsService: EnfantsService) { } - @Roles(RoleType.PARENT) - @Post() - @ApiConsumes('multipart/form-data') - @UseInterceptors( - FileInterceptor('photo', { - storage: diskStorage({ - destination: './uploads/photos', - filename: (req, file, cb) => { - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); - const ext = extname(file.originalname); - cb(null, `enfant-${uniqueSuffix}${ext}`); - }, - }), - fileFilter: (req, file, cb) => { - if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { - return cb(new Error('Seules les images sont autorisées'), false); - } - cb(null, true); - }, - limits: { - fileSize: 5 * 1024 * 1024, - }, - }), + @Roles( + RoleType.PARENT, + RoleType.GESTIONNAIRE, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, ) + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer un enfant', + description: + 'PARENT : multipart éventuel, rattache au compte connecté. Staff : JSON + parent_user_id (foyer). Ticket #132.', + }) + @ApiConsumes('application/json', 'multipart/form-data') + @ApiBody({ type: CreateEnfantsDto }) + @UseInterceptors(OptionalEnfantPhotoInterceptor) create( @Body() dto: CreateEnfantsDto, @UploadedFile() photo: Express.Multer.File, diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index 7f4552c..fc3a301 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity'; import { RoleType, Users } from 'src/entities/users.entity'; import { CreateEnfantsDto } from './dto/create_enfants.dto'; +const STAFF_ROLES: RoleType[] = [ + RoleType.GESTIONNAIRE, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, +]; + @Injectable() export class EnfantsService { constructor( @@ -24,20 +30,34 @@ export class EnfantsService { private readonly parentsChildrenRepository: Repository, ) { } - // Création d'un enfant - async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise { + private isStaff(user: Users): boolean { + return STAFF_ROLES.includes(user.role); + } + + /** + * Création d'un enfant. + * - PARENT : rattache au parent connecté (multipart photo optionnel). + * - Staff : JSON + `parent_user_id` obligatoire (foyer existant). Ticket #132. + */ + async create( + dto: CreateEnfantsDto, + currentUser: Users, + photoFile?: Express.Multer.File, + ): Promise { + const pivotUserId = this.resolvePivotParentUserId(dto, currentUser); + const parent = await this.parentsRepository.findOne({ - where: { user_id: currentUser.id }, + where: { user_id: pivotUserId }, relations: ['co_parent'], }); if (!parent) throw new NotFoundException('Parent introuvable'); - // Vérif métier simple + // Vérif métier simple (aligné comportement historique parent) if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) { throw new BadRequestException('Un enfant né doit avoir une date de naissance'); } - // Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent) + // Vérif doublon éventuel (ex: même prénom + date de naissance) const exist = await this.childrenRepository.findOne({ where: { first_name: dto.first_name, @@ -47,37 +67,84 @@ export class EnfantsService { }); if (exist) throw new ConflictException('Cet enfant existe déjà'); - // Gestion de la photo uploadée + // Gestion de la photo uploadée (parcours parent multipart) + let photoUrl = dto.photo_url; + let consentAt: Date | undefined; if (photoFile) { - dto.photo_url = `/uploads/photos/${photoFile.filename}`; + photoUrl = `/uploads/photos/${photoFile.filename}`; if (dto.consent_photo) { - dto.consent_photo_at = new Date().toISOString(); + consentAt = new Date(); } + } else if (dto.consent_photo) { + consentAt = dto.consent_photo_at + ? new Date(dto.consent_photo_at) + : new Date(); } - // Création - const child = this.childrenRepository.create(dto); + const child = this.childrenRepository.create({ + status: dto.status, + first_name: dto.first_name, + last_name: dto.last_name, + gender: dto.gender, + birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined, + due_date: dto.due_date ? new Date(dto.due_date) : undefined, + photo_url: photoUrl, + consent_photo: !!dto.consent_photo, + consent_photo_at: consentAt, + is_multiple: !!dto.is_multiple, + }); await this.childrenRepository.save(child); - // Lien parent-enfant (Parent 1) - const parentLink = this.parentsChildrenRepository.create({ - parentId: parent.user_id, - enfantId: child.id, - }); - await this.parentsChildrenRepository.save(parentLink); + // Lien parent-enfant (pivot) + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ + parentId: parent.user_id, + enfantId: child.id, + }), + ); // Rattachement automatique au co-parent s'il existe if (parent.co_parent) { - const coParentLink = this.parentsChildrenRepository.create({ - parentId: parent.co_parent.id, - enfantId: child.id, - }); - await this.parentsChildrenRepository.save(coParentLink); + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ + parentId: parent.co_parent.id, + enfantId: child.id, + }), + ); } return this.findOne(child.id, currentUser); } + private resolvePivotParentUserId( + dto: CreateEnfantsDto, + currentUser: Users, + ): string { + if (this.isStaff(currentUser)) { + const id = dto.parent_user_id?.trim(); + if (!id) { + throw new BadRequestException( + 'parent_user_id est obligatoire pour créer un enfant (staff)', + ); + } + return id; + } + + if (currentUser.role === RoleType.PARENT) { + if ( + dto.parent_user_id && + dto.parent_user_id.trim() !== currentUser.id + ) { + throw new ForbiddenException( + 'Un parent ne peut pas créer un enfant pour un autre compte', + ); + } + return currentUser.id; + } + + throw new ForbiddenException('Accès interdit'); + } + // Liste des enfants (admin/gestionnaire) async findAll(): Promise { return this.childrenRepository.find({ @@ -90,7 +157,7 @@ export class EnfantsService { async findOne(id: string, currentUser: Users): Promise { const child = await this.childrenRepository.findOne({ where: { id }, - relations: ['parentLinks'], + relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], }); if (!child) throw new NotFoundException('Enfant introuvable'); @@ -120,7 +187,8 @@ export class EnfantsService { const child = await this.childrenRepository.findOne({ where: { id } }); if (!child) throw new NotFoundException('Enfant introuvable'); - const patch: Partial = { ...dto } as Partial; + const { parent_user_id: _ignored, ...rest } = dto; + const patch: Partial = { ...rest } as Partial; if (dto.consent_photo !== undefined) { patch.consent_photo = dto.consent_photo; patch.consent_photo_at = dto.consent_photo ? new Date() : null!;