Files
petitspas/backend/src/modules/cards/cards.service.ts
T
jmartinandCursor 823ea6cd22 feat(#195): SSE realtime Cartes (stream + emit audience)
GET /api/v1/cards/stream (Bearer ou ?access_token=).
Events card.created|updated|deleted, response.added + heartbeat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:50:30 +02:00

490 lines
16 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 { 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<CardType>,
@InjectRepository(CardInstance)
private readonly cardsRepo: Repository<CardInstance>,
@InjectRepository(CardAudienceMember)
private readonly audienceRepo: Repository<CardAudienceMember>,
@InjectRepository(CardResponse)
private readonly responsesRepo: Repository<CardResponse>,
@InjectRepository(AmChildren)
private readonly amChildrenRepo: Repository<AmChildren>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
private readonly absencesService: AbsencesGardeService,
private readonly realtime: CardsRealtimeService,
) {}
async listerTypes(role: RoleType): Promise<CardType[]> {
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<ListeCartesDto> {
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<CarteDto> {
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<CarteDto> {
const card = await this.loadCard(cardId);
await this.assertInAudience(card, userId);
if (card.statut !== CardInstanceStatutType.OUVERTE) {
throw new BadRequestException('Cette carte nest 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<CarteDto> {
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<void> {
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 labsence 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<string[]> {
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<void> {
// 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<void> {
const members: Partial<CardAudienceMember>[] = [
{
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<string>();
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<CardInstance> {
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<void> {
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<AmChildren> {
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),
};
}
}