diff --git a/backend/src/entities/parents.entity.ts b/backend/src/entities/parents.entity.ts index 5f55d44..ea1db0e 100644 --- a/backend/src/entities/parents.entity.ts +++ b/backend/src/entities/parents.entity.ts @@ -5,6 +5,7 @@ import { import { Users } from './users.entity'; import { ParentsChildren } from './parents_children.entity'; import { Dossier } from './dossiers.entity'; +import { AmChildren } from './am_children.entity'; @Entity('parents', { schema: 'public' }) export class Parents { @@ -25,6 +26,17 @@ export class Parents { @Column({ name: 'numero_dossier', length: 20, nullable: true }) numero_dossier?: string; + /** + * Placement AM↔enfant sélectionné sur le TdB parent (couple actif) — ticket #168. + * Null / absent = le front prend le premier couple actif retourné par l’API. + */ + @Column({ name: 'id_placement_garde_courant', type: 'uuid', nullable: true }) + id_placement_garde_courant?: string; + + @ManyToOne(() => AmChildren, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'id_placement_garde_courant', referencedColumnName: 'id' }) + placement_garde_courant?: AmChildren; + // Lien vers enfants via la table enfants_parents @OneToMany(() => ParentsChildren, pc => pc.parent) parentChildren: ParentsChildren[]; diff --git a/backend/src/routes/parents/dto/couples-garde.dto.ts b/backend/src/routes/parents/dto/couples-garde.dto.ts new file mode 100644 index 0000000..15a13ce --- /dev/null +++ b/backend/src/routes/parents/dto/couples-garde.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** Identité minimale enfant pour le bandeau couple — ticket #168 */ +export class CoupleGardeEnfantDto { + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiPropertyOptional() + prenom?: string | null; + + @ApiPropertyOptional() + nom?: string | null; + + @ApiPropertyOptional() + photo_url?: string | null; +} + +/** Identité minimale AM pour le bandeau couple — ticket #168 */ +export class CoupleGardeAmDto { + @ApiProperty({ format: 'uuid', description: 'UUID utilisateur de l’AM' }) + id: string; + + @ApiPropertyOptional() + prenom?: string | null; + + @ApiPropertyOptional() + nom?: string | null; + + @ApiPropertyOptional() + photo_url?: string | null; +} + +/** Un couple de garde = placement actif enfant ↔ AM */ +export class CoupleGardeDto { + @ApiProperty({ + format: 'uuid', + description: 'Id du placement (enfants_assistantes_maternelles.id)', + }) + id: string; + + @ApiProperty({ type: CoupleGardeEnfantDto }) + enfant: CoupleGardeEnfantDto; + + @ApiProperty({ type: CoupleGardeAmDto }) + am: CoupleGardeAmDto; + + @ApiProperty({ description: 'True si c’est le couple actuellement sélectionné' }) + courant: boolean; +} + +export class CouplesGardeResponseDto { + @ApiProperty({ type: [CoupleGardeDto] }) + couples: CoupleGardeDto[]; + + @ApiPropertyOptional({ + format: 'uuid', + nullable: true, + description: 'Id du couple courant (null si aucun / premier couple implicite côté client)', + }) + couple_courant_id: string | null; +} diff --git a/backend/src/routes/parents/dto/definir-couple-garde-courant.dto.ts b/backend/src/routes/parents/dto/definir-couple-garde-courant.dto.ts new file mode 100644 index 0000000..004bdbc --- /dev/null +++ b/backend/src/routes/parents/dto/definir-couple-garde-courant.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsUUID } from 'class-validator'; + +/** Corps PUT couple de garde courant — ticket #168 */ +export class DefinirCoupleGardeCourantDto { + @ApiProperty({ + format: 'uuid', + description: 'Id du placement (enfants_assistantes_maternelles.id) à sélectionner', + }) + @IsUUID() + @IsNotEmpty() + couple_id: string; +} diff --git a/backend/src/routes/parents/parents.controller.spec.ts b/backend/src/routes/parents/parents.controller.spec.ts index 4452cd6..8fc4f4f 100644 --- a/backend/src/routes/parents/parents.controller.spec.ts +++ b/backend/src/routes/parents/parents.controller.spec.ts @@ -13,7 +13,10 @@ describe('ParentsController', () => { createParentDossierStaff: jest.fn(), addCoParentStaff: jest.fn(), }; - const parentsServiceMock = {}; + const parentsServiceMock = { + listerCouplesGarde: jest.fn(), + definirCoupleGardeCourant: jest.fn(), + }; const userServiceMock = {}; beforeEach(async () => { @@ -39,6 +42,31 @@ describe('ParentsController', () => { expect(controller).toBeDefined(); }); + it('listerCouplesGarde délègue au service (#168)', async () => { + parentsServiceMock.listerCouplesGarde.mockResolvedValue({ + couples: [], + couple_courant_id: null, + }); + const res = await controller.listerCouplesGarde('parent-uuid'); + expect(parentsServiceMock.listerCouplesGarde).toHaveBeenCalledWith('parent-uuid'); + expect(res.couples).toEqual([]); + }); + + it('definirCoupleGardeCourant délègue au service (#168)', async () => { + parentsServiceMock.definirCoupleGardeCourant.mockResolvedValue({ + couples: [{ id: 'pl-1', courant: true }], + couple_courant_id: 'pl-1', + }); + const res = await controller.definirCoupleGardeCourant('parent-uuid', { + couple_id: 'pl-1', + }); + expect(parentsServiceMock.definirCoupleGardeCourant).toHaveBeenCalledWith( + 'parent-uuid', + 'pl-1', + ); + expect(res.couple_courant_id).toBe('pl-1'); + }); + it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => { authServiceMock.createParentDossierStaff.mockResolvedValue({ message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.', diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 1567509..9dd4c78 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -8,6 +8,7 @@ import { Param, Patch, Post, + Put, UseGuards, } from '@nestjs/common'; import { ParentsService } from './parents.service'; @@ -39,6 +40,8 @@ import { User } from 'src/common/decorators/user.decorator'; import { PendingFamilyDto } from './dto/pending-family.dto'; import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto'; import { mapParentForApi, mapParentsForApi } from './parents.mapper'; +import { CouplesGardeResponseDto } from './dto/couples-garde.dto'; +import { DefinirCoupleGardeCourantDto } from './dto/definir-couple-garde-courant.dto'; @ApiTags('Parents') @ApiBearerAuth('access-token') @@ -51,6 +54,39 @@ export class ParentsController { private readonly authService: AuthService, ) {} + @Get('me/couples-garde') + @Roles(RoleType.PARENT) + @ApiOperation({ + summary: 'Lister les couples de garde du parent connecté — ticket #168', + description: + 'Retourne les placements actifs enfant↔AM rattachés au parent, ' + + 'avec indication du couple courant (bandeau TdB quotidien).', + }) + @ApiResponse({ status: 200, type: CouplesGardeResponseDto }) + @ApiResponse({ status: 403, description: 'Réservé au rôle parent' }) + @ApiResponse({ status: 404, description: 'Parent introuvable' }) + listerCouplesGarde(@User('id') userId: string): Promise { + return this.parentsService.listerCouplesGarde(userId); + } + + @Put('me/couples-garde/courant') + @Roles(RoleType.PARENT) + @ApiOperation({ + summary: 'Définir le couple de garde courant — ticket #168', + description: + 'Persiste la préférence de couple actif (enfant|nounou) pour contextualiser le TdB.', + }) + @ApiBody({ type: DefinirCoupleGardeCourantDto }) + @ApiResponse({ status: 200, type: CouplesGardeResponseDto }) + @ApiResponse({ status: 400, description: 'Couple hors périmètre du parent' }) + @ApiResponse({ status: 404, description: 'Parent ou couple introuvable' }) + definirCoupleGardeCourant( + @User('id') userId: string, + @Body() dto: DefinirCoupleGardeCourantDto, + ): Promise { + return this.parentsService.definirCoupleGardeCourant(userId, dto.couple_id); + } + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @Post('dossier') @HttpCode(HttpStatus.CREATED) diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index 35aeb2e..4826f10 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt'; import { Parents } from 'src/entities/parents.entity'; import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; import { ParentsController } from './parents.controller'; import { ParentsService } from './parents.service'; import { Users } from 'src/entities/users.entity'; @@ -13,7 +14,14 @@ import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]), + TypeOrmModule.forFeature([ + Parents, + Users, + DossierFamille, + DossierFamilleEnfant, + ParentsChildren, + AmChildren, + ]), forwardRef(() => UserModule), forwardRef(() => AuthModule), JwtModule.registerAsync({ diff --git a/backend/src/routes/parents/parents.service.spec.ts b/backend/src/routes/parents/parents.service.spec.ts index 83b23f2..a1569c9 100644 --- a/backend/src/routes/parents/parents.service.spec.ts +++ b/backend/src/routes/parents/parents.service.spec.ts @@ -1,12 +1,43 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { ParentsService } from './parents.service'; +import { Parents } from 'src/entities/parents.entity'; +import { Users } from 'src/entities/users.entity'; +import { DossierFamille } from 'src/entities/dossier_famille.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; -describe('ParentsService', () => { +describe('ParentsService — couples de garde (#168)', () => { let service: ParentsService; + const parentsRepository = { + findOne: jest.fn(), + update: jest.fn(), + }; + const parentsChildrenRepository = { + find: jest.fn(), + findOne: jest.fn(), + }; + const amChildrenRepository = { + find: jest.fn(), + findOne: jest.fn(), + }; + beforeEach(async () => { + jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ - providers: [ParentsService], + providers: [ + ParentsService, + { provide: getRepositoryToken(Parents), useValue: parentsRepository }, + { provide: getRepositoryToken(Users), useValue: {} }, + { provide: getRepositoryToken(DossierFamille), useValue: {} }, + { + provide: getRepositoryToken(ParentsChildren), + useValue: parentsChildrenRepository, + }, + { provide: getRepositoryToken(AmChildren), useValue: amChildrenRepository }, + ], }).compile(); service = module.get(ParentsService); @@ -15,4 +46,107 @@ describe('ParentsService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('listerCouplesGarde', () => { + it('retourne une liste vide si le parent n’a pas d’enfant', async () => { + parentsRepository.findOne.mockResolvedValue({ + user_id: 'p1', + id_placement_garde_courant: null, + }); + parentsChildrenRepository.find.mockResolvedValue([]); + + const res = await service.listerCouplesGarde('p1'); + expect(res).toEqual({ couples: [], couple_courant_id: null }); + expect(amChildrenRepository.find).not.toHaveBeenCalled(); + }); + + it('mappe les placements actifs en couples et marque le courant', async () => { + parentsRepository.findOne.mockResolvedValue({ + user_id: 'p1', + id_placement_garde_courant: 'pl-2', + }); + parentsChildrenRepository.find.mockResolvedValue([ + { enfantId: 'e1' }, + { enfantId: 'e2' }, + ]); + amChildrenRepository.find.mockResolvedValue([ + { + id: 'pl-1', + amId: 'am-1', + child: { id: 'e1', first_name: 'Léo', last_name: 'M', photo_url: null }, + am: { user: { prenom: 'Marie', nom: 'N', photo_url: '/a.jpg' } }, + }, + { + id: 'pl-2', + amId: 'am-2', + child: { id: 'e2', first_name: 'Léa', last_name: 'M', photo_url: null }, + am: { user: { prenom: 'Sophie', nom: 'P', photo_url: null } }, + }, + ]); + + const res = await service.listerCouplesGarde('p1'); + expect(res.couples).toHaveLength(2); + expect(res.couple_courant_id).toBe('pl-2'); + expect(res.couples[1].courant).toBe(true); + expect(res.couples[0].am.prenom).toBe('Marie'); + expect(res.couples[0].enfant.prenom).toBe('Léo'); + }); + + it('404 si parent inconnu', async () => { + parentsRepository.findOne.mockResolvedValue(null); + await expect(service.listerCouplesGarde('x')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + }); + + describe('definirCoupleGardeCourant', () => { + it('persiste le couple si l’enfant est rattaché au parent', async () => { + parentsRepository.findOne.mockResolvedValue({ + user_id: 'p1', + id_placement_garde_courant: null, + }); + amChildrenRepository.findOne.mockResolvedValue({ + id: 'pl-1', + enfantId: 'e1', + date_fin: null, + }); + parentsChildrenRepository.findOne.mockResolvedValue({ + parentId: 'p1', + enfantId: 'e1', + }); + parentsRepository.update.mockResolvedValue({ affected: 1 }); + // second call via listerCouplesGarde + parentsChildrenRepository.find.mockResolvedValue([{ enfantId: 'e1' }]); + amChildrenRepository.find.mockResolvedValue([ + { + id: 'pl-1', + amId: 'am-1', + child: { id: 'e1', first_name: 'Léo', last_name: null, photo_url: null }, + am: { user: { prenom: 'Marie', nom: null, photo_url: null } }, + }, + ]); + + const res = await service.definirCoupleGardeCourant('p1', 'pl-1'); + expect(parentsRepository.update).toHaveBeenCalledWith( + { user_id: 'p1' }, + { id_placement_garde_courant: 'pl-1' }, + ); + expect(res.couple_courant_id).toBe('pl-1'); + expect(res.couples[0].courant).toBe(true); + }); + + it('400 si le couple n’appartient pas au parent', async () => { + parentsRepository.findOne.mockResolvedValue({ user_id: 'p1' }); + amChildrenRepository.findOne.mockResolvedValue({ + id: 'pl-1', + enfantId: 'e99', + }); + parentsChildrenRepository.findOne.mockResolvedValue(null); + + await expect( + service.definirCoupleGardeCourant('p1', 'pl-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); }); diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index 8adf1ef..c1205b0 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { In, IsNull, Repository } from 'typeorm'; import { Parents } from 'src/entities/parents.entity'; import { DossierFamille } from 'src/entities/dossier_famille.entity'; import { RoleType, Users } from 'src/entities/users.entity'; @@ -20,6 +20,8 @@ import { import { ParentsChildren } from 'src/entities/parents_children.entity'; import { Children } from 'src/entities/children.entity'; import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { CouplesGardeResponseDto } from './dto/couples-garde.dto'; @Injectable() export class ParentsService { @@ -32,6 +34,8 @@ export class ParentsService { private readonly dossierFamilleRepository: Repository, @InjectRepository(ParentsChildren) private readonly parentsChildrenRepository: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepository: Repository, ) {} // Création d’un parent @@ -505,4 +509,108 @@ export class ParentsService { } return raw.map((r: { id: string }) => r.id); } + + /** + * Liste les couples de garde (enfant ↔ AM) du parent connecté — ticket #168. + * Un couple = un placement actif dans enfants_assistantes_maternelles pour un enfant du parent. + */ + async listerCouplesGarde(parentUserId: string): Promise { + const parent = await this.parentsRepository.findOne({ + where: { user_id: parentUserId }, + }); + if (!parent) { + throw new NotFoundException('Parent introuvable'); + } + + const liensEnfants = await this.parentsChildrenRepository.find({ + where: { parentId: parentUserId }, + select: ['enfantId'], + }); + const enfantIds = liensEnfants.map((l) => l.enfantId); + if (enfantIds.length === 0) { + return { couples: [], couple_courant_id: null }; + } + + const placements = await this.amChildrenRepository.find({ + where: { enfantId: In(enfantIds), date_fin: IsNull() }, + relations: ['child', 'am', 'am.user'], + order: { date_debut: 'ASC' }, + }); + + let idCourant = parent.id_placement_garde_courant ?? null; + const idsValides = new Set(placements.map((p) => p.id)); + if (idCourant && !idsValides.has(idCourant)) { + idCourant = null; + await this.parentsRepository.update( + { user_id: parentUserId }, + { id_placement_garde_courant: () => 'NULL' }, + ); + } + if (!idCourant && placements.length === 1) { + idCourant = placements[0].id; + } + + const couples = placements.map((p) => { + const amUser = p.am?.user; + return { + id: p.id, + enfant: { + id: p.child.id, + prenom: p.child.first_name ?? null, + nom: p.child.last_name ?? null, + photo_url: p.child.photo_url ?? null, + }, + am: { + id: p.amId, + prenom: amUser?.prenom ?? null, + nom: amUser?.nom ?? null, + photo_url: amUser?.photo_url ?? null, + }, + courant: idCourant != null && p.id === idCourant, + }; + }); + + return { + couples, + couple_courant_id: idCourant, + }; + } + + /** + * Persiste le couple de garde actif pour le parent — ticket #168. + */ + async definirCoupleGardeCourant( + parentUserId: string, + coupleId: string, + ): Promise { + const parent = await this.parentsRepository.findOne({ + where: { user_id: parentUserId }, + }); + if (!parent) { + throw new NotFoundException('Parent introuvable'); + } + + const placement = await this.amChildrenRepository.findOne({ + where: { id: coupleId, date_fin: IsNull() }, + }); + if (!placement) { + throw new NotFoundException('Couple de garde introuvable ou inactif'); + } + + const lien = await this.parentsChildrenRepository.findOne({ + where: { parentId: parentUserId, enfantId: placement.enfantId }, + }); + if (!lien) { + throw new BadRequestException( + 'Ce couple ne concerne pas un enfant rattaché à ce parent', + ); + } + + await this.parentsRepository.update( + { user_id: parentUserId }, + { id_placement_garde_courant: coupleId }, + ); + + return this.listerCouplesGarde(parentUserId); + } } diff --git a/database/BDD.sql b/database/BDD.sql index c4e63ab..11aff46 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -154,7 +154,10 @@ CREATE INDEX idx_assistantes_maternelles_numero_dossier CREATE TABLE parents ( id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, id_co_parent UUID REFERENCES utilisateurs(id), - numero_dossier VARCHAR(20) + numero_dossier VARCHAR(20), + -- Préférence couple de garde actif (TdB quotidien) — ticket #168 + -- FK ajoutée après création de enfants_assistantes_maternelles (voir ALTER plus bas) + id_placement_garde_courant UUID ); CREATE INDEX idx_parents_numero_dossier @@ -206,6 +209,16 @@ CREATE UNIQUE INDEX uq_enfant_garde_active ON enfants_assistantes_maternelles (id_enfant) WHERE date_fin IS NULL; +-- FK couple courant parent → placement (#168) — après table enfants_assistantes_maternelles +ALTER TABLE parents + ADD CONSTRAINT fk_parents_placement_garde_courant + FOREIGN KEY (id_placement_garde_courant) + REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL; + +CREATE INDEX idx_parents_placement_garde_courant + ON parents(id_placement_garde_courant) + WHERE id_placement_garde_courant IS NOT NULL; + -- ========================================================== -- Table : dossier_famille (inscription parent — ticket #119) -- ========================================================== diff --git a/database/migrations/2026_parent_placement_garde_courant.sql b/database/migrations/2026_parent_placement_garde_courant.sql new file mode 100644 index 0000000..4e46422 --- /dev/null +++ b/database/migrations/2026_parent_placement_garde_courant.sql @@ -0,0 +1,10 @@ +-- Ticket #168 — Couple de garde courant (préférence parent) +-- Idempotent : safe à rejouer. + +ALTER TABLE parents + ADD COLUMN IF NOT EXISTS id_placement_garde_courant UUID + REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_parents_placement_garde_courant + ON parents(id_placement_garde_courant) + WHERE id_placement_garde_courant IS NOT NULL;