422 lines
13 KiB
TypeScript
422 lines
13 KiB
TypeScript
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 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<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),
|
||
};
|
||
}
|
||
}
|