From f66f8d8bd95926186fee850d44914ce06db105db Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 29 Jul 2026 00:16:08 +0200 Subject: [PATCH] feat(server): type RealtimeService against the event registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../nest/assignments/assignments.service.ts | 3 +- .../booking-import/import-jobs.service.ts | 17 ++++++-- server/src/nest/budget/budget.service.ts | 3 +- server/src/nest/collab/collab.service.ts | 3 +- server/src/nest/days/day-notes.service.ts | 3 +- server/src/nest/days/days.service.ts | 3 +- server/src/nest/files/files.service.ts | 3 +- server/src/nest/memories/memories.service.ts | 3 +- server/src/nest/packing/packing.controller.ts | 3 +- server/src/nest/packing/packing.service.ts | 7 ++-- server/src/nest/places/places.service.ts | 3 +- .../plugins/host/plugin-host-deps.factory.ts | 5 ++- server/src/nest/realtime/realtime.service.ts | 42 ++++++++++++++++++- .../reservations/accommodations.service.ts | 3 +- .../nest/reservations/reservations.service.ts | 3 +- server/src/nest/todo/todo.service.ts | 3 +- server/src/nest/trips/trips.service.ts | 3 +- server/src/nest/vacay/vacay.service.ts | 12 ++++-- 18 files changed, 97 insertions(+), 25 deletions(-) diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 8eb38d06b..609f2efd5 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/booking-import/import-jobs.service.ts b/server/src/nest/booking-import/import-jobs.service.ts index 7d6dad71a..5aaeb65b1 100644 --- a/server/src/nest/booking-import/import-jobs.service.ts +++ b/server/src/nest/booking-import/import-jobs.service.ts @@ -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): void { - this.realtime.broadcastToUser(job.userId, { type, jobId: job.id, tripId: job.tripId, ...payload }); + private push( + job: ImportJob, + type: E, + payload: Omit, '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); } } diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 3f46a13e6..139fa7db1 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/collab/collab.service.ts b/server/src/nest/collab/collab.service.ts index c07ab10b7..9c42e90a6 100644 --- a/server/src/nest/collab/collab.service.ts +++ b/server/src/nest/collab/collab.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/days/day-notes.service.ts b/server/src/nest/days/day-notes.service.ts index 7e41652e6..793d9f8f0 100644 --- a/server/src/nest/days/day-notes.service.ts +++ b/server/src/nest/days/day-notes.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index 607568206..7d9ece2e0 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/files/files.service.ts b/server/src/nest/files/files.service.ts index 30b439447..787cc40ab 100644 --- a/server/src/nest/files/files.service.ts +++ b/server/src/nest/files/files.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/memories/memories.service.ts b/server/src/nest/memories/memories.service.ts index 70a2420a4..f02e0ffd0 100644 --- a/server/src/nest/memories/memories.service.ts +++ b/server/src/nest/memories/memories.service.ts @@ -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, socketId?: string): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId?: string): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/packing/packing.controller.ts b/server/src/nest/packing/packing.controller.ts index f6cef116f..35a66fb6d 100644 --- a/server/src/nest/packing/packing.controller.ts +++ b/server/src/nest/packing/packing.controller.ts @@ -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, item: PackingItemRow, socketId: string | undefined): void { + private emitToViewers(tripId: string, event: E, payload: TrekWsPayload, item: PackingItemRow, socketId: string | undefined): void { const viewers = this.packing.viewersOf(item); if (viewers === null) { this.packing.broadcast(tripId, event, payload, socketId); diff --git a/server/src/nest/packing/packing.service.ts b/server/src/nest/packing/packing.service.ts index 543cd8677..0a25a82f6 100644 --- a/server/src/nest/packing/packing.service.ts +++ b/server/src/nest/packing/packing.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, 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, item: PrivacyFields | null | undefined, socketId: string | undefined): void { + broadcastItem(tripId: string, event: E, payload: TrekWsPayload, 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, viewerIds: number[], socketId: string | undefined): void { + broadcastToViewers(tripId: string, event: E, payload: TrekWsPayload, viewerIds: number[], socketId: string | undefined): void { for (const uid of new Set(viewerIds)) { if (uid != null) this.realtime.broadcast(tripId, event, payload, socketId, uid); } diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index cbf7b6160..a87cad58e 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index c5715baae..e3e5c42d4 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -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, item: PackingPrivacy): void { +function emitPackingToViewers(realtime: RealtimeService, tripId: number, event: E, payload: TrekWsPayload, 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, item: PackingPrivacy): void { +function broadcastPackingItem(realtime: RealtimeService, tripId: number, event: E, payload: TrekWsPayload, item: PackingPrivacy): void { const onlyUserId = item?.is_private && item.owner_id != null ? item.owner_id : undefined; realtime.broadcast(tripId, event, payload, undefined, onlyUserId); } diff --git a/server/src/nest/realtime/realtime.service.ts b/server/src/nest/realtime/realtime.service.ts index db8fa8509..e8ad9468c 100644 --- a/server/src/nest/realtime/realtime.service.ts +++ b/server/src/nest/realtime/realtime.service.ts @@ -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::` 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( + tripId: number | string, + eventType: E, + payload: TrekWsPayload, + excludeSid?: number | string, + onlyUserId?: number, + ): void; + broadcast( + tripId: number | string, + eventType: TrekWsPluginEventName, + payload: Record, + 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:` namespace) with the matching payload fields. + */ + broadcastToUser( + userId: number, + payload: { type: E } & TrekWsPayload, + excludeSid?: number | string, + ): void; + broadcastToUser( + userId: number, + payload: { type: TrekWsPluginEventName } & Record, + excludeSid?: number | string, + ): void; broadcastToUser( ...args: [userId: number, payload: Record, excludeSid?: number | string] ): void { diff --git a/server/src/nest/reservations/accommodations.service.ts b/server/src/nest/reservations/accommodations.service.ts index 325437821..1340db511 100644 --- a/server/src/nest/reservations/accommodations.service.ts +++ b/server/src/nest/reservations/accommodations.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 01f708ca5..0e7c1572f 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/todo/todo.service.ts b/server/src/nest/todo/todo.service.ts index 6d4fafa4b..6f464c633 100644 --- a/server/src/nest/todo/todo.service.ts +++ b/server/src/nest/todo/todo.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 9bc28f573..2a48db62c 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -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, socketId: string | undefined): void { + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { this.realtime.broadcast(tripId, event, payload, socketId); } diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index 8aeda2bd6..40db71209 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -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 */ }