From dfd58d9b6c18afd531b0d32453fd29e6c78b89d1 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 22:12:33 +0100 Subject: [PATCH 1/6] =?UTF-8?q?feat(#103):=20Num=C3=A9ro=20de=20dossier=20?= =?UTF-8?q?=E2=80=93=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Colonne numero_dossier (utilisateurs, assistantes_maternelles, parents) - Table numero_dossier_sequence, format AAAA-NNNNNN, séquence par année - Génération à la soumission AM et parent (famille) - Backfill existants (famille = co_parent ou enfants partagés) - API PATCH /users/:id/numero-dossier (gestionnaire/admin) - Garde-fous: max 2 parents/dossier, pas de mélange AM/parent Made-with: Cursor --- .../assistantes_maternelles.entity.ts | 3 + backend/src/entities/parents.entity.ts | 6 +- backend/src/entities/users.entity.ts | 4 + .../numero-dossier/numero-dossier.module.ts | 8 ++ .../numero-dossier/numero-dossier.service.ts | 55 ++++++++ backend/src/routes/auth/auth.module.ts | 2 + backend/src/routes/auth/auth.service.ts | 12 ++ .../user/dto/affecter-numero-dossier.dto.ts | 14 ++ backend/src/routes/user/user.controller.ts | 21 ++- backend/src/routes/user/user.service.ts | 58 +++++++++ database/BDD.sql | 14 ++ database/migrations/2026_numero_dossier.sql | 33 +++++ .../2026_numero_dossier_backfill.sql | 122 ++++++++++++++++++ 13 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 backend/src/modules/numero-dossier/numero-dossier.module.ts create mode 100644 backend/src/modules/numero-dossier/numero-dossier.service.ts create mode 100644 backend/src/routes/user/dto/affecter-numero-dossier.dto.ts create mode 100644 database/migrations/2026_numero_dossier.sql create mode 100644 database/migrations/2026_numero_dossier_backfill.sql diff --git a/backend/src/entities/assistantes_maternelles.entity.ts b/backend/src/entities/assistantes_maternelles.entity.ts index f8793e6..77db5f1 100644 --- a/backend/src/entities/assistantes_maternelles.entity.ts +++ b/backend/src/entities/assistantes_maternelles.entity.ts @@ -48,4 +48,7 @@ export class AssistanteMaternelle { @Column( { name: 'place_disponible', type: 'integer', nullable: true }) places_available?: number; + /** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */ + @Column({ name: 'numero_dossier', length: 20, nullable: true }) + numero_dossier?: string; } diff --git a/backend/src/entities/parents.entity.ts b/backend/src/entities/parents.entity.ts index b66843c..5f55d44 100644 --- a/backend/src/entities/parents.entity.ts +++ b/backend/src/entities/parents.entity.ts @@ -1,5 +1,5 @@ import { - Entity, PrimaryColumn, OneToOne, JoinColumn, + Entity, PrimaryColumn, Column, OneToOne, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Users } from './users.entity'; @@ -21,6 +21,10 @@ export class Parents { @JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' }) co_parent?: Users; + /** Numéro de dossier famille (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */ + @Column({ name: 'numero_dossier', length: 20, nullable: true }) + numero_dossier?: string; + // Lien vers enfants via la table enfants_parents @OneToMany(() => ParentsChildren, pc => pc.parent) parentChildren: ParentsChildren[]; diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index 649ddda..ca5d2ef 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -152,6 +152,10 @@ export class Users { @Column({ nullable: true, name: 'relais_id' }) relaisId?: string; + /** Numéro de dossier (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */ + @Column({ nullable: true, name: 'numero_dossier', length: 20 }) + numero_dossier?: string; + @ManyToOne(() => Relais, relais => relais.gestionnaires, { nullable: true }) @JoinColumn({ name: 'relais_id' }) relais?: Relais; diff --git a/backend/src/modules/numero-dossier/numero-dossier.module.ts b/backend/src/modules/numero-dossier/numero-dossier.module.ts new file mode 100644 index 0000000..a5724bc --- /dev/null +++ b/backend/src/modules/numero-dossier/numero-dossier.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { NumeroDossierService } from './numero-dossier.service'; + +@Module({ + providers: [NumeroDossierService], + exports: [NumeroDossierService], +}) +export class NumeroDossierModule {} diff --git a/backend/src/modules/numero-dossier/numero-dossier.service.ts b/backend/src/modules/numero-dossier/numero-dossier.service.ts new file mode 100644 index 0000000..2cbb2a2 --- /dev/null +++ b/backend/src/modules/numero-dossier/numero-dossier.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; + +const FORMAT_MAX_SEQUENCE = 990000; + +/** + * Service de génération du numéro de dossier (ticket #103). + * Format AAAA-NNNNNN (année + 6 chiffres), séquence par année. + * Si séquence >= 990000, overflowWarning est true (alerte gestionnaire). + */ +@Injectable() +export class NumeroDossierService { + /** + * Génère le prochain numéro de dossier dans le cadre d'une transaction. + * À appeler avec le manager de la transaction pour garantir l'unicité. + */ + async getNextNumeroDossier(manager: EntityManager): Promise<{ + numero: string; + overflowWarning: boolean; + }> { + const year = new Date().getFullYear(); + + // Garantir l'existence de la ligne pour l'année + await manager.query( + `INSERT INTO numero_dossier_sequence (annee, prochain) + VALUES ($1, 1) + ON CONFLICT (annee) DO NOTHING`, + [year], + ); + + // Prendre le prochain numéro et incrémenter (FOR UPDATE pour concurrence) + const selectRows = await manager.query( + `SELECT prochain FROM numero_dossier_sequence WHERE annee = $1 FOR UPDATE`, + [year], + ); + const currentVal = selectRows?.[0]?.prochain ?? 1; + + await manager.query( + `UPDATE numero_dossier_sequence SET prochain = prochain + 1 WHERE annee = $1`, + [year], + ); + + const nextVal = currentVal; + const overflowWarning = nextVal >= FORMAT_MAX_SEQUENCE; + if (overflowWarning) { + // Log pour alerte gestionnaire (ticket #103) + console.warn( + `[NumeroDossierService] Séquence année ${year} >= ${FORMAT_MAX_SEQUENCE} (valeur ${nextVal}). Prévoir renouvellement ou format.`, + ); + } + + const numero = `${year}-${String(nextVal).padStart(6, '0')}`; + return { numero, overflowWarning }; + } +} diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 3d15615..2a4513a 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -10,12 +10,14 @@ import { Parents } from 'src/entities/parents.entity'; import { Children } from 'src/entities/children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AppConfigModule } from 'src/modules/config'; +import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module'; @Module({ imports: [ TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), forwardRef(() => UserModule), AppConfigModule, + NumeroDossierModule, JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 76f2ddd..45e7ee5 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -24,6 +24,7 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit import { LoginDto } from './dto/login.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'; @Injectable() export class AuthService { @@ -32,6 +33,7 @@ export class AuthService { private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly appConfigService: AppConfigService, + private readonly numeroDossierService: NumeroDossierService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @InjectRepository(Users) @@ -194,6 +196,8 @@ export class AuthService { dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); const resultat = await this.usersRepo.manager.transaction(async (manager) => { + const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); + const parent1 = manager.create(Users, { email: dto.email, prenom: dto.prenom, @@ -206,6 +210,7 @@ export class AuthService { ville: dto.ville, token_creation_mdp: tokenCreationMdp, token_creation_mdp_expire_le: dateExpiration, + numero_dossier: numeroDossier, }); const parent1Enregistre = await manager.save(Users, parent1); @@ -230,6 +235,7 @@ export class AuthService { ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville, token_creation_mdp: tokenCoParent, token_creation_mdp_expire_le: dateExpirationCoParent, + numero_dossier: numeroDossier, }); parent2Enregistre = await manager.save(Users, parent2); @@ -237,6 +243,7 @@ export class AuthService { const entiteParent = manager.create(Parents, { user_id: parent1Enregistre.id, + numero_dossier: numeroDossier, }); entiteParent.user = parent1Enregistre; if (parent2Enregistre) { @@ -248,6 +255,7 @@ export class AuthService { if (parent2Enregistre) { const entiteCoParent = manager.create(Parents, { user_id: parent2Enregistre.id, + numero_dossier: numeroDossier, }); entiteCoParent.user = parent2Enregistre; entiteCoParent.co_parent = parent1Enregistre; @@ -360,6 +368,8 @@ export class AuthService { dto.consentement_photo ? new Date() : undefined; const resultat = await this.usersRepo.manager.transaction(async (manager) => { + const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); + const user = manager.create(Users, { email: dto.email, prenom: dto.prenom, @@ -376,6 +386,7 @@ export class AuthService { consentement_photo: dto.consentement_photo, date_consentement_photo: dateConsentementPhoto, date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, + numero_dossier: numeroDossier, }); const userEnregistre = await manager.save(Users, user); @@ -389,6 +400,7 @@ export class AuthService { 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); diff --git a/backend/src/routes/user/dto/affecter-numero-dossier.dto.ts b/backend/src/routes/user/dto/affecter-numero-dossier.dto.ts new file mode 100644 index 0000000..5227823 --- /dev/null +++ b/backend/src/routes/user/dto/affecter-numero-dossier.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, Matches } from 'class-validator'; + +/** Format AAAA-NNNNNN (année + 6 chiffres) */ +const NUMERO_DOSSIER_REGEX = /^\d{4}-\d{6}$/; + +export class AffecterNumeroDossierDto { + @ApiProperty({ example: '2026-000004', description: 'Numéro de dossier (AAAA-NNNNNN)' }) + @IsNotEmpty({ message: 'Le numéro de dossier est requis' }) + @Matches(NUMERO_DOSSIER_REGEX, { + message: 'Le numéro de dossier doit être au format AAAA-NNNNNN (ex: 2026-000001)', + }) + numero_dossier: string; +} diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index 84682bc..d7df010 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; import { Roles } from 'src/common/decorators/roles.decorator'; import { User } from 'src/common/decorators/user.decorator'; import { RoleType, Users } from 'src/entities/users.entity'; @@ -8,10 +9,11 @@ import { UserService } from './user.service'; import { CreateUserDto } from './dto/create_user.dto'; import { CreateAdminDto } from './dto/create_admin.dto'; import { UpdateUserDto } from './dto/update_user.dto'; +import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto'; @ApiTags('Utilisateurs') @ApiBearerAuth('access-token') -@UseGuards(AuthGuard) +@UseGuards(AuthGuard, RolesGuard) @Controller('users') export class UserController { constructor(private readonly userService: UserService) { } @@ -78,6 +80,23 @@ export class UserController { return this.userService.updateUser(id, dto, currentUser); } + @Patch(':id/numero-dossier') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ + summary: 'Affecter un numéro de dossier à un utilisateur', + description: 'Permet de rapprocher deux dossiers ou d’attribuer un numéro existant à un parent/AM. Réservé aux gestionnaires et administrateurs.', + }) + @ApiParam({ name: 'id', description: "UUID de l'utilisateur (parent ou AM)" }) + @ApiResponse({ status: 200, description: 'Numéro de dossier affecté' }) + @ApiResponse({ status: 400, description: 'Format invalide, rôle non éligible, ou dossier déjà associé à 2 parents' }) + @ApiResponse({ status: 404, description: 'Utilisateur introuvable' }) + affecterNumeroDossier( + @Param('id') id: string, + @Body() dto: AffecterNumeroDossierDto, + ) { + return this.userService.affecterNumeroDossier(id, dto.numero_dossier); + } + @Patch(':id/valider') @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Valider un compte utilisateur' }) diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 69ccaac..3ef45b3 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -270,6 +270,64 @@ export class UserService { await this.validationRepository.save(suspend); return savedUser; } + /** + * Affecter ou modifier le numéro de dossier d'un utilisateur (parent ou AM). + * Permet au gestionnaire/admin de rapprocher deux dossiers (même numéro pour plusieurs personnes). + * Garde-fou : au plus 2 parents par numéro de dossier (couple / co-parents). + */ + async affecterNumeroDossier(userId: string, numeroDossier: string): Promise { + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException('Utilisateur introuvable'); + + if (user.role !== RoleType.PARENT && user.role !== RoleType.ASSISTANTE_MATERNELLE) { + throw new BadRequestException( + 'Le numéro de dossier ne peut être affecté qu\'à un parent ou une assistante maternelle', + ); + } + + if (user.role === RoleType.PARENT) { + const uneAMALe = await this.assistantesRepository.count({ + where: { numero_dossier: numeroDossier }, + }); + if (uneAMALe > 0) { + throw new BadRequestException( + 'Ce numéro de dossier est celui d\'une assistante maternelle. Un numéro AM ne peut pas être affecté à un parent.', + ); + } + const parentsAvecCeNumero = await this.parentsRepository.count({ + where: { numero_dossier: numeroDossier }, + }); + const userADejaCeNumero = user.numero_dossier === numeroDossier; + if (!userADejaCeNumero && parentsAvecCeNumero >= 2) { + throw new BadRequestException( + 'Un numéro de dossier ne peut être associé qu\'à 2 parents au maximum (couple / co-parents). Ce dossier a déjà 2 parents.', + ); + } + } + + if (user.role === RoleType.ASSISTANTE_MATERNELLE) { + const unParentLA = await this.parentsRepository.count({ + where: { numero_dossier: numeroDossier }, + }); + if (unParentLA > 0) { + throw new BadRequestException( + 'Ce numéro de dossier est celui d\'une famille (parent). Un numéro famille ne peut pas être affecté à une assistante maternelle.', + ); + } + } + + user.numero_dossier = numeroDossier; + const savedUser = await this.usersRepository.save(user); + + if (user.role === RoleType.PARENT) { + await this.parentsRepository.update({ user_id: userId }, { numero_dossier: numeroDossier }); + } else { + await this.assistantesRepository.update({ user_id: userId }, { numero_dossier: numeroDossier }); + } + + return savedUser; + } + async remove(id: string, currentUser: Users): Promise { if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins'); diff --git a/database/BDD.sql b/database/BDD.sql index 46a741e..fc04af8 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -355,6 +355,20 @@ ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL; +-- ========================================================== +-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année) +-- ========================================================== +CREATE TABLE IF NOT EXISTS numero_dossier_sequence ( + annee INT PRIMARY KEY, + prochain INT NOT NULL DEFAULT 1 +); +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; +ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; +ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; +CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Seed : Documents légaux génériques v1 -- ========================================================== diff --git a/database/migrations/2026_numero_dossier.sql b/database/migrations/2026_numero_dossier.sql new file mode 100644 index 0000000..4af0866 --- /dev/null +++ b/database/migrations/2026_numero_dossier.sql @@ -0,0 +1,33 @@ +-- Migration #103 : Numéro de dossier (format AAAA-NNNNNN, séquence par année) +-- Colonnes sur utilisateurs, assistantes_maternelles, parents. +-- Table de séquence par année pour génération unique. + +BEGIN; + +-- Table de séquence : une ligne par année, prochain = prochain numéro à attribuer (1..999999) +CREATE TABLE IF NOT EXISTS numero_dossier_sequence ( + annee INT PRIMARY KEY, + prochain INT NOT NULL DEFAULT 1 +); + +-- Colonne sur utilisateurs (AM et parents : numéro attribué à la soumission) +ALTER TABLE utilisateurs + ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; + +-- Colonne sur assistantes_maternelles (redondant avec users pour accès direct) +ALTER TABLE assistantes_maternelles + ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; + +-- Colonne sur parents (un numéro par famille, même valeur sur les deux lignes si co-parent) +ALTER TABLE parents + ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; + +-- Index pour recherche par numéro +CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier + ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier + ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier + ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL; + +COMMIT; diff --git a/database/migrations/2026_numero_dossier_backfill.sql b/database/migrations/2026_numero_dossier_backfill.sql new file mode 100644 index 0000000..ef2590e --- /dev/null +++ b/database/migrations/2026_numero_dossier_backfill.sql @@ -0,0 +1,122 @@ +-- Backfill #103 : attribuer un numero_dossier aux entrées existantes (NULL) +-- Famille = lien co_parent OU partage d'au moins un enfant (même dossier). +-- Ordre : par année, AM puis familles (une entrée par famille), séquence 000001, 000002... +-- À exécuter après 2026_numero_dossier.sql + +DO $$ +DECLARE + yr INT; + seq INT; + num TEXT; + r RECORD; + family_user_ids UUID[]; +BEGIN + -- Réinitialiser pour rejouer le backfill (cohérence AM + familles) + UPDATE parents SET numero_dossier = NULL; + UPDATE utilisateurs SET numero_dossier = NULL + WHERE role IN ('parent', 'assistante_maternelle'); + UPDATE assistantes_maternelles SET numero_dossier = NULL; + + FOR yr IN + SELECT DISTINCT EXTRACT(YEAR FROM u.cree_le)::INT + FROM utilisateurs u + WHERE ( + (u.role = 'assistante_maternelle' AND u.numero_dossier IS NULL) + OR EXISTS (SELECT 1 FROM parents p WHERE p.id_utilisateur = u.id AND p.numero_dossier IS NULL) + ) + ORDER BY 1 + LOOP + seq := 0; + + -- 1) AM : par ordre de création + FOR r IN + SELECT u.id + FROM utilisateurs u + WHERE u.role = 'assistante_maternelle' + AND u.numero_dossier IS NULL + AND EXTRACT(YEAR FROM u.cree_le) = yr + ORDER BY u.cree_le + LOOP + seq := seq + 1; + num := yr || '-' || LPAD(seq::TEXT, 6, '0'); + UPDATE utilisateurs SET numero_dossier = num WHERE id = r.id; + UPDATE assistantes_maternelles SET numero_dossier = num WHERE id_utilisateur = r.id; + END LOOP; + + -- 2) Familles : une entrée par "dossier" (co_parent OU enfants partagés) + -- family_rep = min(id) de la composante connexe (lien co_parent + partage d'enfants) + FOR r IN + WITH RECURSIVE + links AS ( + SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + UNION ALL + SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + ), + rec AS ( + SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents + UNION + SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 + ), + family_rep AS ( + SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id + ), + fam_ordered AS ( + SELECT fr.rep AS family_rep, MIN(u.cree_le) AS cree_le + FROM family_rep fr + JOIN parents p ON p.id_utilisateur = fr.id + JOIN utilisateurs u ON u.id = p.id_utilisateur + WHERE p.numero_dossier IS NULL + AND EXTRACT(YEAR FROM u.cree_le) = yr + GROUP BY fr.rep + ORDER BY MIN(u.cree_le) + ) + SELECT fo.family_rep + FROM fam_ordered fo + LOOP + seq := seq + 1; + num := yr || '-' || LPAD(seq::TEXT, 6, '0'); + + WITH RECURSIVE + links AS ( + SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + UNION ALL + SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + ), + rec AS ( + SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents + UNION + SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 + ), + family_rep AS ( + SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id + ) + SELECT array_agg(DISTINCT fr.id) INTO family_user_ids + FROM family_rep fr + WHERE fr.rep = r.family_rep; + + UPDATE utilisateurs SET numero_dossier = num WHERE id = ANY(family_user_ids); + UPDATE parents SET numero_dossier = num WHERE id_utilisateur = ANY(family_user_ids); + END LOOP; + + INSERT INTO numero_dossier_sequence (annee, prochain) + VALUES (yr, seq + 1) + ON CONFLICT (annee) DO UPDATE + SET prochain = GREATEST(numero_dossier_sequence.prochain, seq + 1); + END LOOP; +END $$; From 393a527c379509a4b3c0a68d529c9a0fad8b362d Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 22:21:12 +0100 Subject: [PATCH 2/6] =?UTF-8?q?feat(#105):=20Statut=20=C2=AB=20refus=C3=A9?= =?UTF-8?q?=20=C2=BB=20=E2=80=93=20enum,=20migration,=20pending/reprise,?= =?UTF-8?q?=20refuser,=20connexion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enum statut_utilisateur_type + valeur 'refuse' (migration + BDD.sql) - GET /users/reprise, PATCH /users/:id/refuser (refus_compte en validations) - PATCH /users/:id/valider accepte en_attente et refuse (reprise) - Connexion refusée si statut refuse Made-with: Cursor --- backend/src/entities/users.entity.ts | 1 + backend/src/routes/auth/auth.service.ts | 6 +++ backend/src/routes/user/user.controller.ts | 22 ++++++++++ backend/src/routes/user/user.service.ts | 41 ++++++++++++++++++- database/BDD.sql | 2 +- .../2026_statut_utilisateur_refuse.sql | 4 ++ 6 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2026_statut_utilisateur_refuse.sql diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index ca5d2ef..d44726e 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -29,6 +29,7 @@ export enum StatutUtilisateurType { EN_ATTENTE = 'en_attente', ACTIF = 'actif', SUSPENDU = 'suspendu', + REFUSE = 'refuse', } export enum SituationFamilialeType { diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 45e7ee5..edaba73 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -96,6 +96,12 @@ export class AuthService { throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.'); } + if (user.statut === StatutUtilisateurType.REFUSE) { + throw new UnauthorizedException( + 'Votre compte a été refusé. Vous pouvez corriger votre dossier et le soumettre à nouveau ; un gestionnaire pourra le réexaminer.', + ); + } + return this.generateTokens(user.id, user.email, user.role); } diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index d7df010..90b8535 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -50,6 +50,16 @@ export class UserController { return this.userService.findPendingUsers(role); } + // Lister les comptes refusés (à corriger / reprise) + @Get('reprise') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ summary: 'Lister les comptes refusés (reprise)' }) + findRefusedUsers( + @Query('role') role?: RoleType + ) { + return this.userService.findRefusedUsers(role); + } + // Lister tous les utilisateurs (super_admin uniquement) @Get() @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @@ -112,6 +122,18 @@ export class UserController { return this.userService.validateUser(id, currentUser, comment); } + @Patch(':id/refuser') + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Refuser un compte (à corriger)' }) + @ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) + refuse( + @Param('id') id: string, + @User() currentUser: Users, + @Body('comment') comment?: string, + ) { + return this.userService.refuseUser(id, currentUser, comment); + } + @Patch(':id/suspendre') @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Suspendre un compte utilisateur' }) diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 3ef45b3..e6c270c 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -140,6 +140,15 @@ export class UserService { return this.usersRepository.find({ where }); } + /** Comptes refusés (à corriger) : liste pour reprise par le gestionnaire */ + async findRefusedUsers(role?: RoleType): Promise { + const where: any = { statut: StatutUtilisateurType.REFUSE }; + if (role) { + where.role = role; + } + return this.usersRepository.find({ where }); + } + async findAll(): Promise { return this.usersRepository.find(); } @@ -214,7 +223,7 @@ export class UserService { return this.usersRepository.save(user); } - // Valider un compte utilisateur + // Valider un compte utilisateur (en_attente ou refuse -> actif) async validateUser(user_id: string, currentUser: Users, comment?: string): Promise { if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); @@ -222,7 +231,11 @@ export class UserService { const user = await this.usersRepository.findOne({ where: { id: user_id } }); if (!user) throw new NotFoundException('Utilisateur introuvable'); - + + if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) { + throw new BadRequestException('Seuls les comptes en attente ou refusés (à corriger) peuvent être validés.'); + } + user.statut = StatutUtilisateurType.ACTIF; const savedUser = await this.usersRepository.save(user); if (user.role === RoleType.PARENT) { @@ -270,6 +283,30 @@ export class UserService { await this.validationRepository.save(suspend); return savedUser; } + + /** Refuser un compte (en_attente -> refuse) ; tracé dans validations */ + async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise { + if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { + throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); + } + const user = await this.usersRepository.findOne({ where: { id: user_id } }); + if (!user) throw new NotFoundException('Utilisateur introuvable'); + if (user.statut !== StatutUtilisateurType.EN_ATTENTE) { + throw new BadRequestException('Seul un compte en attente peut être refusé.'); + } + user.statut = StatutUtilisateurType.REFUSE; + const savedUser = await this.usersRepository.save(user); + const validation = this.validationRepository.create({ + user: savedUser, + type: 'refus_compte', + status: StatutValidationType.REFUSE, + validated_by: currentUser, + comment, + }); + await this.validationRepository.save(validation); + return savedUser; + } + /** * Affecter ou modifier le numéro de dossier d'un utilisateur (parent ou AM). * Permet au gestionnaire/admin de rapprocher deux dossiers (même numéro pour plusieurs personnes). diff --git a/database/BDD.sql b/database/BDD.sql index fc04af8..57d67ee 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -11,7 +11,7 @@ DO $$ BEGIN CREATE TYPE genre_type AS ENUM ('H', 'F'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN - CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu'); + CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise'); diff --git a/database/migrations/2026_statut_utilisateur_refuse.sql b/database/migrations/2026_statut_utilisateur_refuse.sql new file mode 100644 index 0000000..0ce2b78 --- /dev/null +++ b/database/migrations/2026_statut_utilisateur_refuse.sql @@ -0,0 +1,4 @@ +-- Migration #105 : Statut utilisateur « refusé » (à corriger) +-- Ajout de la valeur 'refuse' à l'enum statut_utilisateur_type. + +ALTER TYPE statut_utilisateur_type ADD VALUE IF NOT EXISTS 'refuse'; From 1fa70f405218b69811b49118300a806f388ef707 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 22:36:16 +0100 Subject: [PATCH 3/6] =?UTF-8?q?feat(#106):=20Liste=20familles=20en=20atten?= =?UTF-8?q?te=20=E2=80=93=20GET=20/parents/pending-families?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Une entrée par famille (co_parent + enfants partagés, même logique que backfill #103) - libelle, parentIds, numero_dossier ; filtre statut en_attente - AuthGuard + RolesGuard sur controller parents Made-with: Cursor --- .../routes/parents/dto/pending-family.dto.ts | 20 ++++++++ .../src/routes/parents/parents.controller.ts | 16 ++++++- backend/src/routes/parents/parents.service.ts | 48 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/parents/dto/pending-family.dto.ts diff --git a/backend/src/routes/parents/dto/pending-family.dto.ts b/backend/src/routes/parents/dto/pending-family.dto.ts new file mode 100644 index 0000000..e7706d3 --- /dev/null +++ b/backend/src/routes/parents/dto/pending-family.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PendingFamilyDto { + @ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' }) + libelle: string; + + @ApiProperty({ + type: [String], + example: ['uuid-parent-1', 'uuid-parent-2'], + description: 'IDs utilisateur des parents de la famille', + }) + parentIds: string[]; + + @ApiProperty({ + nullable: true, + example: '2026-000001', + description: 'Numéro de dossier famille (format AAAA-NNNNNN)', + }) + numero_dossier: string | null; +} diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index e4a9ee2..a6ed455 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -6,20 +6,34 @@ import { Param, Patch, Post, + UseGuards, } from '@nestjs/common'; import { ParentsService } from './parents.service'; import { Parents } from 'src/entities/parents.entity'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RoleType } from 'src/entities/users.entity'; -import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CreateParentDto } from '../user/dto/create_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { PendingFamilyDto } from './dto/pending-family.dto'; @ApiTags('Parents') @Controller('parents') +@UseGuards(AuthGuard, RolesGuard) export class ParentsController { constructor(private readonly parentsService: ParentsService) {} + @Get('pending-families') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' }) + @ApiResponse({ status: 200, description: 'Liste des familles (libellé, parentIds, numero_dossier)', type: [PendingFamilyDto] }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) + getPendingFamilies(): Promise { + return this.parentsService.getPendingFamilies(); + } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index d2cafee..e2c50e6 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -10,6 +10,7 @@ import { Parents } from 'src/entities/parents.entity'; import { RoleType, Users } from 'src/entities/users.entity'; import { CreateParentDto } from '../user/dto/create_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto'; +import { PendingFamilyDto } from './dto/pending-family.dto'; @Injectable() export class ParentsService { @@ -71,4 +72,51 @@ export class ParentsService { await this.parentsRepository.update(id, dto); return this.findOne(id); } + + /** + * Liste des familles en attente (une entrée par famille). + * Famille = lien co_parent ou partage d'enfants (même logique que backfill #103). + * Uniquement les parents dont l'utilisateur a statut = en_attente. + */ + async getPendingFamilies(): Promise { + const raw = await this.parentsRepository.query(` + WITH RECURSIVE + links AS ( + SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + UNION ALL + SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + ), + rec AS ( + SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents + UNION + SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 + ), + family_rep AS ( + SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id + ) + SELECT + 'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle, + array_agg(DISTINCT p.id_utilisateur ORDER BY p.id_utilisateur) AS "parentIds", + (array_agg(p.numero_dossier))[1] AS numero_dossier + FROM family_rep fr + JOIN parents p ON p.id_utilisateur = fr.id + JOIN utilisateurs u ON u.id = p.id_utilisateur + WHERE u.role = 'parent' AND u.statut = 'en_attente' + GROUP BY fr.rep + ORDER BY libelle + `); + return raw.map((r: { libelle: string; parentIds: unknown; numero_dossier: string | null }) => ({ + libelle: r.libelle, + parentIds: Array.isArray(r.parentIds) ? r.parentIds.map(String) : [], + numero_dossier: r.numero_dossier ?? null, + })); + } } From dbcb3611d4770f76a12b6d588a761f6b90194464 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 22:46:17 +0100 Subject: [PATCH 4/6] =?UTF-8?q?feat(#108):=20Validation=20dossier=20famill?= =?UTF-8?q?e=20=E2=80=93=20POST=20/parents/:parentId/valider-dossier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getFamilyUserIds(parentId) : tous les user_id de la famille (co_parent + enfants partagés) - Valide en une fois tous les comptes en_attente/refuse de la famille (validateUser) - Réponse : liste des Users validés Made-with: Cursor --- .../src/routes/parents/parents.controller.ts | 36 +++++++++++++-- backend/src/routes/parents/parents.module.ts | 8 +++- backend/src/routes/parents/parents.service.ts | 45 +++++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index a6ed455..edadf2b 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, - Delete, Get, Param, Patch, @@ -9,21 +8,27 @@ import { UseGuards, } from '@nestjs/common'; import { ParentsService } from './parents.service'; +import { UserService } from '../user/user.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 } from 'src/entities/users.entity'; -import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; +import { 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 { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; +import { User } from 'src/common/decorators/user.decorator'; import { PendingFamilyDto } from './dto/pending-family.dto'; @ApiTags('Parents') @Controller('parents') @UseGuards(AuthGuard, RolesGuard) export class ParentsController { - constructor(private readonly parentsService: ParentsService) {} + constructor( + private readonly parentsService: ParentsService, + private readonly userService: UserService, + ) {} @Get('pending-families') @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @@ -34,6 +39,29 @@ export class ParentsController { return this.parentsService.getPendingFamilies(); } + @Post(':parentId/valider-dossier') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' }) + @ApiParam({ name: 'parentId', description: "UUID d'un des parents (user_id)" }) + @ApiResponse({ status: 200, description: 'Utilisateurs validés (famille)' }) + @ApiResponse({ status: 404, description: 'Parent introuvable' }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) + async validerDossierFamille( + @Param('parentId') parentId: string, + @User() currentUser: Users, + @Body('comment') comment?: string, + ): Promise { + const familyIds = await this.parentsService.getFamilyUserIds(parentId); + const validated: Users[] = []; + for (const userId of familyIds) { + const user = await this.userService.findOne(userId); + if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) continue; + const saved = await this.userService.validateUser(userId, currentUser, comment); + validated.push(saved); + } + return validated; + } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index dc57fe6..6cb557b 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -1,12 +1,16 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Parents } from 'src/entities/parents.entity'; import { ParentsController } from './parents.controller'; import { ParentsService } from './parents.service'; import { Users } from 'src/entities/users.entity'; +import { UserModule } from '../user/user.module'; @Module({ - imports: [TypeOrmModule.forFeature([Parents, Users])], + imports: [ + TypeOrmModule.forFeature([Parents, Users]), + forwardRef(() => UserModule), + ], controllers: [ParentsController], providers: [ParentsService], exports: [ParentsService, diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index e2c50e6..3aa174d 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -119,4 +119,49 @@ export class ParentsService { numero_dossier: r.numero_dossier ?? null, })); } + + /** + * Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés). + * @throws NotFoundException si parentId n'est pas un parent + */ + async getFamilyUserIds(parentId: string): Promise { + const raw = await this.parentsRepository.query( + ` + WITH RECURSIVE + links AS ( + SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + UNION ALL + SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + ), + rec AS ( + SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents + UNION + SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 + ), + family_rep AS ( + SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id + ), + input_rep AS ( + SELECT rep FROM family_rep WHERE id = $1::uuid LIMIT 1 + ) + SELECT fr.id::text AS id + FROM family_rep fr + CROSS JOIN input_rep ir + WHERE fr.rep = ir.rep + `, + [parentId], + ); + if (!raw || raw.length === 0) { + throw new NotFoundException('Parent introuvable ou pas encore enregistré en base.'); + } + return raw.map((r: { id: string }) => r.id); + } } From 86d8189038028a2745a5f8a858d030ba746d37b0 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 22:56:27 +0100 Subject: [PATCH 5/6] =?UTF-8?q?feat(#110):=20Refus=20sans=20suppression=20?= =?UTF-8?q?=E2=80=93=20token=20reprise=20+=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Colonnes token_reprise, token_reprise_expire_le (migration + BDD.sql) - refuser: génère token (7j), enregistre, trace validations, envoie email (template refus + lien reprise) - MailService.sendRefusEmail ; échec email ne bloque pas le refus Made-with: Cursor --- backend/src/entities/users.entity.ts | 7 ++++ backend/src/modules/mail/mail.service.ts | 37 +++++++++++++++++++ backend/src/routes/user/user.module.ts | 2 + backend/src/routes/user/user.service.ts | 33 +++++++++++++++-- database/BDD.sql | 7 ++++ .../migrations/2026_token_reprise_refus.sql | 10 +++++ 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2026_token_reprise_refus.sql diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index d44726e..94d12bb 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -119,6 +119,13 @@ export class Users { @Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' }) token_creation_mdp_expire_le?: Date; + /** Token pour reprise après refus (lien email), ticket #110 */ + @Column({ nullable: true, name: 'token_reprise', length: 255 }) + token_reprise?: string; + + @Column({ type: 'timestamptz', nullable: true, name: 'token_reprise_expire_le' }) + token_reprise_expire_le?: Date; + @Column({ nullable: true, name: 'ville' }) ville?: string; diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index c064915..6a1e872 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -97,4 +97,41 @@ export class MailService { await this.sendEmail(to, subject, html); } + + /** + * Email de refus de dossier avec lien reprise (token). + * Ticket #110 – Refus sans suppression + */ + async sendRefusEmail( + to: string, + prenom: string, + nom: string, + comment: string | undefined, + token: string, + ): Promise { + const appName = this.configService.get('app_name', "P'titsPas"); + const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); + const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`; + + const subject = `Votre dossier – compléments demandés`; + const commentBlock = comment + ? `

Message du gestionnaire :

${comment.replace(//g, '>')}

` + : ''; + const html = ` +
+

Bonjour ${prenom} ${nom},

+

Votre dossier d'inscription sur ${appName} n'a pas pu être validé en l'état.

+ ${commentBlock} +

Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.

+ +

Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.

+
+

Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

+
+ `; + + await this.sendEmail(to, subject, html); + } } diff --git a/backend/src/routes/user/user.module.ts b/backend/src/routes/user/user.module.ts index 4d5d7cc..2924082 100644 --- a/backend/src/routes/user/user.module.ts +++ b/backend/src/routes/user/user.module.ts @@ -10,6 +10,7 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module'; import { Parents } from 'src/entities/parents.entity'; import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; +import { MailModule } from 'src/modules/mail/mail.module'; @Module({ imports: [TypeOrmModule.forFeature( @@ -22,6 +23,7 @@ import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; ParentsModule, AssistantesMaternellesModule, GestionnairesModule, + MailModule, ], controllers: [UserController], providers: [UserService], diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index e6c270c..83b6abb 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; import { In, Repository } from "typeorm"; @@ -9,9 +9,13 @@ import * as bcrypt from 'bcrypt'; import { StatutValidationType, Validation } from "src/entities/validations.entity"; import { Parents } from "src/entities/parents.entity"; import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity"; +import { MailService } from "src/modules/mail/mail.service"; +import * as crypto from 'crypto'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); + constructor( @InjectRepository(Users) private readonly usersRepository: Repository, @@ -23,7 +27,9 @@ export class UserService { private readonly parentsRepository: Repository, @InjectRepository(AssistanteMaternelle) - private readonly assistantesRepository: Repository + private readonly assistantesRepository: Repository, + + private readonly mailService: MailService, ) { } async createUser(dto: CreateUserDto, currentUser?: Users): Promise { @@ -284,7 +290,7 @@ export class UserService { return savedUser; } - /** Refuser un compte (en_attente -> refuse) ; tracé dans validations */ + /** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */ async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise { if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); @@ -294,8 +300,16 @@ export class UserService { if (user.statut !== StatutUtilisateurType.EN_ATTENTE) { throw new BadRequestException('Seul un compte en attente peut être refusé.'); } + + const tokenReprise = crypto.randomUUID(); + const expireLe = new Date(); + expireLe.setDate(expireLe.getDate() + 7); + user.statut = StatutUtilisateurType.REFUSE; + user.token_reprise = tokenReprise; + user.token_reprise_expire_le = expireLe; const savedUser = await this.usersRepository.save(user); + const validation = this.validationRepository.create({ user: savedUser, type: 'refus_compte', @@ -304,6 +318,19 @@ export class UserService { comment, }); await this.validationRepository.save(validation); + + try { + await this.mailService.sendRefusEmail( + savedUser.email, + savedUser.prenom ?? '', + savedUser.nom ?? '', + comment, + tokenReprise, + ); + } catch (err) { + this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err); + } + return savedUser; } diff --git a/database/BDD.sql b/database/BDD.sql index 57d67ee..7a7ab17 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -369,6 +369,13 @@ CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numer CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL; +-- ========================================================== +-- Ticket #110 : Token reprise après refus (lien email) +-- ========================================================== +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL; +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL; +CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL; + -- ========================================================== -- Seed : Documents légaux génériques v1 -- ========================================================== diff --git a/database/migrations/2026_token_reprise_refus.sql b/database/migrations/2026_token_reprise_refus.sql new file mode 100644 index 0000000..f805840 --- /dev/null +++ b/database/migrations/2026_token_reprise_refus.sql @@ -0,0 +1,10 @@ +-- Migration #110 : Token reprise après refus (lien email) +-- Permet à l'utilisateur refusé de corriger et resoumettre via un lien sécurisé. + +ALTER TABLE utilisateurs + ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL; + +CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise + ON utilisateurs(token_reprise) + WHERE token_reprise IS NOT NULL; From 86b28abe513d4a55bf7ca2f5245c6aeeaa118627 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 23:04:45 +0100 Subject: [PATCH 6/6] =?UTF-8?q?feat(#111):=20Reprise=20apr=C3=A8s=20refus?= =?UTF-8?q?=20=E2=80=93=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /auth/reprise-dossier?token= : dossier pour préremplir (token seul) - PATCH /auth/reprise-resoumettre : token + champs modifiables → en_attente, token invalidé - POST /auth/reprise-identify : numero_dossier + email → type + token - UserService: findByTokenReprise, resoumettreReprise, findByNumeroDossierAndEmailForReprise Made-with: Cursor --- backend/src/routes/auth/auth.controller.ts | 36 +++++++++++++- backend/src/routes/auth/auth.service.ts | 46 +++++++++++++++++ .../routes/auth/dto/reprise-dossier.dto.ts | 44 +++++++++++++++++ .../routes/auth/dto/reprise-identify.dto.ts | 23 +++++++++ .../auth/dto/resoumettre-reprise.dto.ts | 49 +++++++++++++++++++ backend/src/routes/user/user.service.ts | 48 +++++++++++++++++- 6 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 backend/src/routes/auth/dto/reprise-dossier.dto.ts create mode 100644 backend/src/routes/auth/dto/reprise-identify.dto.ts create mode 100644 backend/src/routes/auth/dto/resoumettre-reprise.dto.ts diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 2eeaf98..258d853 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Patch, Post, Query, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './auth.service'; import { Public } from 'src/common/decorators/public.decorator'; @@ -6,14 +6,17 @@ import { RegisterDto } from './dto/register.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { ChangePasswordRequiredDto } from './dto/change-password.dto'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; import type { Request } from 'express'; import { UserService } from '../user/user.service'; import { ProfileResponseDto } from './dto/profile_response.dto'; import { RefreshTokenDto } from './dto/refresh_token.dto'; +import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto'; +import { RepriseIdentifyBodyDto } from './dto/reprise-identify.dto'; import { User } from 'src/common/decorators/user.decorator'; import { Users } from 'src/entities/users.entity'; +import { RepriseDossierDto } from './dto/reprise-dossier.dto'; @ApiTags('Authentification') @Controller('auth') @@ -65,6 +68,35 @@ export class AuthController { return this.authService.inscrireAMComplet(dto); } + @Public() + @Get('reprise-dossier') + @ApiOperation({ summary: 'Dossier pour reprise (token seul)' }) + @ApiQuery({ name: 'token', required: true, description: 'Token reprise (lien email)' }) + @ApiResponse({ status: 200, description: 'Données dossier pour préremplir', type: RepriseDossierDto }) + @ApiResponse({ status: 404, description: 'Token invalide ou expiré' }) + async getRepriseDossier(@Query('token') token: string): Promise { + return this.authService.getRepriseDossier(token); + } + + @Public() + @Patch('reprise-resoumettre') + @ApiOperation({ summary: 'Resoumettre le dossier (mise à jour + statut en_attente, invalide le token)' }) + @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); + } + + @Public() + @Post('reprise-identify') + @ApiOperation({ summary: 'Modale reprise : numéro + email → type + token' }) + @ApiResponse({ status: 201, description: 'type (parent/AM) + token pour GET reprise-dossier / PUT reprise-resoumettre' }) + @ApiResponse({ status: 404, description: 'Aucun dossier en reprise pour ce numéro et email' }) + async repriseIdentify(@Body() dto: RepriseIdentifyBodyDto) { + return this.authService.identifyReprise(dto.numero_dossier, dto.email); + } + @Public() @Post('refresh') @ApiBearerAuth('refresh_token') diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index edaba73..c6fe021 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable, + NotFoundException, UnauthorizedException, BadRequestException, } from '@nestjs/common'; @@ -22,6 +23,8 @@ import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { LoginDto } from './dto/login.dto'; +import { RepriseDossierDto } from './dto/reprise-dossier.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'; @@ -501,4 +504,47 @@ export class AuthService { async logout(userId: string) { return { success: true, message: 'Deconnexion'} } + + /** GET dossier reprise – token seul. Ticket #111 */ + async getRepriseDossier(token: string): Promise { + const user = await this.usersService.findByTokenReprise(token); + if (!user) { + throw new NotFoundException('Token reprise invalide ou expiré.'); + } + return { + id: user.id, + email: user.email, + prenom: user.prenom, + nom: user.nom, + telephone: user.telephone, + adresse: user.adresse, + ville: user.ville, + code_postal: user.code_postal, + numero_dossier: user.numero_dossier, + role: user.role, + photo_url: user.photo_url, + genre: user.genre, + situation_familiale: user.situation_familiale, + }; + } + + /** 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); + } + + /** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */ + async identifyReprise(numero_dossier: string, email: string): Promise { + const user = await this.usersService.findByNumeroDossierAndEmailForReprise(numero_dossier, email); + if (!user || !user.token_reprise) { + throw new NotFoundException('Aucun dossier en reprise trouvé pour ce numéro et cet email.'); + } + return { + type: user.role === RoleType.PARENT ? 'parent' : 'assistante_maternelle', + token: user.token_reprise, + }; + } } diff --git a/backend/src/routes/auth/dto/reprise-dossier.dto.ts b/backend/src/routes/auth/dto/reprise-dossier.dto.ts new file mode 100644 index 0000000..e81c6e4 --- /dev/null +++ b/backend/src/routes/auth/dto/reprise-dossier.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { RoleType } from 'src/entities/users.entity'; + +/** Réponse GET /auth/reprise-dossier – données dossier pour préremplir le formulaire reprise. Ticket #111 */ +export class RepriseDossierDto { + @ApiProperty() + id: string; + + @ApiProperty() + email: string; + + @ApiProperty({ required: false }) + prenom?: string; + + @ApiProperty({ required: false }) + nom?: string; + + @ApiProperty({ required: false }) + telephone?: string; + + @ApiProperty({ required: false }) + adresse?: string; + + @ApiProperty({ required: false }) + ville?: string; + + @ApiProperty({ required: false }) + code_postal?: string; + + @ApiProperty({ required: false }) + numero_dossier?: string; + + @ApiProperty({ enum: RoleType }) + role: RoleType; + + @ApiProperty({ required: false, description: 'Pour AM' }) + photo_url?: string; + + @ApiProperty({ required: false }) + genre?: string; + + @ApiProperty({ required: false }) + situation_familiale?: string; +} diff --git a/backend/src/routes/auth/dto/reprise-identify.dto.ts b/backend/src/routes/auth/dto/reprise-identify.dto.ts new file mode 100644 index 0000000..f37c483 --- /dev/null +++ b/backend/src/routes/auth/dto/reprise-identify.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, MaxLength } from 'class-validator'; + +/** Body POST /auth/reprise-identify – numéro + email pour obtenir token reprise. Ticket #111 */ +export class RepriseIdentifyBodyDto { + @ApiProperty({ example: '2026-000001' }) + @IsString() + @MaxLength(20) + numero_dossier: string; + + @ApiProperty({ example: 'parent@example.com' }) + @IsEmail() + email: string; +} + +/** Réponse POST /auth/reprise-identify */ +export class RepriseIdentifyResponseDto { + @ApiProperty({ enum: ['parent', 'assistante_maternelle'] }) + type: 'parent' | 'assistante_maternelle'; + + @ApiProperty({ description: 'Token à utiliser pour GET reprise-dossier et PUT reprise-resoumettre' }) + token: string; +} diff --git a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts new file mode 100644 index 0000000..efec456 --- /dev/null +++ b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator'; + +/** Body PUT /auth/reprise-resoumettre – token + champs modifiables. Ticket #111 */ +export class ResoumettreRepriseDto { + @ApiProperty({ description: 'Token reprise (reçu par email)' }) + @IsUUID() + token: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + prenom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + nom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(20) + telephone?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiProperty({ required: false, description: 'Pour AM' }) + @IsOptional() + @IsString() + photo_url?: string; +} diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 83b6abb..7e44081 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; -import { In, Repository } from "typeorm"; +import { In, MoreThan, Repository } from "typeorm"; import { CreateUserDto } from "./dto/create_user.dto"; import { CreateAdminDto } from "./dto/create_admin.dto"; import { UpdateUserDto } from "./dto/update_user.dto"; @@ -392,6 +392,52 @@ export class UserService { return savedUser; } + /** Trouve un user par token reprise valide (non expiré). Ticket #111 */ + async findByTokenReprise(token: string): Promise { + return this.usersRepository.findOne({ + where: { + token_reprise: token, + statut: StatutUtilisateurType.REFUSE, + token_reprise_expire_le: MoreThan(new Date()), + }, + }); + } + + /** Resoumission reprise : met à jour les champs autorisés, passe en en_attente, invalide le token. Ticket #111 */ + async resoumettreReprise( + token: string, + dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string }, + ): Promise { + const user = await this.findByTokenReprise(token); + if (!user) { + throw new NotFoundException('Token reprise invalide ou expiré.'); + } + 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; + if (dto.photo_url !== undefined) user.photo_url = dto.photo_url; + user.statut = StatutUtilisateurType.EN_ATTENTE; + user.token_reprise = undefined; + user.token_reprise_expire_le = undefined; + return this.usersRepository.save(user); + } + + /** Pour modale reprise : numero_dossier + email → user en REFUSE avec token valide. Ticket #111 */ + async findByNumeroDossierAndEmailForReprise(numero_dossier: string, email: string): Promise { + const user = await this.usersRepository.findOne({ + where: { + email: email.trim().toLowerCase(), + numero_dossier: numero_dossier.trim(), + statut: StatutUtilisateurType.REFUSE, + token_reprise_expire_le: MoreThan(new Date()), + }, + }); + return user ?? null; + } + async remove(id: string, currentUser: Users): Promise { if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins');