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>
This commit is contained in:
2026-09-24 11:50:30 +02:00
co-authored by Cursor
parent dfac075a74
commit 823ea6cd22
8 changed files with 313 additions and 8 deletions
+39 -3
View File
@@ -32,6 +32,7 @@ import {
MajCarteDto,
RepondreCarteDto,
} from './dto/cards.dto';
import { CardsRealtimeService } from './cards-realtime.service';
@Injectable()
export class CardsService {
@@ -49,6 +50,7 @@ export class CardsService {
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
private readonly absencesService: AbsencesGardeService,
private readonly realtime: CardsRealtimeService,
) {}
async listerTypes(role: RoleType): Promise<CardType[]> {
@@ -164,7 +166,9 @@ export class CardsService {
await this.buildAudience(saved, type, placement, userId, role);
const full = await this.loadCard(saved.id);
return this.toDto(full, userId);
const dtoOut = this.toDto(full, userId);
this.emitAudience(full, 'card.created', dtoOut);
return dtoOut;
}
async repondre(
@@ -228,7 +232,14 @@ export class CardsService {
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
await this.cardsRepo.save(card);
return this.toDto(await this.loadCard(cardId), userId);
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(
@@ -273,7 +284,10 @@ export class CardsService {
card.statut = CardInstanceStatutType.OUVERTE;
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
await this.cardsRepo.save(card);
return this.toDto(await this.loadCard(cardId), userId);
const full = await this.loadCard(cardId);
const dtoOut = this.toDto(full, userId);
this.emitAudience(full, 'card.updated', dtoOut);
return dtoOut;
}
async supprimer(
@@ -285,6 +299,7 @@ export class CardsService {
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
@@ -300,6 +315,27 @@ export class CardsService {
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 {