Files
petitspas/backend/src/modules/absences-garde/absences-garde.service.ts
T
2026-09-24 09:38:39 +00:00

422 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AbsencesGarde>,
@InjectRepository(AmChildren)
private readonly amChildrenRepo: Repository<AmChildren>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
) {}
async lister(
userId: string,
role: RoleType,
opts: {
placementId?: string;
type?: TypeAbsenceGardeType;
statut?: StatutAbsenceGardeType;
from?: string;
to?: string;
},
): Promise<ListeAbsencesGardeDto> {
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<AbsenceGardeDto> {
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<AbsenceGardeDto> {
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<void> {
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<AbsenceGardeDto> {
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 lAM');
}
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 nest 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 dun congé déjà accepté (S2b)
if (
next === StatutAbsenceGardeType.EN_ATTENTE &&
(row.statut === StatutAbsenceGardeType.REFUSE ||
row.statut === StatutAbsenceGardeType.ACCEPTE)
) {
return;
}
throw new ForbiddenException(
'LAM 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<string[]> {
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<AmChildren> {
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),
};
}
}