Merge branch 'feature/171-api-couples-garde-am' into develop

API couples-garde AM (#171).
This commit is contained in:
2026-09-24 12:17:09 +02:00
10 changed files with 439 additions and 18 deletions
@@ -1,4 +1,4 @@
import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm';
import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn, ManyToOne } from 'typeorm';
import { Users } from './users.entity';
import { AmChildren } from './am_children.entity';
@@ -37,22 +37,32 @@ export class AssistanteMaternelle {
@Column({ name: 'ville_residence', length: 100, nullable: true })
residence_city?: string;
@Column( { name: 'date_agrement', type: 'date', nullable: true })
@Column({ name: 'date_agrement', type: 'date', nullable: true })
agreement_date?: Date;
@Column( { name: 'annee_experience', type: 'smallint', nullable: true })
@Column({ name: 'annee_experience', type: 'smallint', nullable: true })
years_experience?: number;
@Column( { name: 'specialite', length: 100, nullable: true })
@Column({ name: 'specialite', length: 100, nullable: true })
specialty?: string;
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
@Column({ name: 'place_disponible', type: 'integer', nullable: true })
places_available?: number;
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
@Column({ name: 'numero_dossier', length: 20, nullable: true })
numero_dossier?: string;
/**
* Placement AM↔enfant sélectionné sur le TdB AM (couple actif) — ticket #171.
*/
@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;
@OneToMany(() => AmChildren, (ac) => ac.am)
amChildren: AmChildren[];
}
@@ -10,7 +10,10 @@ describe('AssistantesMaternellesController', () => {
const authServiceMock = {
createAmDossierStaff: jest.fn(),
};
const amServiceMock = {};
const amServiceMock = {
listerCouplesGarde: jest.fn(),
definirCoupleGardeCourant: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -66,4 +69,29 @@ describe('AssistantesMaternellesController', () => {
);
expect(res.numero_dossier).toBe('2026-000001');
});
it('listerCouplesGarde délègue au service (#171)', async () => {
amServiceMock.listerCouplesGarde.mockResolvedValue({
couples: [],
couple_courant_id: null,
});
const res = await controller.listerCouplesGarde('am-uuid');
expect(amServiceMock.listerCouplesGarde).toHaveBeenCalledWith('am-uuid');
expect(res.couple_courant_id).toBeNull();
});
it('definirCoupleGardeCourant délègue au service (#171)', async () => {
amServiceMock.definirCoupleGardeCourant.mockResolvedValue({
couples: [{ id: 'pl-1', courant: true }],
couple_courant_id: 'pl-1',
});
const res = await controller.definirCoupleGardeCourant('am-uuid', {
couple_id: 'pl-1',
});
expect(amServiceMock.definirCoupleGardeCourant).toHaveBeenCalledWith(
'am-uuid',
'pl-1',
);
expect(res.couple_courant_id).toBe('pl-1');
});
});
@@ -2,6 +2,7 @@ import {
Controller,
Get,
Post,
Put,
Body,
Patch,
Param,
@@ -20,6 +21,8 @@ import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto';
import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto';
import { CouplesGardeAmResponseDto } from './dto/couples-garde-am.dto';
import { DefinirCoupleGardeCourantAmDto } from './dto/definir-couple-garde-courant-am.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';
@@ -83,6 +86,36 @@ export class AssistantesMaternellesController {
return mapAmsForApi(ams);
}
@Get('me/couples-garde')
@Roles(RoleType.ASSISTANTE_MATERNELLE)
@ApiOperation({
summary: 'Couples de garde de l’AM connectée — ticket #171',
description:
'Placements actifs (enfant + parents) pour peupler le bandeau couple TdB AM, ' +
'avec indication du couple courant.',
})
@ApiResponse({ status: 200, type: CouplesGardeAmResponseDto })
listerCouplesGarde(
@User('id') userId: string,
): Promise<CouplesGardeAmResponseDto> {
return this.assistantesMaternellesService.listerCouplesGarde(userId);
}
@Put('me/couples-garde/courant')
@Roles(RoleType.ASSISTANTE_MATERNELLE)
@ApiOperation({ summary: 'Définir le couple de garde courant (AM) — ticket #171' })
@ApiBody({ type: DefinirCoupleGardeCourantAmDto })
@ApiResponse({ status: 200, type: CouplesGardeAmResponseDto })
definirCoupleGardeCourant(
@User('id') userId: string,
@Body() dto: DefinirCoupleGardeCourantAmDto,
): Promise<CouplesGardeAmResponseDto> {
return this.assistantesMaternellesService.definirCoupleGardeCourant(
userId,
dto.couple_id,
);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get(':id')
@ApiParam({ name: 'id', description: "UUID de la nounou" })
@@ -4,13 +4,21 @@ import { AssistantesMaternellesController } from './assistantes_maternelles.cont
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { Children } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_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, AmChildren, Children, Users]),
AuthModule
imports: [
TypeOrmModule.forFeature([
AssistanteMaternelle,
AmChildren,
Children,
Users,
ParentsChildren,
]),
AuthModule,
],
controllers: [AssistantesMaternellesController],
providers: [AssistantesMaternellesService],
@@ -19,4 +27,4 @@ import { AuthModule } from '../auth/auth.module';
TypeOrmModule,
],
})
export class AssistantesMaternellesModule { }
export class AssistantesMaternellesModule {}
@@ -1,18 +1,150 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Users } from 'src/entities/users.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { Children } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
describe('AssistantesMaternellesService', () => {
describe('AssistantesMaternellesService — couples de garde (#171)', () => {
let service: AssistantesMaternellesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AssistantesMaternellesService],
}).compile();
const amRepo = { findOne: jest.fn(), update: jest.fn() };
const usersRepo = {};
const amChildrenRepo = { find: jest.fn(), findOne: jest.fn() };
const childrenRepo = {};
const parentsChildrenRepo = { find: jest.fn() };
service = module.get<AssistantesMaternellesService>(AssistantesMaternellesService);
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
AssistantesMaternellesService,
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
{ provide: getRepositoryToken(Users), useValue: usersRepo },
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo },
{ provide: getRepositoryToken(Children), useValue: childrenRepo },
{
provide: getRepositoryToken(ParentsChildren),
useValue: parentsChildrenRepo,
},
],
}).compile();
service = module.get(AssistantesMaternellesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('listerCouplesGarde', () => {
it('retourne vide si aucun placement', async () => {
amRepo.findOne.mockResolvedValue({
user_id: 'am-1',
id_placement_garde_courant: null,
});
amChildrenRepo.find.mockResolvedValue([]);
const res = await service.listerCouplesGarde('am-1');
expect(res).toEqual({ couples: [], couple_courant_id: null });
expect(parentsChildrenRepo.find).not.toHaveBeenCalled();
});
it('mappe placements + parents et marque le courant', async () => {
amRepo.findOne.mockResolvedValue({
user_id: 'am-1',
id_placement_garde_courant: 'pl-2',
});
amChildrenRepo.find.mockResolvedValue([
{
id: 'pl-1',
enfantId: 'e1',
child: { id: 'e1', first_name: 'Léa', last_name: 'M', photo_url: null },
},
{
id: 'pl-2',
enfantId: 'e2',
child: { id: 'e2', first_name: 'Emma', last_name: 'M', photo_url: null },
},
]);
parentsChildrenRepo.find.mockResolvedValue([
{
parentId: 'p1',
enfantId: 'e1',
parent: { user: { prenom: 'Claire', nom: 'Martin', photo_url: null } },
},
{
parentId: 'p2',
enfantId: 'e1',
parent: { user: { prenom: 'Thomas', nom: 'Martin', photo_url: null } },
},
{
parentId: 'p1',
enfantId: 'e2',
parent: { user: { prenom: 'Claire', nom: 'Martin', photo_url: null } },
},
]);
const res = await service.listerCouplesGarde('am-1');
expect(res.couples).toHaveLength(2);
expect(res.couple_courant_id).toBe('pl-2');
expect(res.couples[1].courant).toBe(true);
expect(res.couples[0].parents).toHaveLength(2);
expect(res.couples[0].parents[0].prenom).toBe('Claire');
expect(res.couples[0].enfant.prenom).toBe('Léa');
});
it('404 si AM inconnue', async () => {
amRepo.findOne.mockResolvedValue(null);
await expect(service.listerCouplesGarde('x')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('definirCoupleGardeCourant', () => {
it('persiste si le placement appartient à l’AM', async () => {
amRepo.findOne.mockResolvedValue({
user_id: 'am-1',
id_placement_garde_courant: null,
});
amChildrenRepo.findOne.mockResolvedValue({
id: 'pl-1',
amId: 'am-1',
enfantId: 'e1',
});
amRepo.update.mockResolvedValue({ affected: 1 });
amChildrenRepo.find.mockResolvedValue([
{
id: 'pl-1',
enfantId: 'e1',
child: { id: 'e1', first_name: 'Léa', last_name: null, photo_url: null },
},
]);
parentsChildrenRepo.find.mockResolvedValue([
{
parentId: 'p1',
enfantId: 'e1',
parent: { user: { prenom: 'Claire', nom: 'M', photo_url: null } },
},
]);
const res = await service.definirCoupleGardeCourant('am-1', 'pl-1');
expect(amRepo.update).toHaveBeenCalledWith(
{ user_id: 'am-1' },
{ id_placement_garde_courant: 'pl-1' },
);
expect(res.couple_courant_id).toBe('pl-1');
expect(res.couples[0].courant).toBe(true);
});
it('404 si placement d’une autre AM', async () => {
amRepo.findOne.mockResolvedValue({ user_id: 'am-1' });
amChildrenRepo.findOne.mockResolvedValue(null);
await expect(
service.definirCoupleGardeCourant('am-1', 'pl-x'),
).rejects.toBeInstanceOf(NotFoundException);
});
});
});
@@ -5,14 +5,16 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { In, 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 { ParentsChildren } from 'src/entities/parents_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';
import { CouplesGardeAmResponseDto } from './dto/couples-garde-am.dto';
import { validateNir } from 'src/common/utils/nir.util';
const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const;
@@ -28,6 +30,8 @@ export class AssistantesMaternellesService {
private readonly amChildrenRepository: Repository<AmChildren>,
@InjectRepository(Children)
private readonly childrenRepository: Repository<Children>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
) {}
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
@@ -264,4 +268,107 @@ export class AssistantesMaternellesService {
await this.assistantesMaternelleRepository.delete(id);
return { message: 'Assistante maternelle supprimée' };
}
/**
* Liste les couples de garde (enfant ↔ parents) de l’AM connectée — ticket #171.
* Un couple = un placement actif dans enfants_assistantes_maternelles pour cette AM.
*/
async listerCouplesGarde(amUserId: string): Promise<CouplesGardeAmResponseDto> {
const am = await this.assistantesMaternelleRepository.findOne({
where: { user_id: amUserId },
});
if (!am) {
throw new NotFoundException('Assistante maternelle introuvable');
}
const placements = await this.amChildrenRepository.find({
where: { amId: amUserId, date_fin: IsNull() },
relations: ['child'],
order: { date_debut: 'ASC' },
});
if (placements.length === 0) {
return { couples: [], couple_courant_id: null };
}
const enfantIds = placements.map((p) => p.enfantId);
const liens = await this.parentsChildrenRepository.find({
where: { enfantId: In(enfantIds) },
relations: ['parent', 'parent.user'],
});
const parentsByEnfant = new Map<
string,
{ id: string; prenom: string | null; nom: string | null; photo_url: string | null }[]
>();
for (const l of liens) {
const list = parentsByEnfant.get(l.enfantId) ?? [];
const u = l.parent?.user;
list.push({
id: l.parentId,
prenom: u?.prenom ?? null,
nom: u?.nom ?? null,
photo_url: u?.photo_url ?? null,
});
parentsByEnfant.set(l.enfantId, list);
}
let idCourant = am.id_placement_garde_courant ?? null;
const idsValides = new Set(placements.map((p) => p.id));
if (idCourant && !idsValides.has(idCourant)) {
idCourant = null;
await this.assistantesMaternelleRepository.update(
{ user_id: amUserId },
{ id_placement_garde_courant: () => 'NULL' },
);
}
if (!idCourant && placements.length === 1) {
idCourant = placements[0].id;
}
const couples = placements.map((p) => ({
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,
},
parents: parentsByEnfant.get(p.enfantId) ?? [],
courant: idCourant != null && p.id === idCourant,
}));
return {
couples,
couple_courant_id: idCourant,
};
}
/**
* Persiste le couple de garde actif pour l’AM — ticket #171.
*/
async definirCoupleGardeCourant(
amUserId: string,
coupleId: string,
): Promise<CouplesGardeAmResponseDto> {
const am = await this.assistantesMaternelleRepository.findOne({
where: { user_id: amUserId },
});
if (!am) {
throw new NotFoundException('Assistante maternelle introuvable');
}
const placement = await this.amChildrenRepository.findOne({
where: { id: coupleId, amId: amUserId, date_fin: IsNull() },
});
if (!placement) {
throw new NotFoundException('Couple de garde introuvable ou inactif pour cette AM');
}
await this.assistantesMaternelleRepository.update(
{ user_id: amUserId },
{ id_placement_garde_courant: coupleId },
);
return this.listerCouplesGarde(amUserId);
}
}
@@ -0,0 +1,67 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/** Identité minimale enfant — ticket #171 (miroir #168) */
export class CoupleGardeAmEnfantDto {
@ApiProperty({ format: 'uuid' })
id: string;
@ApiPropertyOptional()
prenom?: string | null;
@ApiPropertyOptional()
nom?: string | null;
@ApiPropertyOptional()
photo_url?: string | null;
}
/** Identité minimale parent pour bandeau AM — ticket #171 */
export class CoupleGardeAmParentDto {
@ApiProperty({ format: 'uuid', description: 'UUID utilisateur du parent' })
id: string;
@ApiPropertyOptional()
prenom?: string | null;
@ApiPropertyOptional()
nom?: string | null;
@ApiPropertyOptional()
photo_url?: string | null;
}
/**
* Un couple côté AM = placement actif enfant ↔ foyer parents.
* `id` = enfants_assistantes_maternelles.id (même clé que #168).
*/
export class CoupleGardeAmDto {
@ApiProperty({
format: 'uuid',
description: 'Id du placement (enfants_assistantes_maternelles.id)',
})
id: string;
@ApiProperty({ type: CoupleGardeAmEnfantDto })
enfant: CoupleGardeAmEnfantDto;
@ApiProperty({
type: [CoupleGardeAmParentDto],
description: 'Parents rattachés à l’enfant (0–2 typiquement)',
})
parents: CoupleGardeAmParentDto[];
@ApiProperty({ description: 'True si c’est le couple actuellement sélectionné' })
courant: boolean;
}
export class CouplesGardeAmResponseDto {
@ApiProperty({ type: [CoupleGardeAmDto] })
couples: CoupleGardeAmDto[];
@ApiPropertyOptional({
format: 'uuid',
nullable: true,
description: 'Id du couple courant (null si aucun)',
})
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 AM — ticket #171 */
export class DefinirCoupleGardeCourantAmDto {
@ApiProperty({
format: 'uuid',
description: 'Id du placement (enfants_assistantes_maternelles.id) à sélectionner',
})
@IsUUID()
@IsNotEmpty()
couple_id: string;
}
+14 -1
View File
@@ -143,7 +143,10 @@ CREATE TABLE assistantes_maternelles (
annee_experience SMALLINT,
specialite VARCHAR(100),
place_disponible INT,
numero_dossier VARCHAR(20)
numero_dossier VARCHAR(20),
-- Préférence couple de garde actif (TdB quotidien AM) — ticket #171
-- FK ajoutée après création de enfants_assistantes_maternelles (voir ALTER plus bas)
id_placement_garde_courant UUID
);
CREATE INDEX idx_assistantes_maternelles_numero_dossier
@@ -221,6 +224,16 @@ CREATE INDEX idx_parents_placement_garde_courant
ON parents(id_placement_garde_courant)
WHERE id_placement_garde_courant IS NOT NULL;
-- FK couple courant AM → placement (#171)
ALTER TABLE assistantes_maternelles
ADD CONSTRAINT fk_am_placement_garde_courant
FOREIGN KEY (id_placement_garde_courant)
REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL;
CREATE INDEX idx_am_placement_garde_courant
ON assistantes_maternelles(id_placement_garde_courant)
WHERE id_placement_garde_courant IS NOT NULL;
-- ==========================================================
-- Table : dossier_famille (inscription parent — ticket #119)
-- ==========================================================
@@ -0,0 +1,10 @@
-- Ticket #171 — Couple de garde courant (préférence AM)
-- Idempotent : safe à rejouer.
ALTER TABLE assistantes_maternelles
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_am_placement_garde_courant
ON assistantes_maternelles(id_placement_garde_courant)
WHERE id_placement_garde_courant IS NOT NULL;