From d70577b1c3c4661eacfd1928525f1fa0d0f0a0a7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 16:39:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(#112):=20reprise=20apr=C3=A8s=20refus=20?= =?UTF-8?q?=E2=80=94=20dossier=20complet=20GET/PATCH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET reprise-dossier : parents[], enfants[], texte_motivation (famille) ou fiche AM (#119) - PATCH reprise-resoumettre : co-parent, enfants (update par id), motivation, fiche AM - Resoumission famille : tous les parents en en_attente + invalidation token groupée - DTOs étendus + est_multiple sur enfants dossier famille - Tests unitaires getRepriseDossier parent/AM Co-authored-by: Cursor --- backend/src/routes/auth/auth.controller.ts | 3 +- backend/src/routes/auth/auth.module.ts | 16 +- backend/src/routes/auth/auth.service.spec.ts | 87 ++++- backend/src/routes/auth/auth.service.ts | 340 +++++++++++++++++- .../src/routes/auth/dto/enfant-reprise.dto.ts | 11 + .../routes/auth/dto/reprise-dossier.dto.ts | 49 ++- .../auth/dto/resoumettre-reprise.dto.ts | 162 ++++++++- .../dto/dossier-famille-complet.dto.ts | 3 + backend/src/routes/parents/parents.service.ts | 1 + 9 files changed, 655 insertions(+), 17 deletions(-) create mode 100644 backend/src/routes/auth/dto/enfant-reprise.dto.ts diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 7e2c6d3..cdc016e 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -102,8 +102,7 @@ export class AuthController { @ApiResponse({ status: 200, description: 'Dossier resoumis' }) @ApiResponse({ status: 404, description: 'Token invalide ou expiré' }) async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) { - const { token, ...fields } = dto; - return this.authService.resoumettreReprise(token, fields); + return this.authService.resoumettreReprise(dto); } @Public() diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 34d4631..3ebf0df 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -12,11 +12,25 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit import { AppConfigModule } from 'src/modules/config'; import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module'; import { MailModule } from 'src/modules/mail/mail.module'; +import { ParentsModule } from '../parents/parents.module'; +import { DossiersModule } from '../dossiers/dossiers.module'; +import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), + TypeOrmModule.forFeature([ + Users, + Parents, + Children, + AssistanteMaternelle, + DossierFamille, + DossierFamilleEnfant, + ParentsChildren, + ]), forwardRef(() => UserModule), + ParentsModule, + DossiersModule, AppConfigModule, MailModule, NumeroDossierModule, diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index 0361563..e373abd 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -5,11 +5,13 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { UserService } from '../user/user.service'; +import { ParentsService } from '../parents/parents.service'; +import { DossiersService } from '../dossiers/dossiers.service'; import { AppConfigService } from 'src/modules/config/config.service'; import { MailService } from 'src/modules/mail/mail.service'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; import { Parents } from 'src/entities/parents.entity'; -import { Users } from 'src/entities/users.entity'; +import { Users, RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; import { Children } from 'src/entities/children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; @@ -19,6 +21,20 @@ describe('AuthService (#118 create-password API)', () => { findOne: jest.fn(), createQueryBuilder: jest.fn(), update: jest.fn(), + manager: { transaction: jest.fn() }, + }; + + const userServiceMock = { + findByTokenReprise: jest.fn(), + }; + + const parentsServiceMock = { + getDossierFamilleByNumero: jest.fn(), + getFamilyUserIds: jest.fn(), + }; + + const dossiersServiceMock = { + getDossierByNumero: jest.fn(), }; const mailServiceMock = { @@ -45,7 +61,9 @@ describe('AuthService (#118 create-password API)', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, - { provide: UserService, useValue: {} }, + { provide: UserService, useValue: userServiceMock }, + { provide: ParentsService, useValue: parentsServiceMock }, + { provide: DossiersService, useValue: dossiersServiceMock }, { provide: JwtService, useValue: {} }, { provide: ConfigService, useValue: { get: jest.fn() } }, { provide: AppConfigService, useValue: appConfigMock }, @@ -136,4 +154,69 @@ describe('AuthService (#118 create-password API)', () => { expect(exec).toHaveBeenCalled(); }); }); + + describe('Reprise après refus (#112)', () => { + const token = '11111111-1111-1111-1111-111111111111'; + + it('getRepriseDossier throws when token invalid', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue(null); + await expect(service.getRepriseDossier(token)).rejects.toThrow(NotFoundException); + }); + + it('getRepriseDossier returns famille complète pour parent', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue({ + id: 'p1', + email: 'claire@test.fr', + prenom: 'Claire', + nom: 'MARTIN', + role: RoleType.PARENT, + numero_dossier: '2026-000021', + statut: StatutUtilisateurType.REFUSE, + }); + parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({ + numero_dossier: '2026-000021', + parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }], + enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }], + texte_motivation: 'Motivation test', + }); + + const result = await service.getRepriseDossier(token); + expect(result.parents).toHaveLength(1); + expect(result.enfants).toHaveLength(1); + expect(result.texte_motivation).toBe('Motivation test'); + expect(result.numero_dossier).toBe('2026-000021'); + }); + + it('getRepriseDossier returns fiche AM complète', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue({ + id: 'am1', + email: 'am@test.fr', + role: RoleType.ASSISTANTE_MATERNELLE, + numero_dossier: '2026-000003', + }); + dossiersServiceMock.getDossierByNumero.mockResolvedValue({ + type: 'am', + dossier: { + numero_dossier: '2026-000003', + user: { + id: 'am1', + email: 'am@test.fr', + prenom: 'Marie', + nom: 'DUPONT', + statut: StatutUtilisateurType.REFUSE, + }, + numero_agrement: 'AGR-1', + nir: '123456789012345', + biographie: 'Bio', + nb_max_enfants: 4, + place_disponible: 2, + }, + }); + + const result = await service.getRepriseDossier(token); + expect(result.numero_agrement).toBe('AGR-1'); + expect(result.biographie).toBe('Bio'); + expect(result.nb_max_enfants).toBe(4); + }); + }); }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index b291009..9a42ae9 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -7,7 +7,7 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { QueryFailedError, Repository } from 'typeorm'; +import { EntityManager, In, QueryFailedError, Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; @@ -27,11 +27,15 @@ import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famil import { StatutDossierType } from 'src/entities/dossiers.entity'; import { LoginDto } from './dto/login.dto'; import { RepriseDossierDto } from './dto/reprise-dossier.dto'; +import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto'; import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto'; import { AppConfigService } from 'src/modules/config/config.service'; import { validateNir } from 'src/common/utils/nir.util'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; import { MailService } from 'src/modules/mail/mail.service'; +import { ParentsService } from '../parents/parents.service'; +import { DossiersService } from '../dossiers/dossiers.service'; +import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto'; @Injectable() export class AuthService { @@ -44,6 +48,8 @@ export class AuthService { private readonly appConfigService: AppConfigService, private readonly mailService: MailService, private readonly numeroDossierService: NumeroDossierService, + private readonly parentsService: ParentsService, + private readonly dossiersService: DossiersService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @InjectRepository(Users) @@ -873,12 +879,72 @@ export class AuthService { return { success: true, message: 'Deconnexion'} } - /** GET dossier reprise – token seul. Ticket #111 */ + /** GET dossier reprise – token seul. Ticket #111, enrichi #112 */ async getRepriseDossier(token: string): Promise { const user = await this.usersService.findByTokenReprise(token); if (!user) { throw new NotFoundException('Token reprise invalide ou expiré.'); } + + const base = this.userToRepriseBase(user); + + if (user.role === RoleType.PARENT && user.numero_dossier) { + try { + const famille = await this.parentsService.getDossierFamilleByNumero(user.numero_dossier); + return { + ...base, + parents: famille.parents, + enfants: famille.enfants, + texte_motivation: famille.texte_motivation, + }; + } catch (err) { + if (err instanceof NotFoundException) { + return base; + } + throw err; + } + } + + if (user.role === RoleType.ASSISTANTE_MATERNELLE && user.numero_dossier) { + try { + const unifie = await this.dossiersService.getDossierByNumero(user.numero_dossier); + if (unifie.type === 'am') { + return this.mergeAmRepriseDossier(base, unifie.dossier as DossierAmCompletDto); + } + } catch (err) { + if (err instanceof NotFoundException) { + return base; + } + throw err; + } + } + + return base; + } + + /** PATCH resoumission reprise – dossier complet. Ticket #111, enrichi #112 */ + async resoumettreReprise(dto: ResoumettreRepriseDto): Promise<{ + message: string; + statut: StatutUtilisateurType; + user_id: string; + numero_dossier?: string; + }> { + const user = await this.usersService.findByTokenReprise(dto.token); + if (!user) { + throw new NotFoundException('Token reprise invalide ou expiré.'); + } + + if (user.role === RoleType.PARENT) { + return this.resoumettreRepriseParent(user, dto); + } + if (user.role === RoleType.ASSISTANTE_MATERNELLE) { + return this.resoumettreRepriseAM(user, dto); + } + + throw new BadRequestException('Rôle non pris en charge pour la reprise.'); + } + + private userToRepriseBase(user: Users): RepriseDossierDto { return { id: user.id, email: user.email, @@ -896,12 +962,270 @@ export class AuthService { }; } - /** PUT resoumission reprise. Ticket #111 */ - async resoumettreReprise( - token: string, - dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string }, - ): Promise { - return this.usersService.resoumettreReprise(token, dto); + private mergeAmRepriseDossier(base: RepriseDossierDto, dossier: DossierAmCompletDto): RepriseDossierDto { + const u = dossier.user; + return { + ...base, + email: u.email, + prenom: u.prenom, + nom: u.nom, + telephone: u.telephone, + adresse: u.adresse, + ville: u.ville, + code_postal: u.code_postal, + photo_url: u.photo_url, + consentement_photo: u.consentement_photo, + date_naissance: u.date_naissance, + lieu_naissance_ville: u.lieu_naissance_ville, + lieu_naissance_pays: u.lieu_naissance_pays, + numero_agrement: dossier.numero_agrement, + nir: dossier.nir, + date_agrement: dossier.date_agrement, + nb_max_enfants: dossier.nb_max_enfants, + place_disponible: dossier.place_disponible, + biographie: dossier.biographie, + }; + } + + private applyTitulaireUserFields(user: Users, dto: ResoumettreRepriseDto): void { + if (dto.prenom !== undefined) user.prenom = dto.prenom; + if (dto.nom !== undefined) user.nom = dto.nom; + if (dto.telephone !== undefined) user.telephone = dto.telephone; + if (dto.adresse !== undefined) user.adresse = dto.adresse; + if (dto.ville !== undefined) user.ville = dto.ville; + if (dto.code_postal !== undefined) user.code_postal = dto.code_postal; + } + + private async resoumettreRepriseParent( + titulaire: Users, + dto: ResoumettreRepriseDto, + ): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> { + const motivation = + dto.texte_motivation?.trim() ?? + dto.presentation_dossier?.trim(); + + return this.usersRepo.manager.transaction(async (manager) => { + const familyUsers = titulaire.numero_dossier + ? await manager.find(Users, { + where: { + role: RoleType.PARENT, + numero_dossier: titulaire.numero_dossier, + }, + }) + : [titulaire]; + + const titulaireLive = familyUsers.find((u) => u.id === titulaire.id) ?? titulaire; + this.applyTitulaireUserFields(titulaireLive, dto); + + const coParentUser = familyUsers.find((u) => u.id !== titulaire.id); + if (coParentUser) { + if (dto.co_parent_prenom !== undefined) coParentUser.prenom = dto.co_parent_prenom; + if (dto.co_parent_nom !== undefined) coParentUser.nom = dto.co_parent_nom; + if (dto.co_parent_telephone !== undefined) coParentUser.telephone = dto.co_parent_telephone; + + if (dto.co_parent_meme_adresse) { + coParentUser.adresse = titulaireLive.adresse; + coParentUser.code_postal = titulaireLive.code_postal; + coParentUser.ville = titulaireLive.ville; + } else { + if (dto.co_parent_adresse !== undefined) coParentUser.adresse = dto.co_parent_adresse; + if (dto.co_parent_code_postal !== undefined) coParentUser.code_postal = dto.co_parent_code_postal; + if (dto.co_parent_ville !== undefined) coParentUser.ville = dto.co_parent_ville; + } + } + + if (titulaire.numero_dossier && motivation !== undefined) { + const dossierFamille = await manager.findOne(DossierFamille, { + where: { numero_dossier: titulaire.numero_dossier }, + }); + if (dossierFamille) { + dossierFamille.presentation = motivation || undefined; + await manager.save(DossierFamille, dossierFamille); + } + } + + if (dto.enfants?.length) { + const familyChildIds = await this.getFamilyChildrenIds(manager, titulaireLive.id); + for (const enfantDto of dto.enfants) { + if (!familyChildIds.has(enfantDto.id)) { + throw new BadRequestException( + `Enfant inconnu pour ce dossier : ${enfantDto.id}`, + ); + } + + const enfant = await manager.findOne(Children, { where: { id: enfantDto.id } }); + if (!enfant) { + throw new BadRequestException(`Enfant introuvable : ${enfantDto.id}`); + } + + if (enfantDto.prenom !== undefined) enfant.first_name = enfantDto.prenom; + if (enfantDto.nom !== undefined) enfant.last_name = enfantDto.nom; + if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre; + if (enfantDto.date_naissance !== undefined) { + enfant.birth_date = new Date(enfantDto.date_naissance); + enfant.status = StatutEnfantType.ACTIF; + } + if (enfantDto.date_previsionnelle_naissance !== undefined) { + enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance); + if (!enfantDto.date_naissance) { + enfant.status = StatutEnfantType.A_NAITRE; + } + } + if (enfantDto.grossesse_multiple !== undefined) { + enfant.is_multiple = enfantDto.grossesse_multiple; + } + + if (enfantDto.photo_base64 && enfantDto.photo_filename) { + enfant.photo_url = await this.sauvegarderPhotoDepuisBase64( + enfantDto.photo_base64, + enfantDto.photo_filename, + ); + } + + await manager.save(Children, enfant); + } + } + + for (const familyUser of familyUsers) { + familyUser.statut = StatutUtilisateurType.EN_ATTENTE; + familyUser.token_reprise = undefined; + familyUser.token_reprise_expire_le = undefined; + await manager.save(Users, familyUser); + } + + return { + message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.', + statut: StatutUtilisateurType.EN_ATTENTE, + user_id: titulaireLive.id, + numero_dossier: titulaireLive.numero_dossier, + }; + }); + } + + private async resoumettreRepriseAM( + user: Users, + dto: ResoumettreRepriseDto, + ): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> { + if ( + dto.capacite_accueil !== undefined && + dto.places_disponibles !== undefined && + dto.places_disponibles > dto.capacite_accueil + ) { + throw new BadRequestException( + 'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.', + ); + } + + let urlPhoto: string | undefined; + const photoB64 = (dto.photo_base64 ?? '').trim(); + if (photoB64) { + const nomFichier = (dto.photo_filename ?? '').trim() || 'photo_am.jpg'; + urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier); + } else if (dto.photo_url !== undefined) { + urlPhoto = dto.photo_url; + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + const nirValidation = validateNir(nirNormalized, { + dateNaissance: dto.date_naissance ?? user.date_naissance?.toISOString().slice(0, 10), + }); + if (!nirValidation.valid) { + throw new BadRequestException(nirValidation.error || 'NIR invalide'); + } + } + + return this.usersRepo.manager.transaction(async (manager) => { + const userLive = await manager.findOne(Users, { where: { id: user.id } }); + if (!userLive) { + throw new NotFoundException('Utilisateur introuvable.'); + } + + this.applyTitulaireUserFields(userLive, dto); + if (urlPhoto !== undefined) userLive.photo_url = urlPhoto; + if (dto.consentement_photo !== undefined) { + userLive.consentement_photo = dto.consentement_photo; + if (dto.consentement_photo) { + userLive.date_consentement_photo = new Date(); + } + } + if (dto.date_naissance !== undefined) { + userLive.date_naissance = new Date(dto.date_naissance); + } + if (dto.lieu_naissance_ville !== undefined) { + userLive.lieu_naissance_ville = dto.lieu_naissance_ville; + } + if (dto.lieu_naissance_pays !== undefined) { + userLive.lieu_naissance_pays = dto.lieu_naissance_pays; + } + + const am = await manager.findOne(AssistanteMaternelle, { where: { user_id: user.id } }); + if (!am) { + throw new NotFoundException('Fiche assistante maternelle introuvable.'); + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + const nirDejaUtilise = await manager.findOne(AssistanteMaternelle, { + where: { nir: nirNormalized }, + }); + if (nirDejaUtilise && nirDejaUtilise.user_id !== user.id) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', + ); + } + am.nir = nirNormalized; + } + if (dto.numero_agrement !== undefined) { + const agrement = dto.numero_agrement.trim(); + const agrementDejaUtilise = await manager.findOne(AssistanteMaternelle, { + where: { approval_number: agrement }, + }); + if (agrementDejaUtilise && agrementDejaUtilise.user_id !== user.id) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.', + ); + } + am.approval_number = agrement; + } + if (dto.date_agrement !== undefined) { + am.agreement_date = new Date(dto.date_agrement); + } + if (dto.capacite_accueil !== undefined) { + am.max_children = dto.capacite_accueil; + } + if (dto.places_disponibles !== undefined) { + am.places_available = dto.places_disponibles; + } + if (dto.biographie !== undefined) { + am.biography = dto.biographie; + } + + userLive.statut = StatutUtilisateurType.EN_ATTENTE; + userLive.token_reprise = undefined; + userLive.token_reprise_expire_le = undefined; + + await manager.save(AssistanteMaternelle, am); + await manager.save(Users, userLive); + + return { + message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.', + statut: StatutUtilisateurType.EN_ATTENTE, + user_id: userLive.id, + numero_dossier: userLive.numero_dossier, + }; + }); + } + + private async getFamilyChildrenIds( + manager: EntityManager, + parentUserId: string, + ): Promise> { + const familyUserIds = await this.parentsService.getFamilyUserIds(parentUserId); + const links = await manager.find(ParentsChildren, { + where: { parentId: In(familyUserIds) }, + }); + return new Set(links.map((l) => l.enfantId)); } /** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */ diff --git a/backend/src/routes/auth/dto/enfant-reprise.dto.ts b/backend/src/routes/auth/dto/enfant-reprise.dto.ts new file mode 100644 index 0000000..fa94060 --- /dev/null +++ b/backend/src/routes/auth/dto/enfant-reprise.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsUUID } from 'class-validator'; +import { EnfantInscriptionDto } from './enfant-inscription.dto'; + +/** Enfant existant modifiable lors de la resoumission reprise (#112). */ +export class EnfantRepriseDto extends EnfantInscriptionDto { + @ApiProperty({ description: 'UUID enfant existant (obligatoire en reprise)' }) + @IsUUID() + @IsNotEmpty() + id: string; +} diff --git a/backend/src/routes/auth/dto/reprise-dossier.dto.ts b/backend/src/routes/auth/dto/reprise-dossier.dto.ts index e81c6e4..308a1dd 100644 --- a/backend/src/routes/auth/dto/reprise-dossier.dto.ts +++ b/backend/src/routes/auth/dto/reprise-dossier.dto.ts @@ -1,7 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { RoleType } from 'src/entities/users.entity'; +import { + DossierFamilleEnfantDto, + DossierFamilleParentDto, +} from '../../parents/dto/dossier-famille-complet.dto'; -/** Réponse GET /auth/reprise-dossier – données dossier pour préremplir le formulaire reprise. Ticket #111 */ +/** Réponse GET /auth/reprise-dossier – dossier complet pour préremplir le wizard reprise. #111 + #112 */ export class RepriseDossierDto { @ApiProperty() id: string; @@ -41,4 +45,47 @@ export class RepriseDossierDto { @ApiProperty({ required: false }) situation_familiale?: string; + + // --- Parent (dossier famille, aligné #119) --- + + @ApiProperty({ type: [DossierFamilleParentDto], required: false }) + parents?: DossierFamilleParentDto[]; + + @ApiProperty({ type: [DossierFamilleEnfantDto], required: false }) + enfants?: DossierFamilleEnfantDto[]; + + @ApiProperty({ required: false, description: 'Motivation / présentation dossier famille' }) + texte_motivation?: string; + + // --- AM (aligné DossierAmCompletDto) --- + + @ApiProperty({ required: false }) + consentement_photo?: boolean; + + @ApiProperty({ required: false }) + date_naissance?: Date; + + @ApiProperty({ required: false }) + lieu_naissance_ville?: string; + + @ApiProperty({ required: false }) + lieu_naissance_pays?: string; + + @ApiProperty({ required: false }) + numero_agrement?: string; + + @ApiProperty({ required: false }) + nir?: string; + + @ApiProperty({ required: false }) + date_agrement?: Date; + + @ApiProperty({ required: false }) + nb_max_enfants?: number; + + @ApiProperty({ required: false }) + place_disponible?: number; + + @ApiProperty({ required: false }) + biographie?: string; } diff --git a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts index efec456..046f5f5 100644 --- a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts +++ b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts @@ -1,12 +1,29 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator'; +import { + IsOptional, + IsString, + MaxLength, + IsUUID, + IsBoolean, + IsArray, + ValidateNested, + IsInt, + Min, + Max, + IsDateString, + Matches, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { EnfantRepriseDto } from './enfant-reprise.dto'; -/** Body PUT /auth/reprise-resoumettre – token + champs modifiables. Ticket #111 */ +/** Body PATCH /auth/reprise-resoumettre – token + champs modifiables du wizard. #111 + #112 */ export class ResoumettreRepriseDto { @ApiProperty({ description: 'Token reprise (reçu par email)' }) @IsUUID() token: string; + // --- Identité titulaire (parent principal ou AM) --- + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -42,8 +59,147 @@ export class ResoumettreRepriseDto { @MaxLength(10) code_postal?: string; - @ApiProperty({ required: false, description: 'Pour AM' }) + @ApiProperty({ required: false, description: 'Pour AM (URL photo existante)' }) @IsOptional() @IsString() photo_url?: string; + + @ApiProperty({ required: false, description: 'Pour AM (nouvelle photo base64)' }) + @IsOptional() + @IsString() + photo_base64?: string; + + @ApiProperty({ required: false, description: 'Pour AM (nom fichier photo)' }) + @IsOptional() + @IsString() + photo_filename?: string; + + // --- Co-parent (reprise parent) --- + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_email?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_prenom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_nom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { + message: 'Le numéro de téléphone du co-parent doit être valide', + }) + co_parent_telephone?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + co_parent_meme_adresse?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_adresse?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_code_postal?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_ville?: string; + + // --- Motivation dossier famille --- + + @ApiProperty({ required: false, description: 'Alias texte_motivation' }) + @IsOptional() + @IsString() + @MaxLength(2000) + texte_motivation?: string; + + @ApiProperty({ required: false, description: 'Alias presentation_dossier (inscription)' }) + @IsOptional() + @IsString() + @MaxLength(2000) + presentation_dossier?: string; + + @ApiProperty({ type: [EnfantRepriseDto], required: false }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EnfantRepriseDto) + enfants?: EnfantRepriseDto[]; + + // --- AM --- + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + consentement_photo?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateString() + date_naissance?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_ville?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_pays?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(50) + numero_agrement?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, { + message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)', + }) + nir?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateString() + date_agrement?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + capacite_accueil?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + places_disponibles?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(2000) + biographie?: string; } diff --git a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts index d051b0d..64b565c 100644 --- a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +++ b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts @@ -54,6 +54,9 @@ export class DossierFamilleEnfantDto { description: 'Consentement affichage photo (colonne consentement_photo)', }) consent_photo?: boolean; + + @ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' }) + est_multiple?: boolean; } /** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */ diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index ae9bedd..2e06914 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -221,6 +221,7 @@ export class ParentsService { status: child.status, photo_url: child.photo_url ?? undefined, consent_photo: child.consent_photo, + est_multiple: child.is_multiple, }; }