From ba6078feabc066af00b47104d85767bb0aabfda1 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 24 Sep 2026 09:19:03 +0000 Subject: [PATCH] feat(#172): API absences-garde (liste, CRUD, droits parent/AM) Propagation depuis develop. Co-authored-by: Julien Martin --- backend/src/app.module.ts | 2 + .../absences-garde.controller.spec.ts | 73 +++ .../absences-garde.controller.ts | 124 ++++++ .../absences-garde/absences-garde.module.ts | 17 + .../absences-garde.service.spec.ts | 168 +++++++ .../absences-garde/absences-garde.service.ts | 420 ++++++++++++++++++ .../absences-garde/dto/absences-garde.dto.ts | 119 +++++ backend/src/modules/absences-garde/index.ts | 2 + 8 files changed, 925 insertions(+) create mode 100644 backend/src/modules/absences-garde/absences-garde.controller.spec.ts create mode 100644 backend/src/modules/absences-garde/absences-garde.controller.ts create mode 100644 backend/src/modules/absences-garde/absences-garde.module.ts create mode 100644 backend/src/modules/absences-garde/absences-garde.service.spec.ts create mode 100644 backend/src/modules/absences-garde/absences-garde.service.ts create mode 100644 backend/src/modules/absences-garde/dto/absences-garde.dto.ts create mode 100644 backend/src/modules/absences-garde/index.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 715f83c..ff54b1e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -16,6 +16,7 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters'; import { EnfantsModule } from './routes/enfants/enfants.module'; import { AppConfigModule } from './modules/config/config.module'; import { DocumentsLegauxModule } from './modules/documents-legaux'; +import { AbsencesGardeModule } from './modules/absences-garde'; import { RelaisModule } from './routes/relais/relais.module'; import { DossiersModule } from './routes/dossiers/dossiers.module'; import { SuppressionsModule } from './routes/suppressions/suppressions.module'; @@ -56,6 +57,7 @@ import { SuppressionsModule } from './routes/suppressions/suppressions.module'; AuthModule, AppConfigModule, DocumentsLegauxModule, + AbsencesGardeModule, RelaisModule, DossiersModule, SuppressionsModule, diff --git a/backend/src/modules/absences-garde/absences-garde.controller.spec.ts b/backend/src/modules/absences-garde/absences-garde.controller.spec.ts new file mode 100644 index 0000000..84844d7 --- /dev/null +++ b/backend/src/modules/absences-garde/absences-garde.controller.spec.ts @@ -0,0 +1,73 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AbsencesGardeController } from './absences-garde.controller'; +import { AbsencesGardeService } from './absences-garde.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { RoleType } from 'src/entities/users.entity'; +import { TypeAbsenceGardeType } from 'src/entities/absences_garde.entity'; + +describe('AbsencesGardeController (#172)', () => { + let controller: AbsencesGardeController; + const serviceMock = { + lister: jest.fn(), + creer: jest.fn(), + maj: jest.fn(), + supprimer: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AbsencesGardeController], + providers: [{ provide: AbsencesGardeService, useValue: serviceMock }], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(AbsencesGardeController); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('lister délègue au service', async () => { + serviceMock.lister.mockResolvedValue({ items: [] }); + const res = await controller.lister( + 'u1', + RoleType.PARENT, + 'pl-1', + undefined, + undefined, + '2026-01-01', + '2026-12-31', + ); + expect(serviceMock.lister).toHaveBeenCalledWith('u1', RoleType.PARENT, { + placementId: 'pl-1', + type: undefined, + statut: undefined, + from: '2026-01-01', + to: '2026-12-31', + }); + expect(res.items).toEqual([]); + }); + + it('creer délègue au service', async () => { + serviceMock.creer.mockResolvedValue({ id: 'a1' }); + const dto = { + id_placement: 'pl-1', + type: TypeAbsenceGardeType.ABSENCE_ENFANT, + date_debut: '2026-10-01', + date_fin: '2026-10-02', + }; + await controller.creer('u1', RoleType.PARENT, dto); + expect(serviceMock.creer).toHaveBeenCalledWith( + 'u1', + RoleType.PARENT, + dto, + ); + }); +}); diff --git a/backend/src/modules/absences-garde/absences-garde.controller.ts b/backend/src/modules/absences-garde/absences-garde.controller.ts new file mode 100644 index 0000000..02163ca --- /dev/null +++ b/backend/src/modules/absences-garde/absences-garde.controller.ts @@ -0,0 +1,124 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { User } from 'src/common/decorators/user.decorator'; +import { + StatutAbsenceGardeType, + TypeAbsenceGardeType, +} from 'src/entities/absences_garde.entity'; +import { RoleType } from 'src/entities/users.entity'; +import { AbsencesGardeService } from './absences-garde.service'; +import { + AbsenceGardeDto, + CreerAbsenceGardeDto, + ListeAbsencesGardeDto, + MajAbsenceGardeDto, +} from './dto/absences-garde.dto'; + +@ApiTags('Absences garde') +@ApiBearerAuth('access-token') +@Controller('absences-garde') +@UseGuards(AuthGuard, RolesGuard) +export class AbsencesGardeController { + constructor(private readonly absencesGardeService: AbsencesGardeService) {} + + @Get() + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ + summary: 'Lister les absences / congés / arrêts — ticket #172', + description: + 'Sans placementId : tous les placements du user (parent = famille multi-AM ; AM = tous accueils). ' + + 'Avec placementId : un couple enfant–AM.', + }) + @ApiQuery({ name: 'placementId', required: false }) + @ApiQuery({ name: 'type', required: false, enum: TypeAbsenceGardeType }) + @ApiQuery({ name: 'statut', required: false, enum: StatutAbsenceGardeType }) + @ApiQuery({ name: 'from', required: false, description: 'YYYY-MM-DD' }) + @ApiQuery({ name: 'to', required: false, description: 'YYYY-MM-DD' }) + @ApiResponse({ status: 200, type: ListeAbsencesGardeDto }) + lister( + @User('id') userId: string, + @User('role') role: RoleType, + @Query('placementId') placementId?: string, + @Query('type') type?: TypeAbsenceGardeType, + @Query('statut') statut?: StatutAbsenceGardeType, + @Query('from') from?: string, + @Query('to') to?: string, + ): Promise { + return this.absencesGardeService.lister(userId, role, { + placementId, + type, + statut, + from, + to, + }); + } + + @Post() + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ summary: 'Créer une période d’absence / congé / arrêt — #172' }) + @ApiBody({ type: CreerAbsenceGardeDto }) + @ApiResponse({ status: 201, type: AbsenceGardeDto }) + @HttpCode(HttpStatus.CREATED) + creer( + @User('id') userId: string, + @User('role') role: RoleType, + @Body() dto: CreerAbsenceGardeDto, + ): Promise { + return this.absencesGardeService.creer(userId, role, dto); + } + + @Patch(':id') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @ApiOperation({ + summary: 'Mettre à jour dates / statut / motif — #172', + description: + 'Parent : accept/refuse congé (motivation si refus), ack arrêt. ' + + 'AM : modifier dates (en attente / refuse / accepté), republication après refus.', + }) + @ApiBody({ type: MajAbsenceGardeDto }) + @ApiResponse({ status: 200, type: AbsenceGardeDto }) + maj( + @User('id') userId: string, + @User('role') role: RoleType, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: MajAbsenceGardeDto, + ): Promise { + return this.absencesGardeService.maj(userId, role, id, dto); + } + + @Delete(':id') + @Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Supprimer une absence (hard delete) — #172' }) + @ApiResponse({ status: 204 }) + async supprimer( + @User('id') userId: string, + @User('role') role: RoleType, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.absencesGardeService.supprimer(userId, role, id); + } +} diff --git a/backend/src/modules/absences-garde/absences-garde.module.ts b/backend/src/modules/absences-garde/absences-garde.module.ts new file mode 100644 index 0000000..174fa37 --- /dev/null +++ b/backend/src/modules/absences-garde/absences-garde.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AbsencesGarde } from 'src/entities/absences_garde.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { AbsencesGardeController } from './absences-garde.controller'; +import { AbsencesGardeService } from './absences-garde.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AbsencesGarde, AmChildren, ParentsChildren]), + ], + controllers: [AbsencesGardeController], + providers: [AbsencesGardeService], + exports: [AbsencesGardeService], +}) +export class AbsencesGardeModule {} diff --git a/backend/src/modules/absences-garde/absences-garde.service.spec.ts b/backend/src/modules/absences-garde/absences-garde.service.spec.ts new file mode 100644 index 0000000..1c40cd7 --- /dev/null +++ b/backend/src/modules/absences-garde/absences-garde.service.spec.ts @@ -0,0 +1,168 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ForbiddenException, BadRequestException } from '@nestjs/common'; +import { AbsencesGardeService } from './absences-garde.service'; +import { + AbsencesGarde, + StatutAbsenceGardeType, + TypeAbsenceGardeType, +} from 'src/entities/absences_garde.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { RoleType } from 'src/entities/users.entity'; + +describe('AbsencesGardeService (#172)', () => { + let service: AbsencesGardeService; + const absencesRepo = { + create: jest.fn((x) => x), + save: jest.fn(async (x) => ({ + ...x, + id: x.id ?? 'abs-1', + cree_le: new Date(), + modifie_le: new Date(), + })), + findOne: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + }; + const amChildrenRepo = { + find: jest.fn(), + findOne: jest.fn(), + }; + const parentsChildrenRepo = { + find: jest.fn(), + findOne: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AbsencesGardeService, + { provide: getRepositoryToken(AbsencesGarde), useValue: absencesRepo }, + { provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo }, + { + provide: getRepositoryToken(ParentsChildren), + useValue: parentsChildrenRepo, + }, + ], + }).compile(); + service = module.get(AbsencesGardeService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('parent crée absence_enfant → statut accepte', async () => { + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + parentsChildrenRepo.findOne.mockResolvedValue({ + parentId: 'p-1', + enfantId: 'e-1', + }); + absencesRepo.findOne.mockResolvedValue({ + id: 'abs-1', + id_placement: 'pl-1', + type: TypeAbsenceGardeType.ABSENCE_ENFANT, + date_debut: '2026-10-01', + date_fin: '2026-10-02', + statut: StatutAbsenceGardeType.ACCEPTE, + expire_at: new Date('9999-12-31'), + cree_le: new Date(), + modifie_le: new Date(), + placement: { + enfantId: 'e-1', + amId: 'am-1', + child: { id: 'e-1', first_name: 'Léo' }, + am: { user: { prenom: 'Marie', nom: 'AM' } }, + }, + }); + + const res = await service.creer('p-1', RoleType.PARENT, { + id_placement: 'pl-1', + type: TypeAbsenceGardeType.ABSENCE_ENFANT, + date_debut: '2026-10-01', + date_fin: '2026-10-02', + }); + + expect(absencesRepo.save).toHaveBeenCalled(); + const savedArg = absencesRepo.save.mock.calls[0][0]; + expect(savedArg.statut).toBe(StatutAbsenceGardeType.ACCEPTE); + expect(res.type).toBe(TypeAbsenceGardeType.ABSENCE_ENFANT); + }); + + it('parent ne peut pas créer un congé AM', async () => { + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + parentsChildrenRepo.findOne.mockResolvedValue({ + parentId: 'p-1', + enfantId: 'e-1', + }); + await expect( + service.creer('p-1', RoleType.PARENT, { + id_placement: 'pl-1', + type: TypeAbsenceGardeType.CONGE_AM, + date_debut: '2026-10-01', + date_fin: '2026-10-05', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('refus congé sans motif → BadRequest', async () => { + absencesRepo.findOne.mockResolvedValue({ + id: 'abs-1', + id_placement: 'pl-1', + type: TypeAbsenceGardeType.CONGE_AM, + date_debut: '2026-10-01', + date_fin: '2026-10-05', + statut: StatutAbsenceGardeType.EN_ATTENTE, + expire_at: new Date(), + }); + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + parentsChildrenRepo.findOne.mockResolvedValue({ + parentId: 'p-1', + enfantId: 'e-1', + }); + + await expect( + service.maj('p-1', RoleType.PARENT, 'abs-1', { + statut: StatutAbsenceGardeType.REFUSE, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('dates invalides → BadRequest', async () => { + amChildrenRepo.findOne.mockResolvedValue({ + id: 'pl-1', + amId: 'am-1', + enfantId: 'e-1', + date_fin: null, + }); + parentsChildrenRepo.findOne.mockResolvedValue({ + parentId: 'p-1', + enfantId: 'e-1', + }); + await expect( + service.creer('p-1', RoleType.PARENT, { + id_placement: 'pl-1', + type: TypeAbsenceGardeType.ABSENCE_ENFANT, + date_debut: '2026-10-10', + date_fin: '2026-10-01', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/backend/src/modules/absences-garde/absences-garde.service.ts b/backend/src/modules/absences-garde/absences-garde.service.ts new file mode 100644 index 0000000..a3bf9f5 --- /dev/null +++ b/backend/src/modules/absences-garde/absences-garde.service.ts @@ -0,0 +1,420 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, Repository } from 'typeorm'; +import { + AbsencesGarde, + StatutAbsenceGardeType, + TypeAbsenceGardeType, +} from 'src/entities/absences_garde.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { RoleType } from 'src/entities/users.entity'; +import { + AbsenceGardeDto, + CreerAbsenceGardeDto, + ListeAbsencesGardeDto, + MajAbsenceGardeDto, +} from './dto/absences-garde.dto'; + +const TTL_EN_ATTENTE_MS = 15 * 24 * 60 * 60 * 1000; +const TTL_REFUSE_MS = 7 * 24 * 60 * 60 * 1000; +/** Sentinel « pas de purge » pour les périodes acceptées */ +const EXPIRE_ACCEPTE = new Date('9999-12-31T23:59:59.999Z'); + +@Injectable() +export class AbsencesGardeService { + constructor( + @InjectRepository(AbsencesGarde) + private readonly absencesRepo: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepo: Repository, + @InjectRepository(ParentsChildren) + private readonly parentsChildrenRepo: Repository, + ) {} + + async lister( + userId: string, + role: RoleType, + opts: { + placementId?: string; + type?: TypeAbsenceGardeType; + statut?: StatutAbsenceGardeType; + from?: string; + to?: string; + }, + ): Promise { + const placementIds = await this.resolvePlacementIds( + userId, + role, + opts.placementId, + ); + if (placementIds.length === 0) { + return { items: [] }; + } + + const qb = this.absencesRepo + .createQueryBuilder('ag') + .leftJoinAndSelect('ag.placement', 'placement') + .leftJoinAndSelect('placement.child', 'child') + .leftJoinAndSelect('placement.am', 'am') + .leftJoinAndSelect('am.user', 'amUser') + .where('ag.id_placement IN (:...placementIds)', { placementIds }) + .orderBy('ag.date_debut', 'DESC'); + + if (opts.type) { + qb.andWhere('ag.type = :type', { type: opts.type }); + } + if (opts.statut) { + qb.andWhere('ag.statut = :statut', { statut: opts.statut }); + } + if (opts.from) { + qb.andWhere('ag.date_fin >= :from', { from: opts.from }); + } + if (opts.to) { + qb.andWhere('ag.date_debut <= :to', { to: opts.to }); + } + + const rows = await qb.getMany(); + return { items: rows.map((r) => this.toDto(r)) }; + } + + async creer( + userId: string, + role: RoleType, + dto: CreerAbsenceGardeDto, + ): Promise { + this.assertDates(dto.date_debut, dto.date_fin); + await this.assertCanAccessPlacement(userId, role, dto.id_placement); + this.assertCanCreateType(role, dto.type); + + const statut = this.statutInitial(dto.type); + const entity = this.absencesRepo.create({ + id_placement: dto.id_placement, + type: dto.type, + date_debut: dto.date_debut, + date_fin: dto.date_fin, + statut, + expire_at: this.expireAtFor(statut), + cree_par: userId, + motif: dto.motif?.trim() || undefined, + }); + const saved = await this.absencesRepo.save(entity); + return this.getByIdForUser(saved.id, userId, role); + } + + async maj( + userId: string, + role: RoleType, + id: string, + dto: MajAbsenceGardeDto, + ): Promise { + const row = await this.absencesRepo.findOne({ where: { id } }); + if (!row) { + throw new NotFoundException('Absence introuvable'); + } + await this.assertCanAccessPlacement(userId, role, row.id_placement); + + if (dto.date_debut !== undefined || dto.date_fin !== undefined) { + const debut = dto.date_debut ?? row.date_debut; + const fin = dto.date_fin ?? row.date_fin; + this.assertDates(debut, fin); + // Parent : peut modifier ses absences enfant + // AM : peut modifier congé/arrêt en_attente (avant accept) ou dates si créateur + this.assertCanEditDates(role, row); + row.date_debut = debut; + row.date_fin = fin; + if (row.statut === StatutAbsenceGardeType.EN_ATTENTE) { + row.expire_at = this.expireAtFor(StatutAbsenceGardeType.EN_ATTENTE); + } + } + + if (dto.statut !== undefined && dto.statut !== row.statut) { + this.assertCanChangeStatut(role, row, dto.statut, dto.motif); + if ( + dto.statut === StatutAbsenceGardeType.REFUSE && + (!dto.motif || !dto.motif.trim()) + ) { + throw new BadRequestException( + 'Une motivation est obligatoire en cas de refus', + ); + } + row.statut = dto.statut; + row.expire_at = this.expireAtFor(dto.statut); + if (dto.motif?.trim()) { + row.motif = dto.motif.trim(); + } + } else if (dto.motif !== undefined) { + row.motif = dto.motif.trim() || undefined; + } + + await this.absencesRepo.save(row); + return this.getByIdForUser(id, userId, role); + } + + async supprimer( + userId: string, + role: RoleType, + id: string, + ): Promise { + const row = await this.absencesRepo.findOne({ where: { id } }); + if (!row) { + throw new NotFoundException('Absence introuvable'); + } + await this.assertCanAccessPlacement(userId, role, row.id_placement); + + if ( + role === RoleType.PARENT && + row.type !== TypeAbsenceGardeType.ABSENCE_ENFANT + ) { + throw new ForbiddenException( + 'Un parent ne peut supprimer que les absences enfant', + ); + } + + await this.absencesRepo.delete({ id }); + } + + private async getByIdForUser( + id: string, + userId: string, + role: RoleType, + ): Promise { + const row = await this.absencesRepo.findOne({ + where: { id }, + relations: ['placement', 'placement.child', 'placement.am', 'placement.am.user'], + }); + if (!row) { + throw new NotFoundException('Absence introuvable'); + } + await this.assertCanAccessPlacement(userId, role, row.id_placement); + return this.toDto(row); + } + + private statutInitial(type: TypeAbsenceGardeType): StatutAbsenceGardeType { + if (type === TypeAbsenceGardeType.ABSENCE_ENFANT) { + return StatutAbsenceGardeType.ACCEPTE; + } + return StatutAbsenceGardeType.EN_ATTENTE; + } + + private expireAtFor(statut: StatutAbsenceGardeType): Date { + const now = Date.now(); + if (statut === StatutAbsenceGardeType.ACCEPTE) { + return EXPIRE_ACCEPTE; + } + if (statut === StatutAbsenceGardeType.REFUSE) { + return new Date(now + TTL_REFUSE_MS); + } + return new Date(now + TTL_EN_ATTENTE_MS); + } + + private assertDates(debut: string, fin: string): void { + if (fin < debut) { + throw new BadRequestException( + 'date_fin doit être supérieure ou égale à date_debut', + ); + } + } + + private assertCanCreateType(role: RoleType, type: TypeAbsenceGardeType): void { + if (role === RoleType.PARENT) { + if (type !== TypeAbsenceGardeType.ABSENCE_ENFANT) { + throw new ForbiddenException( + 'Un parent ne peut créer que des absences enfant', + ); + } + return; + } + if (role === RoleType.ASSISTANTE_MATERNELLE) { + if ( + type !== TypeAbsenceGardeType.CONGE_AM && + type !== TypeAbsenceGardeType.ARRET_MALADIE_AM + ) { + throw new ForbiddenException( + 'Une AM ne peut créer que congé ou arrêt maladie', + ); + } + return; + } + throw new ForbiddenException('Rôle non autorisé à créer une absence'); + } + + private assertCanEditDates( + role: RoleType, + row: AbsencesGarde, + ): void { + if (role === RoleType.PARENT) { + if (row.type !== TypeAbsenceGardeType.ABSENCE_ENFANT) { + throw new ForbiddenException( + 'Un parent ne peut modifier que les absences enfant', + ); + } + return; + } + if (role === RoleType.ASSISTANTE_MATERNELLE) { + if ( + row.type === TypeAbsenceGardeType.CONGE_AM || + row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM + ) { + if ( + row.statut !== StatutAbsenceGardeType.EN_ATTENTE && + row.statut !== StatutAbsenceGardeType.REFUSE && + row.statut !== StatutAbsenceGardeType.ACCEPTE + ) { + throw new ForbiddenException('Statut incompatible avec une modification'); + } + // Accepté : autorisé (S2b — re-validation via cartes plus tard ; API permet update dates) + return; + } + throw new ForbiddenException('Type non modifiable par l’AM'); + } + throw new ForbiddenException('Modification non autorisée'); + } + + private assertCanChangeStatut( + role: RoleType, + row: AbsencesGarde, + next: StatutAbsenceGardeType, + _motif?: string, + ): void { + if (role === RoleType.PARENT) { + // Accept / refuse congé ; ack arrêt (accepte) + if ( + row.type === TypeAbsenceGardeType.CONGE_AM || + row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM + ) { + if ( + next !== StatutAbsenceGardeType.ACCEPTE && + next !== StatutAbsenceGardeType.REFUSE + ) { + throw new BadRequestException('Transition de statut invalide'); + } + if ( + row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM && + next === StatutAbsenceGardeType.REFUSE + ) { + throw new BadRequestException( + 'Un arrêt maladie ne se refuse pas (accusé seulement)', + ); + } + if (row.statut !== StatutAbsenceGardeType.EN_ATTENTE) { + throw new BadRequestException('Cette demande n’est plus en attente'); + } + return; + } + throw new ForbiddenException( + 'Pas de changement de statut sur ce type pour un parent', + ); + } + if (role === RoleType.ASSISTANTE_MATERNELLE) { + // Remise en attente après refus (republication) + if ( + row.statut === StatutAbsenceGardeType.REFUSE && + next === StatutAbsenceGardeType.EN_ATTENTE + ) { + return; + } + throw new ForbiddenException( + 'L’AM ne valide pas elle-même (sauf republication après refus)', + ); + } + throw new ForbiddenException('Changement de statut non autorisé'); + } + + private async resolvePlacementIds( + userId: string, + role: RoleType, + placementId?: string, + ): Promise { + if (placementId) { + await this.assertCanAccessPlacement(userId, role, placementId); + return [placementId]; + } + + if (role === RoleType.PARENT) { + const liens = await this.parentsChildrenRepo.find({ + where: { parentId: userId }, + select: ['enfantId'], + }); + const enfantIds = liens.map((l) => l.enfantId); + if (enfantIds.length === 0) return []; + const placements = await this.amChildrenRepo.find({ + where: { enfantId: In(enfantIds), date_fin: IsNull() }, + select: ['id'], + }); + return placements.map((p) => p.id); + } + + if (role === RoleType.ASSISTANTE_MATERNELLE) { + const placements = await this.amChildrenRepo.find({ + where: { amId: userId, date_fin: IsNull() }, + select: ['id'], + }); + return placements.map((p) => p.id); + } + + throw new ForbiddenException('Rôle non autorisé'); + } + + private async assertCanAccessPlacement( + userId: string, + role: RoleType, + placementId: string, + ): Promise { + const placement = await this.amChildrenRepo.findOne({ + where: { id: placementId, date_fin: IsNull() }, + }); + if (!placement) { + throw new NotFoundException('Placement / couple introuvable ou inactif'); + } + + if (role === RoleType.ASSISTANTE_MATERNELLE) { + if (placement.amId !== userId) { + throw new ForbiddenException('Ce placement ne vous appartient pas'); + } + return placement; + } + + if (role === RoleType.PARENT) { + const lien = await this.parentsChildrenRepo.findOne({ + where: { parentId: userId, enfantId: placement.enfantId }, + }); + if (!lien) { + throw new ForbiddenException( + 'Ce placement ne concerne pas un de vos enfants', + ); + } + return placement; + } + + throw new ForbiddenException('Rôle non autorisé'); + } + + private toDto(row: AbsencesGarde): AbsenceGardeDto { + const child = row.placement?.child; + const amUser = row.placement?.am?.user; + return { + id: row.id, + id_placement: row.id_placement, + type: row.type, + date_debut: row.date_debut, + date_fin: row.date_fin, + statut: row.statut, + expire_at: row.expire_at?.toISOString?.() ?? String(row.expire_at), + cree_par: row.cree_par ?? null, + motif: row.motif ?? null, + id_enfant: child?.id ?? row.placement?.enfantId ?? null, + prenom_enfant: child?.first_name ?? null, + id_am: row.placement?.amId ?? null, + prenom_am: amUser?.prenom ?? null, + nom_am: amUser?.nom ?? null, + cree_le: row.cree_le?.toISOString?.() ?? String(row.cree_le), + modifie_le: row.modifie_le?.toISOString?.() ?? String(row.modifie_le), + }; + } +} diff --git a/backend/src/modules/absences-garde/dto/absences-garde.dto.ts b/backend/src/modules/absences-garde/dto/absences-garde.dto.ts new file mode 100644 index 0000000..326be46 --- /dev/null +++ b/backend/src/modules/absences-garde/dto/absences-garde.dto.ts @@ -0,0 +1,119 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; +import { + StatutAbsenceGardeType, + TypeAbsenceGardeType, +} from 'src/entities/absences_garde.entity'; + +export class CreerAbsenceGardeDto { + @ApiProperty({ description: 'Placement AM↔enfant (couple)' }) + @IsUUID() + id_placement: string; + + @ApiProperty({ enum: TypeAbsenceGardeType }) + @IsEnum(TypeAbsenceGardeType) + type: TypeAbsenceGardeType; + + @ApiProperty({ example: '2026-10-01' }) + @IsDateString() + date_debut: string; + + @ApiProperty({ example: '2026-10-05' }) + @IsDateString() + date_fin: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + motif?: string; +} + +export class MajAbsenceGardeDto { + @ApiPropertyOptional({ example: '2026-10-02' }) + @IsOptional() + @IsDateString() + date_debut?: string; + + @ApiPropertyOptional({ example: '2026-10-06' }) + @IsOptional() + @IsDateString() + date_fin?: string; + + @ApiPropertyOptional({ enum: StatutAbsenceGardeType }) + @IsOptional() + @IsEnum(StatutAbsenceGardeType) + statut?: StatutAbsenceGardeType; + + @ApiPropertyOptional({ + description: 'Motivation de refus (parent) ou motif créateur', + }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(2000) + motif?: string; +} + +export class AbsenceGardeDto { + @ApiProperty() + id: string; + + @ApiProperty() + id_placement: string; + + @ApiProperty({ enum: TypeAbsenceGardeType }) + type: TypeAbsenceGardeType; + + @ApiProperty() + date_debut: string; + + @ApiProperty() + date_fin: string; + + @ApiProperty({ enum: StatutAbsenceGardeType }) + statut: StatutAbsenceGardeType; + + @ApiProperty() + expire_at: string; + + @ApiPropertyOptional() + cree_par?: string | null; + + @ApiPropertyOptional() + motif?: string | null; + + @ApiPropertyOptional() + id_enfant?: string | null; + + @ApiPropertyOptional() + prenom_enfant?: string | null; + + @ApiPropertyOptional() + id_am?: string | null; + + @ApiPropertyOptional() + prenom_am?: string | null; + + @ApiPropertyOptional() + nom_am?: string | null; + + @ApiProperty() + cree_le: string; + + @ApiProperty() + modifie_le: string; +} + +export class ListeAbsencesGardeDto { + @ApiProperty({ type: [AbsenceGardeDto] }) + items: AbsenceGardeDto[]; +} diff --git a/backend/src/modules/absences-garde/index.ts b/backend/src/modules/absences-garde/index.ts new file mode 100644 index 0000000..11a9a04 --- /dev/null +++ b/backend/src/modules/absences-garde/index.ts @@ -0,0 +1,2 @@ +export { AbsencesGardeModule } from './absences-garde.module'; +export { AbsencesGardeService } from './absences-garde.service';