From 59afeb0a8ddb5af3c6a505827c5605a3e7f5e746 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 22 Jul 2026 19:03:37 +0200 Subject: [PATCH] feat(#156): POST /assistantes-maternelles/dossier staff (actif + mail MDP). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factorise createAmDossier depuis register/am ; staff crée un dossier déjà actif et envoie l’e-mail de création de mot de passe. Co-authored-by: Cursor --- backend/package.json | 3 + ...assistantes_maternelles.controller.spec.ts | 53 ++++- .../assistantes_maternelles.controller.ts | 36 +++- .../staff-create-am-dossier-response.dto.ts | 23 ++ .../dto/staff-create-am-dossier.dto.ts | 29 +++ backend/src/routes/auth/auth.service.ts | 201 ++++++++++++------ docs/tmp/156-contrat-api-staff-am.md | 75 +++++++ 7 files changed, 356 insertions(+), 64 deletions(-) create mode 100644 backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts create mode 100644 backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts create mode 100644 docs/tmp/156-contrat-api-staff-am.md diff --git a/backend/package.json b/backend/package.json index d5a1710..6cdacdb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -89,6 +89,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts index b0932ef..5a7322d 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts @@ -1,20 +1,69 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AssistantesMaternellesController } from './assistantes_maternelles.controller'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; describe('AssistantesMaternellesController', () => { let controller: AssistantesMaternellesController; + const authServiceMock = { + createAmDossierStaff: jest.fn(), + }; + const amServiceMock = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AssistantesMaternellesController], - providers: [AssistantesMaternellesService], - }).compile(); + providers: [ + { provide: AssistantesMaternellesService, useValue: amServiceMock }, + { provide: AuthService, useValue: authServiceMock }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(AssistantesMaternellesController); + jest.clearAllMocks(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => { + authServiceMock.createAmDossierStaff.mockResolvedValue({ + message: 'ok', + user_id: 'u1', + statut: 'actif', + numero_dossier: '2026-000001', + }); + + const body = { + email: 'am.staff@test.fr', + prenom: 'Marie', + nom: 'TEST', + telephone: '0689567890', + consentement_photo: false, + lieu_naissance_ville: 'Paris', + lieu_naissance_pays: 'France', + nir: '285017512345678', + numero_agrement: 'AGR-TEST-001', + capacite_accueil: 3, + places_disponibles: 2, + }; + + const res = await controller.createDossier(body as any); + expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith( + expect.objectContaining({ + email: body.email, + acceptation_cgu: true, + acceptation_privacy: true, + }), + ); + expect(res.numero_dossier).toBe('2026-000001'); + }); }); diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts index 540c73e..7d5609e 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts @@ -7,6 +7,8 @@ import { Param, Delete, UseGuards, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; @@ -16,17 +18,49 @@ import { RoleType, Users } from 'src/entities/users.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; +import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto'; +import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { User } from 'src/common/decorators/user.decorator'; import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper'; +import { AuthService } from '../auth/auth.service'; +import { RegisterAMCompletDto } from '../auth/dto/register-am-complet.dto'; @ApiTags("Assistantes Maternelles") @ApiBearerAuth('access-token') @UseGuards(AuthGuard, RolesGuard) @Controller('assistantes-maternelles') export class AssistantesMaternellesController { - constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { } + constructor( + private readonly assistantesMaternellesService: AssistantesMaternellesService, + private readonly authService: AuthService, + ) { } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Post('dossier') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer un dossier AM complet (staff) — ticket #156', + description: + 'Crée user + fiche AM avec statut actif, n° dossier, et envoie l’e-mail de création de mot de passe. ' + + 'Ne pas utiliser POST /auth/register/am depuis le dashboard.', + }) + @ApiBody({ type: StaffCreateAmDossierDto }) + @ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto }) + @ApiResponse({ status: 400, description: 'Validation métier / NIR' }) + @ApiResponse({ status: 403, description: 'Rôle non autorisé' }) + @ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' }) + async createDossier( + @Body() dto: StaffCreateAmDossierDto, + ): Promise { + const registerDto = { + ...dto, + acceptation_cgu: true, + acceptation_privacy: true, + } as RegisterAMCompletDto; + return this.authService.createAmDossierStaff(registerDto); + } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Créer nounou' }) diff --git a/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts new file mode 100644 index 0000000..305d382 --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */ +export class StaffCreateAmDossierResponseDto { + @ApiProperty() + message: string; + + @ApiProperty({ format: 'uuid' }) + user_id: string; + + @ApiProperty({ + enum: StatutUtilisateurType, + example: StatutUtilisateurType.ACTIF, + }) + statut: StatutUtilisateurType; + + @ApiProperty({ + example: '2026-000042', + description: 'Numéro de dossier attribué', + }) + numero_dossier: string; +} diff --git a/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts new file mode 100644 index 0000000..eed1381 --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto'; + +/** + * Création dossier AM par staff (#156). + * Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires + * (acceptées côté serveur pour le compte du gestionnaire). + */ +export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [ + '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/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index e31085e..14d0cc4 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -645,11 +645,28 @@ export class AuthService { } /** - * Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU) - * Crée User (role AM) + entrée assistantes_maternelles, token création MDP + * Cœur partagé création dossier AM (#156). + * - public : statut en_attente + mail pending + * - staff : statut actif + mail création MDP */ - async inscrireAMComplet(dto: RegisterAMCompletDto) { - if (!dto.acceptation_cgu || !dto.acceptation_privacy) { + async createAmDossier( + dto: RegisterAMCompletDto, + options: { + statut: StatutUtilisateurType; + sendPendingEmail: boolean; + sendPasswordSetupEmail: boolean; + requireCgu: boolean; + logContext?: string; + }, + ): Promise<{ + message: string; + user_id: string; + statut: StatutUtilisateurType; + numero_dossier: string; + }> { + const logCtx = options.logContext ?? 'createAmDossier'; + + if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) { throw new BadRequestException( "L'acceptation des CGU et de la politique de confidentialité est obligatoire", ); @@ -669,8 +686,7 @@ export class AuthService { throw new BadRequestException(nirValidation.error || 'NIR invalide'); } if (nirValidation.warning) { - // Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse) - console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email); + console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email); } const existe = await this.usersService.findByEmailOrNull(dto.email); @@ -721,79 +737,142 @@ export class AuthService { let resultat: { user: Users }; try { resultat = await this.usersRepo.manager.transaction(async (manager) => { - const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); + const { numero: numeroDossier } = + await this.numeroDossierService.getNextNumeroDossier(manager); - const user = manager.create(Users, { - email: dto.email, - prenom: dto.prenom, - nom: dto.nom, - role: RoleType.ASSISTANTE_MATERNELLE, - statut: StatutUtilisateurType.EN_ATTENTE, - telephone: dto.telephone, - adresse: dto.adresse, - code_postal: dto.code_postal, - ville: dto.ville, - token_creation_mdp: tokenCreationMdp, - token_creation_mdp_expire_le: dateExpiration, - photo_url: urlPhoto ?? undefined, - consentement_photo: dto.consentement_photo, - date_consentement_photo: dateConsentementPhoto, - date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, - lieu_naissance_ville: dto.lieu_naissance_ville, - lieu_naissance_pays: dto.lieu_naissance_pays, - numero_dossier: numeroDossier, + const user = manager.create(Users, { + email: dto.email, + prenom: dto.prenom, + nom: dto.nom, + role: RoleType.ASSISTANTE_MATERNELLE, + statut: options.statut, + telephone: dto.telephone, + adresse: dto.adresse, + code_postal: dto.code_postal, + ville: dto.ville, + token_creation_mdp: tokenCreationMdp, + token_creation_mdp_expire_le: dateExpiration, + photo_url: urlPhoto ?? undefined, + consentement_photo: dto.consentement_photo, + date_consentement_photo: dateConsentementPhoto, + date_naissance: dto.date_naissance + ? new Date(dto.date_naissance) + : undefined, + lieu_naissance_ville: dto.lieu_naissance_ville, + lieu_naissance_pays: dto.lieu_naissance_pays, + numero_dossier: numeroDossier, + }); + const userEnregistre = await manager.save(Users, user); + + const amRepo = manager.getRepository(AssistanteMaternelle); + const am = amRepo.create({ + user_id: userEnregistre.id, + approval_number: dto.numero_agrement, + nir: nirNormalized, + max_children: dto.capacite_accueil, + places_available: dto.places_disponibles, + biography: dto.biographie, + residence_city: dto.ville ?? undefined, + agreement_date: dto.date_agrement + ? new Date(dto.date_agrement) + : undefined, + available: true, + numero_dossier: numeroDossier, + }); + await amRepo.save(am); + + return { user: userEnregistre }; }); - const userEnregistre = await manager.save(Users, user); - - const amRepo = manager.getRepository(AssistanteMaternelle); - const am = amRepo.create({ - user_id: userEnregistre.id, - approval_number: dto.numero_agrement, - nir: nirNormalized, - max_children: dto.capacite_accueil, - places_available: dto.places_disponibles, - biography: dto.biographie, - residence_city: dto.ville ?? undefined, - agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined, - available: true, - numero_dossier: numeroDossier, - }); - await amRepo.save(am); - - 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 new ConflictException( + 'Un compte avec cet email existe déjà (contrainte unique en base).', + ); } throw err; } const numeroDossier = resultat.user.numero_dossier ?? ''; - try { - await this.mailService.sendRegistrationPendingEmail( - resultat.user.email, - resultat.user.prenom ?? '', - resultat.user.nom ?? '', - numeroDossier, - ); - } catch (err) { - this.logger.error( - "[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)", - err instanceof Error ? err.stack : String(err), - ); + if (options.sendPendingEmail) { + try { + await this.mailService.sendRegistrationPendingEmail( + resultat.user.email, + resultat.user.prenom ?? '', + resultat.user.nom ?? '', + numeroDossier, + ); + } catch (err) { + this.logger.error( + `[${logCtx}] É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.user.email, + prenom: resultat.user.prenom ?? '', + nom: resultat.user.nom ?? '', + token: tokenCreationMdp, + numeroDossier, + }, + 'am', + ); + } catch (err) { + this.logger.error( + `[${logCtx}] Échec envoi email création MDP (dossier conservé)`, + err instanceof Error ? err.stack : String(err), + ); + } + } + + const message = + options.statut === StatutUtilisateurType.ACTIF + ? 'Dossier AM 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, user_id: resultat.user.id, - statut: StatutUtilisateurType.EN_ATTENTE, + statut: options.statut, numero_dossier: numeroDossier, }; } + /** + * Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU) + * Crée User (role AM) + entrée assistantes_maternelles, token création MDP + */ + async inscrireAMComplet(dto: RegisterAMCompletDto) { + return this.createAmDossier(dto, { + statut: StatutUtilisateurType.EN_ATTENTE, + sendPendingEmail: true, + sendPasswordSetupEmail: false, + requireCgu: true, + logContext: 'inscrireAMComplet', + }); + } + + /** + * Création dossier AM par staff (#156) — statut actif + e-mail création MDP. + */ + async createAmDossierStaff(dto: RegisterAMCompletDto) { + return this.createAmDossier(dto, { + statut: StatutUtilisateurType.ACTIF, + sendPendingEmail: false, + sendPasswordSetupEmail: true, + requireCgu: false, + logContext: 'createAmDossierStaff', + }); + } + + /** + * Sauvegarde une photo depuis base64 vers le système de fichiers + */ /** * Sauvegarde une photo depuis base64 vers le système de fichiers */ diff --git a/docs/tmp/156-contrat-api-staff-am.md b/docs/tmp/156-contrat-api-staff-am.md new file mode 100644 index 0000000..312e148 --- /dev/null +++ b/docs/tmp/156-contrat-api-staff-am.md @@ -0,0 +1,75 @@ +# Mini-spec API — POST /assistantes-maternelles/dossier (#156) + +Contrat pour le **plan front** (wizard création AM staff). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `POST` | +| **URL** | `{base}/assistantes-maternelles/dossier` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Content-Type** | `application/json` | + +Ne **pas** appeler `POST /auth/register/am` depuis le dashboard. + +## Body (JSON) + +Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur). + +| 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 | | +| `photo_base64` | string | non | data-URL `data:image/…;base64,…` | +| `photo_filename` | string | non | hint nom fichier | +| `consentement_photo` | bool | oui | | +| `date_naissance` | date ISO | non | `YYYY-MM-DD` | +| `lieu_naissance_ville` | string | oui | | +| `lieu_naissance_pays` | string | oui | | +| `nir` | string | oui | 15 car. (Corse 2A/2B OK) | +| `numero_agrement` | string | oui | unique | +| `date_agrement` | date ISO | non | | +| `capacite_accueil` | int | oui | 1–10 | +| `places_disponibles` | int | oui | 0–10, ≤ capacité | +| `biographie` | string | non | max 2000 | + +## Réponses + +### 201 Created + +```json +{ + "message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.", + "user_id": "uuid", + "statut": "actif", + "numero_dossier": "2026-000042" +} +``` + +Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas d’accusé « en attente »). + +### Erreurs + +| Code | Cas | +|------|-----| +| 400 | Validation / NIR / places > capacité | +| 403 | Rôle non staff | +| 409 | Email, NIR ou agrément déjà pris | +| 401 | Token manquant / invalide | + +## Front + +- `UserService.createAmDossier(body)` → cet endpoint +- Après 201 : refresh liste AM ; snackbar OK +- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels) + +## Branche + +`feature/156-creation-dossier-am`