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 OU modification d’un congé déjà accepté (S2b) if ( next === StatutAbsenceGardeType.EN_ATTENTE && (row.statut === StatutAbsenceGardeType.REFUSE || row.statut === StatutAbsenceGardeType.ACCEPTE) ) { return; } throw new ForbiddenException( 'L’AM ne valide pas elle-même (sauf republication / modification)', ); } 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), }; } }