142 lines
4.4 KiB
TypeScript
142 lines
4.4 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) => {
|
|
if (payload && payload.topic === 'general') {
|
|
return {
|
|
topic: 'general',
|
|
themeKey: payload.themeKey,
|
|
registrationsEnabled: payload.registrationsEnabled,
|
|
};
|
|
}
|
|
return {
|
|
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),
|
|
);
|
|
}
|
|
} |