import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Repository } from 'typeorm'; import { AbsencesGardeService } from '../absences-garde/absences-garde.service'; import { 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 { CardType, CardResponseModeType } from 'src/entities/card_types.entity'; import { CardInstance, CardInstanceStatutType, CardOperationType, } from 'src/entities/card_instances.entity'; import { CardAudienceMember } from 'src/entities/card_audience_members.entity'; import { CardResponse, CardResponseActionType, } from 'src/entities/card_responses.entity'; import { CarteDto, CreerCarteDto, ListeCartesDto, MajCarteDto, RepondreCarteDto, } from './dto/cards.dto'; import { CardsRealtimeService } from './cards-realtime.service'; @Injectable() export class CardsService { constructor( @InjectRepository(CardType) private readonly typesRepo: Repository, @InjectRepository(CardInstance) private readonly cardsRepo: Repository, @InjectRepository(CardAudienceMember) private readonly audienceRepo: Repository, @InjectRepository(CardResponse) private readonly responsesRepo: Repository, @InjectRepository(AmChildren) private readonly amChildrenRepo: Repository, @InjectRepository(ParentsChildren) private readonly parentsChildrenRepo: Repository, private readonly absencesService: AbsencesGardeService, private readonly realtime: CardsRealtimeService, ) {} async listerTypes(role: RoleType): Promise { const all = await this.typesRepo.find({ where: { system: true } }); return all.filter((t) => t.emitter_roles.includes(role)); } async lister( userId: string, role: RoleType, placementId?: string, ): Promise { const qb = this.cardsRepo .createQueryBuilder('c') .innerJoin('c.audience', 'aud', 'aud.id_utilisateur = :userId', { userId }) .leftJoinAndSelect('c.type', 'type') .leftJoinAndSelect('c.responses', 'responses') .where('c.purge_at > now()') .orderBy( `CASE c.statut WHEN 'refusee' THEN 0 WHEN 'ouverte' THEN 1 ELSE 2 END`, 'ASC', ) .addOrderBy('c.modifie_le', 'DESC'); if (placementId) { await this.assertPlacementAccess(userId, role, placementId); qb.andWhere('c.id_placement = :placementId', { placementId }); } const rows = await qb.getMany(); return { items: rows.map((c) => this.toDto(c, userId)), }; } async creer( userId: string, role: RoleType, dto: CreerCarteDto, ): Promise { if (dto.date_fin < dto.date_debut) { throw new BadRequestException('date_fin < date_debut'); } const type = await this.typesRepo.findOne({ where: { code: dto.type_code } }); if (!type || !type.system) { throw new NotFoundException('Type de carte inconnu'); } if (!type.emitter_roles.includes(role)) { throw new ForbiddenException('Vous ne pouvez pas émettre ce type de carte'); } const placement = await this.assertPlacementAccess( userId, role, dto.id_placement, ); const operation = dto.operation ?? CardOperationType.CREATE; let absenceId = dto.id_absence; const absenceType = this.mapAbsenceType(dto.type_code); if (operation === CardOperationType.CREATE) { const absence = await this.absencesService.creer(userId, role, { id_placement: dto.id_placement, type: absenceType, date_debut: dto.date_debut, date_fin: dto.date_fin, motif: dto.motif, }); absenceId = absence.id; } else { if (!absenceId) { throw new BadRequestException('id_absence requis pour operation=update'); } await this.absencesService.maj(userId, role, absenceId, { date_debut: dto.date_debut, date_fin: dto.date_fin, motif: dto.motif, // Congé accepté modifié → repasse en attente via cartes respond flow; // pour absence enfant update immédiat déjà fait. ...(dto.type_code === 'conge_am' ? { statut: StatutAbsenceGardeType.EN_ATTENTE } : {}), }); } const statutInitial = type.response_mode === CardResponseModeType.NONE ? CardInstanceStatutType.TRAITEE : CardInstanceStatutType.OUVERTE; const card = this.cardsRepo.create({ type_code: type.code, id_placement: dto.id_placement, id_absence: absenceId, cree_par: userId, operation, statut: statutInitial, payload: { date_debut: dto.date_debut, date_fin: dto.date_fin, motif: dto.motif ?? null, }, purge_at: this.purgeAt(type.retention_days, statutInitial), }); const saved = await this.cardsRepo.save(card); if (absenceId) { await this.linkAbsenceCard(absenceId, saved.id); } await this.buildAudience(saved, type, placement, userId, role); const full = await this.loadCard(saved.id); const dtoOut = this.toDto(full, userId); this.emitAudience(full, 'card.created', dtoOut); return dtoOut; } async repondre( userId: string, role: RoleType, cardId: string, dto: RepondreCarteDto, ): Promise { const card = await this.loadCard(cardId); await this.assertInAudience(card, userId); if (card.statut !== CardInstanceStatutType.OUVERTE) { throw new BadRequestException('Cette carte n’est plus ouverte'); } if (card.cree_par === userId) { throw new ForbiddenException('Le créateur ne répond pas à sa propre carte'); } const mode = card.type.response_mode; if (mode === CardResponseModeType.NONE) { throw new BadRequestException('Ce type de carte ne demande pas de réponse'); } if (mode === CardResponseModeType.ACK && dto.action !== CardResponseActionType.ACK) { throw new BadRequestException('Action attendue : ack'); } if ( mode === CardResponseModeType.ACCEPT_REFUSE && dto.action !== CardResponseActionType.ACCEPT && dto.action !== CardResponseActionType.REFUSE ) { throw new BadRequestException('Action attendue : accept ou refuse'); } if (dto.action === CardResponseActionType.REFUSE && !dto.comment?.trim()) { throw new BadRequestException('Motivation obligatoire en cas de refus'); } await this.responsesRepo.save( this.responsesRepo.create({ id_card: card.id, id_utilisateur: userId, action: dto.action, comment: dto.comment?.trim(), }), ); if (card.id_absence) { if (dto.action === CardResponseActionType.ACCEPT || dto.action === CardResponseActionType.ACK) { await this.absencesService.maj(userId, role, card.id_absence, { statut: StatutAbsenceGardeType.ACCEPTE, }); card.statut = CardInstanceStatutType.TRAITEE; } else if (dto.action === CardResponseActionType.REFUSE) { await this.absencesService.maj(userId, role, card.id_absence, { statut: StatutAbsenceGardeType.REFUSE, motif: dto.comment!.trim(), }); card.statut = CardInstanceStatutType.REFUSEE; } } else if (dto.action === CardResponseActionType.ACK) { card.statut = CardInstanceStatutType.TRAITEE; } card.purge_at = this.purgeAt(card.type.retention_days, card.statut); await this.cardsRepo.save(card); const full = await this.loadCard(cardId); const dtoOut = this.toDto(full, userId); this.emitAudience(full, 'response.added', { action: dto.action, card: dtoOut, }); this.emitAudience(full, 'card.updated', dtoOut); return dtoOut; } async majEnAttente( userId: string, role: RoleType, cardId: string, dto: MajCarteDto, ): Promise { const card = await this.loadCard(cardId); if (card.cree_par !== userId) { throw new ForbiddenException('Seul le créateur peut modifier cette carte'); } if ( card.statut !== CardInstanceStatutType.OUVERTE && card.statut !== CardInstanceStatutType.REFUSEE ) { throw new BadRequestException('Carte non modifiable dans cet état'); } const debut = dto.date_debut ?? String(card.payload?.['date_debut'] ?? ''); const fin = dto.date_fin ?? String(card.payload?.['date_fin'] ?? ''); if (!debut || !fin || fin < debut) { throw new BadRequestException('Dates invalides'); } if (card.id_absence) { await this.absencesService.maj(userId, role, card.id_absence, { date_debut: debut, date_fin: fin, motif: dto.motif, statut: StatutAbsenceGardeType.EN_ATTENTE, }); } card.payload = { ...card.payload, date_debut: debut, date_fin: fin, motif: dto.motif ?? card.payload?.['motif'] ?? null, }; card.statut = CardInstanceStatutType.OUVERTE; card.purge_at = this.purgeAt(card.type.retention_days, card.statut); await this.cardsRepo.save(card); const full = await this.loadCard(cardId); const dtoOut = this.toDto(full, userId); this.emitAudience(full, 'card.updated', dtoOut); return dtoOut; } async supprimer( userId: string, role: RoleType, cardId: string, ): Promise { const card = await this.loadCard(cardId); if (card.cree_par !== userId) { throw new ForbiddenException('Seul le créateur peut supprimer cette carte'); } const audienceIds = await this.audienceUserIds(card); if (card.id_absence) { const absStatut = card.statut === CardInstanceStatutType.TRAITEE ? null : card.id_absence; // Supprime l’absence liée si pas encore acceptée définitivement if ( card.statut === CardInstanceStatutType.OUVERTE || card.statut === CardInstanceStatutType.REFUSEE ) { await this.absencesService.supprimer(userId, role, card.id_absence); } void absStatut; } await this.cardsRepo.delete({ id: cardId }); this.realtime.emitToUsers(audienceIds, 'card.deleted', cardId, { id: cardId, }); } private emitAudience( card: CardInstance, event: 'card.created' | 'card.updated' | 'response.added', data: unknown, ): void { const ids = (card.audience ?? []).map((a) => a.id_utilisateur); if (ids.length === 0) return; this.realtime.emitToUsers(ids, event, card.id, data); } private async audienceUserIds(card: CardInstance): Promise { if (card.audience?.length) { return card.audience.map((a) => a.id_utilisateur); } const rows = await this.audienceRepo.find({ where: { id_card: card.id } }); return rows.map((r) => r.id_utilisateur); } private mapAbsenceType(typeCode: string): TypeAbsenceGardeType { if (typeCode === 'absence_enfant' || typeCode === 'absence_enfant_modif') { return TypeAbsenceGardeType.ABSENCE_ENFANT; } if (typeCode === 'conge_am') return TypeAbsenceGardeType.CONGE_AM; if (typeCode === 'arret_maladie_am') { return TypeAbsenceGardeType.ARRET_MALADIE_AM; } throw new BadRequestException(`Type non mappé à une absence: ${typeCode}`); } private async linkAbsenceCard(absenceId: string, cardId: string): Promise { // Update direct pour éviter droits maj vides await this.cardsRepo.manager.query( `UPDATE absences_garde SET id_card_instance = $1, modifie_le = now() WHERE id = $2`, [cardId, absenceId], ); } private async buildAudience( card: CardInstance, type: CardType, placement: AmChildren, creatorId: string, creatorRole: RoleType, ): Promise { const members: Partial[] = [ { id_card: card.id, id_utilisateur: creatorId, role_snapshot: creatorRole, is_creator: true, }, ]; if (type.audience_resolver === 'couple_am') { members.push({ id_card: card.id, id_utilisateur: placement.amId, role_snapshot: RoleType.ASSISTANTE_MATERNELLE, is_creator: false, }); } else if (type.audience_resolver === 'couple_parents') { const liens = await this.parentsChildrenRepo.find({ where: { enfantId: placement.enfantId }, }); for (const l of liens) { if (l.parentId === creatorId) continue; members.push({ id_card: card.id, id_utilisateur: l.parentId, role_snapshot: RoleType.PARENT, is_creator: false, }); } } // dédup const seen = new Set(); const unique = members.filter((m) => { const k = m.id_utilisateur!; if (seen.has(k)) return false; seen.add(k); return true; }); await this.audienceRepo.save(this.audienceRepo.create(unique)); } private purgeAt( retentionDays: number, statut: CardInstanceStatutType, ): Date { const days = statut === CardInstanceStatutType.OUVERTE ? Math.max(retentionDays, 15) : retentionDays; return new Date(Date.now() + days * 24 * 60 * 60 * 1000); } private async loadCard(id: string): Promise { const card = await this.cardsRepo.findOne({ where: { id }, relations: ['type', 'responses', 'audience'], }); if (!card) throw new NotFoundException('Carte introuvable'); return card; } private async assertInAudience(card: CardInstance, userId: string): Promise { const ok = (card.audience ?? []).some((a) => a.id_utilisateur === userId); if (!ok) { const row = await this.audienceRepo.findOne({ where: { id_card: card.id, id_utilisateur: userId }, }); if (!row) throw new ForbiddenException('Carte hors de votre audience'); } } private async assertPlacementAccess( 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 introuvable ou inactif'); } if (role === RoleType.ASSISTANTE_MATERNELLE) { if (placement.amId !== userId) { throw new ForbiddenException('Placement non autorisé'); } return placement; } if (role === RoleType.PARENT) { const lien = await this.parentsChildrenRepo.findOne({ where: { parentId: userId, enfantId: placement.enfantId }, }); if (!lien) throw new ForbiddenException('Placement non autorisé'); return placement; } throw new ForbiddenException('Rôle non autorisé'); } private toDto(card: CardInstance, userId: string): CarteDto { const lastRefuse = [...(card.responses ?? [])] .reverse() .find((r) => r.action === CardResponseActionType.REFUSE); return { id: card.id, type_code: card.type_code, titre: card.type?.titre ?? card.type_code, couleur: card.type?.couleur ?? null, id_placement: card.id_placement, id_absence: card.id_absence ?? null, operation: card.operation, statut: card.statut, payload: card.payload ?? {}, purge_at: card.purge_at?.toISOString?.() ?? String(card.purge_at), cree_par: card.cree_par ?? null, is_creator: card.cree_par === userId, response_mode: card.type?.response_mode, last_refuse_comment: lastRefuse?.comment ?? null, cree_le: card.cree_le?.toISOString?.() ?? String(card.cree_le), modifie_le: card.modifie_le?.toISOString?.() ?? String(card.modifie_le), }; } }