mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move notifications backend to proper DI
The send() dispatcher (services/notificationService.ts) and the in-app store (services/inAppNotifications.ts) fold together into the DI-native NotificationsService over DatabaseService + RealtimeService; the prefs matrix, transports, channel registry and inAppNotificationActions stay plain infra modules. A 1-export notifications.bridge covers scheduler, legacy adminService/memories and the six lazy fire-and-forget sends; AdminController and the plugin RPC host (24th factory dep) inject. The 5-tool registrar + notifications-in-app resource move to notifications.mcp.ts. Tests move with case IDs preserved (NSVC-*, INOTIF-*, + NSVC-020 bridge pin); ~18 suites repoint path mocks/warm-ups; the module e2e goes DI-native. Also carries the verified-quirk fixes that would normally trail as a separate fix(server) commit (file-overlap with the fold): toUtcIso on all three created_at paths (INOTIF-013/014), respond claims the response CAS before running the action handler with release-on-failure (INOTIF-011/012), synology's fire-and-forget send gains .catch, the dispatch-failure log unwraps Error messages (NSVC-021), and the MCP resource description drops its false 'unread first' claim.
This commit is contained in:
+2
-2
@@ -28,7 +28,7 @@ Wave 4 — the coupled cluster (order matters here)
|
||||
dayService; the budget/day coupling lives in the Nest wrapper's budget-sync seam and the MCP registrars, which keep their legacy imports until those domains migrate. Reservations went first as the frontier residue fold; dayService
|
||||
(done 2026-07 — the 592-line service folded into the wrapper `DaysService`, the accommodations seam in `nest/reservations/` now injects it, and the hand-rolled reorder/insert transactions became `db.transaction()`) followed;
|
||||
budgetService followed (done 2026-07 — the 755-line money core folded into the wrapper `BudgetService` (settlement maths, FX freeze/rebase, items/members/payers CRUD; the raw settlement writes became `insertSettlement`/`applySettlementUpdate` so the MCP paths keep skipping the freeze); the 11-tool mcp registrar + 3 budget resources moved to the decorator-driven `budget.mcp.ts`; TripsService/ReservationsService/BookingImportService now inject `BudgetService`; a 4-export `budget.bridge.ts` serves tripService, userCleanupService and the legacy trips/transports registrars; `exchange-rates.bridge.ts` was deleted with its last consumers; and the controller adopted `budget.dto.ts` over the shared schemas, clearing all nine budget allow-list entries — **tripService is now unblocked**).
|
||||
9. tripService (done 2026-07 — the 1121-line hub folded into the wrapper `TripsService`: generateDays, the updateTrip date-shift transaction, members/guests (#973/#1362), the ICS export, copyTripById and getTripSummary; its six bridge imports became injected services (CollabService/VacayService joined the constructor); the 10-tool trips registrar + 3 trip resources + the trip-summary prompt moved to the decorator-driven `trips.mcp.ts` (first `@Prompt`; the static-token deprecation notice rides the `registry.attach` ctx) and the 3 share-link tools to `share.mcp.ts` on the `canShareTrips` predicate; FeedsService and the plugin RPC host inject `TripsService`; a 3-export `trips.bridge.ts` serves the legacy prompts registrar and budget.mcp.ts's owner/member seam; todo/share/collab/vacay bridges deleted with their last consumers; the controller adopted `trips.dto.ts`, clearing all seven trip allow-list entries). placeService followed (done 2026-07 — the 1029-line place core folded into the wrapper `PlacesService` with its pure half in `places.helpers.ts`; the 10-tool registrar + the trip-places resource moved to the decorator-driven `places.mcp.ts` (`search_place` injects `MapsService`); TripsService/DaysMcp/BookingImportService/the plugin RPC host all inject it, so the domain needs **no bridge at all**; the sibling `placeEnrichment` fold absorbed the last `maps.bridge` consumer and deleted that bridge too, and the DTO ratchet cleared all seven `PlacesController` allow-list entries). transitItineraryService followed (done 2026-08 — the last step-4 link and the first pure-helpers relocation with no service fold: the 287-line module is 100% pure (no SQL, no DB, no broadcasts), so the schemas + endpoint/metadata builders moved byte-identical to `nest/transit/transit-itinerary.helpers.ts` (the schemas must stay module-level plain exports — `transit.mcp.ts` consumes them inside `@Tool` decorators, which evaluate before the container exists), its sole consumer `transit.mcp.ts` was a one-import repoint, and a new 21-case `TRANSIT-ITIN-*` characterization suite pins the previously untested superRefine error strings and builder output — **step 4 is complete; the notifications fan-in heads the frontier**).
|
||||
9. tripService (done 2026-07 — the 1121-line hub folded into the wrapper `TripsService`: generateDays, the updateTrip date-shift transaction, members/guests (#973/#1362), the ICS export, copyTripById and getTripSummary; its six bridge imports became injected services (CollabService/VacayService joined the constructor); the 10-tool trips registrar + 3 trip resources + the trip-summary prompt moved to the decorator-driven `trips.mcp.ts` (first `@Prompt`; the static-token deprecation notice rides the `registry.attach` ctx) and the 3 share-link tools to `share.mcp.ts` on the `canShareTrips` predicate; FeedsService and the plugin RPC host inject `TripsService`; a 3-export `trips.bridge.ts` serves the legacy prompts registrar and budget.mcp.ts's owner/member seam; todo/share/collab/vacay bridges deleted with their last consumers; the controller adopted `trips.dto.ts`, clearing all seven trip allow-list entries). placeService followed (done 2026-07 — the 1029-line place core folded into the wrapper `PlacesService` with its pure half in `places.helpers.ts`; the 10-tool registrar + the trip-places resource moved to the decorator-driven `places.mcp.ts` (`search_place` injects `MapsService`); TripsService/DaysMcp/BookingImportService/the plugin RPC host all inject it, so the domain needs **no bridge at all**; the sibling `placeEnrichment` fold absorbed the last `maps.bridge` consumer and deleted that bridge too, and the DTO ratchet cleared all seven `PlacesController` allow-list entries). transitItineraryService followed (done 2026-08 — the last step-4 link and the first pure-helpers relocation with no service fold: the 287-line module is 100% pure (no SQL, no DB, no broadcasts), so the schemas + endpoint/metadata builders moved byte-identical to `nest/transit/transit-itinerary.helpers.ts` (the schemas must stay module-level plain exports — `transit.mcp.ts` consumes them inside `@Tool` decorators, which evaluate before the container exists), its sole consumer `transit.mcp.ts` was a one-import repoint, and a new 21-case `TRANSIT-ITIN-*` characterization suite pins the previously untested superRefine error strings and builder output — **step 4 is complete**). notificationService followed (done 2026-08 — the notifications fan-in, step 3 of the dependency-honest order, deferred while step 4 ran: the `send()` dispatcher **and** the `inAppNotifications.ts` store folded together into the existing wrapper `NotificationsService` over DatabaseService + RealtimeService; the prefs matrix, smtp/webhook/ntfy transports, channel registry and `inAppNotificationActions` stay plain infra modules (their registry⇄prefs cycle included); a 1-export `notifications.bridge.ts` (`send`) covers scheduler's two cron `require`s, legacy adminService + memories/{unified,synology}, and the six deliberately-lazy fire-and-forget `import()` sends in migrated Nest services (path-only repoints); `AdminController` and the plugin RPC host (24th constructor dep) inject instead; the 5-tool registrar + the `notifications-in-app` resource moved to the decorator-driven `notifications.mcp.ts`; the DTO ratchet cleared all five `NotificationsController` allow-list entries (`testNtfyRequestSchema` gained `.nullable()` server/token — the client sends null to mean "use the saved value") — **adminService is now unblocked and heads the frontier**).
|
||||
|
||||
Wave 5 — the heavyweights, last
|
||||
|
||||
@@ -36,7 +36,7 @@ Wave 5 — the heavyweights, last
|
||||
outside the DI wrapper.
|
||||
|
||||
Two special cases to keep in mind: airportService is lazily required by db/database.ts at boot (the flight-endpoint backfill) — whenever it migrates, that call should move into Nest's bootstrap instead, or you recreate the circular
|
||||
import; and notifications/notificationService are imported by 6+ other services, so like the wave-4 cluster they should migrate before their dependents or keep function shims in the interim.
|
||||
import; and notifications/notificationService were imported by 6+ other services, so they had to migrate before their dependents — **cashed in 2026-08**: the fan-in fold above landed exactly that way, `send` shimmed 1:1 through `notifications.bridge.ts` for the remaining legacy dependents (adminService, memories) until their own folds.
|
||||
|
||||
The through-line: each finished migration lets that domain's tests drop vi.mock('db/database') for overrideProvider(DATABASE_CONNECTION), so the mock seam shrinks wave by wave and disappears with the auth stack. Want me to start on
|
||||
tagService as the pilot?
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { getNotifications } from '../services/inAppNotifications';
|
||||
import { isAddonEnabled } from '../nest/addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
|
||||
@@ -91,16 +90,9 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
// The trip-packing-bags resource moved to the DI-discovered
|
||||
// src/nest/packing/packing.mcp.ts (@ResourceTemplate).
|
||||
|
||||
// In-app notifications
|
||||
if (canRead(scopes, 'notifications')) server.registerResource(
|
||||
'notifications-in-app',
|
||||
'trek://notifications/in-app',
|
||||
{ description: "The current user's in-app notifications (most recent 50, unread first)", mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const result = getNotifications(userId, { limit: 50 });
|
||||
return jsonContent(uri.href, result);
|
||||
}
|
||||
);
|
||||
// The notifications-in-app resource moved to the DI-discovered
|
||||
// src/nest/notifications/notifications.mcp.ts (@Resource, attached via the
|
||||
// nest-mcp registry in registerTools).
|
||||
|
||||
// The atlas-stats and atlas-regions resources moved to the DI-discovered
|
||||
// src/nest/atlas/atlas.mcp.ts (@Resource, attached via the nest-mcp registry
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { registerJourneyTools } from './tools/journey';
|
||||
import { registerMapsWeatherTools } from './tools/mapsWeather';
|
||||
import { registerNotificationTools } from './tools/notifications';
|
||||
import { registerTransportTools } from './tools/transports';
|
||||
import { registerMcpPrompts } from './tools/prompts';
|
||||
import { getMcpRegistry } from './registry-handoff';
|
||||
@@ -35,7 +34,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
|
||||
registerMapsWeatherTools(server, userId, scopes);
|
||||
|
||||
registerNotificationTools(server, userId, scopes);
|
||||
// The notification tools moved to the DI-discovered src/nest/notifications/
|
||||
// notifications.mcp.ts (@McpController, attached via the nest-mcp registry below).
|
||||
|
||||
// The atlas tools moved to the DI-discovered src/nest/atlas/atlas.mcp.ts
|
||||
// (@McpController, attached via the nest-mcp registry below).
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../nest/auth/auth.bridge';
|
||||
import {
|
||||
getNotifications, getUnreadCount,
|
||||
markRead as markNotificationRead, markUnread as markNotificationUnread,
|
||||
markAllRead,
|
||||
} from '../../services/inAppNotifications';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
export function registerNotificationTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'notifications');
|
||||
const W = canWrite(scopes, 'notifications');
|
||||
|
||||
// --- NOTIFICATIONS ---
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_notifications',
|
||||
{
|
||||
description: 'List in-app notifications for the current user.',
|
||||
inputSchema: {
|
||||
limit: z.number().int().positive().optional().default(20),
|
||||
offset: z.number().int().min(0).optional().default(0),
|
||||
unread_only: z.boolean().optional().default(false),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ limit, offset, unread_only }) => {
|
||||
const result = getNotifications(userId, { limit: limit ?? 20, offset: offset ?? 0, unreadOnly: unread_only ?? false });
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'get_unread_notification_count',
|
||||
{
|
||||
description: 'Get the number of unread in-app notifications.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const count = getUnreadCount(userId);
|
||||
return ok({ count });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'mark_notification_read',
|
||||
{
|
||||
description: 'Mark a single notification as read.',
|
||||
inputSchema: {
|
||||
notificationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ notificationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = markNotificationRead(notificationId, userId);
|
||||
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'mark_notification_unread',
|
||||
{
|
||||
description: 'Mark a single notification as unread.',
|
||||
inputSchema: {
|
||||
notificationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ notificationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = markNotificationUnread(notificationId, userId);
|
||||
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'mark_all_notifications_read',
|
||||
{
|
||||
description: "Mark all of the current user's notifications as read.",
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async () => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const count = markAllRead(userId);
|
||||
return ok({ success: true, count });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -29,8 +29,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
|
||||
- **DI-native services (legacy `src/services/*` deleted):** tags, categories,
|
||||
todo, packing, day-notes, trip-invite, assignments, share, settings, files,
|
||||
collab, vacay, reservations, day, permissions, audit, budget, trip, maps,
|
||||
transit, place, transit-itinerary, collections, atlas, auth, oidc, passkey —
|
||||
see the migration recipe below.
|
||||
transit, place, transit-itinerary, collections, atlas, auth, oidc, passkey,
|
||||
notifications — see the migration recipe below.
|
||||
|
||||
## Cross-cutting Foundation pieces
|
||||
|
||||
@@ -415,8 +415,33 @@ PASSKEY-SVC-001…030 were written fresh with the fold (characterization over a
|
||||
real `:memory:` DB, `@simplewebauthn/server` mocked at the ceremony-verdict
|
||||
boundary — the repo's first such mock); auth-guard.test.ts's PasskeyController
|
||||
block swapped its path mock for a constructor stub.)
|
||||
notificationService followed (the notifications fan-in, step 3 of the
|
||||
dependency-honest order: the `send()` dispatcher **and** the
|
||||
`inAppNotifications.ts` store folded together into the existing
|
||||
`NotificationsService` over DatabaseService + RealtimeService — the wrapper's
|
||||
in-app delegation became the real SQL, while the prefs matrix, the smtp/
|
||||
webhook/ntfy transports, the channel registry and `inAppNotificationActions`
|
||||
stay plain-module imports (graph-classified infra helpers, complete with
|
||||
their registry⇄prefs cycle). A one-export `notifications.bridge.ts` (`send`)
|
||||
covers the outside-container consumers — scheduler's two cron `require`s,
|
||||
legacy adminService and memories/{unified,synology} — **and** the six
|
||||
deliberately-lazy fire-and-forget `import().then(({ send }) => …)` sends in
|
||||
migrated Nest services (collab/collections/packing/reservations/trips/vacay,
|
||||
path-only repoints; kept lazy so a send can never block or cycle a domain
|
||||
module). In-container static consumers inject instead: `AdminController`'s
|
||||
dev test send (AdminModule → NotificationsModule) and the plugin host
|
||||
(`NotificationsService` = `PluginHostDepsFactory`'s 24th dep). The 5-tool
|
||||
legacy registrar + the `notifications-in-app` resource moved 1:1 to
|
||||
`notifications.mcp.ts` (no `when:` — the domain is core, not addon-gated).
|
||||
Tests moved with IDs preserved: notificationService.test.ts →
|
||||
notifications.service.test.ts (NSVC-001…019/NTFY-SVCB-*/NSVC-PLUG-001…007,
|
||||
plus the NSVC-020 bridge pin), inAppNotificationPrefs.test.ts →
|
||||
notifications.inapp-prefs.test.ts (INOTIF-*); ~18 suites repointed their
|
||||
path mocks/warm-ups to the bridge; admin.controller.test and the plugin-host
|
||||
suite converted theirs to constructor stubs; the module e2e went DI-native
|
||||
(real notifications DDL, prefs/transports still path-mocked).)
|
||||
Repeat these steps per
|
||||
service (next up: **the notifications fan-in** —
|
||||
service (next up: **adminService**, unblocked by the notifications fold —
|
||||
per the dependency-honest order in
|
||||
`migration-graph.md`). This is a
|
||||
**pure relocation** — byte-identical
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { getClientIp } from '../audit/client-ip';
|
||||
import { logInfo } from '../audit/audit-log.logger';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { send as sendNotification } from '../../services/notificationService';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import type { User } from '../../types';
|
||||
|
||||
/** Throw the legacy {error,status} envelope when a service call reports failure. */
|
||||
@@ -38,6 +38,7 @@ export class AdminController {
|
||||
private readonly admin: AdminService,
|
||||
private readonly pluginRuntime: PluginRuntimeService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
// ── Users ──
|
||||
@@ -353,14 +354,14 @@ export class AdminController {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
try {
|
||||
await sendNotification({
|
||||
await this.notifications.send({
|
||||
event: body.event ?? 'trip_reminder',
|
||||
actorId: user.id,
|
||||
scope: body.scope ?? 'user',
|
||||
targetId: body.targetId ?? user.id,
|
||||
params: { actor: user.email, ...(body.params ?? {}) },
|
||||
inApp: body.inApp,
|
||||
} as unknown as Parameters<typeof sendNotification>[0]);
|
||||
} as unknown as Parameters<NotificationsService['send']>[0]);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
|
||||
|
||||
@@ -7,9 +7,11 @@ import { AuditModule } from '../audit/audit.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
// AuthModule exports PasskeyService for the admin passkey-reset endpoint.
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
// NotificationsModule exports NotificationsService for the dev test-notification send.
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [PluginsModule, SettingsModule, AuditModule, AddonsModule, AuthModule],
|
||||
imports: [PluginsModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
|
||||
@@ -495,7 +495,7 @@ export class CollabService {
|
||||
|
||||
/** Fire-and-forget collab notification (mirrors the legacy route's dynamic import). */
|
||||
notifyCollab(tripId: string, actor: User, preview?: string): void {
|
||||
import('../../services/notificationService').then(({ send }) => {
|
||||
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;
|
||||
|
||||
@@ -104,7 +104,7 @@ const MAX_LABELS_PER_COLLECTION = 50;
|
||||
* 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 legacy notificationService (collab precedent).
|
||||
* lazy import of the notifications bridge (collab precedent).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CollectionsService {
|
||||
@@ -1012,7 +1012,7 @@ export class CollectionsService {
|
||||
|
||||
this.realtime.broadcastToUser(targetUserId, { type: 'collections:invite', from: { id: inviterId, username: inviterUsername }, collectionId });
|
||||
|
||||
import('../../services/notificationService').then(({ send }) => {
|
||||
import('../notifications/notifications.bridge').then(({ send }) => {
|
||||
send({ event: 'collection_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, collectionId: String(collectionId) } }).catch(() => {});
|
||||
}).catch(() => {});
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import type { NotificationPayload } from './notifications.service';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton.
|
||||
*/
|
||||
const notifications = new NotificationsService(new DatabaseService(db), new RealtimeService());
|
||||
|
||||
export function send(payload: NotificationPayload): Promise<void> {
|
||||
return notifications.send(payload);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
McpController, Tool, Resource, type McpContext,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
demoDenied, ok,
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
function jsonContent(uri: string, data: unknown) {
|
||||
return {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(data, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifications MCP surface — ported 1:1 from the legacy registrars: the five
|
||||
* tools from src/mcp/tools/notifications.ts and the trek://notifications/in-app
|
||||
* resource from src/mcp/resources.ts (identical names, descriptions, zod input
|
||||
* schemas, annotations and error/payload shapes — including the legacy
|
||||
* 'Notification not found.' wording, which differs from the REST route's
|
||||
* 'Not found'). The legacy `if (R)` / `if (W)` scope checks map to the
|
||||
* declarative notifications read/write access markers (resolved by
|
||||
* trekMcpAccessPolicy); there is no `when:` gate — notifications are core,
|
||||
* not addon-gated. The WS notification:new/updated broadcasts live inside
|
||||
* NotificationsService, so no safeBroadcast here.
|
||||
*/
|
||||
@McpController()
|
||||
export class NotificationsMcp {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly auth: AuthService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_notifications',
|
||||
description: 'List in-app notifications for the current user.',
|
||||
inputSchema: {
|
||||
limit: z.number().int().positive().optional().default(20),
|
||||
offset: z.number().int().min(0).optional().default(0),
|
||||
unread_only: z.boolean().optional().default(false),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'notifications', mode: 'read' },
|
||||
})
|
||||
async listNotifications(
|
||||
{ limit, offset, unread_only }: { limit?: number; offset?: number; unread_only?: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const result = this.notifications.listInApp(ctx.userId, { limit: limit ?? 20, offset: offset ?? 0, unreadOnly: unread_only ?? false });
|
||||
return ok(result);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_unread_notification_count',
|
||||
description: 'Get the number of unread in-app notifications.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'notifications', mode: 'read' },
|
||||
})
|
||||
async getUnreadNotificationCount(_input: Record<string, never>, ctx: McpContext) {
|
||||
const count = this.notifications.unreadCount(ctx.userId);
|
||||
return ok({ count });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'mark_notification_read',
|
||||
description: 'Mark a single notification as read.',
|
||||
inputSchema: {
|
||||
notificationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'notifications', mode: 'write' },
|
||||
})
|
||||
async markNotificationRead({ notificationId }: { notificationId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const success = this.notifications.markRead(notificationId, ctx.userId);
|
||||
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'mark_notification_unread',
|
||||
description: 'Mark a single notification as unread.',
|
||||
inputSchema: {
|
||||
notificationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'notifications', mode: 'write' },
|
||||
})
|
||||
async markNotificationUnread({ notificationId }: { notificationId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const success = this.notifications.markUnread(notificationId, ctx.userId);
|
||||
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'mark_all_notifications_read',
|
||||
description: "Mark all of the current user's notifications as read.",
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'notifications', mode: 'write' },
|
||||
})
|
||||
async markAllNotificationsRead(_input: Record<string, never>, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const count = this.notifications.markAllRead(ctx.userId);
|
||||
return ok({ success: true, count });
|
||||
}
|
||||
|
||||
@Resource({
|
||||
name: 'notifications-in-app',
|
||||
uri: 'trek://notifications/in-app',
|
||||
description: "The current user's in-app notifications (most recent 50)",
|
||||
mimeType: 'application/json',
|
||||
access: { group: 'notifications', mode: 'read' },
|
||||
})
|
||||
async inAppNotificationsResource(uri: URL, ctx: McpContext) {
|
||||
const result = this.notifications.listInApp(ctx.userId, { limit: 50 });
|
||||
return jsonContent(uri.href, result);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsMcp } from './notifications.mcp';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
/** Notifications domain (L6 leaf module). Registered in AppModule. */
|
||||
/** Notifications domain (L6 leaf module). Registered in AppModule.
|
||||
* AuthModule feeds NotificationsMcp's demo gate; NotificationsService is
|
||||
* exported for in-container consumers (AdminController's dev test send and
|
||||
* the plugin RPC host's PluginHostDepsFactory). */
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
providers: [NotificationsService, NotificationsMcp],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ChannelTestResult } from '@trek/shared';
|
||||
import { readEnv, getAppUrl } from '../../app-config';
|
||||
import { logDebug, logError } from '../audit/audit-log.logger';
|
||||
import {
|
||||
testSmtp,
|
||||
testWebhook,
|
||||
@@ -8,34 +10,313 @@ import {
|
||||
getUserWebhookUrl,
|
||||
getUserNtfyConfig,
|
||||
getAdminNtfyConfig,
|
||||
getEventText,
|
||||
getUserLanguage,
|
||||
} from '../../services/notifications';
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markRead,
|
||||
markUnread,
|
||||
markAllRead,
|
||||
deleteNotification,
|
||||
deleteAll,
|
||||
respondToBoolean,
|
||||
} from '../../services/inAppNotifications';
|
||||
import { getPreferencesMatrix, setPreferences } from '../../services/notificationPreferencesService';
|
||||
import { getChannel } from '../../services/notifications/channelRegistry';
|
||||
getPreferencesMatrix,
|
||||
setPreferences,
|
||||
getActiveChannels,
|
||||
isEnabledForEvent,
|
||||
getAdminGlobalPref,
|
||||
isAdminGlobalChannel,
|
||||
ADMIN_SCOPED_EVENTS,
|
||||
type NotifEventType,
|
||||
} from '../../services/notificationPreferencesService';
|
||||
import { getChannel, listChannels, type ChannelMessage, type ExternalChannel } from '../../services/notifications/channelRegistry';
|
||||
import { getAction } from '../../services/inAppNotificationActions';
|
||||
import { avatarUrl } from '../../services/avatarUrl';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
|
||||
type NtfyConfig = ReturnType<typeof getAdminNtfyConfig>;
|
||||
type RespondResult = Awaited<ReturnType<typeof respondToBoolean>>;
|
||||
type PreferencesMatrix = ReturnType<typeof getPreferencesMatrix>;
|
||||
|
||||
// SQLite's CURRENT_TIMESTAMP is UTC but the string ('YYYY-MM-DD HH:MM:SS') has
|
||||
// no 'T'/'Z', so `new Date(...)` parses it as LOCAL time. Normalize to ISO-UTC
|
||||
// so the client renders notification times in the viewer's own timezone (#1149).
|
||||
function toUtcIso(ts: string): string {
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||
type NotificationResponse = 'positive' | 'negative';
|
||||
|
||||
interface BaseNotificationInput {
|
||||
type: NotificationType;
|
||||
scope: NotificationScope;
|
||||
target: number;
|
||||
sender_id: number | null;
|
||||
event_type?: NotifEventType;
|
||||
title_key: string;
|
||||
title_params?: Record<string, string>;
|
||||
text_key: string;
|
||||
text_params?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SimpleNotificationInput extends BaseNotificationInput {
|
||||
type: 'simple';
|
||||
}
|
||||
|
||||
interface BooleanNotificationInput extends BaseNotificationInput {
|
||||
type: 'boolean';
|
||||
positive_text_key: string;
|
||||
negative_text_key: string;
|
||||
positive_callback: { action: string; payload: Record<string, unknown> };
|
||||
negative_callback: { action: string; payload: Record<string, unknown> };
|
||||
}
|
||||
|
||||
interface NavigateNotificationInput extends BaseNotificationInput {
|
||||
type: 'navigate';
|
||||
navigate_text_key: string;
|
||||
navigate_target: string;
|
||||
}
|
||||
|
||||
type NotificationInput = SimpleNotificationInput | BooleanNotificationInput | NavigateNotificationInput;
|
||||
|
||||
interface NotificationRow {
|
||||
id: number;
|
||||
type: NotificationType;
|
||||
scope: NotificationScope;
|
||||
target: number;
|
||||
sender_id: number | null;
|
||||
sender_username?: string | null;
|
||||
sender_avatar?: string | null;
|
||||
recipient_id: number;
|
||||
title_key: string;
|
||||
title_params: string;
|
||||
text_key: string;
|
||||
text_params: string;
|
||||
positive_text_key: string | null;
|
||||
negative_text_key: string | null;
|
||||
positive_callback: string | null;
|
||||
negative_callback: string | null;
|
||||
response: NotificationResponse | null;
|
||||
navigate_text_key: string | null;
|
||||
navigate_target: string | null;
|
||||
is_read: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type RespondResult = { success: boolean; error?: string; notification?: NotificationRow };
|
||||
|
||||
// ── Event config map ───────────────────────────────────────────────────────
|
||||
|
||||
interface EventNotifConfig {
|
||||
inAppType: 'simple' | 'navigate';
|
||||
titleKey: string;
|
||||
textKey: string;
|
||||
navigateTextKey?: string;
|
||||
navigateTarget: (params: Record<string, string>) => string | null;
|
||||
}
|
||||
|
||||
const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
|
||||
// ── Dev-only test events ──────────────────────────────────────────────────
|
||||
test_simple: {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.simple.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
test_boolean: {
|
||||
inAppType: 'simple', // overridden by inApp.type at call site
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.boolean.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
test_navigate: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.navigate.text',
|
||||
navigateTextKey: 'notif.action.view',
|
||||
navigateTarget: () => '/dashboard',
|
||||
},
|
||||
// ── Plugin-mediated notification (#plugins) — raw title/body carried as params,
|
||||
// rendered by the passthrough keys. The plugin never picks a recipient directly;
|
||||
// the host forces scope/target to the acting user or a trip they belong to. ──────
|
||||
plugin_notification: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.plugin.title',
|
||||
textKey: 'notif.plugin.text',
|
||||
navigateTextKey: 'notif.action.view',
|
||||
navigateTarget: p => p.link || null,
|
||||
},
|
||||
// ── Production events ─────────────────────────────────────────────────────
|
||||
trip_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.trip_invite.title',
|
||||
textKey: 'notif.trip_invite.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
booking_change: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.booking_change.title',
|
||||
textKey: 'notif.booking_change.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
trip_reminder: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.trip_reminder.title',
|
||||
textKey: 'notif.trip_reminder.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
todo_due: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.todo_due.title',
|
||||
textKey: 'notif.todo_due.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
vacay_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.vacay_invite.title',
|
||||
textKey: 'notif.vacay_invite.text',
|
||||
navigateTextKey: 'notif.action.view_vacay',
|
||||
navigateTarget: p => (p.planId ? `/vacay/${p.planId}` : null),
|
||||
},
|
||||
vacay_share: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.vacay_share.title',
|
||||
textKey: 'notif.vacay_share.text',
|
||||
navigateTextKey: 'notif.action.view_vacay',
|
||||
navigateTarget: () => '/vacay',
|
||||
},
|
||||
collection_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.collection_invite.title',
|
||||
textKey: 'notif.collection_invite.text',
|
||||
navigateTextKey: 'notif.action.view_collection',
|
||||
navigateTarget: p => (p.collectionId ? `/collections/${p.collectionId}` : '/collections'),
|
||||
},
|
||||
photos_shared: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.photos_shared.title',
|
||||
textKey: 'notif.photos_shared.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
collab_message: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.collab_message.title',
|
||||
textKey: 'notif.collab_message.text',
|
||||
navigateTextKey: 'notif.action.view_collab',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
packing_tagged: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.packing_tagged.title',
|
||||
textKey: 'notif.packing_tagged.text',
|
||||
navigateTextKey: 'notif.action.view_packing',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
version_available: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.version_available.title',
|
||||
textKey: 'notif.version_available.text',
|
||||
navigateTextKey: 'notif.action.view_admin',
|
||||
navigateTarget: () => '/admin',
|
||||
},
|
||||
synology_session_cleared: {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notifications.synologySessionCleared.title',
|
||||
textKey: 'notifications.synologySessionCleared.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Fallback config for unknown event types ────────────────────────────────
|
||||
|
||||
const FALLBACK_EVENT_CONFIG: EventNotifConfig = {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notif.generic.title',
|
||||
textKey: 'notif.generic.text',
|
||||
navigateTarget: () => null,
|
||||
};
|
||||
|
||||
// ── Unified send() API ─────────────────────────────────────────────────────
|
||||
|
||||
export interface NotificationPayload {
|
||||
event: NotifEventType;
|
||||
actorId: number | null;
|
||||
params: Record<string, string>;
|
||||
scope: 'trip' | 'user' | 'admin';
|
||||
targetId: number; // tripId for trip scope, userId for user scope, 0 for admin
|
||||
/** Optional in-app overrides (e.g. boolean type with callbacks) */
|
||||
inApp?: {
|
||||
type?: 'simple' | 'boolean' | 'navigate';
|
||||
positiveTextKey?: string;
|
||||
negativeTextKey?: string;
|
||||
positiveCallback?: { action: string; payload: Record<string, unknown> };
|
||||
negativeCallback?: { action: string; payload: Record<string, unknown> };
|
||||
navigateTarget?: string; // override the auto-generated navigate target
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing notification services. Channel delivery
|
||||
* (including the WebSocket push in inAppNotifications) and the preference
|
||||
* persistence all stay in the upstream services, so behaviour — including
|
||||
* real-time delivery — is unchanged. The webhook/ntfy fallback resolution that
|
||||
* the legacy route does inline is exposed here as small accessors so the
|
||||
* controller can reproduce it exactly.
|
||||
* Should this channel deliver this event to this recipient?
|
||||
*
|
||||
* Encodes, unchanged, the gating the four hand-written dispatch blocks used to do:
|
||||
* - admin-scoped events (version_available) reach recipients only over a channel
|
||||
* that bypasses the `notification_channels` toggle (email), gated by the admin
|
||||
* global pref rather than the per-user opt-out. Their webhook/ntfy copies go out
|
||||
* once, globally, via sendGlobal() below — not per recipient.
|
||||
* - everything else: the admin enabled the channel, the user didn't opt out of this
|
||||
* event on it, and the user has credentials for it.
|
||||
*/
|
||||
function shouldSendToUser(
|
||||
channel: ExternalChannel,
|
||||
event: NotifEventType,
|
||||
recipientId: number,
|
||||
activeChannels: string[],
|
||||
): boolean {
|
||||
if (!channel.supportsEvent(event)) return false;
|
||||
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
|
||||
|
||||
if (ADMIN_SCOPED_EVENTS.has(event)) {
|
||||
if (!channel.bypassesActiveToggleForAdminEvents) return false;
|
||||
if (!isAdminGlobalChannel(channel.id)) return false;
|
||||
if (!getAdminGlobalPref(event, channel.id)) return false;
|
||||
} else {
|
||||
// A built-in needs the admin's explicit switch; a plugin channel is on because the
|
||||
// admin enabled the plugin — that IS the opt-in (see getActiveChannels).
|
||||
if (channel.source === 'builtin' && !activeChannels.includes(channel.id)) return false;
|
||||
if (!isEnabledForEvent(recipientId, event, channel.id)) return false;
|
||||
}
|
||||
|
||||
return channel.isConfiguredFor(recipientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* DI-native notifications domain service. The in-app notification SQL moved
|
||||
* 1:1 from the legacy `services/inAppNotifications.ts` (identical statements,
|
||||
* post-insert re-selects) and the unified `send()` dispatcher moved 1:1 from
|
||||
* `services/notificationService.ts` (identical event-config map, gating and
|
||||
* log lines). The trailing fix(server) commit then normalized the carried
|
||||
* defects: `toUtcIso` now applies on every broadcast/re-select path (#1149
|
||||
* was originally patched on only one of three), and `respond` claims the
|
||||
* response atomically BEFORE running the action handler (double-submits can
|
||||
* no longer execute the action twice; a handler failure releases the claim).
|
||||
* Channel transports, the preference matrix and the channel
|
||||
* registry stay plain-module imports (`services/notifications*`,
|
||||
* `services/notificationPreferencesService`) — they are classified as
|
||||
* infra helpers, not wave material, in `migration-graph.md`.
|
||||
*
|
||||
* Non-Nest consumers (scheduler, legacy adminService/memories, the lazy
|
||||
* fire-and-forget sends in migrated Nest services) go through
|
||||
* `notifications.bridge.ts`; the plugin RPC host injects this service via
|
||||
* `PluginHostDepsFactory`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
getPreferences(userId: number, role: string): PreferencesMatrix {
|
||||
return getPreferencesMatrix(userId, role, 'user');
|
||||
}
|
||||
@@ -85,37 +366,455 @@ export class NotificationsService {
|
||||
return getAdminNtfyConfig();
|
||||
}
|
||||
|
||||
// ── In-app notification store (from services/inAppNotifications.ts) ───────
|
||||
|
||||
resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
|
||||
let userIds: number[] = [];
|
||||
|
||||
// Guests (#1362) are trip members for assignment purposes but have no inbox/email,
|
||||
// so they must never be resolved as notification recipients on any scope. This is the
|
||||
// single chokepoint for in-app/email/webhook/ntfy, so filtering here covers all channels.
|
||||
if (scope === 'trip') {
|
||||
const owner = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', target);
|
||||
const members = this.db.all<{ user_id: number }>('SELECT m.user_id FROM trip_members m JOIN users u ON u.id = m.user_id WHERE m.trip_id = ? AND COALESCE(u.is_guest, 0) = 0', target);
|
||||
const ids = new Set<number>();
|
||||
if (owner) ids.add(owner.user_id);
|
||||
for (const m of members) ids.add(m.user_id);
|
||||
userIds = Array.from(ids);
|
||||
} else if (scope === 'user') {
|
||||
// A guest can be a todo assignee (scope='user'); never notify them.
|
||||
const u = this.db.get<{ is_guest?: number }>('SELECT is_guest FROM users WHERE id = ?', target);
|
||||
userIds = u && u.is_guest ? [] : [target];
|
||||
} else if (scope === 'admin') {
|
||||
const admins = this.db.all<{ id: number }>("SELECT id FROM users WHERE role = ? AND COALESCE(is_guest, 0) = 0", 'admin');
|
||||
userIds = admins.map(a => a.id);
|
||||
}
|
||||
|
||||
// Only exclude sender for group scopes (trip/admin) — for user scope, the target is explicit
|
||||
if (excludeUserId != null && scope !== 'user') {
|
||||
userIds = userIds.filter(id => id !== excludeUserId);
|
||||
}
|
||||
|
||||
return userIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy multi-recipient insert path (resolve + per-recipient pref check in
|
||||
* one transaction). `send()` resolves recipients itself and uses
|
||||
* `createNotificationForRecipient`; this remains for direct callers/tests.
|
||||
*/
|
||||
createNotification(input: NotificationInput): number[] {
|
||||
const recipients = this.resolveRecipients(input.scope, input.target, input.sender_id);
|
||||
if (recipients.length === 0) return [];
|
||||
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
const textParams = JSON.stringify(input.text_params ?? {});
|
||||
|
||||
// Track inserted id → recipientId pairs (some recipients may be skipped by pref check)
|
||||
const insertedPairs: Array<{ id: number; recipientId: number }> = [];
|
||||
|
||||
this.db.transaction(() => {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback,
|
||||
navigate_text_key, navigate_target
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const recipientId of recipients) {
|
||||
// Check per-user in-app preference if an event_type is provided
|
||||
if (input.event_type && !isEnabledForEvent(recipientId, input.event_type, 'inapp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let positiveTextKey: string | null = null;
|
||||
let negativeTextKey: string | null = null;
|
||||
let positiveCallback: string | null = null;
|
||||
let negativeCallback: string | null = null;
|
||||
let navigateTextKey: string | null = null;
|
||||
let navigateTarget: string | null = null;
|
||||
|
||||
if (input.type === 'boolean') {
|
||||
positiveTextKey = input.positive_text_key;
|
||||
negativeTextKey = input.negative_text_key;
|
||||
positiveCallback = JSON.stringify(input.positive_callback);
|
||||
negativeCallback = JSON.stringify(input.negative_callback);
|
||||
} else if (input.type === 'navigate') {
|
||||
navigateTextKey = input.navigate_text_key;
|
||||
navigateTarget = input.navigate_target;
|
||||
}
|
||||
|
||||
const result = stmt.run(
|
||||
input.type, input.scope, input.target, input.sender_id, recipientId,
|
||||
input.title_key, titleParams, input.text_key, textParams,
|
||||
positiveTextKey, negativeTextKey, positiveCallback, negativeCallback,
|
||||
navigateTextKey, navigateTarget
|
||||
);
|
||||
|
||||
insertedPairs.push({ id: result.lastInsertRowid as number, recipientId });
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch sender info once for WS payloads
|
||||
const sender = input.sender_id
|
||||
? this.db.get<{ username: string; avatar: string | null }>('SELECT username, avatar FROM users WHERE id = ?', input.sender_id)
|
||||
: null;
|
||||
|
||||
// Broadcast to each recipient
|
||||
for (const { id: notificationId, recipientId } of insertedPairs) {
|
||||
const row = this.db.get<NotificationRow>('SELECT * FROM notifications WHERE id = ?', notificationId) as NotificationRow;
|
||||
if (!row) continue;
|
||||
|
||||
this.realtime.broadcastToUser(recipientId, {
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
created_at: toUtcIso(row.created_at),
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: avatarUrl({ avatar: sender?.avatar }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return insertedPairs.map(p => p.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a single in-app notification for one pre-resolved recipient and broadcast via WebSocket.
|
||||
* Used by send() which handles recipient resolution externally.
|
||||
*/
|
||||
createNotificationForRecipient(
|
||||
input: NotificationInput,
|
||||
recipientId: number,
|
||||
sender: { username: string; avatar: string | null } | null
|
||||
): number | null {
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
const textParams = JSON.stringify(input.text_params ?? {});
|
||||
|
||||
let positiveTextKey: string | null = null;
|
||||
let negativeTextKey: string | null = null;
|
||||
let positiveCallback: string | null = null;
|
||||
let negativeCallback: string | null = null;
|
||||
let navigateTextKey: string | null = null;
|
||||
let navigateTarget: string | null = null;
|
||||
|
||||
if (input.type === 'boolean') {
|
||||
positiveTextKey = input.positive_text_key;
|
||||
negativeTextKey = input.negative_text_key;
|
||||
positiveCallback = JSON.stringify(input.positive_callback);
|
||||
negativeCallback = JSON.stringify(input.negative_callback);
|
||||
} else if (input.type === 'navigate') {
|
||||
navigateTextKey = input.navigate_text_key;
|
||||
navigateTarget = input.navigate_target;
|
||||
}
|
||||
|
||||
const result = this.db.run(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback,
|
||||
navigate_text_key, navigate_target
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
input.type, input.scope, input.target, input.sender_id, recipientId,
|
||||
input.title_key, titleParams, input.text_key, textParams,
|
||||
positiveTextKey, negativeTextKey, positiveCallback, negativeCallback,
|
||||
navigateTextKey, navigateTarget
|
||||
);
|
||||
|
||||
const notificationId = result.lastInsertRowid as number;
|
||||
const row = this.db.get<NotificationRow>('SELECT * FROM notifications WHERE id = ?', notificationId);
|
||||
if (!row) return null;
|
||||
|
||||
this.realtime.broadcastToUser(recipientId, {
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
created_at: toUtcIso(row.created_at),
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: avatarUrl({ avatar: sender?.avatar }),
|
||||
},
|
||||
});
|
||||
|
||||
return notificationId;
|
||||
}
|
||||
|
||||
// Returns the native service shape (NotificationRow[] is a superset of the
|
||||
// client-facing InAppListResult contract); the controller surfaces it as-is.
|
||||
listInApp(userId: number, options: { limit?: number; offset?: number; unreadOnly?: boolean }) {
|
||||
return getNotifications(userId, options);
|
||||
listInApp(
|
||||
userId: number,
|
||||
options: { limit?: number; offset?: number; unreadOnly?: boolean } = {}
|
||||
): { notifications: NotificationRow[]; total: number; unread_count: number } {
|
||||
const limit = Math.min(options.limit ?? 20, 50);
|
||||
const offset = options.offset ?? 0;
|
||||
const unreadOnly = options.unreadOnly ?? false;
|
||||
|
||||
const whereAliased = unreadOnly ? 'WHERE n.recipient_id = ? AND n.is_read = 0' : 'WHERE n.recipient_id = ?';
|
||||
const wherePlain = unreadOnly ? 'WHERE recipient_id = ? AND is_read = 0' : 'WHERE recipient_id = ?';
|
||||
|
||||
const rows = this.db.all<NotificationRow>(`
|
||||
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
${whereAliased}
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, userId, limit, offset);
|
||||
|
||||
const { total } = this.db.get<{ total: number }>(`SELECT COUNT(*) as total FROM notifications ${wherePlain}`, userId) as { total: number };
|
||||
const { unread_count } = this.db.get<{ unread_count: number }>('SELECT COUNT(*) as unread_count FROM notifications WHERE recipient_id = ? AND is_read = 0', userId) as { unread_count: number };
|
||||
|
||||
const mapped = rows.map(r => ({
|
||||
...r,
|
||||
created_at: toUtcIso(r.created_at),
|
||||
sender_avatar: avatarUrl({ avatar: r.sender_avatar }),
|
||||
}));
|
||||
|
||||
return { notifications: mapped, total, unread_count };
|
||||
}
|
||||
|
||||
unreadCount(userId: number): number {
|
||||
return getUnreadCount(userId);
|
||||
const row = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM notifications WHERE recipient_id = ? AND is_read = 0', userId) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
markRead(id: number, userId: number): boolean {
|
||||
return markRead(id, userId);
|
||||
markRead(notificationId: number, userId: number): boolean {
|
||||
const result = this.db.run('UPDATE notifications SET is_read = 1 WHERE id = ? AND recipient_id = ?', notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
markUnread(id: number, userId: number): boolean {
|
||||
return markUnread(id, userId);
|
||||
markUnread(notificationId: number, userId: number): boolean {
|
||||
const result = this.db.run('UPDATE notifications SET is_read = 0 WHERE id = ? AND recipient_id = ?', notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
markAllRead(userId: number): number {
|
||||
return markAllRead(userId);
|
||||
const result = this.db.run('UPDATE notifications SET is_read = 1 WHERE recipient_id = ? AND is_read = 0', userId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
deleteOne(id: number, userId: number): boolean {
|
||||
return deleteNotification(id, userId);
|
||||
deleteOne(notificationId: number, userId: number): boolean {
|
||||
const result = this.db.run('DELETE FROM notifications WHERE id = ? AND recipient_id = ?', notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
deleteAll(userId: number): number {
|
||||
return deleteAll(userId);
|
||||
const result = this.db.run('DELETE FROM notifications WHERE recipient_id = ?', userId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
respond(id: number, userId: number, response: 'positive' | 'negative'): Promise<RespondResult> {
|
||||
return respondToBoolean(id, userId, response);
|
||||
async respond(
|
||||
notificationId: number,
|
||||
userId: number,
|
||||
response: NotificationResponse
|
||||
): Promise<RespondResult> {
|
||||
const notification = this.db.get<NotificationRow>('SELECT * FROM notifications WHERE id = ? AND recipient_id = ?', notificationId, userId);
|
||||
|
||||
if (!notification) return { success: false, error: 'Notification not found' };
|
||||
if (notification.type !== 'boolean') return { success: false, error: 'Not a boolean notification' };
|
||||
if (notification.response !== null) return { success: false, error: 'Already responded' };
|
||||
|
||||
const callbackJson = response === 'positive' ? notification.positive_callback : notification.negative_callback;
|
||||
if (!callbackJson) return { success: false, error: 'No callback defined' };
|
||||
|
||||
let callback: { action: string; payload: Record<string, unknown> };
|
||||
try {
|
||||
callback = JSON.parse(callbackJson);
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid callback format' };
|
||||
}
|
||||
|
||||
const handler = getAction(callback.action);
|
||||
if (!handler) return { success: false, error: `Unknown action: ${callback.action}` };
|
||||
|
||||
// Atomic claim BEFORE the handler runs — only updates if response is still
|
||||
// NULL, so a concurrent double-submit can never execute the action twice
|
||||
// (the legacy order ran the handler first, letting both submits through).
|
||||
const result = this.db.run(
|
||||
'UPDATE notifications SET response = ?, is_read = 1 WHERE id = ? AND recipient_id = ? AND response IS NULL',
|
||||
response, notificationId, userId
|
||||
);
|
||||
|
||||
if (result.changes === 0) return { success: false, error: 'Already responded' };
|
||||
|
||||
try {
|
||||
await handler(callback.payload, userId);
|
||||
} catch (err) {
|
||||
// Release the claim so the user can retry — legacy contract: a handler
|
||||
// failure returns its message and leaves the notification unresponded.
|
||||
this.db.run(
|
||||
'UPDATE notifications SET response = NULL, is_read = ? WHERE id = ? AND recipient_id = ?',
|
||||
notification.is_read, notificationId, userId
|
||||
);
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Action failed' };
|
||||
}
|
||||
|
||||
const updated = this.db.get<NotificationRow>(`
|
||||
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
WHERE n.id = ?
|
||||
`, notificationId) as NotificationRow;
|
||||
|
||||
const mappedUpdated = {
|
||||
...updated,
|
||||
created_at: toUtcIso(updated.created_at),
|
||||
sender_avatar: avatarUrl({ avatar: updated.sender_avatar }),
|
||||
};
|
||||
|
||||
this.realtime.broadcastToUser(userId, { type: 'notification:updated', notification: mappedUpdated });
|
||||
|
||||
return { success: true, notification: mappedUpdated };
|
||||
}
|
||||
|
||||
// ── Unified dispatcher (from services/notificationService.ts) ─────────────
|
||||
|
||||
async send(payload: NotificationPayload): Promise<void> {
|
||||
const { event, actorId, params, scope, targetId, inApp } = payload;
|
||||
|
||||
// Resolve recipients based on scope
|
||||
const recipients = this.resolveRecipients(scope, targetId, actorId);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
|
||||
if (!configEntry) {
|
||||
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
|
||||
if (readEnv().app.isDevelopment && actorId != null) {
|
||||
const devSender = this.db.get<{ username: string; avatar: string | null }>('SELECT username, avatar FROM users WHERE id = ?', actorId) ?? null;
|
||||
this.createNotificationForRecipient({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: actorId,
|
||||
sender_id: null,
|
||||
title_key: 'notif.dev.unknown_event.title',
|
||||
text_key: 'notif.dev.unknown_event.text',
|
||||
text_params: { event },
|
||||
}, actorId, devSender);
|
||||
}
|
||||
}
|
||||
const config = configEntry ?? FALLBACK_EVENT_CONFIG;
|
||||
const activeChannels = getActiveChannels();
|
||||
const channels = listChannels();
|
||||
const appUrl = getAppUrl();
|
||||
|
||||
// Build navigate target (used by email/webhook CTA and in-app navigate)
|
||||
const navigateTarget = inApp?.navigateTarget ?? config.navigateTarget(params);
|
||||
const fullLink = navigateTarget ? `${appUrl}${navigateTarget}` : undefined;
|
||||
|
||||
// Fetch sender info once for in-app WS payloads
|
||||
const sender = actorId
|
||||
? this.db.get<{ username: string; avatar: string | null }>('SELECT username, avatar FROM users WHERE id = ?', actorId) ?? null
|
||||
: null;
|
||||
|
||||
logDebug(`notificationService.send event=${event} scope=${scope} targetId=${targetId} recipients=${recipients.length} channels=inapp,${activeChannels.join(',')}`);
|
||||
|
||||
// Dispatch to each recipient in parallel
|
||||
await Promise.all(recipients.map(async (recipientId) => {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// ── In-app ──────────────────────────────────────────────────────────
|
||||
if (isEnabledForEvent(recipientId, event, 'inapp')) {
|
||||
const inAppType = inApp?.type ?? config.inAppType;
|
||||
let notifInput: NotificationInput;
|
||||
|
||||
if (inAppType === 'boolean' && inApp?.positiveCallback && inApp?.negativeCallback) {
|
||||
notifInput = {
|
||||
type: 'boolean',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
positive_text_key: inApp.positiveTextKey ?? 'notif.action.accept',
|
||||
negative_text_key: inApp.negativeTextKey ?? 'notif.action.decline',
|
||||
positive_callback: inApp.positiveCallback,
|
||||
negative_callback: inApp.negativeCallback,
|
||||
};
|
||||
} else if (inAppType === 'navigate' && navigateTarget) {
|
||||
notifInput = {
|
||||
type: 'navigate',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
navigate_text_key: config.navigateTextKey ?? 'notif.action.view',
|
||||
navigate_target: navigateTarget,
|
||||
};
|
||||
} else {
|
||||
notifInput = {
|
||||
type: 'simple',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
};
|
||||
}
|
||||
|
||||
promises.push(
|
||||
Promise.resolve().then(() => this.createNotificationForRecipient(notifInput, recipientId, sender ?? null))
|
||||
);
|
||||
}
|
||||
|
||||
// ── External channels (email, webhook, ntfy, plugin:*) ───────────────
|
||||
// One loop over the registry. The message is rendered once per recipient, in
|
||||
// their language, and handed to every channel that wants it — so a plugin
|
||||
// channel never touches i18n.
|
||||
const deliverable = channels.filter(ch => shouldSendToUser(ch, event, recipientId, activeChannels));
|
||||
if (deliverable.length > 0) {
|
||||
const lang = getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
const msg: ChannelMessage = {
|
||||
event,
|
||||
title,
|
||||
body,
|
||||
navigateTarget: navigateTarget ?? undefined,
|
||||
url: fullLink,
|
||||
tripName: params.trip,
|
||||
};
|
||||
for (const ch of deliverable) promises.push(ch.sendToUser(recipientId, msg));
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logError(`notificationService.send channel dispatch failed event=${event} recipient=${recipientId}: ${result.reason instanceof Error ? result.reason.message : result.reason}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// ── Admin-global copies (scope: admin) ───────────────────────────────
|
||||
// One send per channel, over the admin's own credentials, not per-recipient.
|
||||
// Always rendered in English — there is no single recipient to take a language from.
|
||||
if (scope === 'admin') {
|
||||
const globalChannels = channels.filter(
|
||||
ch => ch.sendGlobal && ch.supportsEvent(event) && isAdminGlobalChannel(ch.id) && getAdminGlobalPref(event, ch.id),
|
||||
);
|
||||
if (globalChannels.length > 0) {
|
||||
const { title, body } = getEventText('en', event, params);
|
||||
const msg: ChannelMessage = { event, title, body, navigateTarget: navigateTarget ?? undefined, url: fullLink };
|
||||
await Promise.all(
|
||||
globalChannels.map(ch =>
|
||||
ch.sendGlobal!(msg).catch((err: unknown) => {
|
||||
logError(`notificationService.send admin ${ch.id} failed event=${event}: ${err instanceof Error ? err.message : err}`);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { NotificationInput, NotificationRow, NotificationType, NotificationScope, NotificationResponse };
|
||||
|
||||
@@ -591,7 +591,7 @@ 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('../../services/notificationService').then(({ send }) => {
|
||||
import('../notifications/notifications.bridge').then(({ send }) => {
|
||||
const tripInfo = this.db.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
|
||||
send({
|
||||
event: 'packing_tagged',
|
||||
|
||||
@@ -135,8 +135,12 @@ the atlas fold — its nine imported symbols (`listVisitedCountries`,
|
||||
`listManuallyVisitedRegions`, `listBucketList`, `markCountryVisited`,
|
||||
`unmarkCountryVisited`, `markRegionVisited`, `unmarkRegionVisited`,
|
||||
`createBucketItem`, `deleteBucketItem`) became the injected `AtlasService`,
|
||||
the factory's 23rd constructor dep; the remaining factory import is the
|
||||
Wave-5 journeys domain).
|
||||
the factory's 23rd constructor dep; notificationService swapped in 2026-08
|
||||
with the notifications fold — its single imported symbol (`send as
|
||||
sendNotification`, the `sendPluginNotification` closure) became the injected
|
||||
`NotificationsService`, the factory's 24th constructor dep; the remaining
|
||||
factory domain imports are `weatherService` (`getWeather`) and the Wave-5
|
||||
journeys domain).
|
||||
|
||||
### Test impact (as landed)
|
||||
|
||||
@@ -155,7 +159,10 @@ Wave-5 journeys domain).
|
||||
collections fold (same sentinel behaviors — the status-tagged throws the
|
||||
factory's `mapCollectionError` maps); the atlasService path mock became
|
||||
`atlasStub` with the 2026-08 atlas fold (same sentinel behaviors, keyed by
|
||||
the service method names — `bucketList`, `markCountry`, …); a
|
||||
the service method names — `bucketList`, `markCountry`, …); the
|
||||
notificationService path mock became `notificationsStub` with the 2026-08
|
||||
notifications fold (the hoisted `notifySend` handle survived as the stub's
|
||||
`send`); a
|
||||
file-local shim keeps the
|
||||
historical `createRealRpcHost(id, granted)` call sites and supplies a default
|
||||
no-op router.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isUpdateConflict } from '../../../services/conflictResult';
|
||||
import { getWeather } from '../../../services/weatherService';
|
||||
import { BLOCKED_EXTENSIONS, filesDir } from '../../files/files.constants';
|
||||
import { joinTripAsMember } from '../../../services/tripMembership';
|
||||
import { send as sendNotification } from '../../../services/notificationService';
|
||||
import { NotificationsService } from '../../notifications/notifications.service';
|
||||
import { createLlmClient } from '../../llm-parse/llm-client.factory';
|
||||
import { readUserSettingDecrypted } from '../plugins.service';
|
||||
import { PluginOAuthService } from '../plugin-oauth.service';
|
||||
@@ -132,8 +132,8 @@ export interface PluginCallRouter {
|
||||
*
|
||||
* DI-native domain services (budget, exchange-rates, reservations, tags,
|
||||
* categories, todo, packing, day-notes, assignments, oauth, files, collab,
|
||||
* vacay, trips, places, collections, atlas, the LLM config resolver) and
|
||||
* the DatabaseService (all inline SQL + the trip-access helper) are
|
||||
* vacay, trips, places, collections, atlas, notifications, the LLM config
|
||||
* resolver) and the DatabaseService (all inline SQL + the trip-access helper) are
|
||||
* constructor-injected; legacy `services/*` domains stay plain function
|
||||
* imports until their own migration lands (DI-MIGRATION.md), at which point
|
||||
* each swaps one import for one injected service.
|
||||
@@ -164,6 +164,7 @@ export class PluginHostDepsFactory {
|
||||
private readonly places: PlacesService,
|
||||
private readonly collections: CollectionsService,
|
||||
private readonly atlas: AtlasService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -362,13 +363,13 @@ export class PluginHostDepsFactory {
|
||||
return { removed: true };
|
||||
},
|
||||
// --- Host-mediated notifications. Recipient resolution + channel fan-out +
|
||||
// per-user preferences are all owned by notificationService.send; the plugin
|
||||
// per-user preferences are all owned by NotificationsService.send; the plugin
|
||||
// supplies only the target (host-scoped by the router) + plain text. actorId is
|
||||
// null (no user sender), so the message body carries the plugin's content. ---
|
||||
canAccessTripForNotify: (tripId, userId) => !!this.db.canAccessTrip(tripId, userId),
|
||||
sendPluginNotification: async (_pluginId, input) => {
|
||||
if (!budgetFor(id).take('notify', Date.now())) throw new BadParams('daily notification budget exhausted (resets at UTC midnight)');
|
||||
await sendNotification({
|
||||
await this.notifications.send({
|
||||
event: 'plugin_notification',
|
||||
actorId: null,
|
||||
params: { title: input.title, body: input.body, ...(input.link ? { link: input.link } : {}) },
|
||||
|
||||
@@ -43,6 +43,7 @@ import { AuditModule } from '../audit/audit.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { CollectionsModule } from '../collections/collections.module';
|
||||
import { AtlasModule } from '../atlas/atlas.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
/**
|
||||
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
|
||||
@@ -54,7 +55,7 @@ import { AtlasModule } from '../atlas/atlas.module';
|
||||
@Module({
|
||||
// The DI-native domain services the plugin host wiring injects
|
||||
// (PluginHostDepsFactory); DatabaseModule is @Global, so not listed.
|
||||
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule, PlacesModule, CollectionsModule, AtlasModule],
|
||||
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule, PlacesModule, CollectionsModule, AtlasModule, NotificationsModule],
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
|
||||
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory],
|
||||
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
|
||||
|
||||
@@ -151,7 +151,7 @@ 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('../../services/notificationService')
|
||||
import('../notifications/notifications.bridge')
|
||||
.then(({ send }) => {
|
||||
try {
|
||||
const actor = this.db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', actorId);
|
||||
|
||||
@@ -1225,7 +1225,7 @@ export class TripsService {
|
||||
|
||||
/** 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('../../services/notificationService').then(({ send }) => {
|
||||
import('../notifications/notifications.bridge').then(({ send }) => {
|
||||
send({
|
||||
event: 'trip_invite',
|
||||
actorId: actor.id,
|
||||
|
||||
@@ -634,7 +634,7 @@ export class VacayService {
|
||||
} catch { /* websocket not available */ }
|
||||
|
||||
// Notify invited user
|
||||
import('../../services/notificationService').then(({ send }) => {
|
||||
import('../notifications/notifications.bridge').then(({ send }) => {
|
||||
send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -865,7 +865,7 @@ export class VacayService {
|
||||
this.realtime.broadcastToUser(ownerId, { type: 'vacay:share', from: { id: ownerId } }, socketId);
|
||||
} catch { /* websocket not available */ }
|
||||
|
||||
import('../../services/notificationService').then(({ send }) => {
|
||||
import('../notifications/notifications.bridge').then(({ send }) => {
|
||||
send({ event: 'vacay_share', actorId: ownerId, scope: 'user', targetId: targetUserId, params: { actor: ownerEmail } }).catch(() => {});
|
||||
});
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ function startTripReminders(): void {
|
||||
reminderTask = cron.schedule('0 9 * * *', async () => {
|
||||
try {
|
||||
const { db } = require('./db/database');
|
||||
const { send } = require('./services/notificationService');
|
||||
const { send } = require('./nest/notifications/notifications.bridge');
|
||||
|
||||
const trips = db.prepare(`
|
||||
SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
|
||||
@@ -214,7 +214,7 @@ function startTodoReminders(): void {
|
||||
const tz = readEnv().app.tz || 'UTC';
|
||||
todoReminderTask = cron.schedule('0 9 * * *', async () => {
|
||||
try {
|
||||
const { send } = require('./services/notificationService');
|
||||
const { send } = require('./nest/notifications/notifications.bridge');
|
||||
|
||||
// Select unchecked todos with a due date inside the lead window
|
||||
// that haven't been reminded in the last 24 hours. `due_date` is
|
||||
|
||||
@@ -13,7 +13,7 @@ import { resolveAuthToggles } from '../nest/auth/auth.bridge';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from './llmConfig';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { send as sendNotification } from './notificationService';
|
||||
import { send as sendNotification } from '../nest/notifications/notifications.bridge';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from '../nest/permissions/permissions.bridge';
|
||||
import { deleteUserCompletely } from './userCleanupService';
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
import { db } from '../db/database';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import { getAction } from './inAppNotificationActions';
|
||||
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
|
||||
|
||||
// SQLite's CURRENT_TIMESTAMP is UTC but the string ('YYYY-MM-DD HH:MM:SS') has
|
||||
// no 'T'/'Z', so `new Date(...)` parses it as LOCAL time. Normalize to ISO-UTC
|
||||
// so the client renders notification times in the viewer's own timezone (#1149).
|
||||
function toUtcIso(ts: string): string {
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||
type NotificationResponse = 'positive' | 'negative';
|
||||
|
||||
interface BaseNotificationInput {
|
||||
type: NotificationType;
|
||||
scope: NotificationScope;
|
||||
target: number;
|
||||
sender_id: number | null;
|
||||
event_type?: NotifEventType;
|
||||
title_key: string;
|
||||
title_params?: Record<string, string>;
|
||||
text_key: string;
|
||||
text_params?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SimpleNotificationInput extends BaseNotificationInput {
|
||||
type: 'simple';
|
||||
}
|
||||
|
||||
interface BooleanNotificationInput extends BaseNotificationInput {
|
||||
type: 'boolean';
|
||||
positive_text_key: string;
|
||||
negative_text_key: string;
|
||||
positive_callback: { action: string; payload: Record<string, unknown> };
|
||||
negative_callback: { action: string; payload: Record<string, unknown> };
|
||||
}
|
||||
|
||||
interface NavigateNotificationInput extends BaseNotificationInput {
|
||||
type: 'navigate';
|
||||
navigate_text_key: string;
|
||||
navigate_target: string;
|
||||
}
|
||||
|
||||
type NotificationInput = SimpleNotificationInput | BooleanNotificationInput | NavigateNotificationInput;
|
||||
|
||||
interface NotificationRow {
|
||||
id: number;
|
||||
type: NotificationType;
|
||||
scope: NotificationScope;
|
||||
target: number;
|
||||
sender_id: number | null;
|
||||
sender_username?: string | null;
|
||||
sender_avatar?: string | null;
|
||||
recipient_id: number;
|
||||
title_key: string;
|
||||
title_params: string;
|
||||
text_key: string;
|
||||
text_params: string;
|
||||
positive_text_key: string | null;
|
||||
negative_text_key: string | null;
|
||||
positive_callback: string | null;
|
||||
negative_callback: string | null;
|
||||
response: NotificationResponse | null;
|
||||
navigate_text_key: string | null;
|
||||
navigate_target: string | null;
|
||||
is_read: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
|
||||
let userIds: number[] = [];
|
||||
|
||||
// Guests (#1362) are trip members for assignment purposes but have no inbox/email,
|
||||
// so they must never be resolved as notification recipients on any scope. This is the
|
||||
// single chokepoint for in-app/email/webhook/ntfy, so filtering here covers all channels.
|
||||
if (scope === 'trip') {
|
||||
const owner = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(target) as { user_id: number } | undefined;
|
||||
const members = db.prepare('SELECT m.user_id FROM trip_members m JOIN users u ON u.id = m.user_id WHERE m.trip_id = ? AND COALESCE(u.is_guest, 0) = 0').all(target) as { user_id: number }[];
|
||||
const ids = new Set<number>();
|
||||
if (owner) ids.add(owner.user_id);
|
||||
for (const m of members) ids.add(m.user_id);
|
||||
userIds = Array.from(ids);
|
||||
} else if (scope === 'user') {
|
||||
// A guest can be a todo assignee (scope='user'); never notify them.
|
||||
const u = db.prepare('SELECT is_guest FROM users WHERE id = ?').get(target) as { is_guest?: number } | undefined;
|
||||
userIds = u && u.is_guest ? [] : [target];
|
||||
} else if (scope === 'admin') {
|
||||
const admins = db.prepare("SELECT id FROM users WHERE role = ? AND COALESCE(is_guest, 0) = 0").all('admin') as { id: number }[];
|
||||
userIds = admins.map(a => a.id);
|
||||
}
|
||||
|
||||
// Only exclude sender for group scopes (trip/admin) — for user scope, the target is explicit
|
||||
if (excludeUserId != null && scope !== 'user') {
|
||||
userIds = userIds.filter(id => id !== excludeUserId);
|
||||
}
|
||||
|
||||
return userIds;
|
||||
}
|
||||
|
||||
function createNotification(input: NotificationInput): number[] {
|
||||
const recipients = resolveRecipients(input.scope, input.target, input.sender_id);
|
||||
if (recipients.length === 0) return [];
|
||||
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
const textParams = JSON.stringify(input.text_params ?? {});
|
||||
|
||||
// Track inserted id → recipientId pairs (some recipients may be skipped by pref check)
|
||||
const insertedPairs: Array<{ id: number; recipientId: number }> = [];
|
||||
|
||||
const insert = db.transaction(() => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback,
|
||||
navigate_text_key, navigate_target
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const recipientId of recipients) {
|
||||
// Check per-user in-app preference if an event_type is provided
|
||||
if (input.event_type && !isEnabledForEvent(recipientId, input.event_type, 'inapp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let positiveTextKey: string | null = null;
|
||||
let negativeTextKey: string | null = null;
|
||||
let positiveCallback: string | null = null;
|
||||
let negativeCallback: string | null = null;
|
||||
let navigateTextKey: string | null = null;
|
||||
let navigateTarget: string | null = null;
|
||||
|
||||
if (input.type === 'boolean') {
|
||||
positiveTextKey = input.positive_text_key;
|
||||
negativeTextKey = input.negative_text_key;
|
||||
positiveCallback = JSON.stringify(input.positive_callback);
|
||||
negativeCallback = JSON.stringify(input.negative_callback);
|
||||
} else if (input.type === 'navigate') {
|
||||
navigateTextKey = input.navigate_text_key;
|
||||
navigateTarget = input.navigate_target;
|
||||
}
|
||||
|
||||
const result = stmt.run(
|
||||
input.type, input.scope, input.target, input.sender_id, recipientId,
|
||||
input.title_key, titleParams, input.text_key, textParams,
|
||||
positiveTextKey, negativeTextKey, positiveCallback, negativeCallback,
|
||||
navigateTextKey, navigateTarget
|
||||
);
|
||||
|
||||
insertedPairs.push({ id: result.lastInsertRowid as number, recipientId });
|
||||
}
|
||||
});
|
||||
|
||||
insert();
|
||||
|
||||
// Fetch sender info once for WS payloads
|
||||
const sender = input.sender_id
|
||||
? (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(input.sender_id) as { username: string; avatar: string | null } | undefined)
|
||||
: null;
|
||||
|
||||
// Broadcast to each recipient
|
||||
for (const { id: notificationId, recipientId } of insertedPairs) {
|
||||
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow;
|
||||
if (!row) continue;
|
||||
|
||||
broadcastToUser(recipientId, {
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: avatarUrl({ avatar: sender?.avatar }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return insertedPairs.map(p => p.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a single in-app notification for one pre-resolved recipient and broadcast via WebSocket.
|
||||
* Used by notificationService.send() which handles recipient resolution externally.
|
||||
*/
|
||||
export function createNotificationForRecipient(
|
||||
input: NotificationInput,
|
||||
recipientId: number,
|
||||
sender: { username: string; avatar: string | null } | null
|
||||
): number | null {
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
const textParams = JSON.stringify(input.text_params ?? {});
|
||||
|
||||
let positiveTextKey: string | null = null;
|
||||
let negativeTextKey: string | null = null;
|
||||
let positiveCallback: string | null = null;
|
||||
let negativeCallback: string | null = null;
|
||||
let navigateTextKey: string | null = null;
|
||||
let navigateTarget: string | null = null;
|
||||
|
||||
if (input.type === 'boolean') {
|
||||
positiveTextKey = input.positive_text_key;
|
||||
negativeTextKey = input.negative_text_key;
|
||||
positiveCallback = JSON.stringify(input.positive_callback);
|
||||
negativeCallback = JSON.stringify(input.negative_callback);
|
||||
} else if (input.type === 'navigate') {
|
||||
navigateTextKey = input.navigate_text_key;
|
||||
navigateTarget = input.navigate_target;
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback,
|
||||
navigate_text_key, navigate_target
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
input.type, input.scope, input.target, input.sender_id, recipientId,
|
||||
input.title_key, titleParams, input.text_key, textParams,
|
||||
positiveTextKey, negativeTextKey, positiveCallback, negativeCallback,
|
||||
navigateTextKey, navigateTarget
|
||||
);
|
||||
|
||||
const notificationId = result.lastInsertRowid as number;
|
||||
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow | undefined;
|
||||
if (!row) return null;
|
||||
|
||||
broadcastToUser(recipientId, {
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
created_at: toUtcIso(row.created_at),
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: avatarUrl({ avatar: sender?.avatar }),
|
||||
},
|
||||
});
|
||||
|
||||
return notificationId;
|
||||
}
|
||||
|
||||
function getNotifications(
|
||||
userId: number,
|
||||
options: { limit?: number; offset?: number; unreadOnly?: boolean } = {}
|
||||
): { notifications: NotificationRow[]; total: number; unread_count: number } {
|
||||
const limit = Math.min(options.limit ?? 20, 50);
|
||||
const offset = options.offset ?? 0;
|
||||
const unreadOnly = options.unreadOnly ?? false;
|
||||
|
||||
const whereAliased = unreadOnly ? 'WHERE n.recipient_id = ? AND n.is_read = 0' : 'WHERE n.recipient_id = ?';
|
||||
const wherePlain = unreadOnly ? 'WHERE recipient_id = ? AND is_read = 0' : 'WHERE recipient_id = ?';
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
${whereAliased}
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(userId, limit, offset) as NotificationRow[];
|
||||
|
||||
const { total } = db.prepare(`SELECT COUNT(*) as total FROM notifications ${wherePlain}`).get(userId) as { total: number };
|
||||
const { unread_count } = db.prepare('SELECT COUNT(*) as unread_count FROM notifications WHERE recipient_id = ? AND is_read = 0').get(userId) as { unread_count: number };
|
||||
|
||||
const mapped = rows.map(r => ({
|
||||
...r,
|
||||
created_at: toUtcIso(r.created_at),
|
||||
sender_avatar: avatarUrl({ avatar: r.sender_avatar }),
|
||||
}));
|
||||
|
||||
return { notifications: mapped, total, unread_count };
|
||||
}
|
||||
|
||||
function getUnreadCount(userId: number): number {
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM notifications WHERE recipient_id = ? AND is_read = 0').get(userId) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
function markRead(notificationId: number, userId: number): boolean {
|
||||
const result = db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND recipient_id = ?').run(notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function markUnread(notificationId: number, userId: number): boolean {
|
||||
const result = db.prepare('UPDATE notifications SET is_read = 0 WHERE id = ? AND recipient_id = ?').run(notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function markAllRead(userId: number): number {
|
||||
const result = db.prepare('UPDATE notifications SET is_read = 1 WHERE recipient_id = ? AND is_read = 0').run(userId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
function deleteNotification(notificationId: number, userId: number): boolean {
|
||||
const result = db.prepare('DELETE FROM notifications WHERE id = ? AND recipient_id = ?').run(notificationId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function deleteAll(userId: number): number {
|
||||
const result = db.prepare('DELETE FROM notifications WHERE recipient_id = ?').run(userId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
async function respondToBoolean(
|
||||
notificationId: number,
|
||||
userId: number,
|
||||
response: NotificationResponse
|
||||
): Promise<{ success: boolean; error?: string; notification?: NotificationRow }> {
|
||||
const notification = db.prepare('SELECT * FROM notifications WHERE id = ? AND recipient_id = ?').get(notificationId, userId) as NotificationRow | undefined;
|
||||
|
||||
if (!notification) return { success: false, error: 'Notification not found' };
|
||||
if (notification.type !== 'boolean') return { success: false, error: 'Not a boolean notification' };
|
||||
if (notification.response !== null) return { success: false, error: 'Already responded' };
|
||||
|
||||
const callbackJson = response === 'positive' ? notification.positive_callback : notification.negative_callback;
|
||||
if (!callbackJson) return { success: false, error: 'No callback defined' };
|
||||
|
||||
let callback: { action: string; payload: Record<string, unknown> };
|
||||
try {
|
||||
callback = JSON.parse(callbackJson);
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid callback format' };
|
||||
}
|
||||
|
||||
const handler = getAction(callback.action);
|
||||
if (!handler) return { success: false, error: `Unknown action: ${callback.action}` };
|
||||
|
||||
try {
|
||||
await handler(callback.payload, userId);
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Action failed' };
|
||||
}
|
||||
|
||||
// Atomic update — only updates if response is still NULL (prevents double-response)
|
||||
const result = db.prepare(
|
||||
'UPDATE notifications SET response = ?, is_read = 1 WHERE id = ? AND recipient_id = ? AND response IS NULL'
|
||||
).run(response, notificationId, userId);
|
||||
|
||||
if (result.changes === 0) return { success: false, error: 'Already responded' };
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
WHERE n.id = ?
|
||||
`).get(notificationId) as NotificationRow;
|
||||
|
||||
const mappedUpdated = {
|
||||
...updated,
|
||||
sender_avatar: avatarUrl({ avatar: updated.sender_avatar }),
|
||||
};
|
||||
|
||||
broadcastToUser(userId, { type: 'notification:updated', notification: mappedUpdated });
|
||||
|
||||
return { success: true, notification: mappedUpdated };
|
||||
}
|
||||
|
||||
export {
|
||||
createNotification,
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markRead,
|
||||
markUnread,
|
||||
markAllRead,
|
||||
deleteNotification,
|
||||
deleteAll,
|
||||
respondToBoolean,
|
||||
};
|
||||
|
||||
export type { NotificationInput, NotificationRow, NotificationType, NotificationScope, NotificationResponse };
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
SyncAlbumResult,
|
||||
AssetInfo
|
||||
} from './helpersService';
|
||||
import { send as sendNotification } from '../notificationService';
|
||||
import { send as sendNotification } from '../../nest/notifications/notifications.bridge';
|
||||
|
||||
const SYNOLOGY_PROVIDER = 'synologyphotos';
|
||||
// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo).
|
||||
@@ -374,13 +374,15 @@ export async function updateSynologySettings(userId: number, synologyUrl: string
|
||||
const sessionCleared = urlChanged || userChanged;
|
||||
if (sessionCleared) {
|
||||
_clearSynologySession(userId);
|
||||
// Fire-and-forget like unifiedService's photos_shared send — without the
|
||||
// .catch an in-app insert failure became an unhandled rejection.
|
||||
sendNotification({
|
||||
event: 'synology_session_cleared',
|
||||
actorId: null,
|
||||
params: {},
|
||||
scope: 'user',
|
||||
targetId: userId,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db, canAccessTrip } from '../../db/database';
|
||||
import { send } from '../notificationService';
|
||||
import { send } from '../../nest/notifications/notifications.bridge';
|
||||
import { broadcast } from '../../websocket';
|
||||
import {
|
||||
ServiceResult,
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
import { readEnv } from '../app-config';
|
||||
import { db } from '../db/database';
|
||||
import { logDebug, logError } from '../nest/audit/audit-log.logger';
|
||||
import {
|
||||
getActiveChannels,
|
||||
isEnabledForEvent,
|
||||
getAdminGlobalPref,
|
||||
isAdminGlobalChannel,
|
||||
ADMIN_SCOPED_EVENTS,
|
||||
type NotifEventType,
|
||||
} from './notificationPreferencesService';
|
||||
import { getEventText, getUserLanguage } from './notifications';
|
||||
import { getAppUrl } from '../app-config';
|
||||
import { listChannels, type ChannelMessage, type ExternalChannel } from './notifications/channelRegistry';
|
||||
import {
|
||||
resolveRecipients,
|
||||
createNotificationForRecipient,
|
||||
type NotificationInput,
|
||||
} from './inAppNotifications';
|
||||
|
||||
// ── Event config map ───────────────────────────────────────────────────────
|
||||
|
||||
interface EventNotifConfig {
|
||||
inAppType: 'simple' | 'navigate';
|
||||
titleKey: string;
|
||||
textKey: string;
|
||||
navigateTextKey?: string;
|
||||
navigateTarget: (params: Record<string, string>) => string | null;
|
||||
}
|
||||
|
||||
const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
|
||||
// ── Dev-only test events ──────────────────────────────────────────────────
|
||||
test_simple: {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.simple.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
test_boolean: {
|
||||
inAppType: 'simple', // overridden by inApp.type at call site
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.boolean.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
test_navigate: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.navigate.text',
|
||||
navigateTextKey: 'notif.action.view',
|
||||
navigateTarget: () => '/dashboard',
|
||||
},
|
||||
// ── Plugin-mediated notification (#plugins) — raw title/body carried as params,
|
||||
// rendered by the passthrough keys. The plugin never picks a recipient directly;
|
||||
// the host forces scope/target to the acting user or a trip they belong to. ──────
|
||||
plugin_notification: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.plugin.title',
|
||||
textKey: 'notif.plugin.text',
|
||||
navigateTextKey: 'notif.action.view',
|
||||
navigateTarget: p => p.link || null,
|
||||
},
|
||||
// ── Production events ─────────────────────────────────────────────────────
|
||||
trip_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.trip_invite.title',
|
||||
textKey: 'notif.trip_invite.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
booking_change: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.booking_change.title',
|
||||
textKey: 'notif.booking_change.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
trip_reminder: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.trip_reminder.title',
|
||||
textKey: 'notif.trip_reminder.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
todo_due: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.todo_due.title',
|
||||
textKey: 'notif.todo_due.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
vacay_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.vacay_invite.title',
|
||||
textKey: 'notif.vacay_invite.text',
|
||||
navigateTextKey: 'notif.action.view_vacay',
|
||||
navigateTarget: p => (p.planId ? `/vacay/${p.planId}` : null),
|
||||
},
|
||||
vacay_share: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.vacay_share.title',
|
||||
textKey: 'notif.vacay_share.text',
|
||||
navigateTextKey: 'notif.action.view_vacay',
|
||||
navigateTarget: () => '/vacay',
|
||||
},
|
||||
collection_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.collection_invite.title',
|
||||
textKey: 'notif.collection_invite.text',
|
||||
navigateTextKey: 'notif.action.view_collection',
|
||||
navigateTarget: p => (p.collectionId ? `/collections/${p.collectionId}` : '/collections'),
|
||||
},
|
||||
photos_shared: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.photos_shared.title',
|
||||
textKey: 'notif.photos_shared.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
collab_message: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.collab_message.title',
|
||||
textKey: 'notif.collab_message.text',
|
||||
navigateTextKey: 'notif.action.view_collab',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
packing_tagged: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.packing_tagged.title',
|
||||
textKey: 'notif.packing_tagged.text',
|
||||
navigateTextKey: 'notif.action.view_packing',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
version_available: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.version_available.title',
|
||||
textKey: 'notif.version_available.text',
|
||||
navigateTextKey: 'notif.action.view_admin',
|
||||
navigateTarget: () => '/admin',
|
||||
},
|
||||
synology_session_cleared: {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notifications.synologySessionCleared.title',
|
||||
textKey: 'notifications.synologySessionCleared.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Fallback config for unknown event types ────────────────────────────────
|
||||
|
||||
const FALLBACK_EVENT_CONFIG: EventNotifConfig = {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notif.generic.title',
|
||||
textKey: 'notif.generic.text',
|
||||
navigateTarget: () => null,
|
||||
};
|
||||
|
||||
// ── Unified send() API ─────────────────────────────────────────────────────
|
||||
|
||||
export interface NotificationPayload {
|
||||
event: NotifEventType;
|
||||
actorId: number | null;
|
||||
params: Record<string, string>;
|
||||
scope: 'trip' | 'user' | 'admin';
|
||||
targetId: number; // tripId for trip scope, userId for user scope, 0 for admin
|
||||
/** Optional in-app overrides (e.g. boolean type with callbacks) */
|
||||
inApp?: {
|
||||
type?: 'simple' | 'boolean' | 'navigate';
|
||||
positiveTextKey?: string;
|
||||
negativeTextKey?: string;
|
||||
positiveCallback?: { action: string; payload: Record<string, unknown> };
|
||||
negativeCallback?: { action: string; payload: Record<string, unknown> };
|
||||
navigateTarget?: string; // override the auto-generated navigate target
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Should this channel deliver this event to this recipient?
|
||||
*
|
||||
* Encodes, unchanged, the gating the four hand-written dispatch blocks used to do:
|
||||
* - admin-scoped events (version_available) reach recipients only over a channel
|
||||
* that bypasses the `notification_channels` toggle (email), gated by the admin
|
||||
* global pref rather than the per-user opt-out. Their webhook/ntfy copies go out
|
||||
* once, globally, via sendGlobal() below — not per recipient.
|
||||
* - everything else: the admin enabled the channel, the user didn't opt out of this
|
||||
* event on it, and the user has credentials for it.
|
||||
*/
|
||||
function shouldSendToUser(
|
||||
channel: ExternalChannel,
|
||||
event: NotifEventType,
|
||||
recipientId: number,
|
||||
activeChannels: string[],
|
||||
): boolean {
|
||||
if (!channel.supportsEvent(event)) return false;
|
||||
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
|
||||
|
||||
if (ADMIN_SCOPED_EVENTS.has(event)) {
|
||||
if (!channel.bypassesActiveToggleForAdminEvents) return false;
|
||||
if (!isAdminGlobalChannel(channel.id)) return false;
|
||||
if (!getAdminGlobalPref(event, channel.id)) return false;
|
||||
} else {
|
||||
// A built-in needs the admin's explicit switch; a plugin channel is on because the
|
||||
// admin enabled the plugin — that IS the opt-in (see getActiveChannels).
|
||||
if (channel.source === 'builtin' && !activeChannels.includes(channel.id)) return false;
|
||||
if (!isEnabledForEvent(recipientId, event, channel.id)) return false;
|
||||
}
|
||||
|
||||
return channel.isConfiguredFor(recipientId);
|
||||
}
|
||||
|
||||
export async function send(payload: NotificationPayload): Promise<void> {
|
||||
const { event, actorId, params, scope, targetId, inApp } = payload;
|
||||
|
||||
// Resolve recipients based on scope
|
||||
const recipients = resolveRecipients(scope, targetId, actorId);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
|
||||
if (!configEntry) {
|
||||
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
|
||||
if (readEnv().app.isDevelopment && actorId != null) {
|
||||
const devSender = (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null;
|
||||
createNotificationForRecipient({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: actorId,
|
||||
sender_id: null,
|
||||
title_key: 'notif.dev.unknown_event.title',
|
||||
text_key: 'notif.dev.unknown_event.text',
|
||||
text_params: { event },
|
||||
}, actorId, devSender);
|
||||
}
|
||||
}
|
||||
const config = configEntry ?? FALLBACK_EVENT_CONFIG;
|
||||
const activeChannels = getActiveChannels();
|
||||
const channels = listChannels();
|
||||
const appUrl = getAppUrl();
|
||||
|
||||
// Build navigate target (used by email/webhook CTA and in-app navigate)
|
||||
const navigateTarget = inApp?.navigateTarget ?? config.navigateTarget(params);
|
||||
const fullLink = navigateTarget ? `${appUrl}${navigateTarget}` : undefined;
|
||||
|
||||
// Fetch sender info once for in-app WS payloads
|
||||
const sender = actorId
|
||||
? (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null
|
||||
: null;
|
||||
|
||||
logDebug(`notificationService.send event=${event} scope=${scope} targetId=${targetId} recipients=${recipients.length} channels=inapp,${activeChannels.join(',')}`);
|
||||
|
||||
// Dispatch to each recipient in parallel
|
||||
await Promise.all(recipients.map(async (recipientId) => {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// ── In-app ──────────────────────────────────────────────────────────
|
||||
if (isEnabledForEvent(recipientId, event, 'inapp')) {
|
||||
const inAppType = inApp?.type ?? config.inAppType;
|
||||
let notifInput: NotificationInput;
|
||||
|
||||
if (inAppType === 'boolean' && inApp?.positiveCallback && inApp?.negativeCallback) {
|
||||
notifInput = {
|
||||
type: 'boolean',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
positive_text_key: inApp.positiveTextKey ?? 'notif.action.accept',
|
||||
negative_text_key: inApp.negativeTextKey ?? 'notif.action.decline',
|
||||
positive_callback: inApp.positiveCallback,
|
||||
negative_callback: inApp.negativeCallback,
|
||||
};
|
||||
} else if (inAppType === 'navigate' && navigateTarget) {
|
||||
notifInput = {
|
||||
type: 'navigate',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
navigate_text_key: config.navigateTextKey ?? 'notif.action.view',
|
||||
navigate_target: navigateTarget,
|
||||
};
|
||||
} else {
|
||||
notifInput = {
|
||||
type: 'simple',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
};
|
||||
}
|
||||
|
||||
promises.push(
|
||||
Promise.resolve().then(() => createNotificationForRecipient(notifInput, recipientId, sender ?? null))
|
||||
);
|
||||
}
|
||||
|
||||
// ── External channels (email, webhook, ntfy, plugin:*) ───────────────
|
||||
// One loop over the registry. The message is rendered once per recipient, in
|
||||
// their language, and handed to every channel that wants it — so a plugin
|
||||
// channel never touches i18n.
|
||||
const deliverable = channels.filter(ch => shouldSendToUser(ch, event, recipientId, activeChannels));
|
||||
if (deliverable.length > 0) {
|
||||
const lang = getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
const msg: ChannelMessage = {
|
||||
event,
|
||||
title,
|
||||
body,
|
||||
navigateTarget: navigateTarget ?? undefined,
|
||||
url: fullLink,
|
||||
tripName: params.trip,
|
||||
};
|
||||
for (const ch of deliverable) promises.push(ch.sendToUser(recipientId, msg));
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logError(`notificationService.send channel dispatch failed event=${event} recipient=${recipientId}: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// ── Admin-global copies (scope: admin) ───────────────────────────────
|
||||
// One send per channel, over the admin's own credentials, not per-recipient.
|
||||
// Always rendered in English — there is no single recipient to take a language from.
|
||||
if (scope === 'admin') {
|
||||
const globalChannels = channels.filter(
|
||||
ch => ch.sendGlobal && ch.supportsEvent(event) && isAdminGlobalChannel(ch.id) && getAdminGlobalPref(event, ch.id),
|
||||
);
|
||||
if (globalChannels.length > 0) {
|
||||
const { title, body } = getEventText('en', event, params);
|
||||
const msg: ChannelMessage = { event, title, body, navigateTarget: navigateTarget ?? undefined, url: fullLink };
|
||||
await Promise.all(
|
||||
globalChannels.map(ch =>
|
||||
ch.sendGlobal!(msg).catch((err: unknown) => {
|
||||
logError(`notificationService.send admin ${ch.id} failed event=${event}: ${err instanceof Error ? err.message : err}`);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: (
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
|
||||
vi.mock('../../src/mcp', () => ({ invalidateMcpSessions: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationPreferencesService', () => ({ getPreferencesMatrix: vi.fn(() => ({})), setAdminPreferences: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
const { adminSvc } = vi.hoisted(() => ({
|
||||
adminSvc: { listUsers: vi.fn(), createUser: vi.fn(), updatePlacesPhotos: vi.fn() },
|
||||
|
||||
@@ -58,7 +58,7 @@ vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock('../../src/db/database', () => ({
|
||||
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Notifications module e2e — exercises the migrated /api/notifications endpoints
|
||||
* through the real JwtAuthGuard against a temp SQLite db. The notification
|
||||
* services are mocked; this focuses on auth, the inline admin gate on
|
||||
* /test-smtp, routing (the /in-app/all ordering trap) and status/body shapes.
|
||||
* through the real JwtAuthGuard against a temp SQLite db. NotificationsService
|
||||
* runs its real (DI-native) in-app SQL — notifications DDL below; the channel
|
||||
* transports and the preference matrix (still plain services/* modules) are
|
||||
* mocked. Focuses on auth, the inline admin gate on /test-smtp, routing (the
|
||||
* /in-app/all ordering trap) and status/body shapes.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -17,18 +19,40 @@ const { db } = vi.hoisted(() => {
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
|
||||
avatar TEXT);`);
|
||||
// NotificationsService runs its real SQL (DI-native since the notifications
|
||||
// fold) — full notifications column set for listInApp/markRead/deleteAll.
|
||||
tmp.exec(`CREATE TABLE notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')),
|
||||
scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')),
|
||||
target INTEGER NOT NULL,
|
||||
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title_key TEXT NOT NULL,
|
||||
title_params TEXT DEFAULT '{}',
|
||||
text_key TEXT NOT NULL,
|
||||
text_params TEXT DEFAULT '{}',
|
||||
positive_text_key TEXT,
|
||||
negative_text_key TEXT,
|
||||
positive_callback TEXT,
|
||||
negative_callback TEXT,
|
||||
response TEXT CHECK(response IN ('positive', 'negative')),
|
||||
navigate_text_key TEXT,
|
||||
navigate_target TEXT,
|
||||
is_read INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { prefs, inapp, channels } = vi.hoisted(() => ({
|
||||
// The preference matrix and the channel transports stay plain services/*
|
||||
// modules after the fold — mock them; the in-app store is real SQL now.
|
||||
const { prefs, channels } = vi.hoisted(() => ({
|
||||
prefs: { getPreferencesMatrix: vi.fn(), setPreferences: vi.fn() },
|
||||
inapp: {
|
||||
getNotifications: vi.fn(), getUnreadCount: vi.fn(), markRead: vi.fn(), markUnread: vi.fn(),
|
||||
markAllRead: vi.fn(), deleteNotification: vi.fn(), deleteAll: vi.fn(), respondToBoolean: vi.fn(),
|
||||
},
|
||||
channels: {
|
||||
testSmtp: vi.fn(), testWebhook: vi.fn(), testNtfy: vi.fn(),
|
||||
getUserWebhookUrl: vi.fn(), getAdminWebhookUrl: vi.fn(),
|
||||
@@ -36,18 +60,29 @@ const { prefs, inapp, channels } = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/notificationPreferencesService', () => prefs);
|
||||
vi.mock('../../src/services/inAppNotifications', () => inapp);
|
||||
vi.mock('../../src/services/notifications', () => channels);
|
||||
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { NotificationsModule } from '../../src/nest/notifications/notifications.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
function seedNotification(recipientId: number, overrides: { is_read?: number } = {}): number {
|
||||
const r = db.prepare(
|
||||
`INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read)
|
||||
VALUES ('simple', 'user', ?, ?, 'notif.test.title', 'notif.test.text', ?)`
|
||||
).run(recipientId, recipientId, overrides.is_read ?? 0);
|
||||
return r.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [NotificationsModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [DatabaseModule, RealtimeModule, NotificationsModule],
|
||||
}).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
@@ -61,8 +96,6 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
prefs.getPreferencesMatrix.mockReturnValue({ preferences: {}, available_channels: {}, event_types: [], implemented_combos: {} });
|
||||
inapp.getUnreadCount.mockReturnValue(2);
|
||||
inapp.deleteAll.mockReturnValue(4);
|
||||
channels.testSmtp.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
@@ -94,18 +127,27 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.body).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('200 unread-count', async () => {
|
||||
it('200 unread-count from the real notifications table', async () => {
|
||||
db.prepare('DELETE FROM notifications').run();
|
||||
seedNotification(2);
|
||||
seedNotification(2);
|
||||
seedNotification(2, { is_read: 1 });
|
||||
const res = await request(server).get('/api/notifications/in-app/unread-count').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ count: 2 });
|
||||
});
|
||||
|
||||
it('DELETE /in-app/all hits deleteAll, not deleteNotification', async () => {
|
||||
it('DELETE /in-app/all hits deleteAll, not the /:id handler', async () => {
|
||||
db.prepare('DELETE FROM notifications').run();
|
||||
for (let i = 0; i < 4; i++) seedNotification(2);
|
||||
const other = seedNotification(1);
|
||||
const res = await request(server).delete('/api/notifications/in-app/all').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, count: 4 });
|
||||
expect(inapp.deleteAll).toHaveBeenCalledWith(2);
|
||||
expect(inapp.deleteNotification).not.toHaveBeenCalled();
|
||||
// Only user 2's rows are gone — the static route deleted per-recipient, it
|
||||
// did not fall through to the /:id param handler (which would 400 on 'all').
|
||||
const rows = db.prepare('SELECT id FROM notifications').all() as { id: number }[];
|
||||
expect(rows.map(r => r.id)).toEqual([other]);
|
||||
});
|
||||
|
||||
it('400 on a non-numeric in-app id', async () => {
|
||||
|
||||
@@ -110,7 +110,7 @@ vi.mock('../../src/db/database', () => ({
|
||||
reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ vi.mock('../../src/db/database', () => ({
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
const { notificationSend } = vi.hoisted(() => ({ notificationSend: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: notificationSend }));
|
||||
vi.mock('../../src/nest/notifications/notifications.bridge', () => ({ send: notificationSend }));
|
||||
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
// The audit domain is DI-native now: writeAudit runs for real against the temp
|
||||
// db's audit_log table; only the file logger is silenced.
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
|
||||
|
||||
@@ -69,7 +69,7 @@ const { db } = vi.hoisted(() => {
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
|
||||
@@ -22,6 +22,8 @@ import { DaysMcp } from '../../src/nest/days/days.mcp';
|
||||
import { DaysService } from '../../src/nest/days/days.service';
|
||||
import { MapsMcp } from '../../src/nest/maps/maps.mcp';
|
||||
import { MapsService } from '../../src/nest/maps/maps.service';
|
||||
import { NotificationsMcp } from '../../src/nest/notifications/notifications.mcp';
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
import { PackingMcp } from '../../src/nest/packing/packing.mcp';
|
||||
import { PackingService } from '../../src/nest/packing/packing.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
@@ -100,6 +102,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService), dbService, authService),
|
||||
new TransitMcp(new TransitService(), daysService, reservationsService, dbService, authService),
|
||||
new AtlasMcp(new AtlasService(dbService)),
|
||||
new NotificationsMcp(new NotificationsService(dbService, realtimeService), authService),
|
||||
],
|
||||
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { TripsService } from '../../src/nest/trips/trips.service';
|
||||
import { PlacesService } from '../../src/nest/places/places.service';
|
||||
import { CollectionsService } from '../../src/nest/collections/collections.service';
|
||||
import { AtlasService } from '../../src/nest/atlas/atlas.service';
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
import { MapsService } from '../../src/nest/maps/maps.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
@@ -72,6 +73,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto
|
||||
places,
|
||||
new CollectionsService(dbs, permissions, realtime),
|
||||
new AtlasService(dbs),
|
||||
new NotificationsService(dbs, realtime),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,10 +74,10 @@ beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
// Warm the notificationService module: notifyCollab does a fire-and-forget
|
||||
// Warm the notifications bridge module: notifyCollab does a fire-and-forget
|
||||
// dynamic import of it, and a cold load can otherwise race the worker
|
||||
// teardown ("Cannot load ... after the environment was torn down").
|
||||
await import('../../src/services/notificationService');
|
||||
await import('../../src/nest/notifications/notifications.bridge');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -58,10 +58,10 @@ beforeAll(async () => {
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
// Warm the notificationService module: notifyTagged does a fire-and-forget
|
||||
// Warm the notifications bridge module: notifyTagged does a fire-and-forget
|
||||
// dynamic import of it, and a cold load can otherwise race the worker
|
||||
// teardown ("Cannot load ... after the environment was torn down").
|
||||
await import('../../src/services/notificationService');
|
||||
await import('../../src/nest/notifications/notifications.bridge');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -67,10 +67,10 @@ beforeAll(async () => {
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
// Warm the notificationService module: notifyInvite does a fire-and-forget
|
||||
// Warm the notifications bridge module: notifyInvite does a fire-and-forget
|
||||
// dynamic import of it, and a cold load can otherwise race the worker
|
||||
// teardown ("Cannot load ... after the environment was torn down").
|
||||
await import('../../src/services/notificationService');
|
||||
await import('../../src/nest/notifications/notifications.bridge');
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
|
||||
@@ -75,10 +75,10 @@ beforeAll(async () => {
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
// Warm the notificationService module: sendInvite/shareCalendar do a
|
||||
// Warm the notifications bridge module: sendInvite/shareCalendar do a
|
||||
// fire-and-forget dynamic import of it, and a cold load can otherwise race
|
||||
// the worker teardown ("Cannot load ... after the environment was torn down").
|
||||
await import('../../src/services/notificationService');
|
||||
await import('../../src/nest/notifications/notifications.bridge');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -39,7 +39,7 @@ vi.mock('../../../src/config', () => ({
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser }));
|
||||
const { notifSend } = vi.hoisted(() => ({ notifSend: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send: notifSend }));
|
||||
vi.mock('../../../src/nest/notifications/notifications.bridge', () => ({ send: notifSend }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
@@ -70,10 +70,10 @@ beforeAll(async () => {
|
||||
// The collections addon is seeded DISABLED by default and every tool rides
|
||||
// the `when:` addon gate (post-fold quirk fix) — enable it for the suite.
|
||||
testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run(ADDON_IDS.COLLECTIONS);
|
||||
// Warm the notificationService module: sendInvite does a fire-and-forget
|
||||
// Warm the notifications bridge module: sendInvite does a fire-and-forget
|
||||
// dynamic import of it, and a cold load can otherwise race the mock registry
|
||||
// under slow (coverage) runs — same precaution as tests/integration/vacay.test.ts.
|
||||
await import('../../../src/services/notificationService');
|
||||
await import('../../../src/nest/notifications/notifications.bridge');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* Unit tests for MCP notification tools:
|
||||
* Unit tests for the notifications MCP surface (NotificationsMcp,
|
||||
* DI-discovered since the notifications fold):
|
||||
* list_notifications, get_unread_notification_count, mark_notification_read,
|
||||
* mark_notification_unread, mark_all_notifications_read, delete_notification,
|
||||
* delete_all_notifications.
|
||||
* mark_notification_unread, mark_all_notifications_read.
|
||||
* Also covers the resource trek://notifications/in-app.
|
||||
*
|
||||
* All of it attaches via the nest-mcp registry inside registerTools, so every
|
||||
* harness here keeps withTools on (the resource is NOT registered by the
|
||||
* legacy registerResources fan-out anymore).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -80,7 +84,9 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
|
||||
// The in-app resource attaches via the nest-mcp registry (withTools), not
|
||||
// the legacy registerResources fan-out.
|
||||
const h = await createMcpHarness({ userId, withTools: true, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock('../../../src/nest/addons/addons.bridge', () => ({
|
||||
}));
|
||||
|
||||
// share_vacay_calendar fires a user notification after inserting; stub it out
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
|
||||
@@ -4,13 +4,12 @@ import type { Request } from 'express';
|
||||
|
||||
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { AdminController } from '../../../src/nest/admin/admin.controller';
|
||||
import type { AdminService } from '../../../src/nest/admin/admin.service';
|
||||
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import { send as sendNotification } from '../../../src/services/notificationService';
|
||||
import type { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'admin', email: 'admin@example.test' } as User;
|
||||
@@ -24,8 +23,12 @@ function svc(o: Partial<AdminService> = {}): AdminService {
|
||||
// wrapper keeps the historical construction sites positional.
|
||||
const writeAudit = vi.fn();
|
||||
const audit = { writeAudit } as unknown as AuditService;
|
||||
// Notifications are a constructor-injected stub since the notifications fold
|
||||
// (same behavior as the old services/notificationService path mock).
|
||||
const sendNotification = vi.fn().mockResolvedValue(undefined);
|
||||
const notifications = { send: sendNotification } as unknown as NotificationsService;
|
||||
const adminCtl = (s: AdminService, rt?: PluginRuntimeService) =>
|
||||
new AdminController(s, rt as unknown as PluginRuntimeService, audit);
|
||||
new AdminController(s, rt as unknown as PluginRuntimeService, audit, notifications);
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
if (err instanceof NotFoundException) return { status: 404, body: err.getResponse() };
|
||||
|
||||
@@ -42,7 +42,7 @@ vi.mock('../../../src/config', () => ({
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser, broadcast: vi.fn() }));
|
||||
const notifSend = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send: notifSend }));
|
||||
vi.mock('../../../src/nest/notifications/notifications.bridge', () => ({ send: notifSend }));
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -78,10 +78,10 @@ function clearCollections() {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
// Warm the notificationService module: sendInvite does a fire-and-forget
|
||||
// Warm the notifications bridge module: sendInvite does a fire-and-forget
|
||||
// dynamic import of it, and a cold load can otherwise race the mock registry
|
||||
// under slow (coverage) runs — same precaution as tests/integration/vacay.test.ts.
|
||||
await import('../../../src/services/notificationService');
|
||||
await import('../../../src/nest/notifications/notifications.bridge');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
+110
-4
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* Unit tests for in-app notification preference filtering in createNotification().
|
||||
* Covers INOTIF-001 to INOTIF-004.
|
||||
* Unit tests for in-app notification preference filtering in
|
||||
* NotificationsService.createNotification() and the respondToBoolean flow —
|
||||
* INOTIF-001 to INOTIF-004 (moved 1:1 from the legacy
|
||||
* tests/unit/services/inAppNotificationPrefs.test.ts when the in-app store
|
||||
* SQL folded into nest/notifications).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -30,13 +33,23 @@ vi.mock('../../../src/config', () => ({
|
||||
// Mock WebSocket broadcast — must use vi.hoisted() so broadcastMock is available
|
||||
// when the vi.mock factory is evaluated (factories are hoisted before const declarations)
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||
// RealtimeService imports both names from src/websocket, so the mock must
|
||||
// export both even though only broadcastToUser is asserted here.
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories';
|
||||
import { createNotification, createNotificationForRecipient, respondToBoolean } from '../../../src/services/inAppNotifications';
|
||||
import { registerAction } from '../../../src/services/inAppNotificationActions';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
|
||||
const notifications = new NotificationsService(new DatabaseService(testDb), new RealtimeService());
|
||||
const createNotification = notifications.createNotification.bind(notifications);
|
||||
const createNotificationForRecipient = notifications.createNotificationForRecipient.bind(notifications);
|
||||
const respondToBoolean = notifications.respond.bind(notifications);
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -291,4 +304,97 @@ describe('respondToBoolean', () => {
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
// ── Regression pins from the post-fold fix(server) commit ──────────────────
|
||||
|
||||
it('INOTIF-011 — a concurrent double-submit executes the action handler exactly once (CAS before handler)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const calls: string[] = [];
|
||||
// Slow handler: with the legacy handler-before-CAS order, both submits
|
||||
// passed the response-IS-NULL pre-check while the first awaited here and
|
||||
// the action ran twice.
|
||||
registerAction('slow_approve', async () => {
|
||||
calls.push('run');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
const id = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||
) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"slow_approve","payload":{}}', '{"action":"slow_approve","payload":{}}'
|
||||
)
|
||||
`).run(user.id, user.id).lastInsertRowid as number;
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
respondToBoolean(id, user.id, 'positive'),
|
||||
respondToBoolean(id, user.id, 'negative'),
|
||||
]);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
const outcomes = [first, second];
|
||||
expect(outcomes.filter((r) => r.success)).toHaveLength(1);
|
||||
expect(outcomes.find((r) => !r.success)?.error).toMatch(/already responded/i);
|
||||
});
|
||||
|
||||
it('INOTIF-012 — a handler failure releases the claim: error returned, notification stays unresponded, retry succeeds', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
let shouldFail = true;
|
||||
registerAction('flaky_approve', async () => {
|
||||
if (shouldFail) throw new Error('downstream unavailable');
|
||||
});
|
||||
const id = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||
) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"flaky_approve","payload":{}}', '{"action":"flaky_approve","payload":{}}'
|
||||
)
|
||||
`).run(user.id, user.id).lastInsertRowid as number;
|
||||
|
||||
const failed = await respondToBoolean(id, user.id, 'positive');
|
||||
expect(failed).toEqual({ success: false, error: 'downstream unavailable' });
|
||||
const row = testDb.prepare('SELECT response, is_read FROM notifications WHERE id = ?').get(id) as { response: string | null; is_read: number };
|
||||
expect(row.response).toBeNull();
|
||||
expect(row.is_read).toBe(0);
|
||||
|
||||
shouldFail = false;
|
||||
const retried = await respondToBoolean(id, user.id, 'positive');
|
||||
expect(retried.success).toBe(true);
|
||||
});
|
||||
|
||||
it('INOTIF-013 — respond result + broadcast carry an ISO-UTC created_at (toUtcIso, #1149 parity across paths)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'positive');
|
||||
|
||||
expect(result.notification?.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
||||
const broadcastPayload = broadcastMock.mock.calls
|
||||
.map((call) => call[1] as { type: string; notification: { created_at: string } })
|
||||
.find((p) => p.type === 'notification:updated');
|
||||
expect(broadcastPayload?.notification.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
||||
});
|
||||
|
||||
it('INOTIF-014 — createNotification broadcast carries an ISO-UTC created_at (was the un-patched #1149 path)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: user.id,
|
||||
sender_id: null,
|
||||
title_key: 'notif.test.title',
|
||||
text_key: 'notif.test.text',
|
||||
});
|
||||
|
||||
const broadcastPayload = broadcastMock.mock.calls
|
||||
.map((call) => call[1] as { type: string; notification: { created_at: string } })
|
||||
.find((p) => p.type === 'notification:new');
|
||||
expect(broadcastPayload?.notification.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
||||
});
|
||||
});
|
||||
+59
-5
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* Unit tests for the unified notificationService.send().
|
||||
* Covers NSVC-001 to NSVC-014.
|
||||
* Unit tests for the DI-native NotificationsService.send() — NSVC-001 through
|
||||
* NSVC-019, NTFY-SVCB-*, NSVC-PLUG-001..007 (moved 1:1 from the legacy
|
||||
* tests/unit/services/notificationService.test.ts when the send dispatcher +
|
||||
* in-app SQL folded into nest/notifications), plus the NSVC-020 bridge
|
||||
* delegation pin. Uses a real in-memory SQLite DB so the SQL is exercised
|
||||
* faithfully; email/webhook/ntfy transports are mocked at the nodemailer/fetch
|
||||
* boundary.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -32,10 +37,18 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
encrypt_api_key: (v: string) => v,
|
||||
}));
|
||||
|
||||
const { sendMailMock, fetchMock, broadcastMock } = vi.hoisted(() => ({
|
||||
const { sendMailMock, fetchMock, broadcastMock, logErrorMock } = vi.hoisted(() => ({
|
||||
sendMailMock: vi.fn().mockResolvedValue({ accepted: ['test@test.com'] }),
|
||||
fetchMock: vi.fn(),
|
||||
broadcastMock: vi.fn(),
|
||||
logErrorMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: logErrorMock,
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('nodemailer', () => ({
|
||||
@@ -48,7 +61,9 @@ vi.mock('nodemailer', () => ({
|
||||
}));
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||
// RealtimeService imports both names from src/websocket, so the mock must
|
||||
// export both even though only broadcastToUser is asserted here.
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: broadcastMock }));
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
|
||||
createPinnedDispatcher: vi.fn(() => ({})),
|
||||
@@ -58,9 +73,15 @@ import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
|
||||
import { send } from '../../../src/services/notificationService';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { NotificationsService, type NotificationPayload } from '../../../src/nest/notifications/notifications.service';
|
||||
import { send as bridgeSend } from '../../../src/nest/notifications/notifications.bridge';
|
||||
import { setPluginChannelSource, type ExternalChannel } from '../../../src/services/notifications/channelRegistry';
|
||||
|
||||
const notifications = new NotificationsService(new DatabaseService(testDb), new RealtimeService());
|
||||
const send = (payload: NotificationPayload) => notifications.send(payload);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function setSmtp(): void {
|
||||
@@ -480,6 +501,7 @@ describe('send() — channel failure resilience', () => {
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── Ntfy dispatch ─────────────────────────────────────────────────────────────
|
||||
@@ -675,4 +697,36 @@ describe('send() — plugin notification channels', () => {
|
||||
await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', tripId: '1' } });
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('NSVC-021 — a rejected channel dispatch logs the unwrapped Error message (fix-commit pin)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
installPluginChannel({ sendToUser: vi.fn().mockRejectedValue(new Error('gotify is down')) });
|
||||
setNotificationChannels(testDb, 'plugin:gotify');
|
||||
logErrorMock.mockClear();
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
|
||||
|
||||
const dispatchLog = logErrorMock.mock.calls.map(([msg]) => String(msg)).find(m => m.includes('channel dispatch failed'));
|
||||
// The legacy per-recipient log interpolated the raw reason ("Error: gotify
|
||||
// is down"); the admin path always unwrapped — now both do.
|
||||
expect(dispatchLog).toContain(': gotify is down');
|
||||
expect(dispatchLog).not.toContain('Error: gotify is down');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bridge delegation (notifications.bridge.ts — outside-container entry point)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('notifications.bridge', () => {
|
||||
it('NSVC-020 — send delegates to the DI service over the shared db proxy', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await bridgeSend({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Lisbon', actor: 'Alice', invitee: 'Bob', tripId: '7' } });
|
||||
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(getInAppNotifications(user.id)[0].navigate_target).toBe('/trips/7');
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ const checkPermission = vi.fn(() => true);
|
||||
const permissionsStub = { checkPermission } as unknown as PermissionsService;
|
||||
|
||||
const { send } = vi.hoisted(() => ({ send: vi.fn(() => Promise.resolve()) }));
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send }));
|
||||
vi.mock('../../../src/nest/notifications/notifications.bridge', () => ({ send }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
|
||||
@@ -42,7 +42,7 @@ const permissionsStub = { checkPermission } as unknown as PermissionsService;
|
||||
const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() };
|
||||
|
||||
const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } }));
|
||||
vi.mock('../../../src/services/notificationService', () => notif);
|
||||
vi.mock('../../../src/nest/notifications/notifications.bridge', () => notif);
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock('../../../src/config', () => ({
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
// notifyInvite fires a notification via a dynamic import — keep it out of unit scope
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock('../../../src/config', () => ({
|
||||
// Mock websocket so notifyPlanUsers doesn't throw
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
// shareCalendar fires a notification after inserting — keep that out of unit scope
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
@@ -53,7 +53,7 @@ beforeAll(async () => {
|
||||
// Warm the (mocked) notificationService module: sendInvite/shareCalendar do a
|
||||
// fire-and-forget dynamic import of it, and a cold load can otherwise race the
|
||||
// worker teardown ("Cannot load ... after the environment was torn down").
|
||||
await import('../../../src/services/notificationService');
|
||||
await import('../../../src/nest/notifications/notifications.bridge');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* host, and trip broadcasts are force-namespaced to plugin:{id}:{event}.
|
||||
* DI-native domains (budget/exchange-rates/reservations/tags/categories/todo/
|
||||
* packing/day-notes/days/assignments/oauth/llm-config/files/collab/vacay/
|
||||
* permissions/trips/places/collections/atlas)
|
||||
* permissions/trips/places/collections/atlas/notifications)
|
||||
* are constructor-injected stubs; legacy services/* domains stay path-mocked
|
||||
* until their own DI migration lands.
|
||||
*/
|
||||
@@ -208,8 +208,10 @@ const collabStub = {
|
||||
vi.mock('../../../src/services/tripMembership', () => ({
|
||||
joinTripAsMember: vi.fn((tripId: number, userId: number) => ({ joined: userId !== 5, tripId })), // owner add = no-op
|
||||
}));
|
||||
const { notifySend } = vi.hoisted(() => ({ notifySend: vi.fn(async () => undefined) }));
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send: notifySend }));
|
||||
// Notifications are a constructor-injected stub since the notifications fold
|
||||
// (same behavior as the old services/notificationService path mock).
|
||||
const notifySend = vi.fn(async () => undefined);
|
||||
const notificationsStub = { send: notifySend } as unknown as NotificationsService;
|
||||
// userId 7 = no provider configured; everyone else resolves to a stub config.
|
||||
// Constructor-injected stub since the resolver became DI-native (settings migration).
|
||||
const llmConfigStub = {
|
||||
@@ -324,6 +326,7 @@ import type { PermissionsService } from '../../../src/nest/permissions/permissio
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import type { CollectionsService } from '../../../src/nest/collections/collections.service';
|
||||
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||||
import type { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
|
||||
@@ -346,7 +349,7 @@ const tripsStub = {
|
||||
list: () => [{ id: 1 }],
|
||||
removeMember: vi.fn(),
|
||||
} as unknown as import('../../../src/nest/trips/trips.service').TripsService;
|
||||
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(), tripsStub, placesStub, collectionsStub, atlasStub);
|
||||
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(), tripsStub, placesStub, collectionsStub, atlasStub, notificationsStub);
|
||||
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
|
||||
const createRealRpcHost = (id: string, granted: ReadonlySet<string>, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);
|
||||
|
||||
|
||||
@@ -20,14 +20,16 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({ decrypt_api_key: (v: stri
|
||||
const { sendMailMock } = vi.hoisted(() => ({ sendMailMock: vi.fn().mockResolvedValue({ accepted: ['a@b.c'] }) }));
|
||||
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: sendMailMock, verify: vi.fn() })) } }));
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })), createPinnedDispatcher: vi.fn(() => ({})) }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, setAppSetting, setNotificationChannels } from '../../helpers/factories';
|
||||
import { send } from '../../../src/services/notificationService';
|
||||
// The send dispatcher is DI-native since the notifications fold; drive it
|
||||
// through the same outside-container entry point the scheduler uses.
|
||||
import { send } from '../../../src/nest/notifications/notifications.bridge';
|
||||
import { getPreferencesMatrix } from '../../../src/services/notificationPreferencesService';
|
||||
import {
|
||||
setPluginChannelSource,
|
||||
|
||||
@@ -36,7 +36,7 @@ vi.mock('../../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/services/notificationService', () => ({
|
||||
vi.mock('../../../src/nest/notifications/notifications.bridge', () => ({
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user