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