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 { 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 { 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 { 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), ); } }