Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d0371cdf0 |
@@ -1,94 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Headers,
|
|
||||||
MessageEvent,
|
|
||||||
Query,
|
|
||||||
Sse,
|
|
||||||
UnauthorizedException,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
|
||||||
ApiQuery,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
|
||||||
import { Public } from 'src/common/decorators/public.decorator';
|
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
|
||||||
import { RoleType } from 'src/entities/users.entity';
|
|
||||||
import { CardsRealtimeService } from './cards-realtime.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SSE Cartes — #195
|
|
||||||
* Auth : Bearer (recommandé) ou `?access_token=` (EventSource navigateur).
|
|
||||||
*/
|
|
||||||
@ApiTags('Cartes')
|
|
||||||
@Controller('cards')
|
|
||||||
export class CardsRealtimeController {
|
|
||||||
constructor(
|
|
||||||
private readonly realtime: CardsRealtimeService,
|
|
||||||
private readonly jwtService: JwtService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Sse('stream')
|
|
||||||
@Public()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Flux SSE bulles (card.created|updated|deleted, response.added) — #195',
|
|
||||||
})
|
|
||||||
@ApiQuery({
|
|
||||||
name: 'access_token',
|
|
||||||
required: false,
|
|
||||||
description: 'JWT si pas de header Authorization (EventSource)',
|
|
||||||
})
|
|
||||||
@ApiBearerAuth('access-token')
|
|
||||||
async stream(
|
|
||||||
@Headers('authorization') authorization?: string,
|
|
||||||
@Query('access_token') accessToken?: string,
|
|
||||||
): Promise<Observable<MessageEvent>> {
|
|
||||||
const userId = await this.resolveUserId(authorization, accessToken);
|
|
||||||
return this.realtime.streamFor(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Variante gardée (clients qui envoient Bearer correctement). */
|
|
||||||
@Get('stream/info')
|
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
|
||||||
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
|
||||||
@ApiBearerAuth('access-token')
|
|
||||||
@ApiOperation({ summary: 'Debug : abonnés SSE pour mon user — #195' })
|
|
||||||
info(@User('id') userId: string) {
|
|
||||||
return {
|
|
||||||
user_id: userId,
|
|
||||||
subscribers: this.realtime.subscriberCount(userId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveUserId(
|
|
||||||
authorization?: string,
|
|
||||||
accessToken?: string,
|
|
||||||
): Promise<string> {
|
|
||||||
let token = accessToken?.trim();
|
|
||||||
if (!token && authorization?.startsWith('Bearer ')) {
|
|
||||||
token = authorization.slice(7).trim();
|
|
||||||
}
|
|
||||||
if (!token) {
|
|
||||||
throw new UnauthorizedException('Token manquant (Bearer ou access_token)');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const payload = await this.jwtService.verifyAsync<{ sub: string }>(token, {
|
|
||||||
secret: this.configService.get<string>('jwt.accessSecret'),
|
|
||||||
});
|
|
||||||
if (!payload?.sub) throw new UnauthorizedException('Token invalide');
|
|
||||||
return payload.sub;
|
|
||||||
} catch {
|
|
||||||
throw new UnauthorizedException('Token invalide ou expiré');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { MessageEvent } from '@nestjs/common';
|
|
||||||
import { CardsRealtimeService } from './cards-realtime.service';
|
|
||||||
|
|
||||||
describe('CardsRealtimeService (#195)', () => {
|
|
||||||
let service: CardsRealtimeService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new CardsRealtimeService();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
service.onModuleDestroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('émet card.created aux abonnés du user', async () => {
|
|
||||||
const events: MessageEvent[] = [];
|
|
||||||
const sub = service.streamFor('user-a').subscribe((e) => events.push(e));
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
expect(events[0]?.type).toBe('heartbeat');
|
|
||||||
expect(service.subscriberCount('user-a')).toBe(1);
|
|
||||||
|
|
||||||
service.emitToUsers(['user-a', 'user-b'], 'card.created', 'card-1', {
|
|
||||||
id: 'card-1',
|
|
||||||
});
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
|
|
||||||
const created = events.find((e) => e.type === 'card.created');
|
|
||||||
expect(created).toBeDefined();
|
|
||||||
expect((created!.data as { card_id: string }).card_id).toBe('card-1');
|
|
||||||
expect(service.subscriberCount('user-b')).toBe(0);
|
|
||||||
|
|
||||||
sub.unsubscribe();
|
|
||||||
expect(service.subscriberCount('user-a')).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ne diffuse pas aux users non abonnés', async () => {
|
|
||||||
const events: MessageEvent[] = [];
|
|
||||||
const sub = service.streamFor('user-a').subscribe((e) => events.push(e));
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
const before = events.length;
|
|
||||||
service.emitToUsers(['user-b'], 'card.updated', 'x');
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
expect(events.length).toBe(before);
|
|
||||||
sub.unsubscribe();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { Injectable, MessageEvent, OnModuleDestroy } from '@nestjs/common';
|
|
||||||
import { Observable, Subject, interval, merge, takeUntil } from 'rxjs';
|
|
||||||
import { map } from 'rxjs/operators';
|
|
||||||
|
|
||||||
export type CardRealtimeEventType =
|
|
||||||
| 'card.created'
|
|
||||||
| 'card.updated'
|
|
||||||
| 'card.deleted'
|
|
||||||
| 'response.added'
|
|
||||||
| 'heartbeat';
|
|
||||||
|
|
||||||
export interface CardRealtimePayload {
|
|
||||||
event: CardRealtimeEventType;
|
|
||||||
card_id?: string;
|
|
||||||
data?: unknown;
|
|
||||||
at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bus SSE in-memory par utilisateur (V1 mono-instance).
|
|
||||||
* Rooms = userId (audience carte).
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class CardsRealtimeService implements OnModuleDestroy {
|
|
||||||
private readonly byUser = new Map<string, Set<Subject<CardRealtimePayload>>>();
|
|
||||||
private readonly destroy$ = new Subject<void>();
|
|
||||||
|
|
||||||
onModuleDestroy(): void {
|
|
||||||
this.destroy$.next();
|
|
||||||
this.destroy$.complete();
|
|
||||||
for (const set of this.byUser.values()) {
|
|
||||||
for (const s of set) s.complete();
|
|
||||||
}
|
|
||||||
this.byUser.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Flux SSE pour un utilisateur authentifié. */
|
|
||||||
streamFor(userId: string): Observable<MessageEvent> {
|
|
||||||
const subject = new Subject<CardRealtimePayload>();
|
|
||||||
let set = this.byUser.get(userId);
|
|
||||||
if (!set) {
|
|
||||||
set = new Set();
|
|
||||||
this.byUser.set(userId, set);
|
|
||||||
}
|
|
||||||
set.add(subject);
|
|
||||||
|
|
||||||
const heartbeat$ = interval(25_000).pipe(
|
|
||||||
map(
|
|
||||||
(): CardRealtimePayload => ({
|
|
||||||
event: 'heartbeat',
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Observable<MessageEvent>((observer) => {
|
|
||||||
const sub = merge(subject, heartbeat$)
|
|
||||||
.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe({
|
|
||||||
next: (payload) =>
|
|
||||||
observer.next({
|
|
||||||
type: payload.event,
|
|
||||||
data: payload,
|
|
||||||
} as MessageEvent),
|
|
||||||
error: (err) => observer.error(err),
|
|
||||||
complete: () => observer.complete(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ping initial
|
|
||||||
subject.next({
|
|
||||||
event: 'heartbeat',
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
sub.unsubscribe();
|
|
||||||
set!.delete(subject);
|
|
||||||
subject.complete();
|
|
||||||
if (set!.size === 0) this.byUser.delete(userId);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
emitToUsers(
|
|
||||||
userIds: string[],
|
|
||||||
event: Exclude<CardRealtimeEventType, 'heartbeat'>,
|
|
||||||
cardId: string | undefined,
|
|
||||||
data?: unknown,
|
|
||||||
): void {
|
|
||||||
const unique = [...new Set(userIds.filter(Boolean))];
|
|
||||||
const payload: CardRealtimePayload = {
|
|
||||||
event,
|
|
||||||
card_id: cardId,
|
|
||||||
data,
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
for (const uid of unique) {
|
|
||||||
const set = this.byUser.get(uid);
|
|
||||||
if (!set) continue;
|
|
||||||
for (const s of set) s.next(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test / debug : nombre d’abonnés actifs. */
|
|
||||||
subscriberCount(userId?: string): number {
|
|
||||||
if (userId) return this.byUser.get(userId)?.size ?? 0;
|
|
||||||
let n = 0;
|
|
||||||
for (const s of this.byUser.values()) n += s.size;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,9 +10,7 @@ import { CardResponse } from 'src/entities/card_responses.entity';
|
|||||||
import { AmChildren } from 'src/entities/am_children.entity';
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { CardsController } from './cards.controller';
|
import { CardsController } from './cards.controller';
|
||||||
import { CardsRealtimeController } from './cards-realtime.controller';
|
|
||||||
import { CardsService } from './cards.service';
|
import { CardsService } from './cards.service';
|
||||||
import { CardsRealtimeService } from './cards-realtime.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -34,8 +32,8 @@ import { CardsRealtimeService } from './cards-realtime.service';
|
|||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [CardsController, CardsRealtimeController],
|
controllers: [CardsController],
|
||||||
providers: [CardsService, CardsRealtimeService],
|
providers: [CardsService],
|
||||||
exports: [CardsService, CardsRealtimeService],
|
exports: [CardsService],
|
||||||
})
|
})
|
||||||
export class CardsModule {}
|
export class CardsModule {}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { ForbiddenException } from '@nestjs/common';
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
import { CardsService } from './cards.service';
|
import { CardsService } from './cards.service';
|
||||||
import { CardsRealtimeService } from './cards-realtime.service';
|
|
||||||
import { AbsencesGardeService } from '../absences-garde/absences-garde.service';
|
import { AbsencesGardeService } from '../absences-garde/absences-garde.service';
|
||||||
import { CardType, CardResponseModeType } from 'src/entities/card_types.entity';
|
import { CardType, CardResponseModeType } from 'src/entities/card_types.entity';
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +32,6 @@ describe('CardsService (#194)', () => {
|
|||||||
create: jest.fn((x) => x),
|
create: jest.fn((x) => x),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
find: jest.fn(),
|
|
||||||
};
|
};
|
||||||
const responsesRepo = {
|
const responsesRepo = {
|
||||||
create: jest.fn((x) => x),
|
create: jest.fn((x) => x),
|
||||||
@@ -46,7 +44,6 @@ describe('CardsService (#194)', () => {
|
|||||||
maj: jest.fn(),
|
maj: jest.fn(),
|
||||||
supprimer: jest.fn(),
|
supprimer: jest.fn(),
|
||||||
};
|
};
|
||||||
const realtime = { emitToUsers: jest.fn() };
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -59,7 +56,6 @@ describe('CardsService (#194)', () => {
|
|||||||
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo },
|
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo },
|
||||||
{ provide: getRepositoryToken(ParentsChildren), useValue: parentsChildrenRepo },
|
{ provide: getRepositoryToken(ParentsChildren), useValue: parentsChildrenRepo },
|
||||||
{ provide: AbsencesGardeService, useValue: absencesService },
|
{ provide: AbsencesGardeService, useValue: absencesService },
|
||||||
{ provide: CardsRealtimeService, useValue: realtime },
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = module.get(CardsService);
|
service = module.get(CardsService);
|
||||||
@@ -116,11 +112,7 @@ describe('CardsService (#194)', () => {
|
|||||||
retention_days: 14,
|
retention_days: 14,
|
||||||
},
|
},
|
||||||
responses: [],
|
responses: [],
|
||||||
audience: [
|
audience: [],
|
||||||
{ id_utilisateur: 'am-1' },
|
|
||||||
{ id_utilisateur: 'p-1' },
|
|
||||||
{ id_utilisateur: 'p-2' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await service.creer('am-1', RoleType.ASSISTANTE_MATERNELLE, {
|
const res = await service.creer('am-1', RoleType.ASSISTANTE_MATERNELLE, {
|
||||||
@@ -132,12 +124,6 @@ describe('CardsService (#194)', () => {
|
|||||||
|
|
||||||
expect(absencesService.creer).toHaveBeenCalled();
|
expect(absencesService.creer).toHaveBeenCalled();
|
||||||
expect(audienceRepo.save).toHaveBeenCalled();
|
expect(audienceRepo.save).toHaveBeenCalled();
|
||||||
expect(realtime.emitToUsers).toHaveBeenCalledWith(
|
|
||||||
['am-1', 'p-1', 'p-2'],
|
|
||||||
'card.created',
|
|
||||||
'card-1',
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
expect(res.type_code).toBe('conge_am');
|
expect(res.type_code).toBe('conge_am');
|
||||||
expect(res.statut).toBe(CardInstanceStatutType.OUVERTE);
|
expect(res.statut).toBe(CardInstanceStatutType.OUVERTE);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
MajCarteDto,
|
MajCarteDto,
|
||||||
RepondreCarteDto,
|
RepondreCarteDto,
|
||||||
} from './dto/cards.dto';
|
} from './dto/cards.dto';
|
||||||
import { CardsRealtimeService } from './cards-realtime.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CardsService {
|
export class CardsService {
|
||||||
@@ -50,7 +49,6 @@ export class CardsService {
|
|||||||
@InjectRepository(ParentsChildren)
|
@InjectRepository(ParentsChildren)
|
||||||
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
|
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
|
||||||
private readonly absencesService: AbsencesGardeService,
|
private readonly absencesService: AbsencesGardeService,
|
||||||
private readonly realtime: CardsRealtimeService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listerTypes(role: RoleType): Promise<CardType[]> {
|
async listerTypes(role: RoleType): Promise<CardType[]> {
|
||||||
@@ -166,9 +164,7 @@ export class CardsService {
|
|||||||
await this.buildAudience(saved, type, placement, userId, role);
|
await this.buildAudience(saved, type, placement, userId, role);
|
||||||
|
|
||||||
const full = await this.loadCard(saved.id);
|
const full = await this.loadCard(saved.id);
|
||||||
const dtoOut = this.toDto(full, userId);
|
return this.toDto(full, userId);
|
||||||
this.emitAudience(full, 'card.created', dtoOut);
|
|
||||||
return dtoOut;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async repondre(
|
async repondre(
|
||||||
@@ -232,14 +228,7 @@ export class CardsService {
|
|||||||
|
|
||||||
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
|
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
|
||||||
await this.cardsRepo.save(card);
|
await this.cardsRepo.save(card);
|
||||||
const full = await this.loadCard(cardId);
|
return this.toDto(await this.loadCard(cardId), userId);
|
||||||
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(
|
async majEnAttente(
|
||||||
@@ -284,10 +273,7 @@ export class CardsService {
|
|||||||
card.statut = CardInstanceStatutType.OUVERTE;
|
card.statut = CardInstanceStatutType.OUVERTE;
|
||||||
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
|
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
|
||||||
await this.cardsRepo.save(card);
|
await this.cardsRepo.save(card);
|
||||||
const full = await this.loadCard(cardId);
|
return this.toDto(await this.loadCard(cardId), userId);
|
||||||
const dtoOut = this.toDto(full, userId);
|
|
||||||
this.emitAudience(full, 'card.updated', dtoOut);
|
|
||||||
return dtoOut;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async supprimer(
|
async supprimer(
|
||||||
@@ -299,7 +285,6 @@ export class CardsService {
|
|||||||
if (card.cree_par !== userId) {
|
if (card.cree_par !== userId) {
|
||||||
throw new ForbiddenException('Seul le créateur peut supprimer cette carte');
|
throw new ForbiddenException('Seul le créateur peut supprimer cette carte');
|
||||||
}
|
}
|
||||||
const audienceIds = await this.audienceUserIds(card);
|
|
||||||
if (card.id_absence) {
|
if (card.id_absence) {
|
||||||
const absStatut =
|
const absStatut =
|
||||||
card.statut === CardInstanceStatutType.TRAITEE
|
card.statut === CardInstanceStatutType.TRAITEE
|
||||||
@@ -315,27 +300,6 @@ export class CardsService {
|
|||||||
void absStatut;
|
void absStatut;
|
||||||
}
|
}
|
||||||
await this.cardsRepo.delete({ id: cardId });
|
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 {
|
private mapAbsenceType(typeCode: string): TypeAbsenceGardeType {
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export { CardsModule } from './cards.module';
|
export { CardsModule } from './cards.module';
|
||||||
export { CardsService } from './cards.service';
|
export { CardsService } from './cards.service';
|
||||||
export { CardsRealtimeService } from './cards-realtime.service';
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Liste des enfants / foyers pour l’AM + contexte courant (symétrique A3).
|
|||||||
| BDD | Table `absences_garde` + drop `evenements` |
|
| BDD | Table `absences_garde` + drop `evenements` |
|
||||||
| API | CRUD + **GET liste** (`placementId` \| tous les placements du user) |
|
| API | CRUD + **GET liste** (`placementId` \| tous les placements du user) |
|
||||||
| Cartes SYSTEM | Module `cards/` types S1–S3 (sans sondages V1) |
|
| Cartes SYSTEM | Module `cards/` types S1–S3 (sans sondages V1) |
|
||||||
| Realtime | WS/SSE bulles → **SSE** `GET /cards/stream` (#195) |
|
| Realtime | WS/SSE bulles |
|
||||||
| Purge TTL | Job `expire_at` / `purge_at` |
|
| Purge TTL | Job `expire_at` / `purge_at` |
|
||||||
|
|
||||||
### Front (après API — hors chantier back immédiat)
|
### Front (après API — hors chantier back immédiat)
|
||||||
|
|||||||
Reference in New Issue
Block a user