From 30ca99fb6579878f97c22da58101d4fb19b9fb06 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 23 Jul 2026 16:59:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(#129):=20POST=20/parents/dossier=20?= =?UTF-8?q?=E2=80=94=20cr=C3=A9ation=20dossier=20famille=20staff.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factorise createParentDossier (actif + mail MDP) depuis l’inscription publique qui reste en_attente + mail pending. Miroir #156 AM. Co-authored-by: Cursor --- backend/src/routes/auth/auth.module.ts | 2 +- backend/src/routes/auth/auth.service.ts | 124 ++++++++++++++---- ...taff-create-parent-dossier-response.dto.ts | 37 ++++++ .../dto/staff-create-parent-dossier.dto.ts | 29 ++++ .../routes/parents/parents.controller.spec.ts | 64 ++++++++- .../src/routes/parents/parents.controller.ts | 51 ++++++- backend/src/routes/parents/parents.module.ts | 2 + docs/tmp/129-contrat-api-staff-parent.md | 89 +++++++++++++ 8 files changed, 370 insertions(+), 28 deletions(-) create mode 100644 backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts create mode 100644 backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts create mode 100644 docs/tmp/129-contrat-api-staff-parent.md diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 3ebf0df..99bb6f4 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity'; ParentsChildren, ]), forwardRef(() => UserModule), - ParentsModule, + forwardRef(() => ParentsModule), DossiersModule, AppConfigModule, MailModule, diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 14d0cc4..423b9b4 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -416,11 +416,20 @@ export class AuthService { } /** - * Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction - * Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU + * Cœur partagé création dossier parent (#129). + * - public : statut en_attente + mails pending + * - staff : statut actif + mails création MDP (pas de mail « dossier en attente ») */ - async inscrireParentComplet(dto: RegisterParentCompletDto) { - if (!dto.acceptation_cgu || !dto.acceptation_privacy) { + async createParentDossier( + dto: RegisterParentCompletDto, + options: { + statut: StatutUtilisateurType; + sendPendingEmail: boolean; + sendPasswordSetupEmail: boolean; + requireCgu: boolean; + }, + ) { + if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) { throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire'); } @@ -471,7 +480,7 @@ export class AuthService { prenom: dto.prenom, nom: dto.nom, role: RoleType.PARENT, - statut: StatutUtilisateurType.EN_ATTENTE, + statut: options.statut, telephone: dto.telephone, adresse: dto.adresse, code_postal: dto.code_postal, @@ -496,7 +505,7 @@ export class AuthService { prenom: dto.co_parent_prenom, nom: dto.co_parent_nom, role: RoleType.PARENT, - statut: StatutUtilisateurType.EN_ATTENTE, + statut: options.statut, telephone: dto.co_parent_telephone, adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse, code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal, @@ -612,38 +621,103 @@ export class AuthService { const numeroDossier = resultat.parent1.numero_dossier ?? ''; - try { - await this.mailService.sendRegistrationPendingEmail( - resultat.parent1.email, - resultat.parent1.prenom ?? '', - resultat.parent1.nom ?? '', - numeroDossier, - ); - if (resultat.parent2) { + if (options.sendPendingEmail) { + try { await this.mailService.sendRegistrationPendingEmail( - resultat.parent2.email, - resultat.parent2.prenom ?? '', - resultat.parent2.nom ?? '', + resultat.parent1.email, + resultat.parent1.prenom ?? '', + resultat.parent1.nom ?? '', numeroDossier, ); + if (resultat.parent2) { + await this.mailService.sendRegistrationPendingEmail( + resultat.parent2.email, + resultat.parent2.prenom ?? '', + resultat.parent2.nom ?? '', + numeroDossier, + ); + } + } catch (err) { + this.logger.error( + "[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)", + err instanceof Error ? err.stack : String(err), + ); } - } catch (err) { - this.logger.error( - "[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)", - err instanceof Error ? err.stack : String(err), - ); } + if (options.sendPasswordSetupEmail) { + try { + await this.mailService.sendValidatedAccountPasswordSetupEmail( + { + email: resultat.parent1.email, + prenom: resultat.parent1.prenom ?? '', + nom: resultat.parent1.nom ?? '', + token: resultat.tokenCreationMdp, + numeroDossier, + }, + 'parent', + ); + if (resultat.parent2 && resultat.tokenCoParent) { + await this.mailService.sendValidatedAccountPasswordSetupEmail( + { + email: resultat.parent2.email, + prenom: resultat.parent2.prenom ?? '', + nom: resultat.parent2.nom ?? '', + token: resultat.tokenCoParent, + numeroDossier, + }, + 'parent', + ); + } + } catch (err) { + this.logger.error( + '[createParentDossier] Échec envoi email création MDP (dossier conservé)', + err instanceof Error ? err.stack : String(err), + ); + } + } + + const message = options.sendPasswordSetupEmail + ? 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.' + : 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.'; + return { - message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', + message, parent_id: resultat.parent1.id, - co_parent_id: resultat.parent2?.id, + parent_user_id: resultat.parent1.id, + co_parent_id: resultat.parent2?.id ?? null, + co_parent_user_id: resultat.parent2?.id ?? null, enfants_ids: resultat.enfants.map(e => e.id), - statut: StatutUtilisateurType.EN_ATTENTE, + enfant_ids: resultat.enfants.map(e => e.id), + statut: options.statut, numero_dossier: numeroDossier, }; } + /** + * Inscription Parent publique — CDC (statut en_attente + mail pending). + */ + async inscrireParentComplet(dto: RegisterParentCompletDto) { + return this.createParentDossier(dto, { + statut: StatutUtilisateurType.EN_ATTENTE, + sendPendingEmail: true, + sendPasswordSetupEmail: false, + requireCgu: true, + }); + } + + /** + * Création dossier parent par le staff (#129) — actif + mail création MDP. + */ + async createParentDossierStaff(dto: RegisterParentCompletDto) { + return this.createParentDossier(dto, { + statut: StatutUtilisateurType.ACTIF, + sendPendingEmail: false, + sendPasswordSetupEmail: true, + requireCgu: false, + }); + } + /** * Cœur partagé création dossier AM (#156). * - public : statut en_attente + mail pending diff --git a/backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts b/backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts new file mode 100644 index 0000000..f378245 --- /dev/null +++ b/backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Réponse 201 POST /parents/dossier (#129). */ +export class StaffCreateParentDossierResponseDto { + @ApiProperty() + message: string; + + @ApiProperty({ + example: '2026-000043', + description: 'Numéro de dossier famille attribué', + }) + numero_dossier: string; + + @ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' }) + parent_user_id: string; + + @ApiPropertyOptional({ + format: 'uuid', + nullable: true, + description: 'UUID user du co-parent, ou null', + }) + co_parent_user_id: string | null; + + @ApiProperty({ + enum: StatutUtilisateurType, + example: StatutUtilisateurType.ACTIF, + }) + statut: StatutUtilisateurType; + + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'IDs des enfants créés', + }) + enfant_ids: string[]; +} diff --git a/backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts b/backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts new file mode 100644 index 0000000..440bb60 --- /dev/null +++ b/backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto'; + +/** + * Création dossier parent/famille par staff (#129). + * Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires + * (acceptées côté serveur pour le compte du gestionnaire). + */ +export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [ + 'acceptation_cgu', + 'acceptation_privacy', +] as const) { + @ApiPropertyOptional({ + description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.', + default: true, + }) + @IsOptional() + @IsBoolean() + acceptation_cgu?: boolean; + + @ApiPropertyOptional({ + description: 'Ignoré côté staff (privacy acceptée serveur).', + default: true, + }) + @IsOptional() + @IsBoolean() + acceptation_privacy?: boolean; +} diff --git a/backend/src/routes/parents/parents.controller.spec.ts b/backend/src/routes/parents/parents.controller.spec.ts index 6735376..0015373 100644 --- a/backend/src/routes/parents/parents.controller.spec.ts +++ b/backend/src/routes/parents/parents.controller.spec.ts @@ -1,18 +1,80 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ParentsController } from './parents.controller'; +import { ParentsService } from './parents.service'; +import { UserService } from '../user/user.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; describe('ParentsController', () => { let controller: ParentsController; + const authServiceMock = { + createParentDossierStaff: jest.fn(), + }; + const parentsServiceMock = {}; + const userServiceMock = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ParentsController], - }).compile(); + providers: [ + { provide: ParentsService, useValue: parentsServiceMock }, + { provide: UserService, useValue: userServiceMock }, + { provide: AuthService, useValue: authServiceMock }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(ParentsController); + jest.clearAllMocks(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => { + authServiceMock.createParentDossierStaff.mockResolvedValue({ + message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.', + parent_user_id: 'p1', + co_parent_user_id: 'p2', + enfant_ids: ['e1'], + statut: StatutUtilisateurType.ACTIF, + numero_dossier: '2026-000043', + }); + + const body = { + email: 'parent.staff@test.fr', + prenom: 'Claire', + nom: 'MARTIN', + telephone: '0689567890', + enfants: [ + { + prenom: 'Emma', + nom: 'MARTIN', + date_naissance: '2023-02-15', + genre: 'F', + }, + ], + }; + + const res = await controller.createDossier(body as any); + expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith( + expect.objectContaining({ + email: body.email, + acceptation_cgu: true, + acceptation_privacy: true, + }), + ); + expect(res.numero_dossier).toBe('2026-000043'); + expect(res.parent_user_id).toBe('p1'); + expect(res.co_parent_user_id).toBe('p2'); + expect(res.enfant_ids).toEqual(['e1']); + expect(res.statut).toBe(StatutUtilisateurType.ACTIF); + }); }); diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 396ac2f..6d821ff 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -3,6 +3,8 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, Patch, Post, @@ -10,14 +12,25 @@ import { } from '@nestjs/common'; import { ParentsService } from './parents.service'; import { UserService } from '../user/user.service'; +import { AuthService } from '../auth/auth.service'; import { Parents } from 'src/entities/parents.entity'; import { Users } from 'src/entities/users.entity'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; -import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { CreateParentDto } from '../user/dto/create_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; +import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto'; +import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto'; +import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { User } from 'src/common/decorators/user.decorator'; @@ -26,14 +39,50 @@ import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto'; import { mapParentForApi, mapParentsForApi } from './parents.mapper'; @ApiTags('Parents') +@ApiBearerAuth('access-token') @Controller('parents') @UseGuards(AuthGuard, RolesGuard) export class ParentsController { constructor( private readonly parentsService: ParentsService, private readonly userService: UserService, + private readonly authService: AuthService, ) {} + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @Post('dossier') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer un dossier famille/parent complet (staff) — ticket #129', + description: + 'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' + + 'et envoie l’e-mail de création de mot de passe. ' + + 'Ne pas utiliser POST /auth/register/parent depuis le dashboard.', + }) + @ApiBody({ type: StaffCreateParentDossierDto }) + @ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto }) + @ApiResponse({ status: 400, description: 'Validation DTO / métier' }) + @ApiResponse({ status: 403, description: 'Rôle non autorisé' }) + @ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' }) + async createDossier( + @Body() dto: StaffCreateParentDossierDto, + ): Promise { + const registerDto = { + ...dto, + acceptation_cgu: true, + acceptation_privacy: true, + } as RegisterParentCompletDto; + const result = await this.authService.createParentDossierStaff(registerDto); + return { + message: result.message, + numero_dossier: result.numero_dossier, + parent_user_id: result.parent_user_id, + co_parent_user_id: result.co_parent_user_id ?? null, + statut: result.statut, + enfant_ids: result.enfant_ids, + }; + } + @Get('pending-families') @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' }) diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index b746e87..35aeb2e 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -9,11 +9,13 @@ import { ParentsController } from './parents.controller'; import { ParentsService } from './parents.service'; import { Users } from 'src/entities/users.entity'; import { UserModule } from '../user/user.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]), forwardRef(() => UserModule), + forwardRef(() => AuthModule), JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ diff --git a/docs/tmp/129-contrat-api-staff-parent.md b/docs/tmp/129-contrat-api-staff-parent.md new file mode 100644 index 0000000..b07e681 --- /dev/null +++ b/docs/tmp/129-contrat-api-staff-parent.md @@ -0,0 +1,89 @@ +# Mini-spec API — POST /parents/dossier (#129) + +Contrat pour le **plan front** (wizard création dossier famille staff). + +Miroir de **#156** (`POST /assistantes-maternelles/dossier`). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `POST` | +| **URL** | `{base}/parents/dossier` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Content-Type** | `application/json` | + +Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard. + +## Body (JSON) + +Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur). + +### Parent 1 (obligatoire) + +| Champ | Type | Obligatoire | Notes | +|-------|------|-------------|--------| +| `email` | string | oui | unique | +| `prenom` | string | oui | | +| `nom` | string | oui | | +| `telephone` | string | oui | `0X…` ou `+33…` | +| `adresse` | string | non | | +| `code_postal` | string | non | | +| `ville` | string | non | | + +### Co-parent (optionnel) + +`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`, +`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`. + +Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register. + +### Enfants (≥ 1) + +| Champ | Type | Notes | +|-------|------|--------| +| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. | + +### Présentation + +| Champ | Type | Obligatoire | +|-------|------|-------------| +| `presentation_dossier` | string | non (max 2000) | + +## Réponses + +### 201 Created + +```json +{ + "message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.", + "numero_dossier": "2026-000043", + "parent_user_id": "uuid-pivot", + "co_parent_user_id": "uuid-ou-null", + "statut": "actif", + "enfant_ids": ["uuid", "..."] +} +``` + +Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier, +**e-mail création MDP** pour chaque compte sans MDP (pas d’accusé « en attente »). + +### Erreurs + +| Code | Cas | +|------|-----| +| 400 | Validation DTO / métier (enfants vides, dates, etc.) | +| 401 | Token manquant / invalide | +| 403 | Rôle non staff | +| 409 | Conflit e-mail (pivot et/ou co-parent) | + +## Front + +- `UserService.createParentDossier(body)` → cet endpoint +- Wizard create basé sur `ValidationFamilyWizard` +- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels) + +## Branche + +`feature/129-creation-dossier-parent`