GET /api/v1/cards/stream (Bearer ou ?access_token=). Events card.created|updated|deleted, response.added + heartbeat. Co-authored-by: Cursor <cursoragent@cursor.com>
490 lines
16 KiB
TypeScript
490 lines
16 KiB
TypeScript
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 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<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 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<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),
|
||
};
|
||
}
|
||
}
|