feat(#135): POST /parents/:id/co-parent — ajout co-parent foyer.
API staff pour foyer mono-parent : compte actif, liens + enfants, mail création MDP. Mini-specs front/back dans docs/tmp. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<number>(
|
||||
'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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<StaffAddCoParentResponseDto> {
|
||||
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' })
|
||||
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user