diff --git a/backend/src/entities/am_children.entity.ts b/backend/src/entities/am_children.entity.ts new file mode 100644 index 0000000..06d209b --- /dev/null +++ b/backend/src/entities/am_children.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { AssistanteMaternelle } from './assistantes_maternelles.entity'; +import { Children } from './children.entity'; +import { Users } from './users.entity'; + +@Entity('enfants_assistantes_maternelles', { schema: 'public' }) +export class AmChildren { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'id_am', type: 'uuid' }) + amId: string; + + @Column({ name: 'id_enfant', type: 'uuid' }) + enfantId: string; + + @Column({ name: 'date_debut', type: 'date' }) + date_debut: Date; + + @Column({ name: 'date_fin', type: 'date', nullable: true }) + date_fin?: Date; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @Column({ name: 'cree_par', type: 'uuid', nullable: true }) + cree_par?: string; + + @ManyToOne(() => AssistanteMaternelle, (am) => am.amChildren, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_am', referencedColumnName: 'user_id' }) + am: AssistanteMaternelle; + + @ManyToOne(() => Children, (c) => c.amLinks, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' }) + child: Children; + + @ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'cree_par', referencedColumnName: 'id' }) + createdBy?: Users; +} diff --git a/backend/src/entities/assistantes_maternelles.entity.ts b/backend/src/entities/assistantes_maternelles.entity.ts index 77db5f1..65d46b8 100644 --- a/backend/src/entities/assistantes_maternelles.entity.ts +++ b/backend/src/entities/assistantes_maternelles.entity.ts @@ -1,5 +1,6 @@ -import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm'; +import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm'; import { Users } from './users.entity'; +import { AmChildren } from './am_children.entity'; @Entity('assistantes_maternelles') export class AssistanteMaternelle { @@ -51,4 +52,7 @@ export class AssistanteMaternelle { /** 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; + + @OneToMany(() => AmChildren, (ac) => ac.am) + amChildren: AmChildren[]; } diff --git a/backend/src/entities/children.entity.ts b/backend/src/entities/children.entity.ts index 3dd691f..3c1db67 100644 --- a/backend/src/entities/children.entity.ts +++ b/backend/src/entities/children.entity.ts @@ -4,12 +4,14 @@ import { } from 'typeorm'; import { Parents } from './parents.entity'; import { ParentsChildren } from './parents_children.entity'; +import { AmChildren } from './am_children.entity'; import { Dossier } from './dossiers.entity'; export enum StatutEnfantType { A_NAITRE = 'a_naitre', - ACTIF = 'actif', SCOLARISE = 'scolarise', + GARDE = 'garde', + SANS_GARDE = 'sans_garde', } export enum GenreType { @@ -68,6 +70,9 @@ export class Children { @OneToMany(() => ParentsChildren, pc => pc.child) parentLinks: ParentsChildren[]; + @OneToMany(() => AmChildren, (ac) => ac.child) + amLinks: AmChildren[]; + // Relation avec Dossier @OneToMany(() => Dossier, d => d.child) dossiers: Dossier[]; diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts index 47d51eb..540c73e 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts @@ -12,11 +12,14 @@ import { AssistantesMaternellesService } from './assistantes_maternelles.service import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { Roles } from 'src/common/decorators/roles.decorator'; -import { RoleType } from 'src/entities/users.entity'; +import { RoleType, Users } from 'src/entities/users.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; +import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { AuthGuard } from 'src/common/guards/auth.guard'; +import { User } from 'src/common/decorators/user.decorator'; +import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper'; @ApiTags("Assistantes Maternelles") @ApiBearerAuth('access-token') @@ -31,28 +34,74 @@ export class AssistantesMaternellesController { @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) @ApiBody({ type: CreateAssistanteDto }) @Post() - create(@Body() dto: CreateAssistanteDto): Promise { - return this.assistantesMaternellesService.create(dto); + async create(@Body() dto: CreateAssistanteDto): Promise { + const am = await this.assistantesMaternellesService.create(dto); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() - @ApiOperation({ summary: 'Récupérer la liste des nounous' }) + @ApiOperation({ summary: 'Récupérer la liste des nounous (inclut amChildren actifs) — ticket #131' }) @ApiResponse({ status: 200, description: 'Liste des nounous' }) @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) - getAll(): Promise { - return this.assistantesMaternellesService.findAll(); + async getAll(): Promise { + const ams = await this.assistantesMaternellesService.findAll(); + return mapAmsForApi(ams); } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get(':id') @ApiParam({ name: 'id', description: "UUID de la nounou" }) - @ApiOperation({ summary: 'Récupérer une nounou par id' }) + @ApiOperation({ summary: 'Récupérer une nounou par id (inclut amChildren) — ticket #131' }) @ApiResponse({ status: 200, description: 'Détails de la nounou' }) @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) - @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) - getOne(@Param('id') user_id: string): Promise { - return this.assistantesMaternellesService.findOne(user_id); + @ApiResponse({ status: 403, description: 'Accès refusé' }) + async getOne(@Param('id') user_id: string): Promise { + const am = await this.assistantesMaternellesService.findOne(user_id); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Patch(':id/fiche') + @ApiBody({ type: UpdateAmFicheAdminDto }) + @ApiOperation({ summary: 'Mettre à jour la fiche AM (identité + pro) — ticket #131' }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiResponse({ status: 200, description: 'Fiche AM mise à jour' }) + async updateFicheAdmin( + @Param('id') id: string, + @Body() dto: UpdateAmFicheAdminDto, + ): Promise { + const am = await this.assistantesMaternellesService.updateFicheAdmin(id, dto); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Post(':id/enfants/:enfantId') + @ApiOperation({ summary: 'Rattacher un enfant à une AM (statut enfant → garde) — ticket #131' }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' }) + async attachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + @User() currentUser: Users, + ): Promise { + const am = await this.assistantesMaternellesService.attachEnfant(id, enfantId, currentUser); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Delete(':id/enfants/:enfantId') + @ApiOperation({ summary: "Clôturer le placement d'un enfant chez une AM — ticket #131" }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' }) + async detachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + ): Promise { + const am = await this.assistantesMaternellesService.detachEnfant(id, enfantId); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -63,14 +112,15 @@ export class AssistantesMaternellesController { @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) @ApiParam({ name: 'id', description: "UUID de la nounou" }) @Patch(':id') - update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise { - return this.assistantesMaternellesService.update(id, dto); + async update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise { + const am = await this.assistantesMaternellesService.update(id, dto); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Supprimer une nounou' }) @ApiResponse({ status: 200, description: 'Nounou supprimée avec succès' }) - @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins, gestionnaires et administrateurs' }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) @ApiParam({ name: 'id', description: "UUID de la nounou" }) @Delete(':id') diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts new file mode 100644 index 0000000..cc33c4c --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts @@ -0,0 +1,28 @@ +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api'; + +/** + * Sérialisation API fiche AM — ticket #131. + * Expose `amChildren` actifs (date_fin null) avec enfant imbriqué, sans secrets user. + */ +export function mapAmForApi(am: AssistanteMaternelle): AssistanteMaternelle { + const activeChildren = (am.amChildren ?? []).filter((link) => !link.date_fin); + + return { + ...am, + user: sanitizeUserForApi(am.user)!, + amChildren: activeChildren.map((link) => ({ + ...link, + child: link.child, + })), + }; +} + +export function mapAmsForApi(ams: AssistanteMaternelle[]): AssistanteMaternelle[] { + return ams.map(mapAmForApi); +} + +export function filterActiveAmChildren(links: AmChildren[] | undefined): AmChildren[] { + return (links ?? []).filter((link) => !link.date_fin); +} diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts index 9e2e231..54a10ae 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts @@ -2,12 +2,14 @@ import { Module } from '@nestjs/common'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { AssistantesMaternellesController } from './assistantes_maternelles.controller'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { Children } from 'src/entities/children.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Users } from 'src/entities/users.entity'; import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([AssistanteMaternelle, Users]), + imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]), AuthModule ], controllers: [AssistantesMaternellesController], diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts index c9ba68f..9949126 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts @@ -5,11 +5,16 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { RoleType, Users } from 'src/entities/users.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; +import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; + +const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const; @Injectable() export class AssistantesMaternellesService { @@ -17,10 +22,13 @@ export class AssistantesMaternellesService { @InjectRepository(AssistanteMaternelle) private readonly assistantesMaternelleRepository: Repository, @InjectRepository(Users) - private readonly usersRepository: Repository + private readonly usersRepository: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepository: Repository, + @InjectRepository(Children) + private readonly childrenRepository: Repository, ) {} - // Création d’une assistante maternelle async create(dto: CreateAssistanteDto): Promise { const user = await this.usersRepository.findOneBy({ id: dto.user_id }); if (!user) throw new NotFoundException('Utilisateur introuvable'); @@ -49,30 +57,167 @@ export class AssistantesMaternellesService { return this.assistantesMaternelleRepository.save(entity); } - // Liste des assistantes maternelles async findAll(): Promise { return this.assistantesMaternelleRepository.find({ - relations: ['user'], + relations: [...AM_CHILDREN_RELATIONS], }); } - // Récupérer une assistante maternelle par user_id async findOne(user_id: string): Promise { const assistante = await this.assistantesMaternelleRepository.findOne({ where: { user_id }, - relations: ['user'], + relations: [...AM_CHILDREN_RELATIONS], }); if (!assistante) throw new NotFoundException('Assistante maternelle introuvable'); return assistante; } - // Mise à jour async update(id: string, dto: UpdateAssistanteDto): Promise { await this.assistantesMaternelleRepository.update(id, dto); return this.findOne(id); } - // Suppression d’une assistante maternelle + /** + * Mise à jour fiche AM (identité + champs pro) par admin/gestionnaire. Ticket #131. + */ + async updateFicheAdmin(amUserId: string, dto: UpdateAmFicheAdminDto): Promise { + const am = await this.findOne(amUserId); + const user = am.user; + + if (dto.email && dto.email !== user.email) { + const existing = await this.usersRepository.findOne({ where: { email: dto.email } }); + if (existing && existing.id !== user.id) { + throw new ConflictException('Cet email est déjà utilisé'); + } + user.email = dto.email; + } + + if (dto.nom !== undefined) user.nom = dto.nom; + if (dto.prenom !== undefined) user.prenom = dto.prenom; + 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.statut !== undefined) user.statut = dto.statut; + + await this.usersRepository.save(user); + + const amPatch: Partial = {}; + if (dto.approval_number !== undefined) amPatch.approval_number = dto.approval_number; + if (dto.residence_city !== undefined) amPatch.residence_city = dto.residence_city; + if (dto.max_children !== undefined) amPatch.max_children = dto.max_children; + if (dto.places_available !== undefined) amPatch.places_available = dto.places_available; + if (dto.biography !== undefined) amPatch.biography = dto.biography; + if (dto.available !== undefined) amPatch.available = dto.available; + + if (Object.keys(amPatch).length > 0) { + await this.assistantesMaternelleRepository.update(amUserId, amPatch); + } + + return this.findOne(amUserId); + } + + /** + * Rattacher un enfant à une AM (placement actif). Ticket #131. + * Passe le statut enfant à `garde` (sauf a_naitre / scolarise). + */ + async attachEnfant(amUserId: string, enfantId: string, createdBy?: Users): Promise { + const am = await this.findOne(amUserId); + + const existingForAm = await this.amChildrenRepository.findOne({ + where: { amId: amUserId, enfantId, date_fin: IsNull() }, + }); + if (existingForAm) { + throw new ConflictException('Cet enfant est déjà rattaché à cette assistante maternelle'); + } + + const child = await this.childrenRepository.findOne({ where: { id: enfantId } }); + if (!child) { + throw new NotFoundException('Enfant introuvable'); + } + + const activeForChild = await this.amChildrenRepository.findOne({ + where: { enfantId, date_fin: IsNull() }, + }); + if (activeForChild && activeForChild.amId !== amUserId) { + throw new ConflictException( + 'Cet enfant est déjà en garde chez une autre assistante maternelle', + ); + } + + const activeCount = await this.amChildrenRepository.count({ + where: { amId: amUserId, date_fin: IsNull() }, + }); + if (am.max_children != null && activeCount >= am.max_children) { + throw new BadRequestException( + `Capacité maximale atteinte (${am.max_children} enfant(s))`, + ); + } + + await this.amChildrenRepository.save( + this.amChildrenRepository.create({ + amId: amUserId, + enfantId, + date_debut: new Date(), + cree_par: createdBy?.id, + }), + ); + + await this.applyGardeStatusOnAttach(child); + + return this.findOne(amUserId); + } + + /** + * Clôturer le placement AM ↔ enfant. Ticket #131. + * Repasse l'enfant en `sans_garde` s'il n'a plus de placement actif. + */ + async detachEnfant(amUserId: string, enfantId: string): Promise { + await this.findOne(amUserId); + + const link = await this.amChildrenRepository.findOne({ + where: { amId: amUserId, enfantId, date_fin: IsNull() }, + relations: ['child'], + }); + if (!link) { + throw new NotFoundException('Lien assistante maternelle-enfant introuvable'); + } + + link.date_fin = new Date(); + await this.amChildrenRepository.save(link); + + const remaining = await this.amChildrenRepository.count({ + where: { enfantId, date_fin: IsNull() }, + }); + if (remaining === 0 && link.child) { + await this.applySansGardeStatusOnDetach(link.child); + } + + return this.findOne(amUserId); + } + + private async applyGardeStatusOnAttach(child: Children): Promise { + if ( + child.status === StatutEnfantType.A_NAITRE || + child.status === StatutEnfantType.SCOLARISE + ) { + return; + } + child.status = StatutEnfantType.GARDE; + await this.childrenRepository.save(child); + } + + private async applySansGardeStatusOnDetach(child: Children): Promise { + if ( + child.status === StatutEnfantType.A_NAITRE || + child.status === StatutEnfantType.SCOLARISE + ) { + return; + } + child.status = StatutEnfantType.SANS_GARDE; + await this.childrenRepository.save(child); + } + async remove(id: string): Promise<{ message: string }> { await this.assistantesMaternelleRepository.delete(id); return { message: 'Assistante maternelle supprimée' }; diff --git a/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts b/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts new file mode 100644 index 0000000..81a665d --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts @@ -0,0 +1,97 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Mise à jour fiche AM par admin/gestionnaire (doc 28 §6.1, ticket #131). */ +export class UpdateAmFicheAdminDto { + @ApiPropertyOptional({ example: 'MARTIN' }) + @IsOptional() + @IsString() + @MaxLength(100) + nom?: string; + + @ApiPropertyOptional({ example: 'Claire' }) + @IsOptional() + @IsString() + @MaxLength(100) + prenom?: string; + + @ApiPropertyOptional({ example: 'claire@example.com' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ example: '0612345678' }) + @IsOptional() + @IsString() + @MaxLength(20) + telephone?: string; + + @ApiPropertyOptional({ example: '5 place Bellecour' }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + @ApiPropertyOptional({ example: '69002' }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiPropertyOptional({ enum: StatutUtilisateurType }) + @IsOptional() + @IsEnum(StatutUtilisateurType) + statut?: StatutUtilisateurType; + + @ApiPropertyOptional({ example: 'AGR-2024-12345' }) + @IsOptional() + @IsString() + @MaxLength(50) + approval_number?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(100) + residence_city?: string; + + @ApiPropertyOptional({ example: 4 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + max_children?: number; + + @ApiPropertyOptional({ example: 2 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + places_available?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + biography?: string; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + available?: boolean; +} diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index e373abd..f24b719 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -176,7 +176,7 @@ describe('AuthService (#118 create-password API)', () => { parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({ numero_dossier: '2026-000021', parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }], - enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }], + enfants: [{ id: 'e1', first_name: 'Emma', status: 'sans_garde' }], texte_motivation: 'Motivation test', }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index f78a06e..308bb81 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -551,7 +551,7 @@ export class AuthService { ? new Date(enfantDto.date_previsionnelle_naissance) : undefined; enfant.photo_url = urlPhoto || undefined; - enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE; + enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; enfant.consent_photo = false; enfant.is_multiple = enfantDto.grossesse_multiple || false; @@ -1063,7 +1063,7 @@ export class AuthService { if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre; if (enfantDto.date_naissance !== undefined) { enfant.birth_date = new Date(enfantDto.date_naissance); - enfant.status = StatutEnfantType.ACTIF; + enfant.status = StatutEnfantType.SANS_GARDE; } if (enfantDto.date_previsionnelle_naissance !== undefined) { enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance); diff --git a/backend/src/routes/enfants/dto/create_enfants.dto.ts b/backend/src/routes/enfants/dto/create_enfants.dto.ts index 3ede09d..7d69067 100644 --- a/backend/src/routes/enfants/dto/create_enfants.dto.ts +++ b/backend/src/routes/enfants/dto/create_enfants.dto.ts @@ -12,7 +12,7 @@ import { import { GenreType, StatutEnfantType } from 'src/entities/children.entity'; export class CreateEnfantsDto { - @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF }) + @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE }) @IsEnum(StatutEnfantType) @IsNotEmpty() status: StatutEnfantType; diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index fb900a1..e4b46c4 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -34,7 +34,7 @@ export class EnfantsService { // Vérif métier simple if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) { - throw new BadRequestException('Un enfant actif doit avoir une date de naissance'); + throw new BadRequestException('Un enfant né doit avoir une date de naissance'); } // Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent) diff --git a/database/BDD.sql b/database/BDD.sql index b7cd431..9cd9f5e 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -1,3 +1,9 @@ +-- ========================================================== +-- P'titsPas — Schéma PostgreSQL (création from scratch) +-- Fichier canonique : toute nouvelle BDD doit partir d'ici. +-- Migrations dans database/migrations/ : BDD existantes uniquement. +-- ========================================================== + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ========================================================== @@ -5,40 +11,63 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ========================================================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN - CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle'); + CREATE TYPE role_type AS ENUM ( + 'parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN - CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse'); + CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente', 'actif', 'suspendu', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN - CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise'); + CREATE TYPE statut_enfant_type AS ENUM ('a_naitre', 'garde', 'sans_garde', 'scolarise'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN - CREATE TYPE statut_dossier_type AS ENUM ('envoye','accepte','refuse'); + CREATE TYPE statut_dossier_type AS ENUM ('envoye', 'accepte', 'refuse'); END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN - CREATE TYPE statut_contrat_type AS ENUM ('brouillon','en_attente_signature','valide','resilie'); + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN + CREATE TYPE statut_contrat_type AS ENUM ( + 'brouillon', 'en_attente_signature', 'valide', 'resilie' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN - CREATE TYPE statut_avenant_type AS ENUM ('propose','accepte','refuse'); + CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN - CREATE TYPE type_evenement_type AS ENUM ('absence_enfant','conge_am','conge_parent','arret_maladie_am','evenement_rpe'); + CREATE TYPE type_evenement_type AS ENUM ( + 'absence_enfant', 'conge_am', 'conge_parent', 'arret_maladie_am', 'evenement_rpe' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN - CREATE TYPE statut_evenement_type AS ENUM ('propose','valide','refuse'); + CREATE TYPE statut_evenement_type AS ENUM ('propose', 'valide', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN - CREATE TYPE statut_validation_type AS ENUM ('en_attente','valide','refuse'); + CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'situation_familiale_type') THEN - CREATE TYPE situation_familiale_type AS ENUM ('celibataire','marie','concubinage','pacse','separe','divorce','veuf','parent_isole'); + CREATE TYPE situation_familiale_type AS ENUM ( + 'celibataire', 'marie', 'concubinage', 'pacse', 'separe', 'divorce', 'veuf', 'parent_isole' + ); END IF; END $$; +-- ========================================================== +-- Table : relais (avant utilisateurs — FK relais_id) +-- ========================================================== +CREATE TABLE relais ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + nom VARCHAR(255) NOT NULL, + adresse TEXT NOT NULL, + horaires_ouverture JSONB, + ligne_fixe VARCHAR(20), + actif BOOLEAN DEFAULT true, + notes TEXT, + cree_le TIMESTAMPTZ DEFAULT now(), + modifie_le TIMESTAMPTZ DEFAULT now() +); + -- ========================================================== -- Table : utilisateurs -- ========================================================== @@ -46,26 +75,33 @@ CREATE TABLE utilisateurs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL UNIQUE, CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'), - password TEXT, -- NULL avant création via token + password TEXT, prenom VARCHAR(100), nom VARCHAR(100), genre genre_type, role role_type NOT NULL, statut statut_utilisateur_type DEFAULT 'en_attente', - telephone VARCHAR(20), -- Unifié (mobile privilégié) + telephone VARCHAR(20), adresse TEXT, date_naissance DATE, lieu_naissance_ville VARCHAR(100), lieu_naissance_pays VARCHAR(100), - photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents + photo_url TEXT, consentement_photo BOOLEAN DEFAULT false, date_consentement_photo TIMESTAMPTZ, - token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation - token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours - -- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp) - password_reset_token VARCHAR(255) NULL, - password_reset_expires TIMESTAMPTZ NULL, + token_creation_mdp VARCHAR(255), + token_creation_mdp_expire_le TIMESTAMPTZ, + password_reset_token VARCHAR(255), + password_reset_expires TIMESTAMPTZ, + token_reprise VARCHAR(255), + token_reprise_expire_le TIMESTAMPTZ, changement_mdp_obligatoire BOOLEAN DEFAULT false, + numero_dossier VARCHAR(20), + cgu_version_acceptee INTEGER, + cgu_acceptee_le TIMESTAMPTZ, + privacy_version_acceptee INTEGER, + privacy_acceptee_le TIMESTAMPTZ, + relais_id UUID REFERENCES relais(id) ON DELETE SET NULL, cree_le TIMESTAMPTZ DEFAULT now(), modifie_le TIMESTAMPTZ DEFAULT now(), ville VARCHAR(150), @@ -74,15 +110,22 @@ CREATE TABLE utilisateurs ( situation_familiale situation_familiale_type ); --- Index pour recherche par token -CREATE INDEX idx_utilisateurs_token_creation_mdp - ON utilisateurs(token_creation_mdp) +CREATE INDEX idx_utilisateurs_token_creation_mdp + ON utilisateurs(token_creation_mdp) WHERE token_creation_mdp IS NOT NULL; CREATE INDEX idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL; +CREATE INDEX idx_utilisateurs_token_reprise + ON utilisateurs(token_reprise) + WHERE token_reprise IS NOT NULL; + +CREATE INDEX idx_utilisateurs_numero_dossier + ON utilisateurs(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : assistantes_maternelles -- ========================================================== @@ -97,17 +140,27 @@ CREATE TABLE assistantes_maternelles ( date_agrement DATE, annee_experience SMALLINT, specialite VARCHAR(100), - place_disponible INT + place_disponible INT, + numero_dossier VARCHAR(20) ); +CREATE INDEX idx_assistantes_maternelles_numero_dossier + ON assistantes_maternelles(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : parents -- ========================================================== CREATE TABLE parents ( id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, - id_co_parent UUID REFERENCES utilisateurs(id) + id_co_parent UUID REFERENCES utilisateurs(id), + numero_dossier VARCHAR(20) ); +CREATE INDEX idx_parents_numero_dossier + ON parents(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : enfants -- ========================================================== @@ -116,7 +169,7 @@ CREATE TABLE enfants ( statut statut_enfant_type, prenom VARCHAR(100), nom VARCHAR(100), - genre genre_type NOT NULL, -- Obligatoire selon CDC + genre genre_type NOT NULL, date_naissance DATE, date_prevue_naissance DATE, photo_url TEXT, @@ -135,7 +188,27 @@ CREATE TABLE enfants_parents ( ); -- ========================================================== --- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119) +-- Table : enfants_assistantes_maternelles (placement AM ↔ enfant — #131) +-- ========================================================== +CREATE TABLE enfants_assistantes_maternelles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + date_debut DATE NOT NULL DEFAULT CURRENT_DATE, + date_fin DATE NULL, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL +); + +CREATE INDEX idx_eam_am ON enfants_assistantes_maternelles(id_am); +CREATE INDEX idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant); + +CREATE UNIQUE INDEX uq_enfant_garde_active + ON enfants_assistantes_maternelles (id_enfant) + WHERE date_fin IS NULL; + +-- ========================================================== +-- Table : dossier_famille (inscription parent — ticket #119) -- ========================================================== CREATE TABLE dossier_famille ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -276,10 +349,6 @@ CREATE TABLE notifications ( -- ========================================================== -- Table : validations -- ========================================================== --- Historique des décisions (validation / refus / suspension de compte). --- Colonnes commentaire + valide_par : requises par l’API Nest (TypeORM). --- FK en ON DELETE SET NULL : conserver la ligne si l’utilisateur référencé --- est supprimé (voir database/docs/FK_POLICIES.md). CREATE TABLE validations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL, @@ -305,13 +374,10 @@ CREATE TABLE configuration ( modifie_par UUID REFERENCES utilisateurs(id) ); --- Index pour performance CREATE INDEX idx_configuration_cle ON configuration(cle); CREATE INDEX idx_configuration_categorie ON configuration(categorie); --- Seed initial de configuration INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES --- === Configuration Email (SMTP) === ('smtp_host', 'localhost', 'string', 'email', 'Serveur SMTP (ex: mail.mairie-bezons.fr, smtp.gmail.com)'), ('smtp_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'), ('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'), @@ -320,14 +386,10 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES ('smtp_password', '', 'encrypted', 'email', 'Mot de passe SMTP (chiffré en AES-256)'), ('email_from_name', 'P''titsPas', 'string', 'email', 'Nom de l''expéditeur affiché dans les emails'), ('email_from_address', 'no-reply@ptits-pas.fr', 'string', 'email', 'Adresse email de l''expéditeur'), - --- === Configuration Application === ('app_name', 'P''titsPas', 'string', 'app', 'Nom de l''application (affiché dans l''interface)'), ('app_url', 'https://app.ptits-pas.fr', 'string', 'app', 'URL publique de l''application (pour les liens dans emails)'), ('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'), ('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'), - --- === Configuration Sécurité === ('password_reset_token_expiry_days', '7', 'number', 'security', 'Durée de validité des tokens de création/réinitialisation de mot de passe (en jours)'), ('jwt_expiry_hours', '24', 'number', 'security', 'Durée de validité des sessions JWT (en heures)'), ('max_upload_size_mb', '5', 'number', 'security', 'Taille maximale des fichiers uploadés (en MB)'), @@ -338,19 +400,18 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES -- ========================================================== CREATE TABLE documents_legaux ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy' - version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté) - fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier - fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage - fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité - actif BOOLEAN DEFAULT false, -- Version actuellement active - televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé - televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload - active_le TIMESTAMPTZ, -- Date d'activation - UNIQUE(type, version) -- Pas de doublon version + type VARCHAR(50) NOT NULL, + version INTEGER NOT NULL, + fichier_nom VARCHAR(255) NOT NULL, + fichier_path VARCHAR(500) NOT NULL, + fichier_hash VARCHAR(64) NOT NULL, + actif BOOLEAN DEFAULT false, + televerse_par UUID REFERENCES utilisateurs(id), + televerse_le TIMESTAMPTZ DEFAULT now(), + active_le TIMESTAMPTZ, + UNIQUE(type, version) ); --- Index pour performance CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif); CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC); @@ -361,73 +422,23 @@ CREATE TABLE acceptations_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE, id_document UUID REFERENCES documents_legaux(id), - type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy' - version_document INTEGER NOT NULL, -- Version acceptée - accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation - ip_address INET, -- IP de l'utilisateur (RGPD) - user_agent TEXT -- Navigateur (preuve) + type_document VARCHAR(50) NOT NULL, + version_document INTEGER NOT NULL, + accepte_le TIMESTAMPTZ DEFAULT now(), + ip_address INET, + user_agent TEXT ); --- Index pour performance CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur); CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document); --- ========================================================== --- Table : relais --- ========================================================== -CREATE TABLE relais ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - nom VARCHAR(255) NOT NULL, - adresse TEXT NOT NULL, - horaires_ouverture JSONB, - ligne_fixe VARCHAR(20), - actif BOOLEAN DEFAULT true, - notes TEXT, - cree_le TIMESTAMPTZ DEFAULT now(), - modifie_le TIMESTAMPTZ DEFAULT now() -); - --- ========================================================== --- Modification Table : utilisateurs (ajout colonnes documents et relais) --- ========================================================== -ALTER TABLE utilisateurs - ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER, - ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER, - ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL; - -- ========================================================== -- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année) -- ========================================================== -CREATE TABLE IF NOT EXISTS numero_dossier_sequence ( - annee INT PRIMARY KEY, - prochain INT NOT NULL DEFAULT 1 +CREATE TABLE 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; - --- ========================================================== --- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription) --- ========================================================== -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL; -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL; -CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL; - --- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes) -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL; -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL; -- ========================================================== -- Seed : Documents légaux génériques v1 @@ -438,10 +449,7 @@ INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_ -- ========================================================== -- Seed : Super Administrateur par défaut --- ========================================================== --- Email: admin@ptits-pas.fr --- Mot de passe: 4dm1n1strateur (hashé bcrypt) --- IMPORTANT: Changer ce mot de passe en production ! +-- Email: admin@ptits-pas.fr | Mot de passe: 4dm1n1strateur -- ========================================================== INSERT INTO utilisateurs ( email, diff --git a/database/README.md b/database/README.md index 98200b5..16846eb 100644 --- a/database/README.md +++ b/database/README.md @@ -15,12 +15,13 @@ Ce projet contient la **base de données** pour l'application PtitsPas, avec scr ## Structure du projet -- `migrations/` : scripts SQL pour la création et l'import de la base -- `bdd/data_test/` : fichiers CSV pour l'import de données de test -- `docs/` : documentation métier et technique -- `seed/` : scripts de seed +- **`BDD.sql`** : schéma canonique (création from scratch — utilisé par `docker-compose.yml` racine) +- `migrations/` : scripts SQL pour **mettre à jour** une BDD déjà en service +- `bdd/data_test/` : fichiers CSV (import legacy) +- `docs/` : documentation métier et technique (`ENUMS.md`, `FK_POLICIES.md`) +- `seed/` : scripts de seed (`03_seed_test_data.sql` — jeu dashboard admin) - `tests/` : tests SQL -- `docker-compose.dev.yml` : configuration Docker pour le développement +- `docker-compose.dev.yml` : Postgres standalone (BDD.sql + seed auto) --- diff --git a/database/bdd/data_test/enfants.csv b/database/bdd/data_test/enfants.csv index 1cf2ab6..43a2275 100644 --- a/database/bdd/data_test/enfants.csv +++ b/database/bdd/data_test/enfants.csv @@ -1,10 +1,10 @@ -"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple" -"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","actif","Emma","Dupont","F","2020-06-01",,,False,,False -"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","actif",,,,"2020-01-01","2025-01-01",,False,,False -"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","actif","Emma","Martin",,"2023-02-15",,,False,,False -"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","actif","Noah","Martin",,"2023-02-15",,,False,,False -"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","actif","Léa","Martin",,"2023-02-15",,,False,,False -"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","actif","Chloé","Rousseau",,"2022-04-20",,,False,,False -"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","actif","Hugo","Rousseau",,"2024-03-10",,,False,,False -"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","actif","Maxime","Lecomte",,"2023-04-15",,,False,,False -"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False +"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple" +"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False +"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False +"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False +"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False +"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False +"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False +"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False +"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False +"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False diff --git a/database/docker-compose.dev.yml b/database/docker-compose.dev.yml index 6c608be..74d6db2 100644 --- a/database/docker-compose.dev.yml +++ b/database/docker-compose.dev.yml @@ -14,9 +14,8 @@ services: ports: - "5433:5432" volumes: - - ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql - - ./migrations/07_import.sql:/docker-entrypoint-initdb.d/07_import.sql - - ./bdd/data_test:/bdd/data_test + - ./BDD.sql:/docker-entrypoint-initdb.d/01_init.sql + - ./seed/03_seed_test_data.sql:/docker-entrypoint-initdb.d/02_seed_test_data.sql - postgres_standalone_data:/var/lib/postgresql/data networks: - ptitspas_dev diff --git a/database/docs/ENUMS.md b/database/docs/ENUMS.md index c24e516..bda4d68 100644 --- a/database/docs/ENUMS.md +++ b/database/docs/ENUMS.md @@ -51,12 +51,17 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base | Valeur | Description | |---|---| | `a_naitre` | Enfant à naître (date prévue renseignée) | -| `actif` | Enfant pris en charge / en cours de garde | +| `sans_garde` | Enfant né, pas encore placé chez une AM (défaut à l'inscription) | +| `garde` | Enfant placé chez une AM (`enfants_assistantes_maternelles` actif) | | `scolarise` | Enfant scolarisé, garde potentiellement périscolaire | **Contraintes associées** : - `a_naitre` → **`date_prevue_naissance` obligatoire** -- `actif`/`scolarise` → **`date_naissance` obligatoire** +- `sans_garde` / `garde` / `scolarise` → **`date_naissance` obligatoire** + +**Transitions** (API admin #131) : +- Rattachement AM ↔ enfant → `garde` (sauf `a_naitre` / `scolarise`) +- Détachement sans autre placement actif → `sans_garde` --- diff --git a/database/docs/FK_POLICIES.md b/database/docs/FK_POLICIES.md index d4686b1..2d861fa 100644 --- a/database/docs/FK_POLICIES.md +++ b/database/docs/FK_POLICIES.md @@ -32,6 +32,9 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo | **parents(id_co_parent)** → `utilisateurs(id)` | **SET NULL** | Conserver le parent principal si co-parent disparaît | | **enfants_parents(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Nettoyage liaisons N:N | | **enfants_parents(id_enfant)** → `enfants(id)` | **CASCADE** | Idem | +| **enfants_assistantes_maternelles(id_am)** → `assistantes_maternelles(id_utilisateur)` | **CASCADE** | Placement supprimé avec l’AM | +| **enfants_assistantes_maternelles(id_enfant)** → `enfants(id)` | **CASCADE** | Idem | +| **enfants_assistantes_maternelles(cree_par)** → `utilisateurs(id)` | **SET NULL** | Historique placement conservé | | **dossiers(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Dossier n’a pas de sens sans parent | | **dossiers(id_enfant)** → `enfants(id)` | **CASCADE** | Dossier n’a pas de sens sans enfant | | **messages(id_dossier)** → `dossiers(id)` | **CASCADE** | Messages détruits avec le dossier | diff --git a/database/migrations/2026_enfants_assistantes_maternelles.sql b/database/migrations/2026_enfants_assistantes_maternelles.sql new file mode 100644 index 0000000..9175051 --- /dev/null +++ b/database/migrations/2026_enfants_assistantes_maternelles.sql @@ -0,0 +1,36 @@ +-- Ticket #131 / #141 — Placement AM ↔ enfant + statuts garde/sans_garde +-- ⚠️ BDD EXISTANTES UNIQUEMENT — ne pas exécuter sur une base créée via BDD.sql à jour. +-- Idempotent : safe à rejouer sur recette / prod. + +-- 1) Nouveaux statuts enfant +DO $$ BEGIN + ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'garde'; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'sans_garde'; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- actif → sans_garde (enfants sans placement AM connu au déploiement) +UPDATE enfants SET statut = 'sans_garde' WHERE statut = 'actif'; + +-- 2) Table de placement AM ↔ enfant +CREATE TABLE IF NOT EXISTS enfants_assistantes_maternelles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + date_debut DATE NOT NULL DEFAULT CURRENT_DATE, + date_fin DATE NULL, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_eam_am ON enfants_assistantes_maternelles(id_am); +CREATE INDEX IF NOT EXISTS idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant); + +-- Au plus une garde active par enfant +CREATE UNIQUE INDEX IF NOT EXISTS uq_enfant_garde_active + ON enfants_assistantes_maternelles (id_enfant) + WHERE date_fin IS NULL; diff --git a/database/seed/02_seed.sql b/database/seed/02_seed.sql index a0a109c..bd95d05 100644 --- a/database/seed/02_seed.sql +++ b/database/seed/02_seed.sql @@ -65,12 +65,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING; -- ------------------------------------------------------------ -- Enfants --- - child A : déjà né (statut = 'actif' et date_naissance requise) +-- - child A : déjà né (statut = 'sans_garde' et date_naissance requise) -- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise) -- ------------------------------------------------------------ INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple) -VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'actif', '2022-04-12', false) +VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false) ON CONFLICT (id) DO NOTHING; INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple) diff --git a/database/seed/03_seed_test_data.sql b/database/seed/03_seed_test_data.sql index 6d54592..8a54c7f 100644 --- a/database/seed/03_seed_test_data.sql +++ b/database/seed/03_seed_test_data.sql @@ -18,15 +18,15 @@ BEGIN; INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo) VALUES - ('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false), - ('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false), - ('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true), - ('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true), - ('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false), - ('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false), - ('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false), - ('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false), - ('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false) + ('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false), + ('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false), + ('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true), + ('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true), + ('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false), + ('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false), + ('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false), + ('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false), + ('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false) ON CONFLICT (email) DO NOTHING; -- ========== PARENTS (avec co-parent pour le couple Martin) ========== @@ -51,12 +51,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING; -- ========== ENFANTS ========== INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple) VALUES - ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true), - ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true), - ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true), - ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false), - ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false), - ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false) + ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), + ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true), + ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), + ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false), + ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false), + ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false) ON CONFLICT (id) DO NOTHING; -- ========== ENFANTS_PARENTS (liaison N:N) ==========