Merge branch 'develop' of https://git.ptits-pas.fr/jmartin/petitspas into develop

This commit is contained in:
MARTIN Julien 2026-03-13 10:52:28 +01:00
commit d832559027
25 changed files with 923 additions and 16 deletions

View File

@ -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;
}

View File

@ -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[];

View File

@ -29,6 +29,7 @@ export enum StatutUtilisateurType {
EN_ATTENTE = 'en_attente',
ACTIF = 'actif',
SUSPENDU = 'suspendu',
REFUSE = 'refuse',
}
export enum SituationFamilialeType {
@ -118,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;
@ -152,6 +160,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;

View File

@ -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<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = this.configService.get<string>('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
? `<p><strong>Message du gestionnaire :</strong></p><p>${comment.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
: '';
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Bonjour ${prenom} ${nom},</h2>
<p>Votre dossier d'inscription sur <strong>${appName}</strong> n'a pas pu être validé en l'état.</p>
${commentBlock}
<p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${repriseLink}" style="background-color: #2196F3; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
</div>
<p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p>
<hr style="border: 1px solid #eee; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">Cet email a é envoyé automatiquement. Merci de ne pas y répondre.</p>
</div>
`;
await this.sendEmail(to, subject, html);
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { NumeroDossierService } from './numero-dossier.service';
@Module({
providers: [NumeroDossierService],
exports: [NumeroDossierService],
})
export class NumeroDossierModule {}

View File

@ -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 };
}
}

View File

@ -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<RepriseDossierDto> {
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')

View File

@ -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) => ({

View File

@ -1,6 +1,7 @@
import {
ConflictException,
Injectable,
NotFoundException,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
@ -22,8 +23,11 @@ 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';
@Injectable()
export class AuthService {
@ -32,6 +36,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<Parents>,
@InjectRepository(Users)
@ -94,6 +99,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);
}
@ -194,6 +205,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 +219,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 +244,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 +252,7 @@ export class AuthService {
const entiteParent = manager.create(Parents, {
user_id: parent1Enregistre.id,
numero_dossier: numeroDossier,
});
entiteParent.user = parent1Enregistre;
if (parent2Enregistre) {
@ -248,6 +264,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 +377,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 +395,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 +409,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);
@ -483,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<RepriseDossierDto> {
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<Users> {
return this.usersService.resoumettreReprise(token, dto);
}
/** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */
async identifyReprise(numero_dossier: string, email: string): Promise<RepriseIdentifyResponseDto> {
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,
};
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,24 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
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, 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)
@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<PendingFamilyDto[]> {
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<Users[]> {
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()

View File

@ -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,

View File

@ -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,96 @@ 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<PendingFamilyDto[]> {
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,
}));
}
/**
* 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<string[]> {
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);
}
}

View File

@ -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;
}

View File

@ -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) { }
@ -48,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)
@ -78,6 +90,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 dattribuer 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' })
@ -93,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' })

View File

@ -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],

View File

@ -1,7 +1,7 @@
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";
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";
@ -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<Users>,
@ -23,7 +27,9 @@ export class UserService {
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly assistantesRepository: Repository<AssistanteMaternelle>
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
private readonly mailService: MailService,
) { }
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
@ -140,6 +146,15 @@ export class UserService {
return this.usersRepository.find({ where });
}
/** Comptes refusés (à corriger) : liste pour reprise par le gestionnaire */
async findRefusedUsers(role?: RoleType): Promise<Users[]> {
const where: any = { statut: StatutUtilisateurType.REFUSE };
if (role) {
where.role = role;
}
return this.usersRepository.find({ where });
}
async findAll(): Promise<Users[]> {
return this.usersRepository.find();
}
@ -214,7 +229,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<Users> {
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 +237,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 +289,155 @@ export class UserService {
await this.validationRepository.save(suspend);
return savedUser;
}
/** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */
async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
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é.');
}
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',
status: StatutValidationType.REFUSE,
validated_by: currentUser,
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;
}
/**
* 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<Users> {
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;
}
/** Trouve un user par token reprise valide (non expiré). Ticket #111 */
async findByTokenReprise(token: string): Promise<Users | null> {
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<Users> {
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<Users | null> {
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<void> {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins');

View File

@ -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');
@ -355,6 +355,27 @@ 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;
-- ==========================================================
-- 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
-- ==========================================================

View File

@ -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;

View File

@ -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 $$;

View File

@ -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';

View File

@ -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;