Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6fabc521e | |||
| 5390276ecd | |||
| 7e9306de01 | |||
| d832559027 | |||
| 54ad1d2aa1 | |||
| 86b28abe51 | |||
| 86d8189038 | |||
| dbcb3611d4 | |||
| 1fa70f4052 | |||
| 393a527c37 | |||
| dfd58d9b6c | |||
| 34a36b069e | |||
| 2fa546e6b7 | |||
| 8636b16659 | |||
| 7e17e5ff8d | |||
| e8b6d906e6 | |||
| ae0be04964 | |||
| 447f3d4137 | |||
| 721f40599b | |||
| a9c6b9e15b | |||
| 38c003ef6f | |||
| 3dbddbb8c4 | |||
| f46740c6ab | |||
| 85bfef7a6b | |||
| 3c2ecdff7a | |||
| 8b83702bd2 | |||
| 19b8be684f | |||
| 5950d85876 | |||
| 4339e1e53d | |||
| defa438edf | |||
| e990d576cf | |||
| e8c6665a06 | |||
| a4e6cfc50e | |||
| 80d69a5463 | |||
| 0579fda553 | |||
| d14550a1cf | |||
| 2645cf1cd6 | |||
| e2ebc6a0a1 | |||
| 090ce6e13b | |||
| d66bdd04be | |||
| d8572e7fd6 | |||
| 222d7c702f | |||
| 537c46127f | |||
| ed18dcab10 | |||
| bb92f010bd | |||
| 42bb872c41 | |||
| fac3ae9baa | |||
| 5c28981ac5 | |||
| 57ce5af0f4 | |||
| c1204a3050 | |||
| 9d4363b2a7 | |||
| af06ab1e66 | |||
| aa148354ec | |||
| a10dc5a195 | |||
| 04c0b05aae | |||
| d0b730c8ab | |||
| bc8362bdb7 | |||
| ac3178903d | |||
| aec1990ec9 | |||
| 5da2ab9005 | |||
| b2d6414fab | |||
| fbafef8f2c | |||
| 135c7c2255 | |||
| 9cce326046 | |||
| d697083f54 | |||
| ae786426fd | |||
| e4f7a35f0f | |||
| 8a6768b316 | |||
| 3892a8beab | |||
| d39bc55be3 | |||
| e0debf0394 |
@ -48,4 +48,7 @@ export class AssistanteMaternelle {
|
|||||||
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
|
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
|
||||||
places_available?: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Entity, PrimaryColumn, OneToOne, JoinColumn,
|
Entity, PrimaryColumn, Column, OneToOne, JoinColumn,
|
||||||
ManyToOne, OneToMany
|
ManyToOne, OneToMany
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Users } from './users.entity';
|
import { Users } from './users.entity';
|
||||||
@ -21,6 +21,10 @@ export class Parents {
|
|||||||
@JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' })
|
@JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' })
|
||||||
co_parent?: Users;
|
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
|
// Lien vers enfants via la table enfants_parents
|
||||||
@OneToMany(() => ParentsChildren, pc => pc.parent)
|
@OneToMany(() => ParentsChildren, pc => pc.parent)
|
||||||
parentChildren: ParentsChildren[];
|
parentChildren: ParentsChildren[];
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export enum StatutUtilisateurType {
|
|||||||
EN_ATTENTE = 'en_attente',
|
EN_ATTENTE = 'en_attente',
|
||||||
ACTIF = 'actif',
|
ACTIF = 'actif',
|
||||||
SUSPENDU = 'suspendu',
|
SUSPENDU = 'suspendu',
|
||||||
|
REFUSE = 'refuse',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SituationFamilialeType {
|
export enum SituationFamilialeType {
|
||||||
@ -118,6 +119,13 @@ export class Users {
|
|||||||
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
|
||||||
token_creation_mdp_expire_le?: Date;
|
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' })
|
@Column({ nullable: true, name: 'ville' })
|
||||||
ville?: string;
|
ville?: string;
|
||||||
|
|
||||||
@ -152,6 +160,10 @@ export class Users {
|
|||||||
@Column({ nullable: true, name: 'relais_id' })
|
@Column({ nullable: true, name: 'relais_id' })
|
||||||
relaisId?: string;
|
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 })
|
@ManyToOne(() => Relais, relais => relais.gestionnaires, { nullable: true })
|
||||||
@JoinColumn({ name: 'relais_id' })
|
@JoinColumn({ name: 'relais_id' })
|
||||||
relais?: Relais;
|
relais?: Relais;
|
||||||
|
|||||||
@ -97,4 +97,41 @@ export class MailService {
|
|||||||
|
|
||||||
await this.sendEmail(to, subject, html);
|
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, '<').replace(/>/g, '>')}</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 été envoyé automatiquement. Merci de ne pas y répondre.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.sendEmail(to, subject, html);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NumeroDossierService } from './numero-dossier.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [NumeroDossierService],
|
||||||
|
exports: [NumeroDossierService],
|
||||||
|
})
|
||||||
|
export class NumeroDossierModule {}
|
||||||
55
backend/src/modules/numero-dossier/numero-dossier.service.ts
Normal file
55
backend/src/modules/numero-dossier/numero-dossier.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 { LoginDto } from './dto/login.dto';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { Public } from 'src/common/decorators/public.decorator';
|
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 { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
|
||||||
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
||||||
import { ChangePasswordRequiredDto } from './dto/change-password.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 { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { ProfileResponseDto } from './dto/profile_response.dto';
|
import { ProfileResponseDto } from './dto/profile_response.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh_token.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 { User } from 'src/common/decorators/user.decorator';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
||||||
|
|
||||||
@ApiTags('Authentification')
|
@ApiTags('Authentification')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@ -65,6 +68,35 @@ export class AuthController {
|
|||||||
return this.authService.inscrireAMComplet(dto);
|
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()
|
@Public()
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@ApiBearerAuth('refresh_token')
|
@ApiBearerAuth('refresh_token')
|
||||||
|
|||||||
@ -10,12 +10,14 @@ import { Parents } from 'src/entities/parents.entity';
|
|||||||
import { Children } from 'src/entities/children.entity';
|
import { Children } from 'src/entities/children.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { AppConfigModule } from 'src/modules/config';
|
import { AppConfigModule } from 'src/modules/config';
|
||||||
|
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
|
NumeroDossierModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -22,8 +23,11 @@ import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
|||||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { LoginDto } from './dto/login.dto';
|
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 { AppConfigService } from 'src/modules/config/config.service';
|
||||||
import { validateNir } from 'src/common/utils/nir.util';
|
import { validateNir } from 'src/common/utils/nir.util';
|
||||||
|
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -32,12 +36,15 @@ export class AuthService {
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly appConfigService: AppConfigService,
|
private readonly appConfigService: AppConfigService,
|
||||||
|
private readonly numeroDossierService: NumeroDossierService,
|
||||||
@InjectRepository(Parents)
|
@InjectRepository(Parents)
|
||||||
private readonly parentsRepo: Repository<Parents>,
|
private readonly parentsRepo: Repository<Parents>,
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly usersRepo: Repository<Users>,
|
private readonly usersRepo: Repository<Users>,
|
||||||
@InjectRepository(Children)
|
@InjectRepository(Children)
|
||||||
private readonly childrenRepo: Repository<Children>,
|
private readonly childrenRepo: Repository<Children>,
|
||||||
|
@InjectRepository(AssistanteMaternelle)
|
||||||
|
private readonly assistantesMaternellesRepo: Repository<AssistanteMaternelle>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,6 +101,12 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.');
|
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);
|
return this.generateTokens(user.id, user.email, user.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,6 +191,11 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.co_parent_email) {
|
if (dto.co_parent_email) {
|
||||||
|
if (dto.email.trim().toLowerCase() === dto.co_parent_email.trim().toLowerCase()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'L\'email du parent et du co-parent doivent être différents.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
||||||
if (coParentExiste) {
|
if (coParentExiste) {
|
||||||
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
||||||
@ -194,6 +212,8 @@ export class AuthService {
|
|||||||
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||||
|
|
||||||
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||||
|
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||||
|
|
||||||
const parent1 = manager.create(Users, {
|
const parent1 = manager.create(Users, {
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
@ -206,6 +226,7 @@ export class AuthService {
|
|||||||
ville: dto.ville,
|
ville: dto.ville,
|
||||||
token_creation_mdp: tokenCreationMdp,
|
token_creation_mdp: tokenCreationMdp,
|
||||||
token_creation_mdp_expire_le: dateExpiration,
|
token_creation_mdp_expire_le: dateExpiration,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parent1Enregistre = await manager.save(Users, parent1);
|
const parent1Enregistre = await manager.save(Users, parent1);
|
||||||
@ -230,6 +251,7 @@ export class AuthService {
|
|||||||
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
|
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
|
||||||
token_creation_mdp: tokenCoParent,
|
token_creation_mdp: tokenCoParent,
|
||||||
token_creation_mdp_expire_le: dateExpirationCoParent,
|
token_creation_mdp_expire_le: dateExpirationCoParent,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
|
|
||||||
parent2Enregistre = await manager.save(Users, parent2);
|
parent2Enregistre = await manager.save(Users, parent2);
|
||||||
@ -237,6 +259,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const entiteParent = manager.create(Parents, {
|
const entiteParent = manager.create(Parents, {
|
||||||
user_id: parent1Enregistre.id,
|
user_id: parent1Enregistre.id,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
entiteParent.user = parent1Enregistre;
|
entiteParent.user = parent1Enregistre;
|
||||||
if (parent2Enregistre) {
|
if (parent2Enregistre) {
|
||||||
@ -248,6 +271,7 @@ export class AuthService {
|
|||||||
if (parent2Enregistre) {
|
if (parent2Enregistre) {
|
||||||
const entiteCoParent = manager.create(Parents, {
|
const entiteCoParent = manager.create(Parents, {
|
||||||
user_id: parent2Enregistre.id,
|
user_id: parent2Enregistre.id,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
entiteCoParent.user = parent2Enregistre;
|
entiteCoParent.user = parent2Enregistre;
|
||||||
entiteCoParent.co_parent = parent1Enregistre;
|
entiteCoParent.co_parent = parent1Enregistre;
|
||||||
@ -343,6 +367,27 @@ export class AuthService {
|
|||||||
throw new ConflictException('Un compte avec cet email existe déjà');
|
throw new ConflictException('Un compte avec cet email existe déjà');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nirDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||||||
|
where: { nir: nirNormalized },
|
||||||
|
});
|
||||||
|
if (nirDejaUtilise) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeroAgrement = (dto.numero_agrement || '').trim();
|
||||||
|
if (numeroAgrement) {
|
||||||
|
const agrementDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||||||
|
where: { approval_number: numeroAgrement },
|
||||||
|
});
|
||||||
|
if (agrementDejaUtilise) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const joursExpirationToken = await this.appConfigService.get<number>(
|
const joursExpirationToken = await this.appConfigService.get<number>(
|
||||||
'password_reset_token_expiry_days',
|
'password_reset_token_expiry_days',
|
||||||
7,
|
7,
|
||||||
@ -360,6 +405,8 @@ export class AuthService {
|
|||||||
dto.consentement_photo ? new Date() : undefined;
|
dto.consentement_photo ? new Date() : undefined;
|
||||||
|
|
||||||
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||||
|
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||||
|
|
||||||
const user = manager.create(Users, {
|
const user = manager.create(Users, {
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
@ -376,6 +423,7 @@ export class AuthService {
|
|||||||
consentement_photo: dto.consentement_photo,
|
consentement_photo: dto.consentement_photo,
|
||||||
date_consentement_photo: dateConsentementPhoto,
|
date_consentement_photo: dateConsentementPhoto,
|
||||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
const userEnregistre = await manager.save(Users, user);
|
const userEnregistre = await manager.save(Users, user);
|
||||||
|
|
||||||
@ -389,6 +437,7 @@ export class AuthService {
|
|||||||
residence_city: dto.ville ?? undefined,
|
residence_city: dto.ville ?? undefined,
|
||||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
||||||
available: true,
|
available: true,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
await amRepo.save(am);
|
await amRepo.save(am);
|
||||||
|
|
||||||
@ -483,4 +532,47 @@ export class AuthService {
|
|||||||
async logout(userId: string) {
|
async logout(userId: string) {
|
||||||
return { success: true, message: 'Deconnexion'}
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
backend/src/routes/auth/dto/reprise-dossier.dto.ts
Normal file
44
backend/src/routes/auth/dto/reprise-dossier.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
23
backend/src/routes/auth/dto/reprise-identify.dto.ts
Normal file
23
backend/src/routes/auth/dto/reprise-identify.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
49
backend/src/routes/auth/dto/resoumettre-reprise.dto.ts
Normal file
49
backend/src/routes/auth/dto/resoumettre-reprise.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
import { StatutDossierType } from 'src/entities/dossiers.entity';
|
||||||
|
import { StatutEnfantType } from 'src/entities/children.entity';
|
||||||
|
|
||||||
|
/** Parent dans le dossier famille (infos utilisateur + parent) */
|
||||||
|
export class DossierFamilleParentDto {
|
||||||
|
@ApiProperty()
|
||||||
|
user_id: string;
|
||||||
|
@ApiProperty()
|
||||||
|
email: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
prenom?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
nom?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
telephone?: string;
|
||||||
|
@ApiProperty({ enum: StatutUtilisateurType })
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
@ApiProperty({ required: false, description: 'Id du co-parent si couple' })
|
||||||
|
co_parent_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enfant dans le dossier famille */
|
||||||
|
export class DossierFamilleEnfantDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
first_name?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
last_name?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
birth_date?: Date;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
due_date?: Date;
|
||||||
|
@ApiProperty({ enum: StatutEnfantType })
|
||||||
|
status: StatutEnfantType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dossier (parent+enfant) avec presentation */
|
||||||
|
export class DossierFamillePresentationDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
@ApiProperty()
|
||||||
|
id_parent: string;
|
||||||
|
@ApiProperty()
|
||||||
|
id_enfant: string;
|
||||||
|
@ApiProperty({ required: false, description: 'Texte de présentation' })
|
||||||
|
presentation?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
type_contrat?: string;
|
||||||
|
@ApiProperty()
|
||||||
|
repas: boolean;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
budget?: number;
|
||||||
|
@ApiProperty({ enum: StatutDossierType })
|
||||||
|
statut: StatutDossierType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
||||||
|
export class DossierFamilleCompletDto {
|
||||||
|
@ApiProperty({ example: '2026-000001', description: 'Numéro de dossier famille' })
|
||||||
|
numero_dossier: string;
|
||||||
|
@ApiProperty({ type: [DossierFamilleParentDto] })
|
||||||
|
parents: DossierFamilleParentDto[];
|
||||||
|
@ApiProperty({ type: [DossierFamilleEnfantDto], description: 'Enfants de la famille' })
|
||||||
|
enfants: DossierFamilleEnfantDto[];
|
||||||
|
@ApiProperty({ type: [DossierFamillePresentationDto], description: 'Dossiers (présentation par parent/enfant)' })
|
||||||
|
presentation: DossierFamillePresentationDto[];
|
||||||
|
}
|
||||||
20
backend/src/routes/parents/dto/pending-family.dto.ts
Normal file
20
backend/src/routes/parents/dto/pending-family.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,24 +1,78 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
|
import { UserService } from '../user/user.service';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RoleType } from 'src/entities/users.entity';
|
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||||
import { UpdateParentsDto } from '../user/dto/update_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';
|
||||||
|
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||||
|
|
||||||
@ApiTags('Parents')
|
@ApiTags('Parents')
|
||||||
@Controller('parents')
|
@Controller('parents')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
export class ParentsController {
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('dossier-famille/:numeroDossier')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({ summary: 'Dossier famille complet par numéro de dossier (Ticket #119)' })
|
||||||
|
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Dossier famille (numero_dossier, parents, enfants, presentation)', type: DossierFamilleCompletDto })
|
||||||
|
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
getDossierFamille(@Param('numeroDossier') numeroDossier: string): Promise<DossierFamilleCompletDto> {
|
||||||
|
return this.parentsService.getDossierFamilleByNumero(numeroDossier);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Get()
|
@Get()
|
||||||
|
|||||||
@ -1,12 +1,26 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { ParentsController } from './parents.controller';
|
import { ParentsController } from './parents.controller';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { UserModule } from '../user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Parents, Users])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Parents, Users]),
|
||||||
|
forwardRef(() => UserModule),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get('jwt.accessSecret'),
|
||||||
|
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
controllers: [ParentsController],
|
controllers: [ParentsController],
|
||||||
providers: [ParentsService],
|
providers: [ParentsService],
|
||||||
exports: [ParentsService,
|
exports: [ParentsService,
|
||||||
|
|||||||
@ -5,11 +5,18 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { In, Repository } from 'typeorm';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||||
|
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||||
|
import {
|
||||||
|
DossierFamilleCompletDto,
|
||||||
|
DossierFamilleParentDto,
|
||||||
|
DossierFamilleEnfantDto,
|
||||||
|
DossierFamillePresentationDto,
|
||||||
|
} from './dto/dossier-famille-complet.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ParentsService {
|
export class ParentsService {
|
||||||
@ -71,4 +78,185 @@ export class ParentsService {
|
|||||||
await this.parentsRepository.update(id, dto);
|
await this.parentsRepository.update(id, dto);
|
||||||
return this.findOne(id);
|
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[]> {
|
||||||
|
let raw: { libelle: string; parentIds: unknown; numero_dossier: string | null }[];
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map((r) => ({
|
||||||
|
libelle: r.libelle ?? '',
|
||||||
|
parentIds: this.normalizeParentIds(r.parentIds),
|
||||||
|
numero_dossier: r.numero_dossier ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */
|
||||||
|
private normalizeParentIds(parentIds: unknown): string[] {
|
||||||
|
if (Array.isArray(parentIds)) return parentIds.map(String);
|
||||||
|
if (typeof parentIds === 'string') {
|
||||||
|
const s = parentIds.replace(/^\{|\}$/g, '').trim();
|
||||||
|
return s ? s.split(',').map((x) => x.trim()) : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dossier famille complet par numéro de dossier. Ticket #119.
|
||||||
|
* Rôles : admin, gestionnaire.
|
||||||
|
* @throws NotFoundException si aucun parent avec ce numéro de dossier
|
||||||
|
*/
|
||||||
|
async getDossierFamilleByNumero(numeroDossier: string): Promise<DossierFamilleCompletDto> {
|
||||||
|
const num = numeroDossier?.trim();
|
||||||
|
if (!num) {
|
||||||
|
throw new NotFoundException('Numéro de dossier requis.');
|
||||||
|
}
|
||||||
|
const firstParent = await this.parentsRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
if (!firstParent || !firstParent.user) {
|
||||||
|
throw new NotFoundException('Aucun dossier famille trouvé pour ce numéro.');
|
||||||
|
}
|
||||||
|
const familyUserIds = await this.getFamilyUserIds(firstParent.user_id);
|
||||||
|
const parents = await this.parentsRepository.find({
|
||||||
|
where: { user_id: In(familyUserIds) },
|
||||||
|
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers', 'dossiers.child'],
|
||||||
|
});
|
||||||
|
const enfantsMap = new Map<string, DossierFamilleEnfantDto>();
|
||||||
|
const presentationList: DossierFamillePresentationDto[] = [];
|
||||||
|
for (const p of parents) {
|
||||||
|
// Enfants via parentChildren
|
||||||
|
if (p.parentChildren) {
|
||||||
|
for (const pc of p.parentChildren) {
|
||||||
|
if (pc.child && !enfantsMap.has(pc.child.id)) {
|
||||||
|
enfantsMap.set(pc.child.id, {
|
||||||
|
id: pc.child.id,
|
||||||
|
first_name: pc.child.first_name,
|
||||||
|
last_name: pc.child.last_name,
|
||||||
|
birth_date: pc.child.birth_date,
|
||||||
|
due_date: pc.child.due_date,
|
||||||
|
status: pc.child.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dossiers (présentation)
|
||||||
|
if (p.dossiers) {
|
||||||
|
for (const d of p.dossiers) {
|
||||||
|
presentationList.push({
|
||||||
|
id: d.id,
|
||||||
|
id_parent: p.user_id,
|
||||||
|
id_enfant: d.child?.id ?? '',
|
||||||
|
presentation: d.presentation,
|
||||||
|
type_contrat: d.type_contrat,
|
||||||
|
repas: d.meals,
|
||||||
|
budget: d.budget != null ? Number(d.budget) : undefined,
|
||||||
|
statut: d.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({
|
||||||
|
user_id: p.user_id,
|
||||||
|
email: p.user.email,
|
||||||
|
prenom: p.user.prenom,
|
||||||
|
nom: p.user.nom,
|
||||||
|
telephone: p.user.telephone,
|
||||||
|
statut: p.user.statut,
|
||||||
|
co_parent_id: p.co_parent?.id,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
numero_dossier: num,
|
||||||
|
parents: parentsDto,
|
||||||
|
enfants: Array.from(enfantsMap.values()),
|
||||||
|
presentation: presentationList,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
backend/src/routes/user/dto/affecter-numero-dossier.dto.ts
Normal file
14
backend/src/routes/user/dto/affecter-numero-dossier.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
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 { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
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 { CreateUserDto } from './dto/create_user.dto';
|
||||||
import { CreateAdminDto } from './dto/create_admin.dto';
|
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||||
import { UpdateUserDto } from './dto/update_user.dto';
|
import { UpdateUserDto } from './dto/update_user.dto';
|
||||||
|
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
||||||
|
|
||||||
@ApiTags('Utilisateurs')
|
@ApiTags('Utilisateurs')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(private readonly userService: UserService) { }
|
constructor(private readonly userService: UserService) { }
|
||||||
@ -48,6 +50,16 @@ export class UserController {
|
|||||||
return this.userService.findPendingUsers(role);
|
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)
|
// Lister tous les utilisateurs (super_admin uniquement)
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ -78,6 +90,23 @@ export class UserController {
|
|||||||
return this.userService.updateUser(id, dto, currentUser);
|
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')
|
@Patch(':id/valider')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Valider un compte utilisateur' })
|
@ApiOperation({ summary: 'Valider un compte utilisateur' })
|
||||||
@ -93,6 +122,18 @@ export class UserController {
|
|||||||
return this.userService.validateUser(id, currentUser, comment);
|
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')
|
@Patch(':id/suspendre')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Suspendre un compte utilisateur' })
|
@ApiOperation({ summary: 'Suspendre un compte utilisateur' })
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
|
|||||||
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
|
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||||
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature(
|
imports: [TypeOrmModule.forFeature(
|
||||||
@ -22,6 +23,7 @@ import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
|||||||
ParentsModule,
|
ParentsModule,
|
||||||
AssistantesMaternellesModule,
|
AssistantesMaternellesModule,
|
||||||
GestionnairesModule,
|
GestionnairesModule,
|
||||||
|
MailModule,
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
|
|||||||
@ -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 { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
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 { CreateUserDto } from "./dto/create_user.dto";
|
||||||
import { CreateAdminDto } from "./dto/create_admin.dto";
|
import { CreateAdminDto } from "./dto/create_admin.dto";
|
||||||
import { UpdateUserDto } from "./dto/update_user.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 { StatutValidationType, Validation } from "src/entities/validations.entity";
|
||||||
import { Parents } from "src/entities/parents.entity";
|
import { Parents } from "src/entities/parents.entity";
|
||||||
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
|
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
|
||||||
|
import { MailService } from "src/modules/mail/mail.service";
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
|
private readonly logger = new Logger(UserService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly usersRepository: Repository<Users>,
|
private readonly usersRepository: Repository<Users>,
|
||||||
@ -23,7 +27,9 @@ export class UserService {
|
|||||||
private readonly parentsRepository: Repository<Parents>,
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
|
|
||||||
@InjectRepository(AssistanteMaternelle)
|
@InjectRepository(AssistanteMaternelle)
|
||||||
private readonly assistantesRepository: Repository<AssistanteMaternelle>
|
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
|
||||||
|
|
||||||
|
private readonly mailService: MailService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
||||||
@ -140,6 +146,15 @@ export class UserService {
|
|||||||
return this.usersRepository.find({ where });
|
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[]> {
|
async findAll(): Promise<Users[]> {
|
||||||
return this.usersRepository.find();
|
return this.usersRepository.find();
|
||||||
}
|
}
|
||||||
@ -214,7 +229,7 @@ export class UserService {
|
|||||||
return this.usersRepository.save(user);
|
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> {
|
async validateUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
|
||||||
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
|
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 } });
|
const user = await this.usersRepository.findOne({ where: { id: user_id } });
|
||||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
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;
|
user.statut = StatutUtilisateurType.ACTIF;
|
||||||
const savedUser = await this.usersRepository.save(user);
|
const savedUser = await this.usersRepository.save(user);
|
||||||
if (user.role === RoleType.PARENT) {
|
if (user.role === RoleType.PARENT) {
|
||||||
@ -270,6 +289,155 @@ export class UserService {
|
|||||||
await this.validationRepository.save(suspend);
|
await this.validationRepository.save(suspend);
|
||||||
return savedUser;
|
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> {
|
async remove(id: string, currentUser: Users): Promise<void> {
|
||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
|
|||||||
@ -11,7 +11,7 @@ DO $$ BEGIN
|
|||||||
CREATE TYPE genre_type AS ENUM ('H', 'F');
|
CREATE TYPE genre_type AS ENUM ('H', 'F');
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
|
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');
|
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 privacy_acceptee_le TIMESTAMPTZ,
|
||||||
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
|
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
|
-- Seed : Documents légaux génériques v1
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
|
|||||||
33
database/migrations/2026_numero_dossier.sql
Normal file
33
database/migrations/2026_numero_dossier.sql
Normal 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;
|
||||||
122
database/migrations/2026_numero_dossier_backfill.sql
Normal file
122
database/migrations/2026_numero_dossier_backfill.sql
Normal 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 $$;
|
||||||
4
database/migrations/2026_statut_utilisateur_refuse.sql
Normal file
4
database/migrations/2026_statut_utilisateur_refuse.sql
Normal 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';
|
||||||
10
database/migrations/2026_token_reprise_refus.sql
Normal file
10
database/migrations/2026_token_reprise_refus.sql
Normal 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;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
||||||
|
|
||||||
**Version** : 1.5
|
**Version** : 1.6
|
||||||
**Date** : 24 Février 2026
|
**Date** : 25 Février 2026
|
||||||
**Auteur** : Équipe PtitsPas
|
**Auteur** : Équipe PtitsPas
|
||||||
**Estimation totale** : ~208h
|
**Estimation totale** : ~208h
|
||||||
|
|
||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
## 🔗 Liste des tickets Gitea
|
## 🔗 Liste des tickets Gitea
|
||||||
|
|
||||||
**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 9 février 2026).
|
**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 25 février 2026).
|
||||||
|
|
||||||
| Gitea # | Titre (dépôt) | Statut |
|
| Gitea # | Titre (dépôt) | Statut |
|
||||||
|--------|----------------|--------|
|
|--------|----------------|--------|
|
||||||
@ -25,21 +25,86 @@
|
|||||||
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
|
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
|
||||||
| 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé |
|
| 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé |
|
||||||
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
|
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
|
||||||
| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
|
| 15 | [Frontend] Écran Paramètres (accès permanent) / Intégration panneau | Ouvert |
|
||||||
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
|
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
|
||||||
| 17 | [Backend] API Création gestionnaire | ✅ Terminé |
|
| 17 | [Backend] API Création gestionnaire | ✅ Terminé |
|
||||||
|
| 18 | [Backend] API Inscription Parent - REFONTE (Workflow complet 6 étapes) | ✅ Terminé |
|
||||||
|
| 19 | [Backend] API Inscription Parent (étape 2 - Parent 2) | ✅ Terminé |
|
||||||
|
| 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé |
|
||||||
|
| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé |
|
||||||
|
| 24 | [Backend] API Création mot de passe | Ouvert |
|
||||||
|
| 25 | [Backend] API Liste comptes en attente | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 26 | [Backend] API Validation/Refus comptes | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 27 | [Backend] Service Email - Installation Nodemailer | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 28 | [Backend] Templates Email - Validation | Ouvert |
|
||||||
|
| 29 | [Backend] Templates Email - Refus | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 30 | [Backend] Connexion - Vérification statut | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 31 | [Backend] Changement MDP obligatoire première connexion | ✅ Terminé |
|
||||||
|
| 32 | [Backend] Service Documents Légaux | Ouvert |
|
||||||
|
| 33 | [Backend] API Documents Légaux | Ouvert |
|
||||||
|
| 34 | [Backend] Traçabilité acceptations documents | Ouvert |
|
||||||
|
| 35 | [Frontend] Écran Création Gestionnaire | Ouvert |
|
||||||
|
| 36 | [Frontend] Inscription Parent - Étape 1 (Parent 1) | ✅ Terminé |
|
||||||
|
| 37 | [Frontend] Inscription Parent - Étape 2 (Parent 2) | Ouvert |
|
||||||
|
| 38 | [Frontend] Inscription Parent - Étape 3 (Enfants) | ✅ Terminé |
|
||||||
|
| 39 | [Frontend] Inscription Parent - Étapes 4-6 (Finalisation) | ✅ Terminé |
|
||||||
|
| 40 | [Frontend] Inscription AM - Panneau 1 (Identité) | ✅ Terminé |
|
||||||
|
| 41 | [Frontend] Inscription AM - Panneau 2 (Infos pro) | ✅ Terminé |
|
||||||
|
| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé |
|
||||||
|
| 43 | [Frontend] Écran Création Mot de Passe | Ouvert |
|
||||||
|
| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé |
|
||||||
|
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert |
|
||||||
|
| 48 | [Frontend] Gestion Erreurs & Messages | Ouvert |
|
||||||
|
| 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert |
|
||||||
|
| 50 | [Frontend] Affichage dynamique CGU lors inscription | Ouvert |
|
||||||
|
| 51 | [Frontend] Écran Logs Admin (optionnel v1.1) | Ouvert |
|
||||||
|
| 52 | [Tests] Tests unitaires Backend | Ouvert |
|
||||||
|
| 53 | [Tests] Tests intégration Backend | Ouvert |
|
||||||
|
| 54 | [Tests] Tests E2E Frontend | Ouvert |
|
||||||
|
| 55 | [Doc] Documentation API OpenAPI/Swagger | Ouvert |
|
||||||
|
| 56 | [Backend] Service Upload & Stockage fichiers | Ouvert |
|
||||||
|
| 58 | [Backend] Service Logging (Winston) | Ouvert |
|
||||||
|
| 59 | [Infra] Volume Docker pour uploads | Ouvert |
|
||||||
|
| 60 | [Infra] Volume Docker pour documents légaux | Ouvert |
|
||||||
|
| 61 | [Doc] Guide installation & configuration | Ouvert |
|
||||||
|
| 62 | [Doc] Amendement CDC v1.4 - Suppression SMS | Ouvert |
|
||||||
|
| 63 | [Doc] Rédaction CGU/Privacy génériques v1 | Ouvert |
|
||||||
|
| 78 | [Frontend] Refonte Infrastructure Formulaires Multi-modes | ✅ Terminé |
|
||||||
|
| 79 | [Frontend] Renommer "Nanny" en "Assistante Maternelle" (AM) | ✅ Terminé |
|
||||||
|
| 81 | [Frontend] Corrections suite refactoring widgets | ✅ Terminé |
|
||||||
|
| 83 | [Frontend] Adapter RegisterChoiceScreen pour mobile | ✅ Terminé |
|
||||||
|
| 86 / 88 | Doublons fermés (voir #12, #14, #15) | ✅ Fermé |
|
||||||
|
| 89 | Log des appels API en mode debug | Ouvert |
|
||||||
| 91 | [Frontend] Inscription AM – Branchement soumission formulaire à l'API | Ouvert |
|
| 91 | [Frontend] Inscription AM – Branchement soumission formulaire à l'API | Ouvert |
|
||||||
| 101 | [Frontend] Inscription Parent – Branchement soumission formulaire à l'API | Ouvert |
|
|
||||||
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé |
|
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé |
|
||||||
| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | ✅ Fermé |
|
| 93 | [Frontend] Panneau Admin - Homogénéisation des onglets | ✅ Fermé |
|
||||||
| 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé |
|
| 94 | [Backend] Relais - Modèle, API CRUD et liaison gestionnaire | ✅ Terminé |
|
||||||
| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | ✅ Fermé |
|
| 95 | [Frontend] Admin - Gestion des Relais et rattachement gestionnaire | ✅ Fermé |
|
||||||
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
|
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
|
||||||
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
|
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
|
||||||
| 89 | Log des appels API en mode debug | Ouvert |
|
| 101 | [Frontend] Inscription Parent – Branchement soumission formulaire à l'API | Ouvert |
|
||||||
|
| 103 | Numéro de dossier – backend | Ouvert |
|
||||||
|
| 104 | Numéro de dossier – frontend | Ouvert |
|
||||||
|
| 105 | Statut « refusé » | Ouvert |
|
||||||
|
| 106 | Liste familles en attente | Ouvert |
|
||||||
|
| 107 | Onglet « À valider » + listes | Ouvert |
|
||||||
|
| 108 | Validation dossier famille | Ouvert |
|
||||||
|
| 109 | Modale de validation | Ouvert |
|
||||||
|
| 110 | Refus sans suppression | Ouvert |
|
||||||
|
| 111 | Reprise après refus – backend | Ouvert |
|
||||||
|
| 112 | Reprise après refus – frontend | Ouvert |
|
||||||
|
| 113 | Doublons à l'inscription | Ouvert |
|
||||||
|
| 114 | Doublons – alerte gestionnaire | Ouvert |
|
||||||
|
| 115 | Rattachement parent – backend | Ouvert |
|
||||||
|
| 116 | Rattachement parent – frontend | Ouvert |
|
||||||
|
| 117 | Évolution du cahier des charges | Ouvert |
|
||||||
|
|
||||||
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
||||||
|
|
||||||
|
*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (voir plan de spec).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Vue d'ensemble
|
## 📊 Vue d'ensemble
|
||||||
@ -576,17 +641,18 @@ Modifier l'endpoint de connexion pour bloquer les comptes en attente ou suspendu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion
|
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion ✅
|
||||||
**Estimation** : 2h
|
**Estimation** : 2h
|
||||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||||
|
**Statut** : ✅ TERMINÉ
|
||||||
|
|
||||||
**Description** :
|
**Description** :
|
||||||
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
|
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
|
||||||
|
|
||||||
**Tâches** :
|
**Tâches** :
|
||||||
- [ ] Endpoint `POST /api/v1/auth/change-password-required`
|
- [x] Endpoint `POST /api/v1/auth/change-password-required`
|
||||||
- [ ] Vérification flag `changement_mdp_obligatoire`
|
- [x] Vérification flag `changement_mdp_obligatoire`
|
||||||
- [ ] Mise à jour flag après changement
|
- [x] Mise à jour flag après changement
|
||||||
- [ ] Tests unitaires
|
- [ ] Tests unitaires
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -1412,7 +1478,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Dernière mise à jour** : 24 Février 2026
|
**Dernière mise à jour** : 25 Février 2026
|
||||||
**Version** : 1.6
|
**Version** : 1.6
|
||||||
**Statut** : ✅ Aligné avec le dépôt Gitea
|
**Statut** : ✅ Aligné avec le dépôt Gitea (tickets #103-#117 créés)
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../models/am_registration_data.dart';
|
import '../../models/am_registration_data.dart';
|
||||||
import '../../utils/data_generator.dart';
|
|
||||||
import '../../widgets/personal_info_form_screen.dart';
|
import '../../widgets/personal_info_form_screen.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
|
|
||||||
@ -14,19 +13,17 @@ class AmRegisterStep1Screen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
||||||
|
|
||||||
// Générer des données de test si vide
|
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
|
||||||
PersonalInfoData initialData;
|
PersonalInfoData initialData;
|
||||||
if (registrationData.firstName.isEmpty) {
|
if (registrationData.firstName.isEmpty) {
|
||||||
final genFirstName = DataGenerator.firstName();
|
|
||||||
final genLastName = DataGenerator.lastName();
|
|
||||||
initialData = PersonalInfoData(
|
initialData = PersonalInfoData(
|
||||||
firstName: genFirstName,
|
firstName: 'Marie',
|
||||||
lastName: genLastName,
|
lastName: 'DUBOIS',
|
||||||
phone: DataGenerator.phone(),
|
phone: '0696345678',
|
||||||
email: DataGenerator.email(genFirstName, genLastName),
|
email: 'marie.dubois@ptits-pas.fr',
|
||||||
address: DataGenerator.address(),
|
address: '25 Rue de la République',
|
||||||
postalCode: DataGenerator.postalCode(),
|
postalCode: '95870',
|
||||||
city: DataGenerator.city(),
|
city: 'Bezons',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
initialData = PersonalInfoData(
|
initialData = PersonalInfoData(
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import 'dart:io';
|
|||||||
|
|
||||||
import '../../models/am_registration_data.dart';
|
import '../../models/am_registration_data.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
import '../../utils/data_generator.dart';
|
|
||||||
import '../../widgets/professional_info_form_screen.dart';
|
import '../../widgets/professional_info_form_screen.dart';
|
||||||
|
|
||||||
class AmRegisterStep2Screen extends StatefulWidget {
|
class AmRegisterStep2Screen extends StatefulWidget {
|
||||||
@ -54,17 +53,17 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
|
|||||||
capacity: registrationData.capacity,
|
capacity: registrationData.capacity,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Générer des données de test si les champs sont vides (NIR = Marie Dubois du seed, Corse 2A)
|
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
|
||||||
if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) {
|
if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) {
|
||||||
initialData = ProfessionalInfoData(
|
initialData = ProfessionalInfoData(
|
||||||
photoPath: 'assets/images/icon_assmat.png',
|
photoPath: 'assets/images/icon_assmat.png',
|
||||||
photoConsent: true,
|
photoConsent: true,
|
||||||
dateOfBirth: DateTime(1980, 6, 8),
|
dateOfBirth: DateTime(1980, 6, 8),
|
||||||
birthCity: 'Ajaccio',
|
birthCity: 'Bezons',
|
||||||
birthCountry: 'France',
|
birthCountry: 'France',
|
||||||
nir: '280062A00100191',
|
nir: '280062A00100191',
|
||||||
agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}',
|
agrementNumber: 'AGR-2019-095001',
|
||||||
capacity: DataGenerator.randomIntInRange(1, 5),
|
capacity: 4,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,12 +13,12 @@ class AmRegisterStep3Screen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = Provider.of<AmRegistrationData>(context, listen: false);
|
final data = Provider.of<AmRegistrationData>(context, listen: false);
|
||||||
|
|
||||||
// Générer un texte de test si vide
|
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
|
||||||
String initialText = data.presentationText;
|
String initialText = data.presentationText;
|
||||||
bool initialCgu = data.cguAccepted;
|
bool initialCgu = data.cguAccepted;
|
||||||
|
|
||||||
if (initialText.isEmpty) {
|
if (initialText.isEmpty) {
|
||||||
initialText = 'Disponible immédiatement, plus de 10 ans d\'expérience avec les tout-petits. Formation aux premiers secours à jour. Je dispose d\'un jardin sécurisé et d\'un espace de jeu adapté.';
|
initialText = 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.';
|
||||||
initialCgu = true;
|
initialCgu = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import 'dart:math' as math;
|
|||||||
import '../../models/am_registration_data.dart';
|
import '../../models/am_registration_data.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
import '../../config/display_config.dart';
|
import '../../config/display_config.dart';
|
||||||
|
import '../../services/auth_service.dart';
|
||||||
import '../../widgets/hover_relief_widget.dart';
|
import '../../widgets/hover_relief_widget.dart';
|
||||||
import '../../widgets/image_button.dart';
|
import '../../widgets/image_button.dart';
|
||||||
import '../../widgets/custom_navigation_button.dart';
|
import '../../widgets/custom_navigation_button.dart';
|
||||||
@ -22,6 +23,28 @@ class AmRegisterStep4Screen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
|
||||||
|
if (_isSubmitting) return;
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
try {
|
||||||
|
await AuthService.registerAM(registrationData);
|
||||||
|
if (!mounted) return;
|
||||||
|
_showConfirmationModal(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur lors de l\'inscription'),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final registrationData = Provider.of<AmRegistrationData>(context);
|
final registrationData = Provider.of<AmRegistrationData>(context);
|
||||||
@ -90,12 +113,9 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: HoverReliefWidget(
|
child: HoverReliefWidget(
|
||||||
child: CustomNavigationButton(
|
child: CustomNavigationButton(
|
||||||
text: 'Soumettre',
|
text: _isSubmitting ? 'Envoi...' : 'Soumettre',
|
||||||
style: NavigationButtonStyle.green,
|
style: NavigationButtonStyle.green,
|
||||||
onPressed: () {
|
onPressed: () => _submitAMRegistration(registrationData),
|
||||||
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
|
|
||||||
_showConfirmationModal(context);
|
|
||||||
},
|
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@ -106,17 +126,14 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ImageButton(
|
ImageButton(
|
||||||
bg: 'assets/images/bg_green.png',
|
bg: 'assets/images/bg_green.png',
|
||||||
text: 'Soumettre ma demande',
|
text: _isSubmitting ? 'Envoi...' : 'Soumettre ma demande',
|
||||||
textColor: const Color(0xFF2D6A4F),
|
textColor: const Color(0xFF2D6A4F),
|
||||||
width: 350,
|
width: 350,
|
||||||
height: 50,
|
height: 50,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
onPressed: () {
|
onPressed: () => _submitAMRegistration(registrationData),
|
||||||
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
|
|
||||||
_showConfirmationModal(context);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
|
import '../models/am_registration_data.dart';
|
||||||
import 'api/api_config.dart';
|
import 'api/api_config.dart';
|
||||||
import 'api/tokenService.dart';
|
import 'api/tokenService.dart';
|
||||||
|
import '../utils/nir_utils.dart';
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
static const String _currentUserKey = 'current_user';
|
static const String _currentUserKey = 'current_user';
|
||||||
@ -133,6 +136,70 @@ class AuthService {
|
|||||||
await prefs.setString(_currentUserKey, jsonEncode(user.toJson()));
|
await prefs.setString(_currentUserKey, jsonEncode(user.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inscription AM complète (POST /auth/register/am).
|
||||||
|
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
|
||||||
|
static Future<void> registerAM(AmRegistrationData data) async {
|
||||||
|
String? photoBase64;
|
||||||
|
if (data.photoPath != null && data.photoPath!.isNotEmpty && !data.photoPath!.startsWith('assets/')) {
|
||||||
|
try {
|
||||||
|
final file = File(data.photoPath!);
|
||||||
|
if (await file.exists()) {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}';
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = {
|
||||||
|
'email': data.email,
|
||||||
|
'prenom': data.firstName,
|
||||||
|
'nom': data.lastName,
|
||||||
|
'telephone': data.phone,
|
||||||
|
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
|
||||||
|
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
|
||||||
|
'ville': data.city.isNotEmpty ? data.city : null,
|
||||||
|
if (photoBase64 != null) 'photo_base64': photoBase64,
|
||||||
|
'consentement_photo': data.photoConsent,
|
||||||
|
'date_naissance': data.dateOfBirth != null
|
||||||
|
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
|
||||||
|
: null,
|
||||||
|
'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null,
|
||||||
|
'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null,
|
||||||
|
'nir': normalizeNir(data.nir),
|
||||||
|
'numero_agrement': data.agrementNumber,
|
||||||
|
'capacite_accueil': data.capacity ?? 1,
|
||||||
|
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
|
||||||
|
'acceptation_cgu': data.cguAccepted,
|
||||||
|
'acceptation_privacy': data.cguAccepted,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'),
|
||||||
|
headers: ApiConfig.headers,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null;
|
||||||
|
final message = _extractErrorMessage(decoded, response.statusCode);
|
||||||
|
throw Exception(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet).
|
||||||
|
static String _extractErrorMessage(dynamic decoded, int statusCode) {
|
||||||
|
const fallback = 'Erreur lors de l\'inscription';
|
||||||
|
if (decoded == null || decoded is! Map) return '$fallback ($statusCode)';
|
||||||
|
final msg = decoded['message'];
|
||||||
|
if (msg == null) return decoded['error'] as String? ?? '$fallback ($statusCode)';
|
||||||
|
if (msg is String) return msg;
|
||||||
|
if (msg is List) return msg.map((e) => e.toString()).join('. ').trim();
|
||||||
|
if (msg is Map && msg['message'] != null) return msg['message'].toString();
|
||||||
|
return '$fallback ($statusCode)';
|
||||||
|
}
|
||||||
|
|
||||||
/// Rafraîchit le profil utilisateur depuis l'API
|
/// Rafraîchit le profil utilisateur depuis l'API
|
||||||
static Future<AppUser?> refreshCurrentUser() async {
|
static Future<AppUser?> refreshCurrentUser() async {
|
||||||
final token = await TokenService.getToken();
|
final token = await TokenService.getToken();
|
||||||
|
|||||||
@ -49,15 +49,11 @@ String nirToRaw(String normalized) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34
|
/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 (Corse).
|
||||||
String formatNir(String raw) {
|
String formatNir(String raw) {
|
||||||
final r = nirToRaw(raw);
|
final r = nirToRaw(raw);
|
||||||
if (r.length < 15) return r;
|
if (r.length < 15) return r;
|
||||||
final dept = r.substring(5, 7);
|
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
|
||||||
final isCorsica = dept == '2A' || dept == '2B';
|
|
||||||
if (isCorsica) {
|
|
||||||
return '${r.substring(0, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}';
|
|
||||||
}
|
|
||||||
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}';
|
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,7 +63,7 @@ bool _isFormatValid(String raw) {
|
|||||||
final dept = raw.substring(5, 7);
|
final dept = raw.substring(5, 7);
|
||||||
final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
|
final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
|
||||||
if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
|
if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
|
||||||
return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{6}$').hasMatch(raw);
|
return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{8}$').hasMatch(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A→19, 2B→18.
|
/// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A→19, 2B→18.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user