Compare commits

..
Author SHA1 Message Date
jmartinandCursor d5a857ae6b feat(#168): API couples de garde parent (liste + couple courant).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 17:45:34 +02:00
10 changed files with 429 additions and 6 deletions
+12
View File
@@ -5,6 +5,7 @@ import {
import { Users } from './users.entity'; import { Users } from './users.entity';
import { ParentsChildren } from './parents_children.entity'; import { ParentsChildren } from './parents_children.entity';
import { Dossier } from './dossiers.entity'; import { Dossier } from './dossiers.entity';
import { AmChildren } from './am_children.entity';
@Entity('parents', { schema: 'public' }) @Entity('parents', { schema: 'public' })
export class Parents { export class Parents {
@@ -25,6 +26,17 @@ export class Parents {
@Column({ name: 'numero_dossier', length: 20, nullable: true }) @Column({ name: 'numero_dossier', length: 20, nullable: true })
numero_dossier?: string; numero_dossier?: string;
/**
* Placement AM↔enfant sélectionné sur le TdB parent (couple actif) — ticket #168.
* Null = le front prend le premier couple actif retourné par lAPI.
*/
@Column({ name: 'id_placement_garde_courant', type: 'uuid', nullable: true })
id_placement_garde_courant?: string | null;
@ManyToOne(() => AmChildren, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'id_placement_garde_courant', referencedColumnName: 'id' })
placement_garde_courant?: AmChildren | null;
// 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[];
@@ -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 lAM' })
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 cest 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;
}
@@ -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;
}
@@ -13,7 +13,10 @@ describe('ParentsController', () => {
createParentDossierStaff: jest.fn(), createParentDossierStaff: jest.fn(),
addCoParentStaff: jest.fn(), addCoParentStaff: jest.fn(),
}; };
const parentsServiceMock = {}; const parentsServiceMock = {
listerCouplesGarde: jest.fn(),
definirCoupleGardeCourant: jest.fn(),
};
const userServiceMock = {}; const userServiceMock = {};
beforeEach(async () => { beforeEach(async () => {
@@ -39,6 +42,31 @@ describe('ParentsController', () => {
expect(controller).toBeDefined(); 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 () => { it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
authServiceMock.createParentDossierStaff.mockResolvedValue({ authServiceMock.createParentDossierStaff.mockResolvedValue({
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.', message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
@@ -8,6 +8,7 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Put,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ParentsService } from './parents.service'; 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 { PendingFamilyDto } from './dto/pending-family.dto';
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto'; import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
import { mapParentForApi, mapParentsForApi } from './parents.mapper'; import { mapParentForApi, mapParentsForApi } from './parents.mapper';
import { CouplesGardeResponseDto } from './dto/couples-garde.dto';
import { DefinirCoupleGardeCourantDto } from './dto/definir-couple-garde-courant.dto';
@ApiTags('Parents') @ApiTags('Parents')
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@@ -51,6 +54,39 @@ export class ParentsController {
private readonly authService: AuthService, 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<CouplesGardeResponseDto> {
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<CouplesGardeResponseDto> {
return this.parentsService.definirCoupleGardeCourant(userId, dto.couple_id);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post('dossier') @Post('dossier')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
+9 -1
View File
@@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { ParentsController } from './parents.controller'; import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service'; import { ParentsService } from './parents.service';
import { Users } from 'src/entities/users.entity'; import { Users } from 'src/entities/users.entity';
@@ -13,7 +14,14 @@ import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]), TypeOrmModule.forFeature([
Parents,
Users,
DossierFamille,
DossierFamilleEnfant,
ParentsChildren,
AmChildren,
]),
forwardRef(() => UserModule), forwardRef(() => UserModule),
forwardRef(() => AuthModule), forwardRef(() => AuthModule),
JwtModule.registerAsync({ JwtModule.registerAsync({
@@ -1,12 +1,43 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ParentsService } from './parents.service'; 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; 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 () => { beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ 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(); }).compile();
service = module.get<ParentsService>(ParentsService); service = module.get<ParentsService>(ParentsService);
@@ -15,4 +46,107 @@ describe('ParentsService', () => {
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('listerCouplesGarde', () => {
it('retourne une liste vide si le parent na pas denfant', 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 lenfant 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 nappartient 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);
});
});
}); });
+109 -1
View File
@@ -5,7 +5,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm'; import { In, IsNull, Repository } from 'typeorm';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { DossierFamille } from 'src/entities/dossier_famille.entity'; import { DossierFamille } from 'src/entities/dossier_famille.entity';
import { RoleType, Users } from 'src/entities/users.entity'; import { RoleType, Users } from 'src/entities/users.entity';
@@ -20,6 +20,8 @@ import {
import { ParentsChildren } from 'src/entities/parents_children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity';
import { Children } from 'src/entities/children.entity'; import { Children } from 'src/entities/children.entity';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; 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() @Injectable()
export class ParentsService { export class ParentsService {
@@ -32,6 +34,8 @@ export class ParentsService {
private readonly dossierFamilleRepository: Repository<DossierFamille>, private readonly dossierFamilleRepository: Repository<DossierFamille>,
@InjectRepository(ParentsChildren) @InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>, private readonly parentsChildrenRepository: Repository<ParentsChildren>,
@InjectRepository(AmChildren)
private readonly amChildrenRepository: Repository<AmChildren>,
) {} ) {}
// Création dun parent // Création dun parent
@@ -505,4 +509,108 @@ export class ParentsService {
} }
return raw.map((r: { id: string }) => r.id); 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<CouplesGardeResponseDto> {
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<CouplesGardeResponseDto> {
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);
}
} }
+14 -1
View File
@@ -154,7 +154,10 @@ CREATE INDEX idx_assistantes_maternelles_numero_dossier
CREATE TABLE parents ( CREATE TABLE parents (
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, 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) 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 CREATE INDEX idx_parents_numero_dossier
@@ -206,6 +209,16 @@ CREATE UNIQUE INDEX uq_enfant_garde_active
ON enfants_assistantes_maternelles (id_enfant) ON enfants_assistantes_maternelles (id_enfant)
WHERE date_fin IS NULL; 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) -- Table : dossier_famille (inscription parent — ticket #119)
-- ========================================================== -- ==========================================================
@@ -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;