Files
HIPCTF2/backend/src/modules/system/system.controller.ts
T

133 lines
4.2 KiB
TypeScript

import { Controller, Get, Sse, MessageEvent } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { interval, map, merge, mergeMap, Observable, startWith, defer, distinctUntilChanged } from 'rxjs';
import { Public } from '../../common/decorators/public.decorator';
import { SystemService } from './system.service';
import { EventStatusService } from '../../common/services/event-status.service';
import { SseHubService } from '../../common/services/sse-hub.service';
import { SettingsService } from '../settings/settings.module';
@ApiTags('system')
@Controller('api/v1')
export class SystemController {
constructor(
private readonly system: SystemService,
private readonly statusSvc: EventStatusService,
private readonly hub: SseHubService,
private readonly settings: SettingsService,
) {}
@Public()
@Get('bootstrap')
@ApiOperation({ summary: 'Public bootstrap payload used by the SPA on startup' })
bootstrap() {
return this.system.bootstrap();
}
@Public()
@Get('event/status')
@ApiOperation({ summary: 'Event status derived from UTC timestamps' })
eventStatus() {
return this.statusSvc.getStatus();
}
@Public()
@Get('settings/event')
@ApiOperation({ summary: 'Public-readable event window timestamps (UTC)' })
async settingsEvent(): Promise<{ eventStartUtc: string | null; eventEndUtc: string | null }> {
const eventStartUtc = (await this.settings.get('eventStartUtc')) || null;
const eventEndUtc = (await this.settings.get('eventEndUtc')) || null;
return { eventStartUtc, eventEndUtc };
}
/**
* Global event status + countdown stream.
* Emits a FLATTENED object every second (and whenever the hub pushes).
*/
@Public()
@Sse('event/stream')
eventStream(): Observable<MessageEvent> {
const tick$ = interval(1000).pipe(
startWith(0),
mergeMap(() =>
defer(() =>
this.statusSvc.getStatus().then((s) => ({
status: s.status,
countdownMs: s.countdownMs,
serverNowUtc: s.serverNowUtc,
startUtc: s.startUtc,
endUtc: s.endUtc,
})),
),
),
);
const hub$ = this.hub.event$().pipe(
map((payload: any) => ({
status: payload?.status,
countdownMs: payload?.countdownMs,
serverNowUtc: payload?.serverNowUtc,
startUtc: payload?.startUtc,
endUtc: payload?.endUtc,
})),
);
return merge(tick$, hub$).pipe(map((src) => ({ data: src }) as MessageEvent));
}
/**
* Authenticated event status SSE.
* Path: GET /api/v1/events/status (note the plural 'events').
* Emits an initial state immediately on connect, then re-emits on transition
* and on a minimum 60s tick while in 'countdown' state.
* Protected by the global JwtAuthGuard (so unauthenticated clients get 401).
*/
@Sse('events/status')
eventsStatus(): Observable<MessageEvent> {
const flat = (s: any) => ({
state: s?.state,
serverNowUtc: s?.serverNowUtc,
eventStartUtc: s?.eventStartUtc,
eventEndUtc: s?.eventEndUtc,
secondsToStart: s?.secondsToStart,
secondsToEnd: s?.secondsToEnd,
});
const initial$ = defer(() =>
this.statusSvc.getState().then((s) => flat(s)),
);
const tick$ = interval(60_000).pipe(
startWith(0),
mergeMap(() =>
defer(() =>
this.statusSvc.getState().then((s) => flat(s)),
),
),
);
const hub$ = this.hub.event$().pipe(map((p) => flat(p)));
return merge(initial$, tick$, hub$).pipe(
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
map((src) => ({ data: src }) as MessageEvent),
);
}
/**
* Live solves stream.
* Emits a FLATTENED solve payload whenever a new solve is published.
*/
@Public()
@Sse('scoreboard/stream')
scoreboardStream(): Observable<MessageEvent> {
return this.hub.scoreboard$().pipe(
map((s: any) => ({
challengeId: s?.challengeId,
userId: s?.userId,
pointsAwarded: s?.pointsAwarded,
rankBonus: s?.rankBonus,
solvedAt: s?.solvedAt,
})),
map((src) => ({ data: src }) as MessageEvent),
);
}
}