From a2861d40f37971b031d50f1682abbe3aae758df5 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 29 Jul 2026 00:07:51 +0200 Subject: [PATCH] feat(server): injectable RealtimeService over the ws singleton Nest services inject RealtimeService (call-time delegation to the websocket module, DatabaseService pattern) instead of importing broadcast/broadcastToUser globals. Zero behavior change; test edits are constructor-arg additions plus RealtimeModule in e2e harness imports only. --- server/src/nest/app.module.ts | 3 +- .../nest/assignments/assignments.bridge.ts | 3 +- .../nest/assignments/assignments.service.ts | 5 +- .../booking-import/booking-import.service.ts | 11 +- .../booking-import/import-jobs.service.ts | 6 +- server/src/nest/budget/budget.bridge.ts | 3 +- server/src/nest/budget/budget.service.ts | 7 +- server/src/nest/collab/collab.bridge.ts | 3 +- server/src/nest/collab/collab.service.ts | 5 +- server/src/nest/days/day-notes.service.ts | 5 +- server/src/nest/days/days.bridge.ts | 3 +- server/src/nest/days/days.service.ts | 5 +- server/src/nest/files/files.bridge.ts | 3 +- server/src/nest/files/files.service.ts | 5 +- server/src/nest/memories/memories.service.ts | 6 +- server/src/nest/packing/packing.bridge.ts | 3 +- server/src/nest/packing/packing.service.ts | 9 +- server/src/nest/places/places.service.ts | 5 +- .../plugins/host/plugin-host-deps.factory.ts | 125 +++++++++--------- server/src/nest/realtime/realtime.module.ts | 16 +++ server/src/nest/realtime/realtime.service.ts | 52 ++++++++ .../reservations/accommodations.service.ts | 5 +- .../nest/reservations/reservations.bridge.ts | 4 +- .../nest/reservations/reservations.service.ts | 15 ++- server/src/nest/todo/todo.bridge.ts | 3 +- server/src/nest/todo/todo.service.ts | 5 +- server/src/nest/trips/trips.service.ts | 5 +- server/src/nest/vacay/vacay.bridge.ts | 3 +- server/src/nest/vacay/vacay.service.ts | 24 ++-- server/tests/e2e/admin.e2e.test.ts | 3 +- server/tests/e2e/assignments.e2e.test.ts | 3 +- server/tests/e2e/budget.e2e.test.ts | 3 +- server/tests/e2e/collab.e2e.test.ts | 3 +- server/tests/e2e/days.e2e.test.ts | 3 +- server/tests/e2e/files.e2e.test.ts | 3 +- server/tests/e2e/memories.e2e.test.ts | 3 +- server/tests/e2e/packing.e2e.test.ts | 3 +- server/tests/e2e/places.e2e.test.ts | 3 +- server/tests/e2e/reservations.e2e.test.ts | 3 +- server/tests/e2e/todo.e2e.test.ts | 3 +- server/tests/e2e/trips.e2e.test.ts | 3 +- server/tests/e2e/vacay.e2e.test.ts | 3 +- server/tests/helpers/mcp-test-controllers.ts | 20 +-- server/tests/helpers/plugin-host.ts | 23 ++-- server/tests/integration/dayReorder.test.ts | 3 +- .../unit/nest/assignments.service.test.ts | 3 +- .../import-jobs.service.test.ts | 3 +- .../unit/nest/budget.service.calc.test.ts | 2 + .../tests/unit/nest/budget.service.db.test.ts | 2 + server/tests/unit/nest/budget.service.test.ts | 3 +- server/tests/unit/nest/collab.service.test.ts | 7 +- .../tests/unit/nest/day-notes.service.test.ts | 3 +- server/tests/unit/nest/days.service.test.ts | 3 +- server/tests/unit/nest/files.service.test.ts | 5 +- .../tests/unit/nest/memories.service.test.ts | 3 +- .../tests/unit/nest/packing.service.test.ts | 3 +- .../tests/unit/nest/realtime.service.test.ts | 52 ++++++++ .../unit/nest/reservations.service.test.ts | 3 +- server/tests/unit/nest/todo.service.test.ts | 3 +- server/tests/unit/nest/trips.service.test.ts | 3 +- server/tests/unit/nest/vacay.service.test.ts | 9 +- .../plugins/plugin-host-deps.factory.test.ts | 3 +- .../unit/services/conflictUpdate.test.ts | 3 +- .../tests/unit/services/tripService.test.ts | 3 +- 64 files changed, 367 insertions(+), 181 deletions(-) create mode 100644 server/src/nest/realtime/realtime.module.ts create mode 100644 server/src/nest/realtime/realtime.service.ts create mode 100644 server/tests/unit/nest/realtime.service.test.ts diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index d88128fef..c9ab8d500 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -3,6 +3,7 @@ import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ZodValidationPipe } from './common/zod-validation.pipe'; import { AppConfigModule } from './app-config/app-config.module'; import { DatabaseModule } from './database/database.module'; +import { RealtimeModule } from './realtime/realtime.module'; import { HealthController } from './health/health.controller'; import { HealthService } from './health/health.service'; import { WeatherModule } from './weather/weather.module'; @@ -58,7 +59,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule], controllers: [HealthController], providers: [ HealthService, diff --git a/server/src/nest/assignments/assignments.bridge.ts b/server/src/nest/assignments/assignments.bridge.ts index 2c23df08a..165a1053f 100644 --- a/server/src/nest/assignments/assignments.bridge.ts +++ b/server/src/nest/assignments/assignments.bridge.ts @@ -1,5 +1,6 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; import { AssignmentsService } from './assignments.service'; import { PermissionsService } from '../permissions/permissions.service'; @@ -26,7 +27,7 @@ import { PermissionsService } from '../permissions/permissions.service'; */ let instance: AssignmentsService | undefined; function assignments(): AssignmentsService { - return (instance ??= new AssignmentsService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)))); + return (instance ??= new AssignmentsService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService())); } export function createAssignment(dayId: string | number, placeId: string | number, notes: string | null) { diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 97435c6b3..8eb38d06b 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../../services/queryHelpers'; @@ -26,6 +26,7 @@ export class AssignmentsService { constructor( private readonly dbs: DatabaseService, private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, ) {} verifyTripAccess(tripId: string | number, userId: number) { @@ -37,7 +38,7 @@ export class AssignmentsService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } /** diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index ec3ab1e66..13850acca 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -1,5 +1,5 @@ import { Injectable, HttpException } from '@nestjs/common'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { verifyTripAccess } from '../../services/tripAccess'; import { ReservationsService } from '../reservations/reservations.service'; @@ -27,6 +27,7 @@ export class BookingImportService { private readonly permissions: PermissionsService, private readonly budget: BudgetService, private readonly addons: AddonsService, + private readonly realtime: RealtimeService, ) {} private get db() { @@ -181,7 +182,7 @@ export class BookingImportService { phone: _venue.phone, }); placeId = (place as any).id; - broadcast(tripId, 'place:created', { place }, socketId); + this.realtime.broadcast(tripId, 'place:created', { place }, socketId); } // Geocode transport endpoints (stations/stops/terminals/rental desks) that @@ -229,9 +230,9 @@ export class BookingImportService { create_accommodation: createAccommodation, } as any); - broadcast(tripId, 'reservation:created', { reservation }, socketId); + this.realtime.broadcast(tripId, 'reservation:created', { reservation }, socketId); if (accommodationCreated) { - broadcast(tripId, 'accommodation:created', {}, socketId); + this.realtime.broadcast(tripId, 'accommodation:created', {}, socketId); } // Turn an extracted price into a real linked cost (Costs addon), so the @@ -255,7 +256,7 @@ export class BookingImportService { // settled position isn't re-opened when live rates drift (#1445). await this.budget.freezeForeignRate(tripId, budgetData); const budgetItem = this.budget.createBudgetItem(tripId, budgetData); - broadcast(tripId, 'budget:created', { item: budgetItem }, socketId); + this.realtime.broadcast(tripId, 'budget:created', { item: budgetItem }, socketId); } catch (err) { console.error( `[booking-import] Failed to create cost for "${item.title}":`, diff --git a/server/src/nest/booking-import/import-jobs.service.ts b/server/src/nest/booking-import/import-jobs.service.ts index 8ba1ea379..7d6dad71a 100644 --- a/server/src/nest/booking-import/import-jobs.service.ts +++ b/server/src/nest/booking-import/import-jobs.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { broadcastToUser } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { BookingImportService } from './booking-import.service'; import type { BookingImportMode, BookingImportPreviewResponse } from '@trek/shared'; @@ -36,7 +36,7 @@ export class ImportJobsService { /** Tail of each user's job chain — parses run one at a time per user, not all at once. */ private readonly chains = new Map>(); - constructor(private readonly bookingImport: BookingImportService) {} + constructor(private readonly bookingImport: BookingImportService, private readonly realtime: RealtimeService) {} /** Create a job and queue it behind the user's other parses; returns the job id at once. */ start(tripId: string, files: Express.Multer.File[], mode: BookingImportMode, userId: number): string { @@ -80,6 +80,6 @@ export class ImportJobsService { } private push(job: ImportJob, type: string, payload: Record): void { - broadcastToUser(job.userId, { type, jobId: job.id, tripId: job.tripId, ...payload }); + this.realtime.broadcastToUser(job.userId, { type, jobId: job.id, tripId: job.tripId, ...payload }); } } diff --git a/server/src/nest/budget/budget.bridge.ts b/server/src/nest/budget/budget.bridge.ts index 026475481..88b84a256 100644 --- a/server/src/nest/budget/budget.bridge.ts +++ b/server/src/nest/budget/budget.bridge.ts @@ -1,5 +1,6 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; import { BudgetService } from './budget.service'; import { PermissionsService } from '../permissions/permissions.service'; import { ExchangeRatesService } from './exchange-rates.service'; @@ -21,7 +22,7 @@ import { ExchangeRatesService } from './exchange-rates.service'; * cache module-scoped on purpose so this instance and the DI singleton share * one cache. */ -const budget = new BudgetService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new ExchangeRatesService()); +const budget = new BudgetService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new ExchangeRatesService(), new RealtimeService()); export function listBudgetItems(tripId: string | number) { return budget.listBudgetItems(tripId); diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 96c7dea61..3f46a13e6 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { verifyTripAccess } from '../../services/tripAccess'; import { avatarUrl } from '../../services/avatarUrl'; @@ -41,6 +41,7 @@ export class BudgetService { private readonly db: DatabaseService, private readonly permissions: PermissionsService, private readonly exchangeRates: ExchangeRatesService, + private readonly realtime: RealtimeService, ) {} verifyTripAccess(tripId: string | number, userId: number) { @@ -52,7 +53,7 @@ export class BudgetService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } // ------------------------------------------------------------------------- @@ -893,7 +894,7 @@ export class BudgetService { meta.price = String(totalPrice); this.db.run('UPDATE reservations SET metadata = ? WHERE id = ?', JSON.stringify(meta), reservation.id); const updatedRes = this.db.get('SELECT * FROM reservations WHERE id = ?', reservation.id); - broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, socketId); + this.realtime.broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, socketId); } catch (err) { console.error('[budget] Failed to sync price to reservation:', err); } diff --git a/server/src/nest/collab/collab.bridge.ts b/server/src/nest/collab/collab.bridge.ts index 6a09189a9..7fb7ad7b5 100644 --- a/server/src/nest/collab/collab.bridge.ts +++ b/server/src/nest/collab/collab.bridge.ts @@ -1,5 +1,6 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; import { CollabService } from './collab.service'; import { PermissionsService } from '../permissions/permissions.service'; @@ -17,7 +18,7 @@ import { PermissionsService } from '../permissions/permissions.service'; * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton. */ -const collab = new CollabService(new DatabaseService(db), new PermissionsService(new DatabaseService(db))); +const collab = new CollabService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService()); export function listNotes(tripId: string | number) { return collab.listNotes(tripId); diff --git a/server/src/nest/collab/collab.service.ts b/server/src/nest/collab/collab.service.ts index dd7465682..c07ab10b7 100644 --- a/server/src/nest/collab/collab.service.ts +++ b/server/src/nest/collab/collab.service.ts @@ -2,7 +2,7 @@ import path from 'path'; import fs from 'fs'; import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { verifyTripAccess } from '../../services/tripAccess'; import { avatarUrl } from '../../services/avatarUrl'; @@ -65,6 +65,7 @@ export class CollabService { constructor( private readonly db: DatabaseService, private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, ) {} verifyTripAccess(tripId: string | number, userId: number) { @@ -80,7 +81,7 @@ export class CollabService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + 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 9ebb978b8..7e41652e6 100644 --- a/server/src/nest/days/day-notes.service.ts +++ b/server/src/nest/days/day-notes.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import type { DayNote, User } from '../../types'; import { DatabaseService, type TripAccess } from '../database/database.service'; @@ -16,6 +16,7 @@ export class DayNotesService { constructor( private readonly dbs: DatabaseService, private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, ) {} verifyTripAccess(tripId: string | number, userId: number): TripAccess | undefined { @@ -27,7 +28,7 @@ export class DayNotesService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } list(dayId: string | number, tripId: string | number) { diff --git a/server/src/nest/days/days.bridge.ts b/server/src/nest/days/days.bridge.ts index 3040c4737..3d3865e50 100644 --- a/server/src/nest/days/days.bridge.ts +++ b/server/src/nest/days/days.bridge.ts @@ -1,5 +1,6 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; import { DaysService, addDays } from './days.service'; import { PermissionsService } from '../permissions/permissions.service'; @@ -17,7 +18,7 @@ import { PermissionsService } from '../permissions/permissions.service'; * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton. */ -const days = new DaysService(new DatabaseService(db), new PermissionsService(new DatabaseService(db))); +const days = new DaysService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService()); export { addDays }; diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index 44086f9e7..607568206 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../../services/queryHelpers'; @@ -87,6 +87,7 @@ export class DaysService { constructor( private readonly db: DatabaseService, private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, ) {} verifyTripAccess(tripId: string | number, userId: number) { @@ -98,7 +99,7 @@ export class DaysService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } // ------------------------------------------------------------------------- diff --git a/server/src/nest/files/files.bridge.ts b/server/src/nest/files/files.bridge.ts index 4c3ff5330..ab0cc6bb0 100644 --- a/server/src/nest/files/files.bridge.ts +++ b/server/src/nest/files/files.bridge.ts @@ -1,5 +1,6 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; import { FilesService } from './files.service'; import { PermissionsService } from '../permissions/permissions.service'; @@ -15,7 +16,7 @@ import { PermissionsService } from '../permissions/permissions.service'; * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton. */ -const files = new FilesService(new DatabaseService(db), new PermissionsService(new DatabaseService(db))); +const files = new FilesService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService()); export function getAllowedExtensions(): string { return files.getAllowedExtensions(); diff --git a/server/src/nest/files/files.service.ts b/server/src/nest/files/files.service.ts index 89cd6403c..30b439447 100644 --- a/server/src/nest/files/files.service.ts +++ b/server/src/nest/files/files.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import path from 'path'; import fs from 'fs'; import type { Request } from 'express'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { avatarUrl } from '../../services/avatarUrl'; import { consumeEphemeralToken } from '../../services/ephemeralTokens'; @@ -57,6 +57,7 @@ export class FilesService { constructor( private readonly db: DatabaseService, private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, ) {} verifyTripAccess(tripId: string | number, userId: number) { @@ -68,7 +69,7 @@ export class FilesService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } getAllowedExtensions(): string { diff --git a/server/src/nest/memories/memories.service.ts b/server/src/nest/memories/memories.service.ts index e96faf64a..70a2420a4 100644 --- a/server/src/nest/memories/memories.service.ts +++ b/server/src/nest/memories/memories.service.ts @@ -38,7 +38,7 @@ import { } from '../../services/memories/synologyService'; import { canAccessUserPhoto } from '../../services/memories/helpersService'; import type { Selection } from '../../services/memories/helpersService'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; /** * Thin Nest wrapper around the existing memories (photo-providers) services. @@ -49,13 +49,15 @@ import { broadcast } from '../../websocket'; */ @Injectable() export class MemoriesService { + constructor(private readonly realtime: RealtimeService) {} + // ── Access check (reused by both provider asset routes) ────────────────── canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { return canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider); } broadcast(tripId: string, event: string, payload: Record, socketId?: string): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } // ── Unified ────────────────────────────────────────────────────────────── diff --git a/server/src/nest/packing/packing.bridge.ts b/server/src/nest/packing/packing.bridge.ts index 52aaf7d63..72c3a4720 100644 --- a/server/src/nest/packing/packing.bridge.ts +++ b/server/src/nest/packing/packing.bridge.ts @@ -1,5 +1,6 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; import { PackingService } from './packing.service'; import { PermissionsService } from '../permissions/permissions.service'; @@ -17,7 +18,7 @@ import { PermissionsService } from '../permissions/permissions.service'; * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton. */ -const packing = new PackingService(new DatabaseService(db), new PermissionsService(new DatabaseService(db))); +const packing = new PackingService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService()); export function listItems(tripId: string | number, userId?: number) { return packing.listItems(tripId, userId); diff --git a/server/src/nest/packing/packing.service.ts b/server/src/nest/packing/packing.service.ts index 214fb531d..543cd8677 100644 --- a/server/src/nest/packing/packing.service.ts +++ b/server/src/nest/packing/packing.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { verifyTripAccess } from '../../services/tripAccess'; import { avatarUrl } from '../../services/avatarUrl'; @@ -42,6 +42,7 @@ export class PackingService { constructor( private readonly db: DatabaseService, private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, ) {} verifyTripAccess(tripId: string | number, userId: number) { @@ -54,7 +55,7 @@ export class PackingService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } /** @@ -64,14 +65,14 @@ export class PackingService { */ broadcastItem(tripId: string, event: string, payload: Record, item: PrivacyFields | null | undefined, socketId: string | undefined): void { const onlyUserId = item?.is_private && item.owner_id != null ? item.owner_id : undefined; - broadcast(tripId, event, payload, socketId, onlyUserId); + 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 { for (const uid of new Set(viewerIds)) { - if (uid != null) broadcast(tripId, event, payload, socketId, uid); + 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 3da38ae5f..cbf7b6160 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { TRACK_COLORS } from '@trek/shared'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import type { User } from '../../types'; @@ -22,6 +22,7 @@ export class PlacesService { constructor( private readonly dbs: DatabaseService, private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, ) {} verifyTripAccess(tripId: string, userId: number) { @@ -33,7 +34,7 @@ export class PlacesService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } list(tripId: string, filters: { search?: string; category?: string; tag?: string }) { 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 eae84c92b..c5715baae 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { readEnv } from '../../../app-config'; -import { broadcast, broadcastToUser } from '../../../websocket'; +import { RealtimeService } from '../../realtime/realtime.service'; import { isUpdateConflict } from '../../../services/conflictResult'; import { getWeather } from '../../../services/weatherService'; import { BLOCKED_EXTENSIONS, filesDir } from '../../files/files.constants'; @@ -72,35 +72,35 @@ function packingViewersOf(item: PackingPrivacy | null | undefined): number[] | n } /** CREATE/DELETE fan-out: whole room for a Common item, else owner + recipients only. */ -function emitPackingToViewers(tripId: number, event: string, payload: Record, item: PackingPrivacy): void { +function emitPackingToViewers(realtime: RealtimeService, tripId: number, event: string, payload: Record, item: PackingPrivacy): void { const viewers = packingViewersOf(item); if (viewers === null) { - broadcast(tripId, event, payload, undefined); + realtime.broadcast(tripId, event, payload, undefined); return; } - for (const uid of new Set(viewers)) if (uid != null) broadcast(tripId, event, payload, undefined, uid); + for (const uid of new Set(viewers)) if (uid != null) realtime.broadcast(tripId, event, payload, undefined, uid); } /** An item event delivered owner-only when the item is private (else to the room). */ -function broadcastPackingItem(tripId: number, event: string, payload: Record, item: PackingPrivacy): void { +function broadcastPackingItem(realtime: RealtimeService, tripId: number, event: string, payload: Record, item: PackingPrivacy): void { const onlyUserId = item?.is_private && item.owner_id != null ? item.owner_id : undefined; - broadcast(tripId, event, payload, undefined, onlyUserId); + realtime.broadcast(tripId, event, payload, undefined, onlyUserId); } /** The four public<->private transitions (packing.controller.broadcastUpdate). `wasPrivate` * is read BEFORE the write — getting this wrong LEAKS a freshly-privatized item. */ -function broadcastPackingUpdate(tripId: number, itemId: number, item: PackingPrivacy, wasPrivate: boolean): void { +function broadcastPackingUpdate(realtime: RealtimeService, tripId: number, itemId: number, item: PackingPrivacy, wasPrivate: boolean): void { const nowPrivate = !!item.is_private; if (nowPrivate) { if (wasPrivate) { - broadcastPackingItem(tripId, 'packing:updated', { item }, item); // stays private -> owner-only + broadcastPackingItem(realtime, tripId, 'packing:updated', { item }, item); // stays private -> owner-only } else { - broadcast(tripId, 'packing:deleted', { itemId }, undefined); // public->private: drop from the room... - broadcastPackingItem(tripId, 'packing:created', { item }, item); // ...then re-add for the owner + realtime.broadcast(tripId, 'packing:deleted', { itemId }, undefined); // public->private: drop from the room... + broadcastPackingItem(realtime, tripId, 'packing:created', { item }, item); // ...then re-add for the owner } } else { - if (wasPrivate) broadcast(tripId, 'packing:created', { item }, undefined); // private->public: add for members who lacked it - broadcast(tripId, 'packing:updated', { item }, undefined); + if (wasPrivate) realtime.broadcast(tripId, 'packing:created', { item }, undefined); // private->public: add for members who lacked it + realtime.broadcast(tripId, 'packing:updated', { item }, undefined); } } @@ -118,9 +118,9 @@ export interface PluginCallRouter { /** * Wires a plugin's capability host to the REAL privileged modules (#plugins, - * M1). This is the ONLY plugin file that imports the websocket — it runs in the - * host (parent), never in the child. Broadcasts are force-namespaced to - * `plugin:{id}:{event}` so a plugin can't forge a core event. + * M1). This is the ONLY plugin file that touches the realtime broadcast facade + * — it runs in the host (parent), never in the child. Broadcasts are + * force-namespaced to `plugin:{id}:{event}` so a plugin can't forge a core event. * * DI-native domain services (budget, exchange-rates, reservations, tags, * categories, todo, packing, day-notes, assignments, oauth, files, collab, @@ -151,6 +151,7 @@ export class PluginHostDepsFactory { private readonly permissions: PermissionsService, private readonly exchangeRates: ExchangeRatesService, private readonly addons: AddonsService, + private readonly realtime: RealtimeService, ) {} /** @@ -206,8 +207,8 @@ export class PluginHostDepsFactory { LIMIT 1`, ) .get(actingUserId, targetUserId, actingUserId, targetUserId), - broadcastToTrip: (tripId, event, payload) => broadcast(tripId, `plugin:${id}:${event}`, payload), - broadcastToUser: (userId, payload) => broadcastToUser(userId, { type: `plugin:${id}`, ...payload }), + broadcastToTrip: (tripId, event, payload) => this.realtime.broadcast(tripId, `plugin:${id}:${event}`, payload), + broadcastToUser: (userId, payload) => this.realtime.broadcastToUser(userId, { type: `plugin:${id}`, ...payload }), audit: (entry) => appendAudit(this.db.connection, entry), // --- Costs (budget items) --- budgetAddonEnabled: () => this.addons.isAddonEnabled(ADDON_IDS.BUDGET), @@ -273,7 +274,7 @@ export class PluginHostDepsFactory { actingUserId, { place_id: i.place_id != null ? String(i.place_id) : null, reservation_id: i.reservation_id != null ? String(i.reservation_id) : null, description: i.description ?? null }, ); - broadcast(tripId, 'file:created', { file }, undefined); + this.realtime.broadcast(tripId, 'file:created', { file }, undefined); return file; }, createTripFileLink: (tripId, fileId, opts) => { @@ -290,13 +291,13 @@ export class PluginHostDepsFactory { const foreign = this.files.findForeignLinkTarget(tripId, { reservation_id: i.reservation_id ?? null, place_id: i.place_id ?? null }); if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`); const file = this.files.updateFile(fileId, current, { description: i.description, place_id: i.place_id != null ? String(i.place_id) : i.place_id === null ? null : undefined, reservation_id: i.reservation_id != null ? String(i.reservation_id) : i.reservation_id === null ? null : undefined }); - broadcast(tripId, 'file:updated', { file }, undefined); + this.realtime.broadcast(tripId, 'file:updated', { file }, undefined); return file; }, softDeleteTripFile: (tripId, fileId) => { if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`); this.files.softDeleteFile(fileId); - broadcast(tripId, 'file:deleted', { fileId }, undefined); + this.realtime.broadcast(tripId, 'file:deleted', { fileId }, undefined); return { deleted: true }; }, // --- Collab reads (collab addon; membership checked by the host). Same hydrated @@ -309,27 +310,27 @@ export class PluginHostDepsFactory { createCollabNote: (tripId, input, actingUserId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); const note = this.collab.createNote(String(tripId), actingUserId, input as never); - broadcast(tripId, 'collab:note:created', { note }, undefined); + this.realtime.broadcast(tripId, 'collab:note:created', { note }, undefined); return note; }, createCollabPoll: (tripId, input, actingUserId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); const poll = this.collab.createPoll(String(tripId), actingUserId, input as never); - broadcast(tripId, 'collab:poll:created', { poll }, undefined); + this.realtime.broadcast(tripId, 'collab:poll:created', { poll }, undefined); return poll; }, voteCollabPoll: (tripId, pollId, optionIndex, actingUserId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); const result = this.collab.votePoll(String(tripId), String(pollId), actingUserId, optionIndex); if (result.error) throw new BadParams(result.error); - broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, undefined); + this.realtime.broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, undefined); return result.poll; }, createCollabMessage: (tripId, text, replyTo, actingUserId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); const result = this.collab.createMessage(String(tripId), actingUserId, text, replyTo ?? null); if (result.error) throw new BadParams(result.error); - broadcast(tripId, 'collab:message:created', { message: result.message }, undefined); + this.realtime.broadcast(tripId, 'collab:message:created', { message: result.message }, undefined); return result.message; }, // --- Member add (member_manage). Grants trip access — the target must exist; @@ -437,7 +438,7 @@ export class PluginHostDepsFactory { // live. No X-Socket-Id — a plugin has no originating socket. createCost: async (tripId, input) => { const item = await this.budget.create(String(tripId), input); - broadcast(tripId, 'budget:created', { item }); + this.realtime.broadcast(tripId, 'budget:created', { item }); return item; }, // Reuses BudgetService.update (re-frozen FX on a currency change), then @@ -446,7 +447,7 @@ export class PluginHostDepsFactory { updateCost: async (tripId, itemId, input) => { const item = await this.budget.update(String(itemId), String(tripId), input); if (item == null) throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`); - broadcast(tripId, 'budget:updated', { item }); + this.realtime.broadcast(tripId, 'budget:updated', { item }); return item; }, // Reuses BudgetService.remove, then broadcasts 'budget:deleted' with the @@ -454,7 +455,7 @@ export class PluginHostDepsFactory { deleteCost: (tripId, itemId) => { const deleted = this.budget.remove(String(itemId), String(tripId)); if (!deleted) throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`); - broadcast(tripId, 'budget:deleted', { itemId }); + this.realtime.broadcast(tripId, 'budget:deleted', { itemId }); return { deleted: true }; }, // --- Places (place_edit). Delegate to the same placeService the REST/MCP paths @@ -462,19 +463,19 @@ export class PluginHostDepsFactory { canEditPlaces: (tripId, userId) => this.canEditTripAs('place_edit', tripId, userId), createPlace: (tripId, input) => { const place = createPlace(String(tripId), input as Parameters[1]); - broadcast(tripId, 'place:created', { place }); + this.realtime.broadcast(tripId, 'place:created', { place }); return place; }, updatePlace: (tripId, placeId, input) => { const place = updatePlace(String(tripId), String(placeId), input as Parameters[2]); if (place === null) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`); - broadcast(tripId, 'place:updated', { place }); + this.realtime.broadcast(tripId, 'place:updated', { place }); return place; }, deletePlace: (tripId, placeId) => { const deleted = deletePlace(String(tripId), String(placeId)); if (!deleted) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`); - broadcast(tripId, 'place:deleted', { placeId }); + this.realtime.broadcast(tripId, 'place:deleted', { placeId }); return { deleted: true }; }, // --- Days (day_edit). getDay scopes the row to the trip before any write. --- @@ -482,21 +483,21 @@ export class PluginHostDepsFactory { createDay: (tripId, input) => { const i = input as { date?: string; notes?: string }; const day = this.days.create(tripId, i.date, i.notes); - broadcast(tripId, 'day:created', { day }); + this.realtime.broadcast(tripId, 'day:created', { day }); return day; }, updateDay: (tripId, dayId, input) => { const current = this.days.getDay(dayId, tripId); if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); const day = this.days.update(dayId, current, input as { notes?: string; title?: string | null }); - broadcast(tripId, 'day:updated', { day }); + this.realtime.broadcast(tripId, 'day:updated', { day }); return day; }, deleteDay: (tripId, dayId) => { const current = this.days.getDay(dayId, tripId); if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); this.days.remove(dayId); - broadcast(tripId, 'day:deleted', { dayId }); + this.realtime.broadcast(tripId, 'day:deleted', { dayId }); return { deleted: true }; }, // --- Itinerary (day_edit). Both the day AND the place must belong to the trip, @@ -506,7 +507,7 @@ export class PluginHostDepsFactory { if (!this.assignments.dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); if (!this.assignments.placeExists(placeId, tripId)) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`); const assignment = this.assignments.createAssignment(dayId, placeId, notes); - broadcast(tripId, 'assignment:created', { assignment }); + this.realtime.broadcast(tripId, 'assignment:created', { assignment }); return assignment; }, unassignPlace: (tripId, assignmentId) => { @@ -515,7 +516,7 @@ export class PluginHostDepsFactory { this.assignments.deleteAssignment(assignmentId); // Same payload shape as the REST/MCP delete, so client stores can // evict the assignment from the right day. - broadcast(tripId, 'assignment:deleted', { assignmentId, dayId: existing.day_id }); + this.realtime.broadcast(tripId, 'assignment:deleted', { assignmentId, dayId: existing.day_id }); return { deleted: true }; }, // --- Trip creation (trip_create; owner = acting user). No broadcast — a new trip @@ -551,7 +552,7 @@ export class PluginHostDepsFactory { const u = this.db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined; try { const result = updateTrip(tripId, userId, input as Parameters[2], u?.role ?? 'user'); - broadcast(tripId, 'trip:updated', { trip: result.updatedTrip }); + this.realtime.broadcast(tripId, 'trip:updated', { trip: result.updatedTrip }); return result.updatedTrip; } catch (e) { if (e instanceof ValidationError) throw new BadParams(e.message); @@ -588,8 +589,8 @@ export class PluginHostDepsFactory { check_in: i.check_in ?? undefined, check_in_end: i.check_in_end ?? undefined, check_out: i.check_out ?? undefined, confirmation: i.confirmation ?? undefined, notes: i.notes ?? undefined, }); - broadcast(tripId, 'accommodation:created', { accommodation }); - broadcast(tripId, 'reservation:created', {}); + this.realtime.broadcast(tripId, 'accommodation:created', { accommodation }); + this.realtime.broadcast(tripId, 'reservation:created', {}); return accommodation; }, updateAccommodation: (tripId, accommodationId, input) => { @@ -599,15 +600,15 @@ export class PluginHostDepsFactory { const errors = this.days.validateAccommodationRefs(tripId, i.place_id, i.start_day_id, i.end_day_id); if (errors.length > 0) throw new ForbiddenResource(errors[0].message); const accommodation = this.days.updateAccommodation(accommodationId, existing, i); - broadcast(tripId, 'accommodation:updated', { accommodation }); + this.realtime.broadcast(tripId, 'accommodation:updated', { accommodation }); return accommodation; }, deleteAccommodation: (tripId, accommodationId) => { if (!this.days.getAccommodation(accommodationId, tripId)) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`); const { linkedReservationId, deletedBudgetItemId } = this.days.deleteAccommodation(accommodationId); - if (linkedReservationId) broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }); - if (deletedBudgetItemId) broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }); - broadcast(tripId, 'accommodation:deleted', { accommodationId }); + if (linkedReservationId) this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }); + if (deletedBudgetItemId) this.realtime.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }); + this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId }); return { deleted: true }; }, // --- User-scoped addon reads (the acting user's own data across all trips). Each @@ -712,21 +713,21 @@ export class PluginHostDepsFactory { if (!this.dayNotes.dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`); const i = input as { text?: string; time?: string; icon?: string; sort_order?: number }; const note = this.dayNotes.create(dayId, tripId, i.text ?? '', i.time, i.icon, i.sort_order); - broadcast(tripId, 'dayNote:created', { dayId, note }, undefined); + this.realtime.broadcast(tripId, 'dayNote:created', { dayId, note }, undefined); return note; }, updateDayNote: (tripId, dayId, noteId, input) => { const current = this.dayNotes.getNote(noteId, dayId, tripId); if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`); const note = this.dayNotes.update(noteId, current as never, input as { text?: string; time?: string; icon?: string; sort_order?: number }); - broadcast(tripId, 'dayNote:updated', { dayId, note }, undefined); + this.realtime.broadcast(tripId, 'dayNote:updated', { dayId, note }, undefined); return note; }, deleteDayNote: (tripId, dayId, noteId) => { const current = this.dayNotes.getNote(noteId, dayId, tripId); if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`); this.dayNotes.remove(noteId); - broadcast(tripId, 'dayNote:deleted', { noteId, dayId }, undefined); + this.realtime.broadcast(tripId, 'dayNote:deleted', { noteId, dayId }, undefined); return { deleted: true }; }, // --- Reservations (bookings, reservation_edit). Delegates to ReservationsService @@ -735,10 +736,10 @@ export class PluginHostDepsFactory { canEditReservations: (tripId, userId) => this.canEditTripAs('reservation_edit', tripId, userId), createReservation: (tripId, input, actingUserId) => { const { reservation, accommodationCreated } = this.reservations.create(String(tripId), input as never); - if (accommodationCreated) broadcast(tripId, 'accommodation:created', {}, undefined); + if (accommodationCreated) this.realtime.broadcast(tripId, 'accommodation:created', {}, undefined); const i = input as { title?: string; type?: string; create_budget_entry?: unknown }; this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined); - broadcast(tripId, 'reservation:created', { reservation }, undefined); + this.realtime.broadcast(tripId, 'reservation:created', { reservation }, undefined); this.notifyBooking(actingUserId, tripId, i.title ?? '', i.type ?? ''); return reservation; }, @@ -746,20 +747,20 @@ export class PluginHostDepsFactory { const current = this.reservations.getReservation(String(reservationId), String(tripId)); if (!current) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`); const { reservation, accommodationChanged } = this.reservations.update(String(reservationId), String(tripId), input as never, current as never); - if (accommodationChanged) broadcast(tripId, 'accommodation:updated', {}, undefined); + if (accommodationChanged) this.realtime.broadcast(tripId, 'accommodation:updated', {}, undefined); const cur = current as { title: string; type?: string }; const i = input as { title?: string; type?: string; create_budget_entry?: unknown }; this.reservations.syncBudgetOnUpdate(String(tripId), String(reservationId), i.title ?? '', i.type, cur.title, cur.type, i.create_budget_entry as never, undefined); - broadcast(tripId, 'reservation:updated', { reservation }, undefined); + this.realtime.broadcast(tripId, 'reservation:updated', { reservation }, undefined); this.notifyBooking(actingUserId, tripId, i.title || cur.title, i.type || cur.type || ''); return reservation; }, deleteReservation: (tripId, reservationId, actingUserId) => { const { deleted, accommodationDeleted, deletedBudgetItemId } = this.reservations.remove(String(reservationId), String(tripId)); if (!deleted) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`); - if (accommodationDeleted) broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }, undefined); - if (deletedBudgetItemId) broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, undefined); - broadcast(tripId, 'reservation:deleted', { reservationId: Number(reservationId) }, undefined); + if (accommodationDeleted) this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }, undefined); + if (deletedBudgetItemId) this.realtime.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, undefined); + this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId: Number(reservationId) }, undefined); this.notifyBooking(actingUserId, tripId, deleted.title, deleted.type || ''); return { deleted: true }; }, @@ -770,7 +771,7 @@ export class PluginHostDepsFactory { createPackingItem: (tripId, input, actingUserId) => { const i = input as { name: string; category?: string; checked?: boolean; is_private?: boolean; visibility?: 'common' | 'personal' | 'shared'; recipient_ids?: number[] }; const item = this.packing.createItem(String(tripId), i, actingUserId) as PackingPrivacy; - emitPackingToViewers(tripId, 'packing:created', { item }, item); + emitPackingToViewers(this.realtime, tripId, 'packing:created', { item }, item); return item; }, updatePackingItem: (tripId, itemId, input, actingUserId) => { @@ -779,13 +780,13 @@ export class PluginHostDepsFactory { const updated = this.packing.updateItem(String(tripId), String(itemId), input as never, Object.keys(input), undefined, actingUserId); if (!updated) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`); if (isUpdateConflict(updated)) throw new BadParams('packing item was modified concurrently'); - broadcastPackingUpdate(tripId, itemId, updated as PackingPrivacy, !!before?.is_private); + broadcastPackingUpdate(this.realtime, tripId, itemId, updated as PackingPrivacy, !!before?.is_private); return updated; }, deletePackingItem: (tripId, itemId) => { const deleted = this.packing.deleteItem(String(tripId), String(itemId)) as PackingPrivacy | null; if (!deleted) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`); - emitPackingToViewers(tripId, 'packing:deleted', { itemId }, deleted); + emitPackingToViewers(this.realtime, tripId, 'packing:deleted', { itemId }, deleted); return { deleted: true }; }, // --- Packing bags (no privacy — plain room broadcasts). --- @@ -793,24 +794,24 @@ export class PluginHostDepsFactory { createPackingBag: (tripId, input) => { const i = input as { name: string; color?: string }; const bag = this.packing.createBag(String(tripId), { name: i.name, color: i.color }); - broadcast(tripId, 'packing:bag-created', { bag }, undefined); + this.realtime.broadcast(tripId, 'packing:bag-created', { bag }, undefined); return bag; }, updatePackingBag: (tripId, bagId, input) => { const bag = this.packing.updateBag(String(tripId), String(bagId), input as never, Object.keys(input)); if (!bag) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`); - broadcast(tripId, 'packing:bag-updated', { bag }, undefined); + this.realtime.broadcast(tripId, 'packing:bag-updated', { bag }, undefined); return bag; }, deletePackingBag: (tripId, bagId) => { if (!this.packing.deleteBag(String(tripId), String(bagId))) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`); - broadcast(tripId, 'packing:bag-deleted', { bagId }, undefined); + this.realtime.broadcast(tripId, 'packing:bag-deleted', { bagId }, undefined); return { deleted: true }; }, setPackingBagMembers: (tripId, bagId, userIds) => { const members = this.packing.setBagMembers(String(tripId), String(bagId), userIds); if (!members) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`); - broadcast(tripId, 'packing:bag-members-updated', { bagId, members }, undefined); + this.realtime.broadcast(tripId, 'packing:bag-members-updated', { bagId, members }, undefined); return members; }, // --- Read-convenience: weather (host cache, tenant-free), categories (global), roster --- @@ -835,18 +836,18 @@ export class PluginHostDepsFactory { listTodos: (tripId) => this.todos.listItems(String(tripId)) as unknown[], createTodo: (tripId, input) => { const item = this.todos.createItem(String(tripId), input as never); - broadcast(tripId, 'todo:created', { item }, undefined); + this.realtime.broadcast(tripId, 'todo:created', { item }, undefined); return item; }, updateTodo: (tripId, todoId, input) => { const updated = this.todos.updateItem(String(tripId), String(todoId), input as never, Object.keys(input)); if (!updated) throw new ForbiddenResource(`no todo ${todoId} on trip ${tripId}`); - broadcast(tripId, 'todo:updated', { item: updated }, undefined); + this.realtime.broadcast(tripId, 'todo:updated', { item: updated }, undefined); return updated; }, deleteTodo: (tripId, todoId) => { if (!this.todos.deleteItem(String(tripId), String(todoId))) throw new ForbiddenResource(`no todo ${todoId} on trip ${tripId}`); - broadcast(tripId, 'todo:deleted', { itemId: todoId }, undefined); + this.realtime.broadcast(tripId, 'todo:deleted', { itemId: todoId }, undefined); return { deleted: true }; }, // --- Plugin metadata (db:meta). A per-plugin namespaced key/value store keyed diff --git a/server/src/nest/realtime/realtime.module.ts b/server/src/nest/realtime/realtime.module.ts new file mode 100644 index 000000000..a6eee3472 --- /dev/null +++ b/server/src/nest/realtime/realtime.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { RealtimeService } from './realtime.service'; + +/** + * Global so every migrated module can inject RealtimeService without + * re-importing (same rationale as DatabaseModule). The service is + * stateless and dependency-free — the ws server itself is still wired + * in index.ts via setupWebSocket(); this module only provides the + * injectable broadcast seam. + */ +@Global() +@Module({ + providers: [RealtimeService], + exports: [RealtimeService], +}) +export class RealtimeModule {} diff --git a/server/src/nest/realtime/realtime.service.ts b/server/src/nest/realtime/realtime.service.ts new file mode 100644 index 000000000..db8fa8509 --- /dev/null +++ b/server/src/nest/realtime/realtime.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { broadcast, broadcastToUser } from '../../websocket'; + +/** + * Injectable facade over the websocket module singleton (roadmap Phase 0 item 3). + * + * Migrated Nest services inject this instead of importing `broadcast`/ + * `broadcastToUser` module globals. The actual transport (rooms, auth, + * heartbeat, rate limiting) stays in src/websocket.ts — a future + * @WebSocketGateway swap happens behind this facade, invisibly. + * + * Both methods delegate to the live module exports *at call time* (same + * pattern as DatabaseService.canAccessTrip): tests vi.mock src/websocket + * per file, and the stubs must keep flowing through here. Never capture + * the functions at construction, and never dereference an export the + * method wasn't asked for — many test mocks provide `broadcast` only. + * + * The service is deliberately dependency-free so module-level bridge code + * (todo.bridge.ts pattern) can construct it with a bare `new RealtimeService()`. + */ +@Injectable() +export class RealtimeService { + /** + * Broadcast an event to all sockets in a trip room. Mirrors the + * websocket.ts signature exactly: `excludeSid` is the X-Socket-Id + * echo-suppression contract (the originating client is skipped); + * `onlyUserId` narrows delivery to one user's sockets (#858 private + * packing items). + * + * 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( + ...args: [ + tripId: number | string, + eventType: string, + payload: Record, + excludeSid?: number | string, + onlyUserId?: number, + ] + ): void { + broadcast(...args); + } + + /** Send a message to all sockets of one user; the payload carries its own `type`. */ + broadcastToUser( + ...args: [userId: number, payload: Record, excludeSid?: number | string] + ): void { + broadcastToUser(...args); + } +} diff --git a/server/src/nest/reservations/accommodations.service.ts b/server/src/nest/reservations/accommodations.service.ts index ff09b5b02..325437821 100644 --- a/server/src/nest/reservations/accommodations.service.ts +++ b/server/src/nest/reservations/accommodations.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import type { User } from '../../types'; @@ -19,6 +19,7 @@ export class AccommodationsService { private readonly dbs: DatabaseService, private readonly days: DaysService, private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, ) {} /** Mirrors the requireTripAccess middleware (owner or member), returning the trip. */ @@ -31,7 +32,7 @@ export class AccommodationsService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } list(tripId: string) { diff --git a/server/src/nest/reservations/reservations.bridge.ts b/server/src/nest/reservations/reservations.bridge.ts index 99fc48456..505b72562 100644 --- a/server/src/nest/reservations/reservations.bridge.ts +++ b/server/src/nest/reservations/reservations.bridge.ts @@ -1,5 +1,6 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; import { ReservationsService } from './reservations.service'; import { PermissionsService } from '../permissions/permissions.service'; import { BudgetService } from '../budget/budget.service'; @@ -24,7 +25,8 @@ export type { EndpointInput, ReservationEndpoint, ReservationTraveler } from './ const reservations = new ReservationsService( new DatabaseService(db), new PermissionsService(new DatabaseService(db)), - new BudgetService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new ExchangeRatesService()), + new BudgetService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new ExchangeRatesService(), new RealtimeService()), + new RealtimeService(), ); export function listReservations(tripId: string | number) { diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index afc94fed1..01f708ca5 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { verifyTripAccess } from '../../services/tripAccess'; import { avatarUrl } from '../../services/avatarUrl'; @@ -133,6 +133,7 @@ export class ReservationsService { private readonly db: DatabaseService, private readonly permissions: PermissionsService, private readonly budget: BudgetService, + private readonly realtime: RealtimeService, ) {} verifyTripAccess(tripId: string | number, userId: number) { @@ -144,7 +145,7 @@ export class ReservationsService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } /** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */ @@ -745,7 +746,7 @@ export class ReservationsService { category: entry.category || type || 'Other', total_price: entry.total_price!, }); - broadcast(tripId, 'budget:created', { item }, socketId); + this.realtime.broadcast(tripId, 'budget:created', { item }, socketId); } catch (err) { console.error('[reservations] Failed to create budget entry:', err); } @@ -763,7 +764,7 @@ export class ReservationsService { const newCat = typeToCostCategory(type); if (oldCat !== newCat && linked.category === oldCat) { const updated = this.budget.updateBudgetItem(linked.id, tripId, { category: newCat }); - broadcast(tripId, 'budget:updated', { item: updated }, socketId); + this.realtime.broadcast(tripId, 'budget:updated', { item: updated }, socketId); } } } @@ -778,7 +779,7 @@ export class ReservationsService { const linked = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id); if (linked) { this.budget.deleteBudgetItem(linked.id, tripId); - broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId); + this.realtime.broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId); } return; } @@ -789,12 +790,12 @@ export class ReservationsService { const existing = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id); if (existing) { const updated = this.budget.updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price }); - broadcast(tripId, 'budget:updated', { item: updated }, socketId); + this.realtime.broadcast(tripId, 'budget:updated', { item: updated }, socketId); } else { const item = this.budget.createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price }); this.db.run('UPDATE budget_items SET reservation_id = ? WHERE id = ?', id, item.id); item.reservation_id = Number(id); - broadcast(tripId, 'budget:created', { item }, socketId); + this.realtime.broadcast(tripId, 'budget:created', { item }, socketId); } } catch (err) { console.error('[reservations] Failed to create/update budget entry:', err); diff --git a/server/src/nest/todo/todo.bridge.ts b/server/src/nest/todo/todo.bridge.ts index f38653188..6410a1ed4 100644 --- a/server/src/nest/todo/todo.bridge.ts +++ b/server/src/nest/todo/todo.bridge.ts @@ -1,5 +1,6 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; import { TodoService } from './todo.service'; import { PermissionsService } from '../permissions/permissions.service'; @@ -16,7 +17,7 @@ import { PermissionsService } from '../permissions/permissions.service'; * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton. */ -const todos = new TodoService(new DatabaseService(db), new PermissionsService(new DatabaseService(db))); +const todos = new TodoService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService()); export function listItems(tripId: string | number) { return todos.listItems(tripId); diff --git a/server/src/nest/todo/todo.service.ts b/server/src/nest/todo/todo.service.ts index 0985c5b1d..6d4fafa4b 100644 --- a/server/src/nest/todo/todo.service.ts +++ b/server/src/nest/todo/todo.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { verifyTripAccess } from '../../services/tripAccess'; import type { User } from '../../types'; @@ -21,6 +21,7 @@ export class TodoService { constructor( private readonly db: DatabaseService, private readonly permissions: PermissionsService, + private readonly realtime: RealtimeService, ) {} verifyTripAccess(tripId: string | number, userId: number) { @@ -32,7 +33,7 @@ export class TodoService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } listItems(tripId: string | number) { diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 49643fb28..9bc28f573 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; -import { broadcast } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import type { User } from '../../types'; import * as tripSvc from '../../services/tripService'; @@ -31,6 +31,7 @@ export class TripsService { private readonly days: DaysService, private readonly permissions: PermissionsService, private readonly budget: BudgetService, + private readonly realtime: RealtimeService, ) {} private get db() { @@ -46,7 +47,7 @@ export class TripsService { } broadcast(tripId: string, event: string, payload: Record, socketId: string | undefined): void { - broadcast(tripId, event, payload, socketId); + this.realtime.broadcast(tripId, event, payload, socketId); } list(userId: number, archived: number) { diff --git a/server/src/nest/vacay/vacay.bridge.ts b/server/src/nest/vacay/vacay.bridge.ts index 4a2ff99c9..7212725cd 100644 --- a/server/src/nest/vacay/vacay.bridge.ts +++ b/server/src/nest/vacay/vacay.bridge.ts @@ -1,5 +1,6 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; import { VacayService } from './vacay.service'; /** @@ -15,7 +16,7 @@ import { VacayService } from './vacay.service'; * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton. */ -const vacay = new VacayService(new DatabaseService(db)); +const vacay = new VacayService(new DatabaseService(db), new RealtimeService()); export function shiftOwnerEntriesForTripWindow( ownerId: number, diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index 135c25f3b..8aeda2bd6 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { broadcastToUser } from '../../websocket'; +import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService } from '../database/database.service'; // --------------------------------------------------------------------------- @@ -160,7 +160,7 @@ function windowEndYear(end: string): number { */ @Injectable() export class VacayService { - constructor(private readonly db: DatabaseService) {} + constructor(private readonly db: DatabaseService, private readonly realtime: RealtimeService) {} private readonly holidayCache = new Map(); @@ -383,7 +383,7 @@ export class VacayService { const userIds = [plan.owner_id]; const members = this.db.all<{ user_id: number }>("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'", planId); members.forEach(m => userIds.push(m.user_id)); - userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid)); + 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') { @@ -399,7 +399,7 @@ export class VacayService { `SELECT DISTINCT user_id FROM vacay_shares WHERE owner_id IN (${ownerIds.map(() => '?').join(',')})`, ...ownerIds ); - rows.forEach(r => broadcastToUser(r.user_id, { type: 'vacay:shared-update' }, excludeSid)); + rows.forEach(r => this.realtime.broadcastToUser(r.user_id, { type: 'vacay:shared-update' }, excludeSid)); } catch { /* websocket not available */ } } @@ -619,7 +619,7 @@ export class VacayService { this.db.run('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)', planId, targetUserId, 'pending'); try { - broadcastToUser(targetUserId, { + this.realtime.broadcastToUser(targetUserId, { type: 'vacay:invite', from: { id: inviterId, username: inviterUsername }, planId, @@ -693,7 +693,7 @@ export class VacayService { this.db.run("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, targetUserId); try { - broadcastToUser(targetUserId, { type: 'vacay:cancelled' }); + this.realtime.broadcastToUser(targetUserId, { type: 'vacay:cancelled' }); } catch { /* */ } } @@ -733,7 +733,7 @@ export class VacayService { }); try { - allUserIds.filter(id => id !== userId).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' })); + allUserIds.filter(id => id !== userId).forEach(id => this.realtime.broadcastToUser(id, { type: 'vacay:dissolved' })); } catch { /* */ } // Everyone's entries just moved back to their own plans — refresh read-only viewers. this.notifyShareViewers(allUserIds, socketId); @@ -853,9 +853,9 @@ export class VacayService { this.db.run('INSERT INTO vacay_shares (owner_id, user_id) VALUES (?, ?)', ownerId, targetUserId); try { - broadcastToUser(targetUserId, { type: 'vacay:share', from: { id: ownerId } }); + this.realtime.broadcastToUser(targetUserId, { type: 'vacay:share', from: { id: ownerId } }); // The owner's other devices refresh their outgoing list too. - broadcastToUser(ownerId, { type: 'vacay:share', from: { id: ownerId } }, socketId); + this.realtime.broadcastToUser(ownerId, { type: 'vacay:share', from: { id: ownerId } }, socketId); } catch { /* websocket not available */ } import('../../services/notificationService').then(({ send }) => { @@ -871,8 +871,8 @@ export class VacayService { if (!share || (share.owner_id !== userId && share.user_id !== userId)) return false; this.db.run('DELETE FROM vacay_shares WHERE id = ?', shareId); try { - broadcastToUser(share.owner_id, { type: 'vacay:share-removed' }, socketId); - broadcastToUser(share.user_id, { type: 'vacay:share-removed' }, socketId); + this.realtime.broadcastToUser(share.owner_id, { type: 'vacay:share-removed' }, socketId); + this.realtime.broadcastToUser(share.user_id, { type: 'vacay:share-removed' }, socketId); } catch { /* websocket not available */ } return true; } @@ -883,7 +883,7 @@ export class VacayService { this.db.run('UPDATE vacay_shares SET hidden = ? WHERE id = ?', hidden ? 1 : 0, shareId); try { // Keep the viewer's other devices in sync; nobody else is affected. - broadcastToUser(userId, { type: 'vacay:shared-update' }, socketId); + this.realtime.broadcastToUser(userId, { type: 'vacay:shared-update' }, socketId); } catch { /* websocket not available */ } return true; } diff --git a/server/tests/e2e/admin.e2e.test.ts b/server/tests/e2e/admin.e2e.test.ts index 74b0cdc4b..f6ef6fdbd 100644 --- a/server/tests/e2e/admin.e2e.test.ts +++ b/server/tests/e2e/admin.e2e.test.ts @@ -10,6 +10,7 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -53,7 +54,7 @@ describe('Admin e2e (real auth + admin guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AdminModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, AdminModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index 5061c4a84..f92f65a2c 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -9,6 +9,7 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -64,7 +65,7 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AssignmentsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, AssignmentsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalPipes(new ZodValidationPipe()); diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index b66e0cccd..580e86222 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -10,6 +10,7 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { sessionCookie } from './harness'; @@ -51,7 +52,7 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, BudgetModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, BudgetModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/collab.e2e.test.ts b/server/tests/e2e/collab.e2e.test.ts index 2dc10e73f..1b6e5610a 100644 --- a/server/tests/e2e/collab.e2e.test.ts +++ b/server/tests/e2e/collab.e2e.test.ts @@ -11,6 +11,7 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -73,7 +74,7 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, CollabModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, CollabModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts index fb4b3c994..e625c5802 100644 --- a/server/tests/e2e/days.e2e.test.ts +++ b/server/tests/e2e/days.e2e.test.ts @@ -11,6 +11,7 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -88,7 +89,7 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, DaysModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, DaysModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalPipes(new ZodValidationPipe()); diff --git a/server/tests/e2e/files.e2e.test.ts b/server/tests/e2e/files.e2e.test.ts index c5d91b34d..7690e20f1 100644 --- a/server/tests/e2e/files.e2e.test.ts +++ b/server/tests/e2e/files.e2e.test.ts @@ -61,6 +61,7 @@ vi.mock('../../src/services/memories/photoResolverService', () => photoSvc); vi.mock('../../src/services/memories/helpersService', () => helperSvc); import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { FilesModule } from '../../src/nest/files/files.module'; import { PhotosModule } from '../../src/nest/photos/photos.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; @@ -70,7 +71,7 @@ describe('Files + photos e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, FilesModule, PhotosModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, FilesModule, PhotosModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/memories.e2e.test.ts b/server/tests/e2e/memories.e2e.test.ts index 708fd8370..4e604e3db 100644 --- a/server/tests/e2e/memories.e2e.test.ts +++ b/server/tests/e2e/memories.e2e.test.ts @@ -62,6 +62,7 @@ vi.mock('../../src/services/memories/helpersService', async () => { }); import { MemoriesModule } from '../../src/nest/memories/memories.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; const BASE = '/api/integrations/memories'; @@ -74,7 +75,7 @@ describe('Memories e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [MemoriesModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [RealtimeModule, MemoriesModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/packing.e2e.test.ts b/server/tests/e2e/packing.e2e.test.ts index 80d1cb580..3c267355a 100644 --- a/server/tests/e2e/packing.e2e.test.ts +++ b/server/tests/e2e/packing.e2e.test.ts @@ -120,6 +120,7 @@ let checkPermission: MockInstance; import { PackingModule } from '../../src/nest/packing/packing.module'; import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -136,7 +137,7 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, PackingModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, PackingModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index 89199cc95..5a10999d1 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -9,6 +9,7 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -53,7 +54,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, PlacesModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, PlacesModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 9b0ba0025..f412976ae 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -11,6 +11,7 @@ import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { ReservationsModule } from '../../src/nest/reservations/reservations.module'; import { sessionCookie } from './harness'; import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; @@ -57,7 +58,7 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, ReservationsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/todo.e2e.test.ts b/server/tests/e2e/todo.e2e.test.ts index 2910a1309..2bddd5456 100644 --- a/server/tests/e2e/todo.e2e.test.ts +++ b/server/tests/e2e/todo.e2e.test.ts @@ -71,6 +71,7 @@ let checkPermission: MockInstance; import { TodoModule } from '../../src/nest/todo/todo.module'; import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -87,7 +88,7 @@ describe('To-do e2e (real auth guard + real SQL over temp SQLite)', () => { let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, TodoModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, TodoModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index e400c0034..58b3e31d5 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -9,6 +9,7 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; @@ -115,7 +116,7 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, TripsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, TripsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts index 9f7e23632..42b07f653 100644 --- a/server/tests/e2e/vacay.e2e.test.ts +++ b/server/tests/e2e/vacay.e2e.test.ts @@ -72,6 +72,7 @@ vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() })); vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { VacayModule } from '../../src/nest/vacay/vacay.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -82,7 +83,7 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, VacayModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, VacayModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index d70939cb8..d0eb8b132 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -26,6 +26,7 @@ import { TodoMcp } from '../../src/nest/todo/todo.mcp'; import { TodoService } from '../../src/nest/todo/todo.service'; import { VacayMcp } from '../../src/nest/vacay/vacay.mcp'; import { VacayService } from '../../src/nest/vacay/vacay.service'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; /** * Hand-wired counterpart of the boot-time discovery in McpRegistryService, @@ -37,22 +38,23 @@ import { VacayService } from '../../src/nest/vacay/vacay.service'; export function createMcpTestRegistry(): McpRegistry { const dbService = new DatabaseService(db); const permissionsService = new PermissionsService(dbService); - const daysService = new DaysService(dbService, permissionsService); + const realtimeService = new RealtimeService(); + const daysService = new DaysService(dbService, permissionsService, realtimeService); const exchangeRatesService = new ExchangeRatesService(); - const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService); + const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService, realtimeService); return createTestRegistry( [ new TagsMcp(new TagsService(dbService)), new CategoriesMcp(new CategoriesService(dbService)), - new TodoMcp(new TodoService(dbService, permissionsService)), - new PackingMcp(new PackingService(dbService, permissionsService)), + new TodoMcp(new TodoService(dbService, permissionsService, realtimeService)), + new PackingMcp(new PackingService(dbService, permissionsService, realtimeService)), new BudgetMcp(budgetService, exchangeRatesService, dbService), - new ReservationsMcp(new ReservationsService(dbService, permissionsService, budgetService), daysService, budgetService), - new DayNotesMcp(new DayNotesService(dbService, permissionsService)), + new ReservationsMcp(new ReservationsService(dbService, permissionsService, budgetService, realtimeService), daysService, budgetService), + new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService)), new DaysMcp(daysService, dbService), - new AssignmentsMcp(new AssignmentsService(dbService, permissionsService), daysService), - new CollabMcp(new CollabService(dbService, permissionsService)), - new VacayMcp(new VacayService(dbService)), + new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService), daysService), + new CollabMcp(new CollabService(dbService, permissionsService, realtimeService)), + new VacayMcp(new VacayService(dbService, realtimeService)), ], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, ); diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 397386c84..76dce8f5e 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -21,6 +21,7 @@ import { VacayService } from '../../src/nest/vacay/vacay.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { AuditService } from '../../src/nest/audit/audit.service'; import { AddonsService } from '../../src/nest/addons/addons.service'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests @@ -31,27 +32,29 @@ import { AddonsService } from '../../src/nest/addons/addons.service'; export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFactory { const permissions = new PermissionsService(dbs); const exchangeRates = new ExchangeRatesService(); - const budget = new BudgetService(dbs, permissions, exchangeRates); + const realtime = new RealtimeService(); + const budget = new BudgetService(dbs, permissions, exchangeRates, realtime); const addons = new AddonsService(dbs); return new PluginHostDepsFactory( budget, - new ReservationsService(dbs, permissions, budget), + new ReservationsService(dbs, permissions, budget, realtime), new TagsService(dbs), new CategoriesService(dbs), - new TodoService(dbs, permissions), - new PackingService(dbs, permissions), + new TodoService(dbs, permissions, realtime), + new PackingService(dbs, permissions, realtime), new PluginOAuthService(dbs), - new DayNotesService(dbs, permissions), - new AssignmentsService(dbs, permissions), + new DayNotesService(dbs, permissions, realtime), + new AssignmentsService(dbs, permissions, realtime), new LlmConfigResolver(new SettingsService(dbs), dbs, addons), dbs, - new FilesService(dbs, permissions), - new CollabService(dbs, permissions), - new VacayService(dbs), - new DaysService(dbs, permissions), + new FilesService(dbs, permissions, realtime), + new CollabService(dbs, permissions, realtime), + new VacayService(dbs, realtime), + new DaysService(dbs, permissions, realtime), permissions, exchangeRates, addons, + realtime, ); } diff --git a/server/tests/integration/dayReorder.test.ts b/server/tests/integration/dayReorder.test.ts index fedcd459c..1feec6eea 100644 --- a/server/tests/integration/dayReorder.test.ts +++ b/server/tests/integration/dayReorder.test.ts @@ -31,8 +31,9 @@ import { createUser, createTrip, createPlace, createDay, createDayAssignment, cr import { DatabaseService } from '../../src/nest/database/database.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { DaysService, DayReorderError } from '../../src/nest/days/days.service'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; -const svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb))); +const svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); const reorderDays = (tripId: number, orderedIds: number[]) => svc.reorder(tripId, orderedIds); const insertDay = (tripId: number, position?: number) => svc.insert(tripId, position); diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index e5aacd5d3..0c89490b1 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -52,8 +52,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; import { createAssignment, dayExists, placeExists, getAssignmentForTrip } from '../../../src/nest/assignments/assignments.bridge'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -const svc = new AssignmentsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb))); +const svc = new AssignmentsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/nest/booking-import/import-jobs.service.test.ts b/server/tests/unit/nest/booking-import/import-jobs.service.test.ts index 07328ee01..95c156811 100644 --- a/server/tests/unit/nest/booking-import/import-jobs.service.test.ts +++ b/server/tests/unit/nest/booking-import/import-jobs.service.test.ts @@ -4,10 +4,11 @@ const { broadcastToUser } = vi.hoisted(() => ({ broadcastToUser: vi.fn() })); vi.mock('../../../../src/websocket', () => ({ broadcastToUser })); import { ImportJobsService } from '../../../../src/nest/booking-import/import-jobs.service'; +import { RealtimeService } from '../../../../src/nest/realtime/realtime.service'; type Preview = ReturnType; function makeService(preview: Preview) { - return new ImportJobsService({ preview } as never); + return new ImportJobsService({ preview } as never, new RealtimeService()); } const files = (n: number) => Array.from({ length: n }, (_, i) => ({ originalname: `f${i}.pdf` })) as never; const eventsFor = (jobId: string) => broadcastToUser.mock.calls.map((c) => c[1]).filter((p) => p.jobId === jobId); diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts index cbfce335f..8604a644d 100644 --- a/server/tests/unit/nest/budget.service.calc.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -39,6 +39,7 @@ import type { PermissionsService } from '../../../src/nest/permissions/permissio import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types'; import type Database from 'better-sqlite3'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; const permissionsStub = { checkPermission: vi.fn(() => true) } as unknown as PermissionsService; @@ -46,6 +47,7 @@ const budget = new BudgetService( new DatabaseService(mockDb.db as unknown as Database.Database), permissionsStub, mockRates as unknown as ExchangeRatesService, + new RealtimeService(), ); // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index f8eed3698..4e8364c11 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -62,11 +62,13 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; import * as bridge from '../../../src/nest/budget/budget.bridge'; import { createGuest, deleteGuest } from '../../../src/services/tripService'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; const budget = new BudgetService( new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new ExchangeRatesService(), + new RealtimeService(), ); beforeAll(() => { diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index dedcd1887..4f067642a 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -38,9 +38,10 @@ const getRates = vi.fn(); const exchangeRatesStub = { getRates } as unknown as ExchangeRatesService; import { BudgetService } from '../../../src/nest/budget/budget.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; function svc() { - return new BudgetService(new DatabaseService(dbConn), permissionsStub, exchangeRatesStub); + return new BudgetService(new DatabaseService(dbConn), permissionsStub, exchangeRatesStub, new RealtimeService()); } beforeEach(() => { diff --git a/server/tests/unit/nest/collab.service.test.ts b/server/tests/unit/nest/collab.service.test.ts index a00e2d1e2..c32b5a899 100644 --- a/server/tests/unit/nest/collab.service.test.ts +++ b/server/tests/unit/nest/collab.service.test.ts @@ -62,8 +62,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { CollabService } from '../../../src/nest/collab/collab.service'; import { listNotes as bridgeListNotes, listPolls as bridgeListPolls, countMessages as bridgeCountMessages } from '../../../src/nest/collab/collab.bridge'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb))); +const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); beforeAll(() => { createTables(testDb); @@ -441,7 +442,7 @@ describe('hardening', () => { it('COLLAB-SVC-034: votePoll switch is atomic — prior vote survives a failed INSERT', () => { const { user1, trip } = setup(); const dbs = new DatabaseService(testDb); - const failing = new CollabService(dbs, new PermissionsService(dbs)); + const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService()); const poll = failing.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); failing.votePoll(trip.id, poll!.id, user1.id, 0); @@ -462,7 +463,7 @@ describe('hardening', () => { it('COLLAB-SVC-035: deleteNote is atomic — trip_files rows survive a failed note DELETE', () => { const { user1, trip } = setup(); const dbs = new DatabaseService(testDb); - const failing = new CollabService(dbs, new PermissionsService(dbs)); + const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService()); const note = failing.createNote(trip.id, user1.id, { title: 'With file' }); testDb.prepare('INSERT INTO trip_files (trip_id, note_id, filename, original_name) VALUES (?, ?, ?, ?)') .run(trip.id, note.id, 'files/a.pdf', 'a.pdf'); diff --git a/server/tests/unit/nest/day-notes.service.test.ts b/server/tests/unit/nest/day-notes.service.test.ts index 2e5ca5784..1f81ad982 100644 --- a/server/tests/unit/nest/day-notes.service.test.ts +++ b/server/tests/unit/nest/day-notes.service.test.ts @@ -50,8 +50,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { DayNotesService } from '../../../src/nest/days/day-notes.service'; import type { DayNote } from '../../../src/types'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -const svc = new DayNotesService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb))); +const svc = new DayNotesService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index 5520c8e09..68b813378 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -59,8 +59,9 @@ import { restampReservationDates as bridgeRestampReservationDates, resyncAccommodationDays as bridgeResyncAccommodationDays, } from '../../../src/nest/days/days.bridge'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -const svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb))); +const svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index 1c6551f6b..ea8102fa2 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -73,8 +73,9 @@ import { isVideoExtension, } from '../../../src/nest/files/files.constants'; import type { TripFile, User } from '../../../src/types'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -const svc = new FilesService(new DatabaseService(testDb), permissionsStub); +const svc = new FilesService(new DatabaseService(testDb), permissionsStub, new RealtimeService()); beforeAll(() => { createTables(testDb); @@ -182,7 +183,7 @@ describe('getAllowedExtensions', () => { }); it('FILE-SVC-009: returns the default when the query throws (no app_settings table)', () => { - const bareSvc = new FilesService(new DatabaseService(bareDb), permissionsStub); + const bareSvc = new FilesService(new DatabaseService(bareDb), permissionsStub, new RealtimeService()); expect(bareSvc.getAllowedExtensions()).toBe(DEFAULT_ALLOWED_EXTENSIONS); }); }); diff --git a/server/tests/unit/nest/memories.service.test.ts b/server/tests/unit/nest/memories.service.test.ts index 36bb3f375..96b8da91a 100644 --- a/server/tests/unit/nest/memories.service.test.ts +++ b/server/tests/unit/nest/memories.service.test.ts @@ -53,6 +53,7 @@ const ws = vi.hoisted(() => ({ broadcast: vi.fn() })); vi.mock('../../../src/websocket', () => ws); import { MemoriesService } from '../../../src/nest/memories/memories.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; const res = {} as import('express').Response; @@ -61,7 +62,7 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () => beforeEach(() => { vi.clearAllMocks(); - svc = new MemoriesService(); + svc = new MemoriesService(new RealtimeService()); }); it('access check + broadcast forward verbatim', () => { diff --git a/server/tests/unit/nest/packing.service.test.ts b/server/tests/unit/nest/packing.service.test.ts index c1ec3df2e..152823111 100644 --- a/server/tests/unit/nest/packing.service.test.ts +++ b/server/tests/unit/nest/packing.service.test.ts @@ -59,8 +59,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { PackingService } from '../../../src/nest/packing/packing.service'; import { listItems as bridgeListItems } from '../../../src/nest/packing/packing.bridge'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -const svc = new PackingService(new DatabaseService(testDb), permissionsStub); +const svc = new PackingService(new DatabaseService(testDb), permissionsStub, new RealtimeService()); // ── Lifecycle ───────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/realtime.service.test.ts b/server/tests/unit/nest/realtime.service.test.ts new file mode 100644 index 000000000..48916a340 --- /dev/null +++ b/server/tests/unit/nest/realtime.service.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { broadcast, broadcastToUser } = vi.hoisted(() => ({ + broadcast: vi.fn(), + broadcastToUser: vi.fn(), +})); +vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser })); + +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; + +const svc = new RealtimeService(); + +beforeEach(() => vi.clearAllMocks()); + +describe('RealtimeService', () => { + it('broadcast delegates every argument to the websocket module untouched', () => { + svc.broadcast('7', 'place:created', { place: { id: 1 } }, '42', 9); + expect(broadcast).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith('7', 'place:created', { place: { id: 1 } }, '42', 9); + }); + + it('broadcast preserves the caller argument arity exactly (no undefined padding)', () => { + // The excludeSid truthiness quirk and onlyUserId `!= null` check live in + // websocket.ts — the facade must not normalize, default, or pad anything + // on the way through: existing suites assert exact 3/4/5-arg call shapes. + svc.broadcast(7, 'day:updated', { day: null }); + expect(broadcast.mock.calls[0]).toEqual([7, 'day:updated', { day: null }]); + svc.broadcast(7, 'day:updated', { day: null }, undefined); + expect(broadcast.mock.calls[1]).toEqual([7, 'day:updated', { day: null }, undefined]); + expect(broadcast.mock.calls[1].length).toBe(4); + }); + + it('broadcastToUser delegates every argument untouched', () => { + const payload = { type: 'vacay:invite', planId: 3 }; + svc.broadcastToUser(5, payload, '11'); + expect(broadcastToUser).toHaveBeenCalledTimes(1); + expect(broadcastToUser).toHaveBeenCalledWith(5, payload, '11'); + // Same object reference — the facade must not clone or re-envelope. + expect(broadcastToUser.mock.calls[0][1]).toBe(payload); + }); + + it('per-file vi.mock of src/websocket flows through the facade (call-time delegation)', () => { + // The 106 existing suites mock src/websocket by path and assert on those + // mocks; this pins that a facade constructed before/after the mock still + // routes through the mocked module functions rather than captured bindings. + const late = new RealtimeService(); + late.broadcast('1', 'todo:created', { item: {} }, undefined); + late.broadcastToUser(2, { type: 'notification:new' }); + expect(broadcast).toHaveBeenCalledWith('1', 'todo:created', { item: {} }, undefined); + expect(broadcastToUser).toHaveBeenCalledWith(2, { type: 'notification:new' }); + }); +}); diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index 512c1d316..645ef6675 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -53,8 +53,9 @@ import type { PermissionsService } from '../../../src/nest/permissions/permissio import { ReservationsService } from '../../../src/nest/reservations/reservations.service'; import type { BudgetService } from '../../../src/nest/budget/budget.service'; import * as bridge from '../../../src/nest/reservations/reservations.bridge'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -const svc = new ReservationsService(new DatabaseService(testDb), permissionsStub, budget as unknown as BudgetService); +const svc = new ReservationsService(new DatabaseService(testDb), permissionsStub, budget as unknown as BudgetService, new RealtimeService()); beforeAll(() => { createTables(testDb); runMigrations(testDb); }); beforeEach(() => { diff --git a/server/tests/unit/nest/todo.service.test.ts b/server/tests/unit/nest/todo.service.test.ts index 7b22dceb7..e4e5de62d 100644 --- a/server/tests/unit/nest/todo.service.test.ts +++ b/server/tests/unit/nest/todo.service.test.ts @@ -47,8 +47,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { TodoService } from '../../../src/nest/todo/todo.service'; import { listItems, createItem, updateItem, deleteItem } from '../../../src/nest/todo/todo.bridge'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -const svc = new TodoService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb))); +const svc = new TodoService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index f8b4ac23d..ccc49f1ac 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -36,6 +36,7 @@ import type { FilesService } from '../../../src/nest/files/files.service'; import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; import type { DaysService } from '../../../src/nest/days/days.service'; import type { BudgetService } from '../../../src/nest/budget/budget.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; // Constructor-injected since the budget fold (was a path mock of the deleted // services/budgetService). @@ -48,7 +49,7 @@ const packingStub = { listItems: packingListItems } as unknown as PackingService const filesStub = { listFiles: () => [] } as unknown as FilesService; const reservationsStub = { list: () => [] } as unknown as ReservationsService; const daysStub = { list: () => ({ days: [1] }), listAccommodations: () => [] } as unknown as DaysService; -function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub, daysStub, permissionsStub, budgetStub); } +function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub, daysStub, permissionsStub, budgetStub, new RealtimeService()); } beforeEach(() => vi.clearAllMocks()); beforeAll(async () => { // Warm the (mocked) notificationService module: notifyInvite does a diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index 211ce9d7d..1e6b3a389 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -36,13 +36,14 @@ import { createUser } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { VacayService } from '../../../src/nest/vacay/vacay.service'; import { shiftOwnerEntriesForTripWindow } from '../../../src/nest/vacay/vacay.bridge'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; // VACAY-SVC-001 through VACAY-SVC-066 moved 1:1 from the legacy // tests/unit/services/vacayService.test.ts (the named-function imports became // method calls on a directly constructed VacayService; the legacy // updateUserYearSettings is the class's updateYearSettings). VACAY-SVC-067 // pins the vacay.bridge delegation for the one outside-container consumer. -const svc = new VacayService(new DatabaseService(testDb)); +const svc = new VacayService(new DatabaseService(testDb), new RealtimeService()); // ── Lifecycle ───────────────────────────────────────────────────────────────── @@ -1391,7 +1392,7 @@ describe('quirk fixes', () => { if (sql.includes(match)) throw new Error('boom'); return realRun(sql, ...params); }); - return new VacayService(failingDb); + return new VacayService(failingDb, new RealtimeService()); } it('VACAY-SVC-068: acceptInvite is atomic — a failure mid-flow rolls the status flip back', () => { @@ -1422,7 +1423,7 @@ describe('quirk fixes', () => { it('VACAY-SVC-070: getCountries surfaces an upstream non-2xx as the fetch error and caches nothing', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 502, json: async () => ({}) }); vi.stubGlobal('fetch', fetchMock); - const fresh = new VacayService(new DatabaseService(testDb)); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService()); expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' }); // Nothing cached: a retry hits the network again. @@ -1437,7 +1438,7 @@ describe('quirk fixes', () => { testDb.prepare("INSERT INTO vacay_holiday_calendars (plan_id, region) VALUES (?, 'DE')").run(plan.id); const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); vi.stubGlobal('fetch', fetchMock); - const fresh = new VacayService(new DatabaseService(testDb)); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService()); await fresh.applyHolidayCalendars(plan.id); const afterFirst = fetchMock.mock.calls.length; diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index bbbaac3a6..bbfd7e07b 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -319,12 +319,13 @@ import type { CollabService } from '../../../src/nest/collab/collab.service'; import type { VacayService } from '../../../src/nest/vacay/vacay.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; // The factory under test, wired exactly like PluginsModule does — but with the // DI-native domain services replaced by the stubs above. The shim keeps the // ~45 historical call sites unchanged and supplies a default no-op router. const addonsStub = { isAddonEnabled } as unknown as import('../../../src/nest/addons/addons.service').AddonsService; -const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub); +const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub, new RealtimeService()); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; const createRealRpcHost = (id: string, granted: ReadonlySet, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router); diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index a5fa81ee2..dd514863b 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -43,8 +43,9 @@ import { isUpdateConflict } from '../../../src/services/conflictResult'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { PackingService } from '../../../src/nest/packing/packing.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -const packing = new PackingService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb))); +const packing = new PackingService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/services/tripService.test.ts b/server/tests/unit/services/tripService.test.ts index 66cab408a..f24e4a44e 100644 --- a/server/tests/unit/services/tripService.test.ts +++ b/server/tests/unit/services/tripService.test.ts @@ -39,8 +39,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { DaysService } from '../../../src/nest/days/days.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import fs from 'fs'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -const daysSvc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb))); +const daysSvc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); const createAccommodation = daysSvc.createAccommodation.bind(daysSvc); beforeAll(() => {