Merge branch 'develop' (squash) – Numéro de dossier #103 et autres avancements
Made-with: Cursor
This commit is contained in:
parent
aefe590d2c
commit
af489f39b4
@ -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[];
|
||||||
|
|||||||
@ -152,6 +152,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;
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) => ({
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
|
|||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.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,6 +33,7 @@ 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)
|
||||||
@ -194,6 +196,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 +210,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 +235,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 +243,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 +255,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;
|
||||||
@ -360,6 +368,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 +386,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 +400,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);
|
||||||
|
|
||||||
|
|||||||
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) { }
|
||||||
@ -78,6 +80,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' })
|
||||||
|
|||||||
@ -270,6 +270,64 @@ export class UserService {
|
|||||||
await this.validationRepository.save(suspend);
|
await this.validationRepository.save(suspend);
|
||||||
return savedUser;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
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');
|
||||||
|
|||||||
@ -355,6 +355,20 @@ 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;
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- 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 $$;
|
||||||
@ -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 | Ouvert |
|
||||||
|
| 26 | [Backend] API Validation/Refus comptes | Ouvert |
|
||||||
|
| 27 | [Backend] Service Email - Installation Nodemailer | Ouvert |
|
||||||
|
| 28 | [Backend] Templates Email - Validation | Ouvert |
|
||||||
|
| 29 | [Backend] Templates Email - Refus | Ouvert |
|
||||||
|
| 30 | [Backend] Connexion - Vérification statut | Ouvert |
|
||||||
|
| 31 | [Backend] Changement MDP obligatoire première connexion | Ouvert |
|
||||||
|
| 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 | Ouvert |
|
||||||
|
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | Ouvert |
|
||||||
|
| 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 » (plan + sync via `backend/scripts/sync-gitea-validation-tickets.js`).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Vue d'ensemble
|
## 📊 Vue d'ensemble
|
||||||
@ -1412,7 +1477,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)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user