feat(server): type RealtimeService against the event registry

broadcast/broadcastToUser gain registry-typed overloads (event name
must be a TREK_WS_EVENTS key, payload must match its schema) with the
plugin: namespace as the deliberate escape hatch; the 13 service
wrappers forward the generic signature. Compile-time only — the
runtime pass-through and all call sites are unchanged.
This commit is contained in:
jubnl
2026-07-29 00:16:08 +02:00
parent c0baaeb898
commit f66f8d8bd9
18 changed files with 97 additions and 25 deletions
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -37,7 +38,7 @@ export class AssignmentsService {
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { RealtimeService } from '../realtime/realtime.service';
import { BookingImportService } from './booking-import.service';
import type { BookingImportMode, BookingImportPreviewResponse } from '@trek/shared';
import type { BookingImportMode, BookingImportPreviewResponse, TrekWsPayload } from '@trek/shared';
type JobStatus = 'running' | 'done' | 'error';
@@ -79,7 +79,18 @@ export class ImportJobsService {
}
}
private push(job: ImportJob, type: string, payload: Record<string, unknown>): void {
this.realtime.broadcastToUser(job.userId, { type, jobId: job.id, tripId: job.tripId, ...payload });
private push<E extends 'import:progress' | 'import:done' | 'import:error'>(
job: ImportJob,
type: E,
payload: Omit<TrekWsPayload<E>, 'jobId' | 'tripId'>,
): void {
// jobId/tripId are injected here; TS can't re-associate the spread with the
// deferred generic payload, so re-assert the completed shape it just built.
this.realtime.broadcastToUser(job.userId, {
type,
jobId: job.id,
tripId: job.tripId,
...payload,
} as { type: E } & TrekWsPayload<E>);
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { verifyTripAccess } from '../../services/tripAccess';
@@ -52,7 +53,7 @@ export class BudgetService {
return this.permissions.checkPermission('budget_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
+2 -1
View File
@@ -2,6 +2,7 @@ import path from 'path';
import fs from 'fs';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { verifyTripAccess } from '../../services/tripAccess';
@@ -80,7 +81,7 @@ export class CollabService {
return this.permissions.checkPermission('file_upload', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import type { DayNote, User } from '../../types';
@@ -27,7 +28,7 @@ export class DayNotesService {
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -98,7 +99,7 @@ export class DaysService {
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import path from 'path';
import fs from 'fs';
import type { Request } from 'express';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../../services/avatarUrl';
@@ -68,7 +69,7 @@ export class FilesService {
return this.permissions.checkPermission(action, user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
+2 -1
View File
@@ -38,6 +38,7 @@ import {
} from '../../services/memories/synologyService';
import { canAccessUserPhoto } from '../../services/memories/helpersService';
import type { Selection } from '../../services/memories/helpersService';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
/**
@@ -56,7 +57,7 @@ export class MemoriesService {
return canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId?: string): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId?: string): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
@@ -11,6 +11,7 @@ import {
Put,
UseGuards,
} from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import type { User } from '../../types';
import { PackingService } from './packing.service';
import { isUpdateConflict } from '../../services/conflictResult';
@@ -114,7 +115,7 @@ export class PackingController {
/** Deliver an item event to exactly the people who can see it (#858): the whole
* room for a Common item, or owner + recipients for a restricted one. */
private emitToViewers(tripId: string, event: string, payload: Record<string, unknown>, item: PackingItemRow, socketId: string | undefined): void {
private emitToViewers<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, item: PackingItemRow, socketId: string | undefined): void {
const viewers = this.packing.viewersOf(item);
if (viewers === null) {
this.packing.broadcast(tripId, event, payload, socketId);
+4 -3
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { verifyTripAccess } from '../../services/tripAccess';
@@ -54,7 +55,7 @@ export class PackingService {
return this.permissions.checkPermission('packing_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
@@ -63,14 +64,14 @@ export class PackingService {
* screens: when the item is private the event is delivered only to its owner's
* sockets. Shared items broadcast to the whole trip room as before.
*/
broadcastItem(tripId: string, event: string, payload: Record<string, unknown>, item: PrivacyFields | null | undefined, socketId: string | undefined): void {
broadcastItem<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, item: PrivacyFields | null | undefined, socketId: string | undefined): void {
const onlyUserId = item?.is_private && item.owner_id != null ? item.owner_id : undefined;
this.realtime.broadcast(tripId, event, payload, socketId, onlyUserId);
}
/** Deliver an item event to a specific set of viewers (#858 shared items) — the
* owner plus the recipients it was shared with — without leaking to the room. */
broadcastToViewers(tripId: string, event: string, payload: Record<string, unknown>, viewerIds: number[], socketId: string | undefined): void {
broadcastToViewers<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, viewerIds: number[], socketId: string | undefined): void {
for (const uid of new Set(viewerIds)) {
if (uid != null) this.realtime.broadcast(tripId, event, payload, socketId, uid);
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { TRACK_COLORS } from '@trek/shared';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -33,7 +34,7 @@ export class PlacesService {
return this.permissions.checkPermission('place_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { readEnv } from '../../../app-config';
import { RealtimeService } from '../../realtime/realtime.service';
import { isUpdateConflict } from '../../../services/conflictResult';
@@ -72,7 +73,7 @@ function packingViewersOf(item: PackingPrivacy | null | undefined): number[] | n
}
/** CREATE/DELETE fan-out: whole room for a Common item, else owner + recipients only. */
function emitPackingToViewers(realtime: RealtimeService, tripId: number, event: string, payload: Record<string, unknown>, item: PackingPrivacy): void {
function emitPackingToViewers<E extends TrekWsTripEventName>(realtime: RealtimeService, tripId: number, event: E, payload: TrekWsPayload<E>, item: PackingPrivacy): void {
const viewers = packingViewersOf(item);
if (viewers === null) {
realtime.broadcast(tripId, event, payload, undefined);
@@ -82,7 +83,7 @@ function emitPackingToViewers(realtime: RealtimeService, tripId: number, event:
}
/** An item event delivered owner-only when the item is private (else to the room). */
function broadcastPackingItem(realtime: RealtimeService, tripId: number, event: string, payload: Record<string, unknown>, item: PackingPrivacy): void {
function broadcastPackingItem<E extends TrekWsTripEventName>(realtime: RealtimeService, tripId: number, event: E, payload: TrekWsPayload<E>, item: PackingPrivacy): void {
const onlyUserId = item?.is_private && item.owner_id != null ? item.owner_id : undefined;
realtime.broadcast(tripId, event, payload, undefined, onlyUserId);
}
+41 -1
View File
@@ -1,4 +1,10 @@
import { Injectable } from '@nestjs/common';
import type {
TrekWsPayload,
TrekWsPluginEventName,
TrekWsTripEventName,
TrekWsUserEventName,
} from '@trek/shared';
import { broadcast, broadcastToUser } from '../../websocket';
/**
@@ -27,10 +33,30 @@ export class RealtimeService {
* `onlyUserId` narrows delivery to one user's sockets (#858 private
* packing items).
*
* Compile-time contract (runtime is a pure pass-through): the event name
* must be a key of the shared TREK_WS_EVENTS registry and the payload must
* match its schema — a typo'd name or drifted payload fails `tsc`, never
* ships silence. The `plugin:` overload is the deliberate escape hatch for
* the host's force-namespaced `plugin:<id>:<event>` broadcasts.
*
* Rest-spread keeps the caller's exact argument arity — dozens of test
* mocks assert the precise call shape (3, 4 or 5 args), so the facade
* must not pad omitted optionals with explicit `undefined`s.
*/
broadcast<E extends TrekWsTripEventName>(
tripId: number | string,
eventType: E,
payload: TrekWsPayload<E>,
excludeSid?: number | string,
onlyUserId?: number,
): void;
broadcast(
tripId: number | string,
eventType: TrekWsPluginEventName,
payload: Record<string, unknown>,
excludeSid?: number | string,
onlyUserId?: number,
): void;
broadcast(
...args: [
tripId: number | string,
@@ -43,7 +69,21 @@ export class RealtimeService {
broadcast(...args);
}
/** Send a message to all sockets of one user; the payload carries its own `type`. */
/**
* Send a message to all sockets of one user; the payload carries its own
* `type`, which must be a user-scoped registry event (or the reserved
* `plugin:<id>` namespace) with the matching payload fields.
*/
broadcastToUser<E extends TrekWsUserEventName>(
userId: number,
payload: { type: E } & TrekWsPayload<E>,
excludeSid?: number | string,
): void;
broadcastToUser(
userId: number,
payload: { type: TrekWsPluginEventName } & Record<string, unknown>,
excludeSid?: number | string,
): void;
broadcastToUser(
...args: [userId: number, payload: Record<string, unknown>, excludeSid?: number | string]
): void {
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -31,7 +32,7 @@ export class AccommodationsService {
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { verifyTripAccess } from '../../services/tripAccess';
@@ -144,7 +145,7 @@ export class ReservationsService {
return this.permissions.checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { verifyTripAccess } from '../../services/tripAccess';
@@ -32,7 +33,7 @@ export class TodoService {
return this.permissions.checkPermission('packing_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import type { User } from '../../types';
@@ -46,7 +47,7 @@ export class TripsService {
return this.permissions.checkPermission(action, role, ownerId, userId, isMember);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast<E extends TrekWsTripEventName>(tripId: string, event: E, payload: TrekWsPayload<E>, socketId: string | undefined): void {
this.realtime.broadcast(tripId, event, payload, socketId);
}
+9 -3
View File
@@ -376,7 +376,11 @@ export class VacayService {
// WebSocket notifications
// -------------------------------------------------------------------------
notifyPlanUsers(planId: number, excludeSid: string | undefined, event = 'vacay:update'): void {
notifyPlanUsers(
planId: number,
excludeSid: string | undefined,
event: 'vacay:update' | 'vacay:settings' | 'vacay:accepted' | 'vacay:declined' = 'vacay:update',
): void {
try {
const plan = this.db.get<{ owner_id: number }>('SELECT owner_id FROM vacay_plans WHERE id = ?', planId);
if (!plan) return;
@@ -385,8 +389,10 @@ export class VacayService {
members.forEach(m => userIds.push(m.user_id));
userIds.forEach(id => this.realtime.broadcastToUser(id, { type: event }, excludeSid));
// Pending-invite events carry nothing a read-only viewer could see; every
// other event may change entries, colors or company holidays.
if (event !== 'vacay:invite' && event !== 'vacay:declined' && event !== 'vacay:cancelled') {
// other event may change entries, colors or company holidays. (The event
// union proves invite/cancelled never reach this method — their senders
// call broadcastToUser directly — so only declined needs excluding here.)
if (event !== 'vacay:declined') {
this.notifyShareViewers(userIds, excludeSid);
}
} catch { /* websocket not available */ }