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.
This commit is contained in:
jubnl
2026-07-29 00:07:51 +02:00
parent 80f3958569
commit a2861d40f3
64 changed files with 367 additions and 181 deletions
+2 -1
View File
@@ -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,
@@ -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) {
@@ -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<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
this.realtime.broadcast(tripId, event, payload, socketId);
}
/**
@@ -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}":`,
@@ -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<number, Promise<void>>();
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<string, unknown>): 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 });
}
}
+2 -1
View File
@@ -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);
+4 -3
View File
@@ -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<string, unknown>, 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);
}
+2 -1
View File
@@ -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);
+3 -2
View File
@@ -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<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
this.realtime.broadcast(tripId, event, payload, socketId);
}
/* ------------------------------------------------------------------ */
+3 -2
View File
@@ -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<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
this.realtime.broadcast(tripId, event, payload, socketId);
}
list(dayId: string | number, tripId: string | number) {
+2 -1
View File
@@ -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 };
+3 -2
View File
@@ -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<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
this.realtime.broadcast(tripId, event, payload, socketId);
}
// -------------------------------------------------------------------------
+2 -1
View File
@@ -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();
+3 -2
View File
@@ -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<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
this.realtime.broadcast(tripId, event, payload, socketId);
}
getAllowedExtensions(): string {
+4 -2
View File
@@ -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<string, unknown>, socketId?: string): void {
broadcast(tripId, event, payload, socketId);
this.realtime.broadcast(tripId, event, payload, socketId);
}
// ── Unified ──────────────────────────────────────────────────────────────
+2 -1
View File
@@ -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);
+5 -4
View File
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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);
}
}
+3 -2
View File
@@ -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<string, unknown>, 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 }) {
@@ -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<string, unknown>, item: PackingPrivacy): void {
function emitPackingToViewers(realtime: RealtimeService, tripId: number, event: string, payload: Record<string, unknown>, 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<string, unknown>, item: PackingPrivacy): void {
function broadcastPackingItem(realtime: RealtimeService, tripId: number, event: string, payload: Record<string, unknown>, 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<typeof createPlace>[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<typeof updatePlace>[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<typeof updateTrip>[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
@@ -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 {}
@@ -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<string, unknown>,
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<string, unknown>, excludeSid?: number | string]
): void {
broadcastToUser(...args);
}
}
@@ -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<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
this.realtime.broadcast(tripId, event, payload, socketId);
}
list(tripId: string) {
@@ -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) {
@@ -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<string, unknown>, 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);
+2 -1
View File
@@ -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);
+3 -2
View File
@@ -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<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
this.realtime.broadcast(tripId, event, payload, socketId);
}
listItems(tripId: string | number) {
+3 -2
View File
@@ -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<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
this.realtime.broadcast(tripId, event, payload, socketId);
}
list(userId: number, archived: number) {
+2 -1
View File
@@ -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,
+12 -12
View File
@@ -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<string, { data: unknown; time: number }>();
@@ -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;
}
+2 -1
View File
@@ -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<ReturnType<typeof build>>;
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());
+2 -1
View File
@@ -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<ReturnType<typeof build>>;
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());
+2 -1
View File
@@ -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());
+2 -1
View File
@@ -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<ReturnType<typeof build>>;
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());
+2 -1
View File
@@ -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<ReturnType<typeof build>>;
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());
+2 -1
View File
@@ -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<ReturnType<typeof build>>;
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());
+2 -1
View File
@@ -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<ReturnType<typeof build>>;
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());
+2 -1
View File
@@ -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());
+2 -1
View File
@@ -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<ReturnType<typeof build>>;
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());
+2 -1
View File
@@ -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());
+2 -1
View File
@@ -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());
+2 -1
View File
@@ -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<ReturnType<typeof build>>;
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());
+2 -1
View File
@@ -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<ReturnType<typeof build>>;
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());
+11 -9
View File
@@ -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 },
);
+13 -10
View File
@@ -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,
);
}
+2 -1
View File
@@ -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);
@@ -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);
@@ -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<typeof vi.fn>;
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);
@@ -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 ──────────────────────────────────────────────────────────────────
@@ -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(() => {
@@ -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(() => {
@@ -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');
@@ -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);
+2 -1
View File
@@ -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);
+3 -2
View File
@@ -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);
});
});
@@ -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', () => {
@@ -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 ─────────────────────────────────────────────────────────────────
@@ -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' });
});
});
@@ -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(() => {
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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
+5 -4
View File
@@ -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;
@@ -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<string>, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);
@@ -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);
@@ -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(() => {