diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 423b9b4..bfbd28e 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -36,6 +36,7 @@ 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'; +import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto'; @Injectable() export class AuthService { @@ -718,6 +719,167 @@ export class AuthService { }); } + /** + * Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135. + * Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer. + */ + async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) { + const pivotParent = await this.parentsRepo.findOne({ + where: { user_id: pivotUserId }, + relations: ['user', 'co_parent', 'parentChildren'], + }); + if (!pivotParent?.user) { + throw new NotFoundException('Parent introuvable'); + } + + if (pivotParent.co_parent) { + throw new BadRequestException('Ce foyer a déjà un co-parent.'); + } + + const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim(); + if (!numeroDossier) { + throw new BadRequestException("Ce parent n'a pas de numéro de dossier."); + } + + const sameDossierCount = await this.parentsRepo.count({ + where: { numero_dossier: numeroDossier }, + }); + if (sameDossierCount >= 2) { + throw new BadRequestException('Ce dossier a déjà deux responsables.'); + } + + const email = dto.email.trim().toLowerCase(); + if (pivotParent.user.email.trim().toLowerCase() === email) { + throw new BadRequestException( + "L'email du co-parent doit être différent de celui du parent principal.", + ); + } + + const emailExiste = await this.usersService.findByEmailOrNull(dto.email); + if (emailExiste) { + throw new ConflictException("L'email du co-parent est déjà utilisé"); + } + + const memeAdresse = dto.meme_adresse ?? true; + if (!memeAdresse) { + if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) { + throw new BadRequestException( + "Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.", + ); + } + } + + const joursExpirationToken = await this.appConfigService.get( + 'password_reset_token_expiry_days', + 7, + ); + const tokenCreationMdp = crypto.randomUUID(); + const dateExpiration = new Date(); + dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); + + let coParent: Users; + + try { + coParent = await this.usersRepo.manager.transaction(async (manager) => { + const pivotUser = await manager.findOne(Users, { + where: { id: pivotUserId }, + }); + if (!pivotUser) { + throw new NotFoundException('Parent introuvable'); + } + + const pivotEntite = await manager.findOne(Parents, { + where: { user_id: pivotUserId }, + relations: ['parentChildren'], + }); + if (!pivotEntite) { + throw new NotFoundException('Parent introuvable'); + } + + const coUser = manager.create(Users, { + email: dto.email.trim(), + prenom: dto.prenom, + nom: dto.nom, + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + telephone: dto.telephone, + adresse: memeAdresse ? pivotUser.adresse : dto.adresse, + code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal, + ville: memeAdresse ? pivotUser.ville : dto.ville, + token_creation_mdp: tokenCreationMdp, + token_creation_mdp_expire_le: dateExpiration, + numero_dossier: numeroDossier, + }); + const coUserSaved = await manager.save(Users, coUser); + + pivotEntite.co_parent = coUserSaved; + pivotEntite.numero_dossier = numeroDossier; + await manager.save(Parents, pivotEntite); + + const coEntite = manager.create(Parents, { + user_id: coUserSaved.id, + numero_dossier: numeroDossier, + }); + coEntite.user = coUserSaved; + coEntite.co_parent = pivotUser; + await manager.save(Parents, coEntite); + + const enfantIds = (pivotEntite.parentChildren ?? []) + .map((pc) => pc.enfantId) + .filter(Boolean); + for (const enfantId of enfantIds) { + const existing = await manager.findOne(ParentsChildren, { + where: { parentId: coUserSaved.id, enfantId }, + }); + if (existing) continue; + await manager.save( + ParentsChildren, + manager.create(ParentsChildren, { + parentId: coUserSaved.id, + enfantId, + }), + ); + } + + return coUserSaved; + }); + } catch (err) { + if (this.isPostgresUniqueViolation(err)) { + throw new ConflictException( + 'Un compte avec cet email existe déjà (contrainte unique en base).', + ); + } + throw err; + } + + try { + await this.mailService.sendValidatedAccountPasswordSetupEmail( + { + email: coParent.email, + prenom: coParent.prenom ?? '', + nom: coParent.nom ?? '', + token: tokenCreationMdp, + numeroDossier, + }, + 'parent', + ); + } catch (err) { + this.logger.error( + '[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)', + err instanceof Error ? err.stack : String(err), + ); + } + + return { + message: + 'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.', + numero_dossier: numeroDossier, + parent_user_id: pivotUserId, + co_parent_user_id: coParent.id, + statut: StatutUtilisateurType.ACTIF, + }; + } + /** * Cœur partagé création dossier AM (#156). * - public : statut en_attente + mail pending diff --git a/backend/src/routes/parents/dto/staff-add-co-parent-response.dto.ts b/backend/src/routes/parents/dto/staff-add-co-parent-response.dto.ts new file mode 100644 index 0000000..6abf6af --- /dev/null +++ b/backend/src/routes/parents/dto/staff-add-co-parent-response.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Réponse 201 POST /parents/:id/co-parent (#135). */ +export class StaffAddCoParentResponseDto { + @ApiProperty() + message: string; + + @ApiProperty({ example: '2026-000043' }) + numero_dossier: string; + + @ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' }) + parent_user_id: string; + + @ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' }) + co_parent_user_id: string; + + @ApiProperty({ + enum: StatutUtilisateurType, + example: StatutUtilisateurType.ACTIF, + }) + statut: StatutUtilisateurType; +} diff --git a/backend/src/routes/parents/dto/staff-add-co-parent.dto.ts b/backend/src/routes/parents/dto/staff-add-co-parent.dto.ts new file mode 100644 index 0000000..0f7345c --- /dev/null +++ b/backend/src/routes/parents/dto/staff-add-co-parent.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +/** + * Ajout d’un co-parent sur un foyer existant (staff) — ticket #135. + * Corps sans préfixe `co_parent_*` (l’URL cible déjà le pivot). + */ +export class StaffAddCoParentDto { + @ApiProperty({ example: 'thomas.martin@ptits-pas.fr' }) + @IsEmail({}, { message: 'Email invalide' }) + @IsNotEmpty({ message: "L'email est requis" }) + email: string; + + @ApiProperty({ example: 'Thomas' }) + @IsString() + @IsNotEmpty({ message: 'Le prénom est requis' }) + @MinLength(2) + @MaxLength(100) + prenom: string; + + @ApiProperty({ example: 'MARTIN' }) + @IsString() + @IsNotEmpty({ message: 'Le nom est requis' }) + @MinLength(2) + @MaxLength(100) + nom: string; + + @ApiProperty({ example: '0678456789' }) + @IsString() + @IsNotEmpty({ message: 'Le téléphone est requis' }) + @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { + message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)', + }) + telephone: string; + + @ApiPropertyOptional({ + example: true, + description: 'Si true, copie l’adresse du parent pivot', + }) + @IsOptional() + @IsBoolean() + meme_adresse?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + adresse?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; +} diff --git a/backend/src/routes/parents/parents.controller.spec.ts b/backend/src/routes/parents/parents.controller.spec.ts index 0015373..4452cd6 100644 --- a/backend/src/routes/parents/parents.controller.spec.ts +++ b/backend/src/routes/parents/parents.controller.spec.ts @@ -11,6 +11,7 @@ describe('ParentsController', () => { let controller: ParentsController; const authServiceMock = { createParentDossierStaff: jest.fn(), + addCoParentStaff: jest.fn(), }; const parentsServiceMock = {}; const userServiceMock = {}; @@ -77,4 +78,27 @@ describe('ParentsController', () => { expect(res.enfant_ids).toEqual(['e1']); expect(res.statut).toBe(StatutUtilisateurType.ACTIF); }); + + it('addCoParent delegates to authService.addCoParentStaff', async () => { + authServiceMock.addCoParentStaff.mockResolvedValue({ + message: 'ok', + numero_dossier: '2026-000043', + parent_user_id: 'p1', + co_parent_user_id: 'p2', + statut: StatutUtilisateurType.ACTIF, + }); + + const body = { + email: 'coparent@test.fr', + prenom: 'Thomas', + nom: 'MARTIN', + telephone: '0678456789', + meme_adresse: true, + }; + + const res = await controller.addCoParent('p1', body as any); + expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body); + expect(res.co_parent_user_id).toBe('p2'); + 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 6d821ff..1567509 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -30,6 +30,8 @@ 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 { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto'; +import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-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'; @@ -176,6 +178,28 @@ export class ParentsController { return mapParentForApi(parent); } + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @Post(':id/co-parent') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135', + description: + 'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' + + 'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.', + }) + @ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' }) + @ApiBody({ type: StaffAddCoParentDto }) + @ApiResponse({ status: 201, type: StaffAddCoParentResponseDto }) + @ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' }) + @ApiResponse({ status: 404, description: 'Parent introuvable' }) + @ApiResponse({ status: 409, description: 'Email déjà pris' }) + async addCoParent( + @Param('id') id: string, + @Body() dto: StaffAddCoParentDto, + ): Promise { + return this.authService.addCoParentStaff(id, dto); + } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Post(':id/enfants/:enfantId') @ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' }) diff --git a/docs/tmp/135-contrat-api-ajout-co-parent.md b/docs/tmp/135-contrat-api-ajout-co-parent.md new file mode 100644 index 0000000..dd47421 --- /dev/null +++ b/docs/tmp/135-contrat-api-ajout-co-parent.md @@ -0,0 +1,73 @@ +# Mini-spec API — POST /parents/:id/co-parent (#135) + +Contrat back pour l’ajout d’un **2ᵉ parent** sur un foyer mono-parent (staff). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `POST` | +| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Succès** | **201** | + +`parentUserId` = UUID du **parent pivot** (déjà dans le dossier). + +Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`. + +--- + +## Body (JSON) + +| Champ | Type | Obligatoire | Notes | +|-------|------|-------------|--------| +| `email` | string | oui | unique | +| `prenom` | string | oui | | +| `nom` | string | oui | | +| `telephone` | string | oui | `0X…` ou `+33…` | +| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot | +| `adresse` | string | si `meme_adresse=false` | | +| `code_postal` | string | si `meme_adresse=false` | | +| `ville` | string | si `meme_adresse=false` | | + +--- + +## Comportement 201 + +- User co-parent **actif** + token création MDP +- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier` +- Enfants du foyer rattachés au co-parent +- E-mail **création MDP** (pas mail « en attente ») + +```json +{ + "message": "Co-parent ajouté au foyer. 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-co", + "statut": "actif" +} +``` + +## Erreurs + +| Code | Cas | +|------|-----| +| 400 | Déjà un co-parent / 2 responsables / validation adresse | +| 401 | Token invalide | +| 403 | Rôle non staff | +| 404 | Pivot introuvable | +| 409 | Email déjà pris | + +## Réemploi édition identité + +| Endpoint | Usage | +|----------|--------| +| `GET /dossiers/:numero` | Préremplir wizard edit | +| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant | +| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM | + +## Branche + +`feature/135-edition-dossier` diff --git a/docs/tmp/135-mini-spec-front-edition-dossier.md b/docs/tmp/135-mini-spec-front-edition-dossier.md new file mode 100644 index 0000000..f20113b --- /dev/null +++ b/docs/tmp/135-mini-spec-front-edition-dossier.md @@ -0,0 +1,83 @@ +# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135) + +Branche : `feature/135-edition-dossier` +Ticket : **#135** (full-stack) + +Prérequis : **#153** (liste Dossiers) livré. + +--- + +## Objectif + +1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`** +2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent +3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent + +--- + +## Modes wizard + +| Mode | Famille | AM | +|------|---------|-----| +| `review` | déjà | déjà | +| `create` | déjà (#129) | déjà (#156) | +| **`edit`** | **à faire** | **à faire** | + +Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)` +Préremplir via `UserService.getDossierByNumero(numero)`. + +--- + +## APIs + +| Action | Endpoint | +|--------|----------| +| Charger | `GET /dossiers/:numero` | +| Sauver parent | `PATCH /parents/:id/fiche` | +| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` | +| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` | + +Body co-parent : + +```json +{ + "email": "thomas@…", + "prenom": "Thomas", + "nom": "MARTIN", + "telephone": "0678456789", + "meme_adresse": true +} +``` + +`UserService.addCoParent(pivotUserId, body)` → cet endpoint. + +--- + +## UX + +- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending) +- Pending : garder validation (review) ; dossiers actifs → edit +- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau +- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ +- Pas de bouton créer dans l’onglet Dossiers + +--- + +## Hors scope + +- Famille N responsables (#139) +- Suppressions (#154) +- Création dossier initial (#129 / #156) + +--- + +## Critères d’acceptation + +- [ ] Clic dossier actif → wizard edit prérempli +- [ ] PATCH fiche enregistre les modifs +- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP) +- [ ] review / create inchangés + +## Branche + +`feature/135-edition-dossier`