mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user