Inject the notification senders instead of importing them lazily

Nine fire-and-forget sends reached notifications.bridge through
import('...').then(({ send }) => ...). The laziness was working around a
module cycle that no longer exists, and it cost twice: it hid the edge from
the module graph, and it handed each send a NotificationsService built
outside the container rather than the container's own.

The sweep needed a method rather than a grep. tests/ sits outside
tsconfig's include, so a service that gains a constructor parameter does not
break its hand-wired call sites at compile time — the argument arrives as
undefined and throws inside the send. Adding tests to a throwaway tsconfig
and diffing the errors before and after named all 46 sites exactly. The same
config reports 288 pre-existing errors in the suites, almost all deliberate
partial constructions in the harnesses, which is why tests/ is not in the
build's include; that cleanup is its own piece of work and is written down
in the nest README.

notifications.instance.ts holds the out-of-container instance, apart from
notifications.bridge.ts on purpose: several suites replace the bridge
wholesale with vi.mock(..., () => ({ send })), and a sibling bridge taking
its instance from there would break under those mocks.

The suites that assert on a send now hand their spy to the injected service.
The reservations e2e overrides the provider, because its app resolves the
real one out of its own container and never saw the module mock.
This commit is contained in:
Maurice
2026-08-10 17:04:15 +02:00
committed by Maurice
parent c58d797117
commit 6cf5760383
43 changed files with 303 additions and 170 deletions
+20 -6
View File
@@ -216,12 +216,26 @@ Four in-container uses are left, each for a reason that injection does not fix:
interceptor options are module-scope literals evaluated before any container
exists. The fix is `MulterModule.registerAsync`, not a different import.
The lazy `import('../notifications/notifications.bridge')` senders in six
services are resolvable by injection — nothing cycles — but the sweep is its own
change: those services have ~41 hand-wired `new` sites across the suites, and
`tests/` is outside `tsconfig`'s `include`, so a missed argument would not fail
to compile. It would land as `undefined` inside a fire-and-forget send whose
`.catch(() => {})` swallows the TypeError.
The nine fire-and-forget notification senders inject too. They were lazy
`import('../notifications/notifications.bridge').then(({ send }) => …)` calls
working around a cycle that no longer exists, and the laziness hid the edge
while handing each send a NotificationsService built outside the container.
That sweep needed a method, because `tests/` sits outside `tsconfig`'s `include`:
a missed constructor argument does not fail to compile, it arrives as
`undefined`. Adding `tests` to a throwaway `tsconfig` and diffing the errors
before and after named all **46** hand-wired `new` sites exactly; a grep would
have been a guess. Worth repeating for any change that widens a service
constructor — and worth knowing that such a config reports **288 pre-existing
errors** in the suites, almost all deliberate partial constructions in the
harnesses (`new AuthService(db, permissions, atlas)` where the class takes six).
That is why `tests/` is not in the build's `include`, and cleaning it up is its
own piece of work.
`notifications.instance.ts` holds the out-of-container instance, deliberately
apart from `notifications.bridge.ts`: several suites replace the bridge wholesale
with `vi.mock(…, () => ({ send }))`, and a sibling bridge importing the instance
from there would break under those mocks.
- `app-config/` — the `@nestjs/config` binding (`AppConfigModule`, global). Never
read `process.env` in a module (ESLint enforces this): inject a boot-stable
namespace via its `registerAs` token (`@Inject(mcpConfig.KEY) … ConfigType<…>`)
+2 -1
View File
@@ -8,9 +8,10 @@ import { CollabMcp } from './collab.mcp';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
import { AddonsModule } from '../addons/addons.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule],
imports: [NotificationsModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule],
controllers: [CollabController],
providers: [CollabService, CollabMcp, CollabRpc],
// For in-container consumers (CollabRpc).
+10 -6
View File
@@ -8,6 +8,7 @@ import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl';
import { checkSsrf, createPinnedDispatcher } from '../../utils/ssrfGuard';
import type { CollabNote, CollabPoll, CollabMessage, TripFile, User } from '../../types';
import { NotificationsService } from '../notifications/notifications.service';
type Trip = TripAccess;
@@ -67,6 +68,7 @@ export class CollabService {
private readonly db: DatabaseService,
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
) {}
verifyTripAccess(tripId: string | number, userId: number) {
@@ -494,11 +496,13 @@ export class CollabService {
/** Fire-and-forget collab notification (mirrors the legacy route's dynamic import). */
notifyCollab(tripId: string, actor: User, preview?: string): void {
import('../notifications/notifications.bridge').then(({ send }) => {
const tripInfo = this.db.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
const params: Record<string, string> = { trip: tripInfo?.title || 'Untitled', actor: actor.email, tripId: String(tripId) };
if (preview !== undefined) params.preview = preview;
send({ event: 'collab_message', actorId: actor.id, scope: 'trip', targetId: Number(tripId), params }).catch(() => {});
}).catch(() => {});
// Injected, not a lazy import of notifications.bridge. The laziness bought
// nothing the module graph does not already give — NotificationsModule
// reaches nothing in this direction — and it hid the edge while handing the
// send a second NotificationsService built outside the container.
const tripInfo = this.db.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
const params: Record<string, string> = { trip: tripInfo?.title || 'Untitled', actor: actor.email, tripId: String(tripId) };
if (preview !== undefined) params.preview = preview;
this.notifications.send({ event: 'collab_message', actorId: actor.id, scope: 'trip', targetId: Number(tripId), params }).catch(() => {});
}
}
@@ -8,12 +8,13 @@ import { CollectionsMcp } from './collections.mcp';
import { AddonsModule } from '../addons/addons.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
/** Collections domain (saved-places library). Registered in AppModule.
* Exports CollectionsService for in-container consumers (PluginsModule's
* RPC host deps factory). */
@Module({
imports: [AddonsModule, PermissionsModule, AuthModule, AppConfigModule, PluginGuardsModule],
imports: [NotificationsModule, AddonsModule, PermissionsModule, AuthModule, AppConfigModule, PluginGuardsModule],
controllers: [CollectionsController],
providers: [CollectionsService, CollectionsMcp, CollectionsRpc],
exports: [CollectionsService],
@@ -22,6 +22,7 @@ import type {
CollectionStatus,
CollectionLabel,
} from '@trek/shared';
import { NotificationsService } from '../notifications/notifications.service';
/** Links are stored as a JSON TEXT column; parse on read, stringify on write. */
function parseLinks(raw: unknown): CollectionLink[] | undefined {
@@ -103,8 +104,8 @@ const MAX_LABELS_PER_COLLECTION = 50;
* defaults, the post-write re-selects, the exact error strings and the
* per-user broadcast fan-outs). The `place_edit` trip permission (copyToTrip)
* goes through PermissionsService; per-user WebSocket delivery goes through
* RealtimeService; the notification send in sendInvite keeps its call-time
* lazy import of the notifications bridge (collab precedent).
* RealtimeService; the notification send in sendInvite goes through an injected
* NotificationsService (collab precedent).
*/
@Injectable()
export class CollectionsService {
@@ -112,6 +113,7 @@ export class CollectionsService {
private readonly db: DatabaseService,
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
) {}
// -------------------------------------------------------------------------
@@ -1012,9 +1014,11 @@ export class CollectionsService {
this.realtime.broadcastToUser(targetUserId, { type: 'collections:invite', from: { id: inviterId, username: inviterUsername }, collectionId });
import('../notifications/notifications.bridge').then(({ send }) => {
send({ event: 'collection_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, collectionId: String(collectionId) } }).catch(() => {});
}).catch(() => {});
// Injected, not a lazy import of notifications.bridge. The laziness bought
// nothing the module graph does not already give — NotificationsModule
// reaches nothing in this direction — and it hid the edge while handing the
// send a second NotificationsService built outside the container.
this.notifications.send({ event: 'collection_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, collectionId: String(collectionId) } }).catch(() => {});
return {};
}
@@ -10,6 +10,7 @@ import { ReservationsService } from '../reservations/reservations.service';
import { AirtrailClient } from './airtrail.client';
import { AirtrailService } from './airtrail.service';
import { AirtrailSyncService } from './airtrail-sync.service';
import { notificationsInstance } from '../notifications/notifications.instance';
/**
* The ONE airtrail edge that cannot be an injection, and why.
@@ -38,6 +39,7 @@ const reservations = new ReservationsService(
permissions,
new BudgetService(dbs(), permissions, new ExchangeRatesService(), realtime),
realtime,
notificationsInstance(),
);
const sync = new AirtrailSyncService(
dbs(),
+2 -1
View File
@@ -18,6 +18,7 @@ import { PHOTO_PROVIDERS } from './photo-provider';
import { PhotoProviderRegistry } from './photo-provider.registry';
import { ImmichPhotoProvider } from './providers/immich.provider';
import { SynologyPhotoProvider } from './providers/synology.provider';
import { NotificationsModule } from '../notifications/notifications.module';
/**
* Memories (photo-providers) domain — mounted at /api/integrations/memories.
@@ -40,7 +41,7 @@ import { SynologyPhotoProvider } from './providers/synology.provider';
* adapters themselves so the set is readable in one place.
*/
@Module({
imports: [AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule],
imports: [NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule],
controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController],
providers: [
MemoriesService,
+5 -2
View File
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
import { send as sendNotification } from '../notifications/notifications.bridge';
import { DatabaseService } from '../database/database.service';
import { MemoriesAccessService } from './memories-access.service';
import {
@@ -17,6 +16,7 @@ import {
type ServiceResult,
type StatusResult,
} from './memories.helpers';
import { NotificationsService } from '../notifications/notifications.service';
const SYNOLOGY_PROVIDER = 'synologyphotos';
// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo).
@@ -129,6 +129,7 @@ export class SynologyService {
constructor(
private readonly db: DatabaseService,
private readonly access: MemoriesAccessService,
private readonly notifications: NotificationsService,
) {}
private _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
@@ -388,7 +389,9 @@ export class SynologyService {
this._clearSynologySession(userId);
// Fire-and-forget like unifiedService's photos_shared send — without the
// .catch an in-app insert failure became an unhandled rejection.
sendNotification({
// Injected, not imported from notifications.bridge — that module builds
// its own NotificationsService outside the container.
this.notifications.send({
event: 'synology_session_cleared',
actorId: null,
params: {},
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { broadcast } from '../../websocket';
import { encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { DatabaseService } from '../database/database.service';
import { send } from '../notifications/notifications.bridge';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
@@ -15,6 +14,7 @@ import {
type ServiceResult,
type SyncAlbumResult,
} from './memories.helpers';
import { NotificationsService } from '../notifications/notifications.service';
/**
* The provider-agnostic trip photo surface: which photos a trip shows, which
@@ -30,6 +30,7 @@ export class UnifiedMemoriesService {
private readonly immich: ImmichService,
private readonly synology: SynologyService,
private readonly access: MemoriesAccessService,
private readonly notifications: NotificationsService,
) {}
private _providers(): Array<{id: string; enabled: boolean}> {
@@ -327,7 +328,7 @@ export class UnifiedMemoriesService {
const tripInfo = this.db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
send({ event: 'photos_shared', actorId: actorUserId, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: actorRow?.email || 'Unknown', count: String(added), tripId: String(tripId) } }).catch(() => {});
this.notifications.send({ event: 'photos_shared', actorId: actorUserId, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: actorRow?.email || 'Unknown', count: String(added), tripId: String(tripId) } }).catch(() => {});
return success(undefined);
} catch {
return fail('Failed to send notifications', 500);
@@ -1,45 +1,28 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { MailerService } from './mailer/mailer.service';
import { NotificationPreferencesService } from './notification-preferences.service';
import { NtfyService } from './transports/ntfy.service';
import { WebhookService } from './transports/webhook.service';
import { NotificationsService } from './notifications.service';
import type { NotificationPayload } from './notifications.service';
import { notificationsInstance } from './notifications.instance';
/**
* Non-Nest entry point for the notifications domain — for code running
* OUTSIDE the Nest container (the scheduler's trip_reminder/todo_due cron
* requires, the legacy services/adminService and services/memories/* senders)
* plus the deliberately-lazy fire-and-forget `import(...).then(({ send }) =>
* …)` sends in migrated Nest services (collab/collections/packing/
* reservations/trips/vacay — kept lazy so a notification send can never
* block or cycle a domain module). Exports only the legacy
* services/notificationService name still consumed there, 1:1, so repointing
* a consumer is an import-path-only diff. Inside the container, inject
* NotificationsService instead (AdminController and the plugin RPC host do).
* Delete this file when the legacy admin/memories services migrate and the
* lazy senders inject.
* Non-Nest entry point for the notifications domain — for code running OUTSIDE
* the Nest container: the scheduler's trip_reminder/todo_due cron requires and
* the legacy services/adminService sender. Exports only the legacy
* services/notificationService name still consumed there, 1:1, so repointing a
* consumer is an import-path-only diff. Inside the container, inject
* NotificationsService — every domain service does now.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton, and the channel registry is module-scoped
* on purpose, so this instance and the DI singleton see the same channels — the
* built-ins it registers in its constructor and the plugin channels the runtime
* pushes in. A registry that were a provider would leave this instance empty and
* silence every plugin channel on the scheduler's path.
* The six fire-and-forget senders that used to reach this file through a lazy
* `import(...).then(({ send }) => …)` inject instead. The laziness was working
* around a cycle that no longer exists, and it hid the edge from the module
* graph while handing the send a service built outside the container.
*
* The instance itself lives in notifications.instance.ts, not here: several
* suites replace this module wholesale with `vi.mock(..., () => ({ send }))`,
* and a sibling bridge importing the instance from here would break under those
* mocks. Delete this file when the scheduler and adminService migrate.
*/
const dbs = new DatabaseService(db);
const mailer = new MailerService(dbs);
const notifications = new NotificationsService(
dbs,
new RealtimeService(),
mailer,
new WebhookService(dbs),
new NtfyService(dbs),
new NotificationPreferencesService(dbs, mailer),
);
const notifications = notificationsInstance();
export function send(payload: NotificationPayload): Promise<void> {
return notifications.send(payload);
}
@@ -0,0 +1,41 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { MailerService } from './mailer/mailer.service';
import { NotificationPreferencesService } from './notification-preferences.service';
import { NtfyService } from './transports/ntfy.service';
import { WebhookService } from './transports/webhook.service';
import { NotificationsService } from './notifications.service';
/**
* The out-of-container NotificationsService, for the bridges that hand-build
* their collaborators.
*
* Separate from notifications.bridge.ts on purpose. That file is the *send*
* entry point for non-Nest callers and several suites replace it wholesale with
* `vi.mock(..., () => ({ send }))`; a sibling bridge importing its instance from
* there would break the moment one of those mocks ran. This module only hands
* out the object.
*
* One instance, module-scoped, because the channel registry is: the built-ins
* registered in the constructor and the channels the plugin runtime pushes in
* have to be visible on every path. A second copy would silence plugin channels
* for whichever caller got it.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const dbs = new DatabaseService(db);
const mailer = new MailerService(dbs);
const notifications = new NotificationsService(
dbs,
new RealtimeService(),
mailer,
new WebhookService(dbs),
new NtfyService(dbs),
new NotificationPreferencesService(dbs, mailer),
);
export function notificationsInstance(): NotificationsService {
return notifications;
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { PackingService } from './packing.service';
import { PermissionsService } from '../permissions/permissions.service';
import { notificationsInstance } from '../notifications/notifications.instance';
/**
* Non-Nest entry point for the packing domain — for code running OUTSIDE the
@@ -19,7 +20,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)), new RealtimeService());
const packing = new PackingService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService(), notificationsInstance());
export function listItems(tripId: string | number, userId?: number) {
return packing.listItems(tripId, userId);
+2 -1
View File
@@ -10,12 +10,13 @@ import { PermissionsModule } from '../permissions/permissions.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { AddonsModule } from '../addons/addons.module';
import { NotificationsModule } from '../notifications/notifications.module';
/** Packing domain (S2 — Phase 2 trip sub-domain). Registered in AppModule.
* Exports PackingService for in-container consumers (TripsService bundle,
* PackingRpc). */
@Module({
imports: [PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule, AuditModule],
imports: [NotificationsModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule, AuditModule],
controllers: [PackingController, AdminPackingTemplatesController],
providers: [PackingService, PackingMcp, PackingRpc],
exports: [PackingService],
+14 -10
View File
@@ -6,6 +6,7 @@ import { avatarUrl } from '../common/avatarUrl';
import type { UpdateConflict } from '../common/conflictResult';
import type { User } from '../../types';
import { DatabaseService, type TripAccess } from '../database/database.service';
import { NotificationsService } from '../notifications/notifications.service';
/** Privacy fields stamped on a packing item (#858). */
type PrivacyFields = { is_private?: number; owner_id?: number | null };
@@ -43,6 +44,7 @@ export class PackingService {
private readonly db: DatabaseService,
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
) {}
verifyTripAccess(tripId: string | number, userId: number) {
@@ -780,15 +782,17 @@ export class PackingService {
/** Fire-and-forget tag notification, mirroring the legacy dynamic import. */
notifyTagged(tripId: string, actor: User, category: string, userIds: unknown): void {
if (!Array.isArray(userIds) || userIds.length === 0) return;
import('../notifications/notifications.bridge').then(({ send }) => {
const tripInfo = this.db.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
send({
event: 'packing_tagged',
actorId: actor.id,
scope: 'trip',
targetId: Number(tripId),
params: { trip: tripInfo?.title || 'Untitled', actor: actor.email, category, tripId: String(tripId) },
}).catch(() => {});
});
// Injected, not a lazy import of notifications.bridge. The laziness bought
// nothing the module graph does not already give — NotificationsModule
// reaches nothing in this direction — and it hid the edge while handing the
// send a second NotificationsService built outside the container.
const tripInfo = this.db.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
this.notifications.send({
event: 'packing_tagged',
actorId: actor.id,
scope: 'trip',
targetId: Number(tripId),
params: { trip: tripInfo?.title || 'Untitled', actor: actor.email, category, tripId: String(tripId) },
}).catch(() => {});
}
}
@@ -5,6 +5,7 @@ import { ReservationsService } from './reservations.service';
import { PermissionsService } from '../permissions/permissions.service';
import { BudgetService } from '../budget/budget.service';
import { ExchangeRatesService } from '../budget/exchange-rates.service';
import { notificationsInstance } from '../notifications/notifications.instance';
export type { EndpointInput, ReservationEndpoint, ReservationTraveler } from './reservations.service';
@@ -28,6 +29,7 @@ const reservations = new ReservationsService(
new PermissionsService(new DatabaseService(db)),
new BudgetService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new ExchangeRatesService(), new RealtimeService()),
new RealtimeService(),
notificationsInstance(),
);
export function listReservations(tripId: string | number) {
@@ -11,6 +11,7 @@ import { ReservationsMcp } from './reservations.mcp';
import { UpcomingReservationsController } from './upcoming-reservations.controller';
import { AuthModule } from '../auth/auth.module';
import { AssignmentsModule } from '../assignments/assignments.module';
import { NotificationsModule } from '../notifications/notifications.module';
/**
* Reservations + accommodations domain (S5 — Phase 2 trip sub-domain).
@@ -21,7 +22,7 @@ import { AssignmentsModule } from '../assignments/assignments.module';
@Module({
// DaysModule: ReservationsMcp injects DaysService for its nine getDay calls.
// BudgetModule: ReservationsService + ReservationsMcp inject BudgetService (budget-sync seam).
imports: [DaysModule, AssignmentsModule, PermissionsModule, BudgetModule, AuthModule, RealtimeModule, PluginGuardsModule],
imports: [NotificationsModule, DaysModule, AssignmentsModule, PermissionsModule, BudgetModule, AuthModule, RealtimeModule, PluginGuardsModule],
controllers: [ReservationsController, UpcomingReservationsController],
providers: [ReservationsService, ReservationsMcp, ReservationsRpc],
// For in-container consumers (ReservationsRpc, TripsService, BookingImportService).
@@ -7,6 +7,7 @@ import { avatarUrl } from '../common/avatarUrl';
import type { Reservation, User } from '../../types';
import { BudgetService } from '../budget/budget.service';
import { typeToCostCategory } from '@trek/shared';
import { NotificationsService } from '../notifications/notifications.service';
type Trip = TripAccess;
type BudgetEntry = { total_price?: number; category?: string } | undefined;
@@ -134,6 +135,7 @@ export class ReservationsService {
private readonly permissions: PermissionsService,
private readonly budget: BudgetService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
) {}
verifyTripAccess(tripId: string | number, userId: number) {
@@ -150,30 +152,30 @@ export class ReservationsService {
/** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */
notifyBookingChange(tripId: string | number, actorId: number, booking: string, type: string): void {
import('../notifications/notifications.bridge')
.then(({ send }) => {
try {
const actor = this.db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', actorId);
if (!actor) return;
const trip = this.db.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
send({
event: 'booking_change',
actorId,
scope: 'trip',
targetId: Number(tripId),
params: {
trip: trip?.title || 'Untitled',
actor: actor.email,
booking,
type: type || 'booking',
tripId: String(tripId),
},
}).catch(() => {});
} catch {
// Notifications must never make the booking write fail.
}
})
.catch(() => {});
// Injected, not a lazy import of notifications.bridge. The laziness bought
// nothing the module graph does not already give — NotificationsModule
// reaches nothing in this direction — and it hid the edge while handing the
// send a second NotificationsService built outside the container.
try {
const actor = this.db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', actorId);
if (!actor) return;
const trip = this.db.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
this.notifications.send({
event: 'booking_change',
actorId,
scope: 'trip',
targetId: Number(tripId),
params: {
trip: trip?.title || 'Untitled',
actor: actor.email,
booking,
type: type || 'booking',
tripId: String(tripId),
},
}).catch(() => {});
} catch {
// Notifications must never make the booking write fail.
}
}
loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
@@ -7,6 +7,7 @@ import { RealtimeModule } from '../realtime/realtime.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { BudgetModule } from '../budget/budget.module';
import { NotificationsModule } from '../notifications/notifications.module';
/**
* The member/guest roster of a trip.
@@ -18,7 +19,7 @@ import { BudgetModule } from '../budget/budget.module';
* it these dependencies would close the cycle.
*/
@Module({
imports: [DatabaseModule, PermissionsModule, RealtimeModule, AuditModule, AuthModule, BudgetModule],
imports: [NotificationsModule, DatabaseModule, PermissionsModule, RealtimeModule, AuditModule, AuthModule, BudgetModule],
controllers: [TripMembersController],
providers: [TripMembersService],
exports: [TripMembersService],
@@ -11,6 +11,7 @@ import { TRIP_SELECT } from '../trips/trips.service';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
import { NotFoundError, ValidationError } from '../common/domain-errors';
import { NotificationsService } from '../notifications/notifications.service';
export interface AddMemberResult {
member: { id: number; username: string; email: string; avatar?: string | null; role: string; avatar_url: string | null };
@@ -64,6 +65,7 @@ export class TripMembersService {
private readonly userCleanup: UserCleanupService,
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
) {}
private get db() {
@@ -90,15 +92,17 @@ export class TripMembersService {
/** Fire-and-forget trip-invite notification (mirrors the route's dynamic import). */
notifyInvite(tripId: string, actor: User, targetUserId: number, tripTitle: string, inviteeEmail: string): void {
import('../notifications/notifications.bridge').then(({ send }) => {
send({
event: 'trip_invite',
actorId: actor.id,
scope: 'user',
targetId: targetUserId,
params: { trip: tripTitle, actor: actor.email, invitee: inviteeEmail, tripId: String(tripId) },
}).catch(() => {});
});
// Injected, not a lazy import of notifications.bridge. The laziness bought
// nothing the module graph does not already give — NotificationsModule
// reaches nothing in this direction — and it hid the edge while handing the
// send a second NotificationsService built outside the container.
this.notifications.send({
event: 'trip_invite',
actorId: actor.id,
scope: 'user',
targetId: targetUserId,
params: { trip: tripTitle, actor: actor.email, invitee: inviteeEmail, tripId: String(tripId) },
}).catch(() => {});
}
// ── Members ───────────────────────────────────────────────────────────────
+6 -5
View File
@@ -24,6 +24,7 @@ import { UserCleanupService } from '../auth/user-cleanup.service';
import { AccommodationsService } from '../accommodations/accommodations.service';
import { TripMembersService } from '../trip-members/trip-members.service';
import { TripReadModelService } from '../trip-read-model/trip-read-model.service';
import { notificationsInstance } from '../notifications/notifications.instance';
/**
* Non-Nest entry point for the trip domain — for the two consumers that cannot
@@ -50,7 +51,7 @@ const permissions = new PermissionsService(dbs());
const realtime = new RealtimeService();
const days = new DaysService(dbs(), permissions, realtime, new QueryHelpersService(dbs()));
const accommodations = new AccommodationsService(dbs(), permissions, realtime);
const reservations = new ReservationsService(dbs(), permissions, budget, realtime);
const reservations = new ReservationsService(dbs(), permissions, budget, realtime, notificationsInstance());
const places = new PlacesService(dbs(), permissions, realtime, new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService()), photoCache, journeyDomain);
const trips = new TripsService(
dbs(),
@@ -58,20 +59,20 @@ const trips = new TripsService(
days,
permissions,
budget,
new VacayService(dbs(), realtime),
new VacayService(dbs(), realtime, notificationsInstance()),
realtime,
new UnsplashService(dbs(), new RuntimeEnvService()),
);
const members = new TripMembersService(dbs(), budget, new UserCleanupService(dbs()), permissions, realtime);
const members = new TripMembersService(dbs(), budget, new UserCleanupService(dbs()), permissions, realtime, notificationsInstance());
const readModel = new TripReadModelService(
dbs(),
members,
days,
accommodations,
budget,
new PackingService(dbs(), permissions, realtime),
new PackingService(dbs(), permissions, realtime, notificationsInstance()),
reservations,
new CollabService(dbs(), permissions, realtime),
new CollabService(dbs(), permissions, realtime, notificationsInstance()),
places,
new TodoService(dbs(), permissions, realtime),
new FilesService(dbs(), permissions, realtime),
+2 -1
View File
@@ -6,6 +6,7 @@ import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { VacayMcp } from './vacay.mcp';
import { AuthModule } from '../auth/auth.module';
import { AddonsModule } from '../addons/addons.module';
import { NotificationsModule } from '../notifications/notifications.module';
/**
* Vacay addon domain (S1 — Phase 2 trip sub-domain). Registered in AppModule.
@@ -13,7 +14,7 @@ import { AddonsModule } from '../addons/addons.module';
* VacayMcp carries the DI-discovered MCP tools/resources.
*/
@Module({
imports: [AuthModule, PluginGuardsModule, AddonsModule],
imports: [NotificationsModule, AuthModule, PluginGuardsModule, AddonsModule],
controllers: [VacayController],
providers: [VacayService, VacayMcp, VacayRpc],
exports: [VacayService],
+12 -7
View File
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { NotificationsService } from '../notifications/notifications.service';
// ---------------------------------------------------------------------------
// Types
@@ -161,7 +162,11 @@ function windowEndYear(end: string): number {
*/
@Injectable()
export class VacayService {
constructor(private readonly db: DatabaseService, private readonly realtime: RealtimeService) {}
constructor(
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
) {}
private readonly holidayCache = new Map<string, { data: unknown; time: number }>();
@@ -634,9 +639,11 @@ export class VacayService {
} catch { /* websocket not available */ }
// Notify invited user
import('../notifications/notifications.bridge').then(({ send }) => {
send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {});
}).catch(() => {});
// Injected, not a lazy import of notifications.bridge. The laziness bought
// nothing the module graph does not already give — NotificationsModule
// reaches nothing in this direction — and it hid the edge while handing the
// send a second NotificationsService built outside the container.
this.notifications.send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {});
return {};
}
@@ -865,9 +872,7 @@ export class VacayService {
this.realtime.broadcastToUser(ownerId, { type: 'vacay:share', from: { id: ownerId } }, socketId);
} catch { /* websocket not available */ }
import('../notifications/notifications.bridge').then(({ send }) => {
send({ event: 'vacay_share', actorId: ownerId, scope: 'user', targetId: targetUserId, params: { actor: ownerEmail } }).catch(() => {});
}).catch(() => {});
this.notifications.send({ event: 'vacay_share', actorId: ownerId, scope: 'user', targetId: targetUserId, params: { actor: ownerEmail } }).catch(() => {});
return {};
}
+12 -1
View File
@@ -42,6 +42,13 @@ vi.mock('../../src/db/database', () => ({
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
const { notificationSend } = vi.hoisted(() => ({ notificationSend: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../../src/nest/notifications/notifications.bridge', () => ({ send: notificationSend }));
// ReservationsService injects NotificationsService now instead of reaching the
// bridge through a lazy import, so the e2e app resolves the real provider out of
// its own container — the module mock above never sees the send. The override
// below is what intercepts it.
vi.mock('../../src/nest/notifications/notifications.instance', () => ({
notificationsInstance: () => ({ send: notificationSend }),
}));
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
@@ -54,6 +61,7 @@ let checkPermission: MockInstance;
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real reservation SQL)', () => {
let server: Server;
@@ -61,7 +69,10 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real
let tripId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] })
.overrideProvider(NotificationsService)
.useValue({ send: notificationSend })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+8 -7
View File
@@ -66,6 +66,7 @@ import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-
import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service';
import { makeNotificationsService, makeNotificationPreferencesService } from './notifications';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { notificationsStub } from './notifications';
/**
* Hand-wired counterpart of the boot-time discovery in McpRegistryService,
@@ -84,8 +85,8 @@ export function createMcpTestRegistry(): McpRegistry {
const exchangeRatesService = new ExchangeRatesService();
const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService, realtimeService);
const todoService = new TodoService(dbService, permissionsService, realtimeService);
const packingService = new PackingService(dbService, permissionsService, realtimeService);
const collabService = new CollabService(dbService, permissionsService, realtimeService);
const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub());
const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub());
const mapsService = new MapsService(dbService);
const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService));
// The last three were previously omitted, which left them `undefined` at
@@ -98,16 +99,16 @@ export function createMcpTestRegistry(): McpRegistry {
new PlacePhotoCacheService(dbService, new RuntimeEnvService()),
journeyDomain,
);
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService);
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub());
const accommodationsService = new AccommodationsService(dbService, permissionsService, realtimeService);
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService), permissionsService, realtimeService);
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService), permissionsService, realtimeService, notificationsStub());
const tripsService = new TripsService(
dbService,
reservationsService,
daysService,
permissionsService,
budgetService,
new VacayService(dbService, realtimeService),
new VacayService(dbService, realtimeService, notificationsStub()),
realtimeService,
new UnsplashService(dbService, new RuntimeEnvService()),
);
@@ -142,12 +143,12 @@ export function createMcpTestRegistry(): McpRegistry {
new AccommodationsMcp(accommodationsService, dbService, placesService, authService),
new AssignmentsMcp(assignmentsService, daysService, authService),
new CollabMcp(collabService, authService, addonsService),
new VacayMcp(new VacayService(dbService, realtimeService), authService, addonsService),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService), authService),
new MapsMcp(mapsService),
new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain),
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService), dbService, authService, addonsService),
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub()), dbService, authService, addonsService),
new TransitMcp(new TransitService(), daysService, reservationsService, dbService, authService),
new AtlasMcp(new AtlasService(dbService), addonsService, authService),
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain), addonsService, authService),
+16
View File
@@ -29,3 +29,19 @@ export function makeNotificationsService(dbs: DatabaseService, realtime = new Re
export function makeNotificationPreferencesService(dbs: DatabaseService): NotificationPreferencesService {
return new NotificationPreferencesService(dbs, new MailerService(dbs));
}
/**
* A send that records instead of delivering.
*
* For the six services that took NotificationsService as a constructor
* parameter when their fire-and-forget sends stopped being lazy imports. Most
* suites do not care that a notification went out, only that the write around
* it succeeded — but the parameter is required, and `tests/` is outside
* `tsconfig`'s `include`, so leaving it off would not fail to compile. It would
* land as `undefined` and throw inside the send.
*/
export function notificationsStub(send: NotificationSend = async () => {}): NotificationsService {
return { send } as unknown as NotificationsService;
}
type NotificationSend = (payload: Parameters<NotificationsService['send']>[0]) => Promise<void>;
+7 -6
View File
@@ -62,6 +62,7 @@ import { VacayRpc } from '../../src/nest/vacay/vacay.rpc';
import { JournalRpc } from '../../src/nest/journey/journal.rpc';
import { CollectionsRpc } from '../../src/nest/collections/collections.rpc';
import { makeNotificationsService } from './notifications';
import { notificationsStub } from './notifications';
/**
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
@@ -81,14 +82,14 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
const addons = new AddonsService(dbs);
const queryHelpers = new QueryHelpersService(dbs);
const todos = new TodoService(dbs, permissions, realtime);
const packing = new PackingService(dbs, permissions, realtime);
const packing = new PackingService(dbs, permissions, realtime, notificationsStub());
const files = new FilesService(dbs, permissions, realtime);
const reservations = new ReservationsService(dbs, permissions, budget, realtime);
const collab = new CollabService(dbs, permissions, realtime);
const vacay = new VacayService(dbs, realtime);
const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub());
const collab = new CollabService(dbs, permissions, realtime, notificationsStub());
const vacay = new VacayService(dbs, realtime, notificationsStub());
const days = new DaysService(dbs, permissions, realtime, queryHelpers);
const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs), queryHelpers);
const collections = new CollectionsService(dbs, permissions, realtime);
const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub());
const atlas = new AtlasService(dbs);
const dayNotes = new DayNotesService(dbs, permissions, realtime);
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers);
@@ -101,7 +102,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
// Was 12 arguments for a 14-parameter constructor; unsplash and userCleanup were
// undefined here and tsconfig only typechecks src, so nothing said so.
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, undefined as never);
const members = new TripMembersService(dbs, budget, undefined as never, permissions, realtime);
const members = new TripMembersService(dbs, budget, undefined as never, permissions, realtime, notificationsStub());
const guards = new PluginGuards(dbs, permissions, addons);
const registry = createTestPluginRegistry([
+2 -1
View File
@@ -82,6 +82,7 @@ import type { TripsService } from '../../../src/nest/trips/trips.service';
import type { TodoService } from '../../../src/nest/todo/todo.service';
import type { CollabService } from '../../../src/nest/collab/collab.service';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { notificationsStub } from '../../helpers/notifications';
// The trip-summary prompt moved to the DI-discovered TripsMcp — its cases below
// exercise it through a hand-built registry over a stub TripsService whose
@@ -111,7 +112,7 @@ const tripsMcp = new TripsMcp(
const promptDbs = () => new DatabaseService(testDb);
const authStub = { isDemoUser: () => false } as unknown as AuthService;
const packingMcp = new PackingMcp(
new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService()),
new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService(), notificationsStub()),
authStub,
addonsStub,
);
@@ -72,6 +72,7 @@ import { DaysService } from '../../../src/nest/days/days.service';
import { CollabService } from '../../../src/nest/collab/collab.service';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { notificationsStub } from '../../helpers/notifications';
const budget = new BudgetService(
new DatabaseService(testDb),
@@ -91,6 +92,7 @@ const membersSvc = new TripMembersService(
new UserCleanupService(dbs()),
new PermissionsService(dbs()),
new RealtimeService(),
notificationsStub(),
);
const createGuest = membersSvc.createGuest.bind(membersSvc);
const deleteGuest = membersSvc.deleteGuest.bind(membersSvc);
@@ -54,6 +54,7 @@ import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.se
import { CalendarService, foldICS } from '../../../src/nest/calendar/calendar.service';
import { CalendarModule } from '../../../src/nest/calendar/calendar.module';
import { expectRegisteredProvider } from '../../helpers/module-providers';
import { notificationsStub } from '../../helpers/notifications';
const dbs = () => new DatabaseService(testDb);
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
@@ -61,7 +62,7 @@ const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new Ex
// Named `svc` so the moved cases below read exactly as they did on TripsService.
const svc = new CalendarService(
dbs(),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub()),
);
beforeAll(() => {
@@ -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 { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { notificationsStub } from '../../helpers/notifications';
const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService());
const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub());
beforeAll(() => {
createTables(testDb);
@@ -415,7 +416,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), new RealtimeService());
const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub());
const poll = failing.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
failing.votePoll(trip.id, poll!.id, user1.id, 0);
@@ -436,7 +437,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), new RealtimeService());
const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService(), notificationsStub());
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');
@@ -56,8 +56,9 @@ import { CollectionsService } from '../../../src/nest/collections/collections.se
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { PLACE_IMAGES_DIR } from '../../../src/nest/places/place-image';
import { notificationsStub } from '../../helpers/notifications';
const svc = new CollectionsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService());
const svc = new CollectionsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub(notifSend));
// The real cache: these cases assert what removeIfUnreferenced actually does
// about collection_places (#1081), so a stub would assert nothing.
const photoCache = new PlacePhotoCacheService(new DatabaseService(testDb), new RuntimeEnvService());
@@ -66,8 +66,9 @@ import type { PermissionsService } from '../../../src/nest/permissions/permissio
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';
import { notificationsStub } from '../../helpers/notifications';
const svc = new PackingService(new DatabaseService(testDb), permissionsStub, new RealtimeService());
const svc = new PackingService(new DatabaseService(testDb), permissionsStub, new RealtimeService(), notificationsStub(send));
// ── Lifecycle ─────────────────────────────────────────────────────────────────
@@ -43,6 +43,12 @@ const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBud
const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } }));
vi.mock('../../../src/nest/notifications/notifications.bridge', () => notif);
// The bridge builds its own collaborators, so its NotificationsService comes
// from notifications.instance rather than from the module above — RESV-BRIDGE-009
// asserts on what the bridge sends, so that is the module to intercept.
vi.mock('../../../src/nest/notifications/notifications.instance', () => ({
notificationsInstance: () => notif,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
@@ -54,8 +60,9 @@ import { ReservationsService } from '../../../src/nest/reservations/reservations
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';
import { notificationsStub } from '../../helpers/notifications';
const svc = new ReservationsService(new DatabaseService(testDb), permissionsStub, budget as unknown as BudgetService, new RealtimeService());
const svc = new ReservationsService(new DatabaseService(testDb), permissionsStub, budget as unknown as BudgetService, new RealtimeService(), notificationsStub(notif.send));
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeEach(() => {
@@ -50,9 +50,10 @@ import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { SynologyService } from '../../../src/nest/memories/synology.service';
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import { notificationsStub } from '../../helpers/notifications';
const access = { getAlbumLinkForSync: vi.fn(), updateSyncTimeForAlbumLink: vi.fn() };
const svc = new SynologyService(new DatabaseService(testDb), access as unknown as MemoriesAccessService);
const svc = new SynologyService(new DatabaseService(testDb), access as unknown as MemoriesAccessService, notificationsStub());
const USER = 1;
@@ -65,10 +65,11 @@ import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service'
import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
import { NotFoundError, ValidationError } from '../../../src/nest/common/domain-errors';
import type { User } from '../../../src/types';
import { notificationsStub } from '../../helpers/notifications';
const dbs = () => new DatabaseService(testDb);
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const roster = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService());
const roster = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(notifySend));
/**
* A roster whose connection reports "no such row" for the first result of every
@@ -98,7 +99,7 @@ function rosterWithMissingRow(match: string) {
},
});
const frozen = { connection: conn, canAccessTrip: dbMock.canAccessTrip, isOwner: dbMock.isOwner } as unknown as DatabaseService;
return new TripMembersService(frozen, budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService());
return new TripMembersService(frozen, budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(notifySend));
}
beforeAll(() => {
@@ -66,6 +66,7 @@ import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-rea
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
import { MapsService } from '../../../src/nest/maps/maps.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { notificationsStub } from '../../helpers/notifications';
// Real sibling services over the same in-memory DB — the aggregation runs the
// actual SQL of every domain it fans out to, so a shape change downstream shows
@@ -75,14 +76,14 @@ const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new Ex
const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs()));
const placesSvc = new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs()));
const accommodationsSvc = new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService());
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService());
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
const buildReadModel = (database: DatabaseService, roster: TripMembersService = membersSvc) =>
new TripReadModelService(
database, roster, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService()),
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub()),
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()),
placesSvc,
new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
+10 -8
View File
@@ -77,6 +77,7 @@ import { MapsService } from '../../../src/nest/maps/maps.service';
import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nest/trips/trips.bridge';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import fs from 'fs';
import { notificationsStub } from '../../helpers/notifications';
// Real sibling services over the same in-memory DB — updateTrip's date-shift
// resyncs and the summary/bundle aggregation run their actual SQL.
@@ -89,20 +90,20 @@ const createAccommodation = accommodationsSvc.createAccommodation.bind(accommoda
const svc = new TripsService(
dbs(),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub()),
daysSvc,
new PermissionsService(dbs()),
budgetSvc,
new VacayService(dbs(), new RealtimeService()),
new VacayService(dbs(), new RealtimeService(), notificationsStub()),
new RealtimeService(),
undefined as never, // unsplash — not exercised here
);
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService());
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
const readModelSvc = new TripReadModelService(
dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService()),
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub()),
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()),
placesSvc,
new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
@@ -917,11 +918,11 @@ describe('quirk fixes', () => {
const fdbs = failingConnection(match);
return new TripsService(
fdbs,
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub()),
daysSvc,
new PermissionsService(dbs()),
budgetSvc,
new VacayService(dbs(), new RealtimeService()),
new VacayService(dbs(), new RealtimeService(), notificationsStub()),
new RealtimeService(),
undefined as never,
);
@@ -932,6 +933,7 @@ describe('quirk fixes', () => {
return new TripMembersService(
failingConnection(match), budgetSvc, new UserCleanupService(dbs()),
new PermissionsService(dbs()), new RealtimeService(),
notificationsStub(),
);
}
@@ -51,6 +51,7 @@ import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repos
import { DatabaseService } from '../../../src/nest/database/database.service';
import type { ImmichService } from '../../../src/nest/memories/immich.service';
import type { SynologyService } from '../../../src/nest/memories/synology.service';
import { notificationsStub } from '../../helpers/notifications';
// The album-sync paths are the providers' half and have their own suites; these
// cases never reach them, so stubs keep the graph small.
@@ -61,6 +62,7 @@ const svc = new UnifiedMemoriesService(
{} as ImmichService,
{} as SynologyService,
new MemoriesAccessService(dbs),
notificationsStub(),
);
// Legacy free-function names bound to the service, so the moved cases read as before.
+5 -4
View File
@@ -36,6 +36,7 @@ import { createUser } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { notificationsStub } from '../../helpers/notifications';
// VACAY-SVC-001 through VACAY-SVC-066 moved 1:1 from the legacy
// tests/unit/services/vacayService.test.ts (the named-function imports became
@@ -43,7 +44,7 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
// updateUserYearSettings is the class's updateYearSettings).
// VACAY-SVC-067 (vacay.bridge delegation) died with the bridge — its only
// consumer, the legacy tripService, folded into the DI-native TripsService.
const svc = new VacayService(new DatabaseService(testDb), new RealtimeService());
const svc = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub());
// ── Lifecycle ─────────────────────────────────────────────────────────────────
@@ -1378,7 +1379,7 @@ describe('quirk fixes', () => {
if (sql.includes(match)) throw new Error('boom');
return realRun(sql, ...params);
});
return new VacayService(failingDb, new RealtimeService());
return new VacayService(failingDb, new RealtimeService(), notificationsStub());
}
it('VACAY-SVC-068: acceptInvite is atomic — a failure mid-flow rolls the status flip back', () => {
@@ -1409,7 +1410,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), new RealtimeService());
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub());
expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' });
// Nothing cached: a retry hits the network again.
@@ -1424,7 +1425,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), new RealtimeService());
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub());
await fresh.applyHolidayCalendars(plan.id);
const afterFirst = fetchMock.mock.calls.length;
@@ -20,6 +20,7 @@ import type { PermissionsService } from '../../../src/nest/permissions/permissio
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
import { notificationsStub } from '../../helpers/notifications';
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
@@ -31,7 +32,7 @@ type Item = { id: number; name?: string; is_private?: number; owner_id?: number
*/
function build(opts: { canEdit?: boolean; before?: Item | undefined; updated?: Item | null } = {}) {
const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType<typeof vi.fn> };
const packing = new PackingService({} as never, {} as never, realtime);
const packing = new PackingService({} as never, {} as never, realtime, notificationsStub());
const data = {
listItems: vi.fn(() => [{ id: 70, name: 'Socks' }]),
createItem: vi.fn((_t: string, i: Record<string, unknown>) => ({ id: 70, ...i })),
@@ -20,6 +20,7 @@ import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.se
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.client';
import type { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
import { notificationsStub } from '../../helpers/notifications';
// The client and the per-user credentials are the only stubs; the reservation
// writes go through the real service against the real test DB, as before. They
@@ -39,6 +40,7 @@ function makeImportService(): AirtrailImportService {
permissions,
new BudgetService(dbs(), permissions, new ExchangeRatesService(), realtime),
realtime,
notificationsStub(),
),
{ listFlights } as unknown as AirtrailClient,
{
@@ -46,8 +46,9 @@ import { MapsService } from '../../../src/nest/maps/maps.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { notificationsStub } from '../../helpers/notifications';
const packing = new PackingService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService());
const packing = new PackingService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub());
const places = new PlacesService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new MapsService(new DatabaseService(testDb)), new QueryHelpersService(new DatabaseService(testDb)));
beforeAll(() => {
+8 -8
View File
@@ -75,8 +75,8 @@ export default defineConfig({
'src/nest/budget/**/*.ts': { statements: 91, branches: 77, functions: 97, lines: 95 },
'src/nest/calendar/**/*.ts': { statements: 96, branches: 93, functions: 99, lines: 99 },
'src/nest/categories/**/*.ts': { statements: 99, branches: 80, functions: 99, lines: 99 },
'src/nest/collab/**/*.ts': { statements: 92, branches: 85, functions: 94, lines: 97 },
'src/nest/collections/**/*.ts': { statements: 83, branches: 70, functions: 91, lines: 92 },
'src/nest/collab/**/*.ts': { statements: 92, branches: 85, functions: 95, lines: 97 },
'src/nest/collections/**/*.ts': { statements: 83, branches: 70, functions: 92, lines: 92 },
'src/nest/common/**/*.ts': { statements: 95, branches: 89, functions: 99, lines: 97 },
'src/nest/config/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
'src/nest/database/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
@@ -91,11 +91,11 @@ export default defineConfig({
'src/nest/journey/**/*.ts': { statements: 90, branches: 84, functions: 84, lines: 91 },
'src/nest/llm-parse/**/*.ts': { statements: 91, branches: 85, functions: 85, lines: 94 },
'src/nest/maps/**/*.ts': { statements: 93, branches: 86, functions: 97, lines: 96 },
'src/nest/memories/**/*.ts': { statements: 88, branches: 81, functions: 96, lines: 90 },
'src/nest/notifications/**/*.ts': { statements: 83, branches: 72, functions: 84, lines: 86 },
'src/nest/memories/**/*.ts': { statements: 88, branches: 81, functions: 97, lines: 90 },
'src/nest/notifications/**/*.ts': { statements: 83, branches: 72, functions: 85, lines: 87 },
'src/nest/oauth/**/*.ts': { statements: 96, branches: 95, functions: 97, lines: 97 },
'src/nest/oidc/**/*.ts': { statements: 88, branches: 83, functions: 91, lines: 92 },
'src/nest/packing/**/*.ts': { statements: 92, branches: 83, functions: 98, lines: 97 },
'src/nest/packing/**/*.ts': { statements: 92, branches: 83, functions: 99, lines: 97 },
'src/nest/permissions/**/*.ts': { statements: 97, branches: 92, functions: 99, lines: 97 },
'src/nest/photos/**/*.ts': { statements: 95, branches: 92, functions: 89, lines: 95 },
// Landed on dev without an entry, so it sat on the 80 catch-all while
@@ -109,7 +109,7 @@ export default defineConfig({
'src/nest/query-helpers/**/*.ts': { statements: 90, branches: 75, functions: 99, lines: 92 },
'src/nest/realtime/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
'src/nest/reservation-import/**/*.ts': { statements: 61, branches: 55, functions: 41, lines: 61 },
'src/nest/reservations/**/*.ts': { statements: 92, branches: 83, functions: 95, lines: 96 },
'src/nest/reservations/**/*.ts': { statements: 92, branches: 83, functions: 96, lines: 96 },
'src/nest/settings/**/*.ts': { statements: 85, branches: 68, functions: 99, lines: 87 },
'src/nest/share/**/*.ts': { statements: 97, branches: 87, functions: 99, lines: 99 },
'src/nest/system-notices/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 },
@@ -118,11 +118,11 @@ export default defineConfig({
'src/nest/transit/**/*.ts': { statements: 92, branches: 83, functions: 97, lines: 94 },
'src/nest/trip-invite/**/*.ts': { statements: 91, branches: 93, functions: 93, lines: 89 },
'src/nest/trip-membership/**/*.ts': { statements: 99, branches: 86, functions: 99, lines: 99 },
'src/nest/trip-members/**/*.ts': { statements: 93, branches: 89, functions: 99, lines: 94 },
'src/nest/trip-members/**/*.ts': { statements: 95, branches: 91, functions: 99, lines: 95 },
'src/nest/trip-read-model/**/*.ts': { statements: 97, branches: 96, functions: 99, lines: 97 },
'src/nest/trips/**/*.ts': { statements: 92, branches: 82, functions: 91, lines: 94 },
'src/nest/unsplash/**/*.ts': { statements: 97, branches: 82, functions: 99, lines: 96 },
'src/nest/vacay/**/*.ts': { statements: 82, branches: 66, functions: 89, lines: 86 },
'src/nest/vacay/**/*.ts': { statements: 82, branches: 66, functions: 90, lines: 86 },
'src/nest/weather/**/*.ts': { statements: 93, branches: 78, functions: 91, lines: 97 },
'src/nest/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
},