mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(server): fold the notifications domain out of services/
Four files taken apart rather than moved, because they only worked as a knot. The mailer becomes its own leaf module. AuthService sends the password-reset mail while NotificationsModule imports AuthModule for the demo gate in NotificationsMcp - a mailer inside the notifications domain would have closed a hard cycle that only a forwardRef could open again. Webhook and ntfy stay with notifications; auth has no business importing them. The channel registry stays a module singleton and does NOT become a provider. PluginRuntimeService pushes its channel getter in at onModuleInit, and notifications.bridge.ts builds its own NotificationsService for the scheduler crons and the memories senders. A provider would hand the bridge a second, empty registry - every plugin channel would then go quiet with no error anywhere. CHOVR-015 drives that seam end to end: a channel published through the runtime, delivered through the bridge. Nothing pinned it before; the existing cases all set the source themselves. The built-ins no longer self-register by side-effect import. They used to exist only because notificationPreferencesService happened to `import './notifications/builtins'`; removing that line would have silenced email, webhook and ntfy without a failure. They are built from the injected transports and registered from NotificationsService's constructor, so both instances have them. No cached nodemailer transport: MailerService.createTransport re-reads the SMTP config and skipTlsVerify per send, exactly as the three inline call sites did, so an admin changing them does not have to restart. Preferences become a provider over DatabaseService. Eight places built NotificationsService by hand, so a tests/helpers/notifications.ts factory keeps the next constructor parameter a one-file diff instead of an eight-file one.
This commit is contained in:
@@ -29,8 +29,9 @@ remains as the platform underneath `@nestjs/platform-express`.
|
||||
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,
|
||||
notifications, admin, webauthn-config, user-cleanup, oauth, wiki — see the
|
||||
migration recipe below.
|
||||
notifications, admin, webauthn-config, user-cleanup, oauth, wiki, mailer,
|
||||
notification transports, notification preferences — see the migration recipe
|
||||
below.
|
||||
- **Foundation (BE-Phase 1, complete):** the eight stateless helpers moved to
|
||||
`common/`; every trip-access check routes through `DatabaseService`
|
||||
(`services/tripAccess.ts` and the dead `middleware/tripAccess.ts` are gone);
|
||||
@@ -41,9 +42,11 @@ remains as the platform underneath `@nestjs/platform-express`.
|
||||
addon guards are one `AddonGuard` + `@RequireAddon`; the demo-mode block is one
|
||||
condition. Still open: `TripAccessGuard`, default-deny, the MFA policy.
|
||||
|
||||
`src/services/` is down to five top-level files (journey, journeyShare,
|
||||
notifications, notificationPreferences, backup) plus the `airtrail/`,
|
||||
`memories/` and `notifications/` directories. Note that the trip-access and
|
||||
`src/services/` is down to three top-level files (journey, journeyShare, backup)
|
||||
plus the `airtrail/` and `memories/` directories. What is left each waits on an
|
||||
open decision: memories on its Zod contracts, journey on memories, backup on the
|
||||
plugins module split and the db-lifecycle question, airtrail on the credential
|
||||
handling. Note that the trip-access and
|
||||
`canEdit` methods on the domain services are **not** dead weight waiting for a
|
||||
guard: their callers are overwhelmingly the `*.mcp.ts` tools, which never pass
|
||||
through an HTTP guard. In the five domains piloted for `TripAccessGuard`, 40 of
|
||||
@@ -119,6 +122,20 @@ singleton rather than a second instance.
|
||||
for free just because it is global: a provider injecting `RuntimeEnvService`
|
||||
fails to resolve and takes every suite importing that module down with it.
|
||||
Import it explicitly, the way `PermissionsModule` is handled.
|
||||
- **A module-scoped registry that outside code writes into must stay a module,
|
||||
not a provider.** The notification channel registry is the live example:
|
||||
`PluginRuntimeService` pushes its channel getter in at `onModuleInit`, and
|
||||
`notifications.bridge.ts` builds its own `NotificationsService` for the
|
||||
scheduler crons. As a provider the bridge would get a second, empty registry
|
||||
and every plugin channel would go quiet — with no error anywhere.
|
||||
`CHOVR-015` is the regression test. Same reasoning as
|
||||
`oauth/oauth.pending-codes.ts`.
|
||||
- **Self-registration by side-effect import is a trap.** The built-in channels
|
||||
used to register because `notificationPreferencesService` happened to `import
|
||||
'./notifications/builtins'`. Dropping that one line would have silenced email,
|
||||
webhook and ntfy without a failure. They are built from injected transports and
|
||||
registered in `NotificationsService`'s constructor now, so every path that can
|
||||
dispatch has them.
|
||||
- A guard with a constructor dependency has to be a registered provider
|
||||
everywhere it is used. The three auth guards are dependency-free on purpose —
|
||||
38 directories apply them and 21 do not import `AuthModule`, so giving them a
|
||||
|
||||
@@ -11,6 +11,10 @@ import { WebauthnConfigService } from '../auth/webauthn-config.service';
|
||||
import { TripMembershipService } from '../trip-membership/trip-membership.service';
|
||||
import { PackingService } from '../packing/packing.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { MailerService } from '../notifications/mailer/mailer.service';
|
||||
import { NotificationPreferencesService } from '../notifications/notification-preferences.service';
|
||||
import { NtfyService } from '../notifications/transports/ntfy.service';
|
||||
import { WebhookService } from '../notifications/transports/webhook.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@@ -35,7 +39,9 @@ const realtime = new RealtimeService();
|
||||
const permissions = new PermissionsService(dbs);
|
||||
const webauthn = new WebauthnConfigService(dbs);
|
||||
const userCleanup = new UserCleanupService(dbs);
|
||||
const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup);
|
||||
const mailer = new MailerService(dbs);
|
||||
const notifPrefs = new NotificationPreferencesService(dbs, mailer);
|
||||
const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup, mailer);
|
||||
const admin = new AdminService(
|
||||
dbs,
|
||||
new SettingsService(dbs),
|
||||
@@ -44,8 +50,9 @@ const admin = new AdminService(
|
||||
new PackingService(dbs, permissions, realtime),
|
||||
auth,
|
||||
permissions,
|
||||
new NotificationsService(dbs, realtime),
|
||||
new NotificationsService(dbs, realtime, mailer, new WebhookService(dbs), new NtfyService(dbs), notifPrefs),
|
||||
userCleanup,
|
||||
notifPrefs,
|
||||
);
|
||||
|
||||
export function checkAndNotifyVersion(): Promise<void> {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from '../llm-parse/
|
||||
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
|
||||
import { validatePassword } from '../common/passwordPolicy';
|
||||
import { UserCleanupService } from '../auth/user-cleanup.service';
|
||||
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
|
||||
import { NotificationPreferencesService } from '../notifications/notification-preferences.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
@@ -78,6 +78,7 @@ export class AdminService {
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly userCleanup: UserCleanupService,
|
||||
private readonly notifPrefs: NotificationPreferencesService,
|
||||
) {}
|
||||
|
||||
// ── User CRUD ──────────────────────────────────────────────────────────────
|
||||
@@ -851,6 +852,6 @@ export class AdminService {
|
||||
|
||||
getAdminUserDefaults() { return this.settings.getAdminUserDefaults(); }
|
||||
setAdminUserDefaults(body: Record<string, unknown>) { return this.settings.setAdminUserDefaults(body); }
|
||||
getPreferencesMatrix(userId: number, role: string) { return getPreferencesMatrix(userId, role, 'admin'); }
|
||||
setAdminPreferences(userId: number, body: unknown) { return setAdminPreferences(userId, body as Parameters<typeof setAdminPreferences>[1]); }
|
||||
getPreferencesMatrix(userId: number, role: string) { return this.notifPrefs.getPreferencesMatrix(userId, role, 'admin'); }
|
||||
setAdminPreferences(userId: number, body: unknown) { return this.notifPrefs.setAdminPreferences(userId, body as Parameters<NotificationPreferencesService['setAdminPreferences']>[1]); }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { AtlasService } from '../atlas/atlas.service';
|
||||
import { TripMembershipService } from '../trip-membership/trip-membership.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { MailerService } from '../notifications/mailer/mailer.service';
|
||||
import { UserCleanupService } from './user-cleanup.service';
|
||||
import { WebauthnConfigService } from './webauthn-config.service';
|
||||
import { User } from '../../types';
|
||||
@@ -42,6 +43,7 @@ const auth = new AuthService(
|
||||
new TripMembershipService(dbs),
|
||||
new WebauthnConfigService(dbs),
|
||||
new UserCleanupService(dbs),
|
||||
new MailerService(dbs),
|
||||
);
|
||||
|
||||
export { stripUserForClient } from './auth.helpers';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { UserCleanupService } from './user-cleanup.service';
|
||||
import { WebauthnConfigService } from './webauthn-config.service';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { MailerModule } from '../notifications/mailer/mailer.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AtlasModule } from '../atlas/atlas.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
@@ -20,7 +21,9 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module'
|
||||
* sub-paths explicitly rather than claiming all of /api/auth.
|
||||
*
|
||||
* PermissionsModule feeds getAppConfig's permissions block; AtlasModule feeds
|
||||
* getTravelStats' hidden-countries subtraction. AuthService is
|
||||
* getTravelStats' hidden-countries subtraction; MailerModule sends the
|
||||
* password-reset mail. It is a leaf module of its own precisely so this import
|
||||
* does not become AuthModule -> NotificationsModule -> AuthModule. AuthService is
|
||||
* exported for the in-container consumers (the domain *.mcp.ts demo guards,
|
||||
* OidcService, PasskeyEnabledGuard); PasskeyService for AdminService's passkey
|
||||
* reset; UserCleanupService for the two account-deletion paths (AdminService)
|
||||
@@ -28,7 +31,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module'
|
||||
* through auth.bridge.ts.
|
||||
*/
|
||||
@Module({
|
||||
imports: [RateLimitModule, AuditModule, PermissionsModule, AtlasModule, TripMembershipModule, AppConfigModule],
|
||||
imports: [RateLimitModule, AuditModule, PermissionsModule, AtlasModule, TripMembershipModule, MailerModule, AppConfigModule],
|
||||
controllers: [AuthPublicController, AuthController, PasskeyController],
|
||||
providers: [AuthService, PasskeyService, UserCleanupService, WebauthnConfigService],
|
||||
exports: [AuthService, PasskeyService, UserCleanupService],
|
||||
|
||||
@@ -32,7 +32,7 @@ import { avatarUrl } from '../common/avatarUrl';
|
||||
import { TripMembershipService } from '../trip-membership/trip-membership.service';
|
||||
import { WebauthnConfigService } from './webauthn-config.service';
|
||||
import { setAuthCookie, clearAuthCookie } from '../common/cookie';
|
||||
import { sendPasswordResetEmail } from '../../services/notifications';
|
||||
import { MailerService } from '../notifications/mailer/mailer.service';
|
||||
import { getAppUrl } from '../../app-config';
|
||||
import {
|
||||
ADMIN_SETTINGS_KEYS,
|
||||
@@ -140,6 +140,7 @@ export class AuthService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly membership: TripMembershipService,
|
||||
private readonly webauthn: WebauthnConfigService,
|
||||
private readonly userCleanup: UserCleanupService,
|
||||
private readonly mailer: MailerService,
|
||||
) {}
|
||||
|
||||
// Cookie
|
||||
@@ -148,7 +149,7 @@ export class AuthService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// Reset-email delivery (canonical app URL, never request headers)
|
||||
getAppUrl() { return getAppUrl(); }
|
||||
sendPasswordResetEmail(email: string, url: string, userId: number | null) { return sendPasswordResetEmail(email, url, userId); }
|
||||
sendPasswordResetEmail(email: string, url: string, userId: number | null) { return this.mailer.sendPasswordResetEmail(email, url, userId); }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Toggles + tokens
|
||||
|
||||
+12
-7
@@ -1,11 +1,16 @@
|
||||
import type { ExternalChannel } from '../../nest/notifications/notification-events';
|
||||
import type { ExternalChannel } from './notification-events';
|
||||
|
||||
// The channel contract itself (ChannelMessage, ExternalChannel) lives in
|
||||
// nest/notifications/notification-events.ts so that this registry, the
|
||||
// preferences service and the transports can all name it without importing each
|
||||
// other. Re-exported here for the consumers that reach the contract through the
|
||||
// registry.
|
||||
export type { ChannelMessage, ExternalChannel } from '../../nest/notifications/notification-events';
|
||||
/**
|
||||
* The live set of notification channels.
|
||||
*
|
||||
* Deliberately a module singleton and not a provider. Two things read it from
|
||||
* outside the container: notifications.bridge.ts constructs its own
|
||||
* NotificationsService for the scheduler crons and the memories senders, and
|
||||
* PluginRuntimeService pushes its channel getter in at onModuleInit. Make this a
|
||||
* provider and the bridge gets a second, empty registry - plugin channels would
|
||||
* then go quiet with no error anywhere. The built-ins are registered from
|
||||
* NotificationsService's constructor, so every path that can dispatch has them.
|
||||
*/
|
||||
|
||||
/** Namespace for plugin channel ids, so a plugin can never claim `email`. */
|
||||
export const PLUGIN_CHANNEL_PREFIX = 'plugin:';
|
||||
@@ -0,0 +1,120 @@
|
||||
import { registerChannel } from '../channel-registry';
|
||||
import type { ChannelMessage, ExternalChannel } from '../notification-events';
|
||||
import type { MailerService } from '../mailer/mailer.service';
|
||||
import { resolveAdminNtfyUrl, resolveNtfyUrl, type NtfyService } from '../transports/ntfy.service';
|
||||
import type { WebhookService } from '../transports/webhook.service';
|
||||
|
||||
// The three built-in external channels, wrapping the transports that were free
|
||||
// functions in services/notifications.ts before the fold. No delivery logic is
|
||||
// rewritten here - it is only relocated behind the ExternalChannel interface so
|
||||
// NotificationsService.send() can iterate instead of branching.
|
||||
|
||||
function supportsAllButSynology(event: string): boolean {
|
||||
return event !== 'synology_session_cleared';
|
||||
}
|
||||
|
||||
export interface BuiltinChannelDeps {
|
||||
mailer: MailerService;
|
||||
webhook: WebhookService;
|
||||
ntfy: NtfyService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the three built-in channels over the given transports.
|
||||
*
|
||||
* They take their dependencies as an argument rather than importing them,
|
||||
* because the registry they land in is a module singleton while the transports
|
||||
* are providers. Both NotificationsService instances - the container singleton
|
||||
* and the one notifications.bridge.ts constructs - register from their
|
||||
* constructor, so the registry is populated on every path that can dispatch.
|
||||
* It used to happen through a side-effect import in the preferences service,
|
||||
* which meant the built-ins existed only because somebody happened to import
|
||||
* preferences; dropping that import would have silenced email, webhook and ntfy
|
||||
* without a single error.
|
||||
*/
|
||||
export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDeps): ExternalChannel[] {
|
||||
const emailChannel: ExternalChannel = {
|
||||
id: 'email',
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.email',
|
||||
// Admin-scoped events (version_available) reach admins by email even when the
|
||||
// admin has not put `email` in notification_channels — the admin global pref
|
||||
// gates it instead. Verbatim from the pre-registry dispatch.
|
||||
bypassesActiveToggleForAdminEvents: true,
|
||||
supportsEvent: supportsAllButSynology,
|
||||
isInstanceConfigured: () => mailer.isSmtpConfigured(),
|
||||
isConfiguredFor: (userId) => !!mailer.getUserEmail(userId),
|
||||
async sendToUser(userId, msg) {
|
||||
const email = mailer.getUserEmail(userId);
|
||||
if (!email) return false;
|
||||
return mailer.sendEmail(email, msg.title, msg.body, userId, msg.navigateTarget);
|
||||
},
|
||||
async test(userId) {
|
||||
const email = mailer.getUserEmail(userId);
|
||||
if (!email) return { success: false, error: 'No email address on file' };
|
||||
return mailer.testSmtp(email);
|
||||
},
|
||||
};
|
||||
|
||||
const webhookChannel: ExternalChannel = {
|
||||
id: 'webhook',
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.webhook',
|
||||
supportsAdminGlobal: true,
|
||||
supportsEvent: supportsAllButSynology,
|
||||
isConfiguredFor: (userId) => !!webhook.getUserWebhookUrl(userId),
|
||||
async sendToUser(userId, msg) {
|
||||
const url = webhook.getUserWebhookUrl(userId);
|
||||
if (!url) return false;
|
||||
return webhook.sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, tripName: msg.tripName, link: msg.url });
|
||||
},
|
||||
async sendGlobal(msg: ChannelMessage) {
|
||||
const url = webhook.getAdminWebhookUrl();
|
||||
if (!url) return false;
|
||||
return webhook.sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
},
|
||||
async test(userId, override) {
|
||||
const url = (typeof override?.url === 'string' && override.url) || webhook.getUserWebhookUrl(userId);
|
||||
if (!url) return { success: false, error: 'No webhook URL configured' };
|
||||
return webhook.testWebhook(url);
|
||||
},
|
||||
};
|
||||
|
||||
const ntfyChannel: ExternalChannel = {
|
||||
id: 'ntfy',
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.ntfy',
|
||||
supportsAdminGlobal: true,
|
||||
supportsEvent: supportsAllButSynology,
|
||||
isConfiguredFor: (userId) => !!resolveNtfyUrl(ntfy.getAdminNtfyConfig(), ntfy.getUserNtfyConfig(userId)),
|
||||
async sendToUser(userId, msg) {
|
||||
const userCfg = ntfy.getUserNtfyConfig(userId);
|
||||
const adminCfg = ntfy.getAdminNtfyConfig();
|
||||
const url = resolveNtfyUrl(adminCfg, userCfg);
|
||||
if (!url) return false;
|
||||
return ntfy.sendNtfy(url, userCfg?.token ?? adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
},
|
||||
async sendGlobal(msg: ChannelMessage) {
|
||||
const adminCfg = ntfy.getAdminNtfyConfig();
|
||||
const url = resolveAdminNtfyUrl(adminCfg);
|
||||
if (!url) return false;
|
||||
return ntfy.sendNtfy(url, adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
},
|
||||
async test(userId, override) {
|
||||
const topic = typeof override?.topic === 'string' ? override.topic : ntfy.getUserNtfyConfig(userId)?.topic;
|
||||
if (!topic) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
|
||||
return ntfy.testNtfy({
|
||||
topic,
|
||||
server: typeof override?.server === 'string' ? override.server : null,
|
||||
token: typeof override?.token === 'string' ? override.token : null,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return [emailChannel, webhookChannel, ntfyChannel];
|
||||
}
|
||||
|
||||
/** Idempotent - safe to call from every entry point that needs the registry populated. */
|
||||
export function registerBuiltinChannels(deps: BuiltinChannelDeps): void {
|
||||
for (const channel of buildBuiltinChannels(deps)) registerChannel(channel);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { EMAIL_I18N as I18N, EVENT_TEXTS } from '@trek/shared/i18n/externalNotifications';
|
||||
import type { EventText, PasswordResetStrings } from '@trek/shared/i18n/externalNotifications';
|
||||
import { getAppUrl } from '../../../app-config';
|
||||
import type { NotifEventType } from '../notification-events';
|
||||
|
||||
/**
|
||||
* Pure rendering for outgoing mail: the shell, the CTA and the password-reset
|
||||
* body. No database, no transport, no DI - which is why it stays a plain module
|
||||
* and not a provider. `getAppUrl()` is the canonical app URL from app-config,
|
||||
* never a request header.
|
||||
*/
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Localized title/body for an event.
|
||||
export function getEventText(lang: string, event: NotifEventType, params: Record<string, string>): EventText {
|
||||
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
|
||||
const fn = texts[event] ?? EVENT_TEXTS.en[event];
|
||||
if (!fn) return { title: event, body: '' };
|
||||
return fn(params);
|
||||
}
|
||||
|
||||
export function buildEmailHtml(
|
||||
subject: string,
|
||||
body: string,
|
||||
lang: string,
|
||||
navigateTarget?: string,
|
||||
rawBody = false,
|
||||
): string {
|
||||
const s = I18N[lang] || I18N.en;
|
||||
const appUrl = getAppUrl();
|
||||
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : appUrl || '');
|
||||
const safeSubject = escapeHtml(subject);
|
||||
const safeBody = rawBody ? body : escapeHtml(body);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 40px 20px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
|
||||
<!-- Header -->
|
||||
<tr><td style="background: linear-gradient(135deg, #000000 0%, #1a1a2e 100%); padding: 32px 32px 28px; text-align: center;">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4NCiAgPGRlZnM+DQogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFlMjkzYiIvPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMGYxNzJhIi8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8Y2xpcFBhdGggaWQ9Imljb24iPg0KICAgICAgPHBhdGggZD0iTSA4NTUuNjM2NzE5IDY5OS4yMDMxMjUgTCAyMjIuMjQ2MDk0IDY5OS4yMDMxMjUgQyAxOTcuNjc5Njg4IDY5OS4yMDMxMjUgMTc5LjkwNjI1IDY3NS43NSAxODYuNTM5MDYyIDY1Mi4xMDE1NjIgTCAzNjAuNDI5Njg4IDMyLjM5MDYyNSBDIDM2NC45MjE4NzUgMTYuMzg2NzE5IDM3OS41MTE3MTkgNS4zMjgxMjUgMzk2LjEzMjgxMiA1LjMyODEyNSBMIDEwMjkuNTI3MzQ0IDUuMzI4MTI1IEMgMTA1NC4wODk4NDQgNS4zMjgxMjUgMTA3MS44NjcxODggMjguNzc3MzQ0IDEwNjUuMjMwNDY5IDUyLjQyOTY4OCBMIDg5MS4zMzk4NDQgNjcyLjEzNjcxOSBDIDg4Ni44NTE1NjIgNjg4LjE0MDYyNSA4NzIuMjU3ODEyIDY5OS4yMDMxMjUgODU1LjYzNjcxOSA2OTkuMjAzMTI1IFogTSA0NDQuMjM4MjgxIDExNjYuOTgwNDY5IEwgNTMzLjc3MzQzOCA4NDcuODk4NDM4IEMgNTQwLjQxMDE1NiA4MjQuMjQ2MDk0IDUyMi42MzI4MTIgODAwLjc5Njg3NSA0OTguMDcwMzEyIDgwMC43OTY4NzUgTCAxNzIuNDcyNjU2IDgwMC43OTY4NzUgQyAxNTUuODUxNTYyIDgwMC43OTY4NzUgMTQxLjI2MTcxOSA4MTEuODU1NDY5IDEzNi43Njk1MzEgODI3Ljg1OTM3NSBMIDQ3LjIzNDM3NSAxMTQ2Ljk0MTQwNiBDIDQwLjU5NzY1NiAxMTcwLjU5Mzc1IDU4LjM3NSAxMTk0LjA0Mjk2OSA4Mi45Mzc1IDExOTQuMDQyOTY5IEwgNDA4LjUzNTE1NiAxMTk0LjA0Mjk2OSBDIDQyNS4xNTYyNSAxMTk0LjA0Mjk2OSA0MzkuNzUgMTE4Mi45ODQzNzUgNDQ0LjIzODI4MSAxMTY2Ljk4MDQ2OSBaIE0gNjA5LjAwMzkwNiA4MjcuODU5Mzc1IEwgNDM1LjExMzI4MSAxNDQ3LjU3MDMxMiBDIDQyOC40NzY1NjIgMTQ3MS4yMTg3NSA0NDYuMjUzOTA2IDE0OTQuNjcxODc1IDQ3MC44MTY0MDYgMTQ5NC42NzE4NzUgTCAxMTA0LjIxMDkzOCAxNDk0LjY3MTg3NSBDIDExMjAuODMyMDMxIDE0OTQuNjcxODc1IDExMzUuNDIxODc1IDE0ODMuNjA5Mzc1IDExMzkuOTE0MDYyIDE0NjcuNjA1NDY5IEwgMTMxMy44MDQ2ODggODQ3Ljg5ODQzOCBDIDEzMjAuNDQxNDA2IDgyNC4yNDYwOTQgMTMwMi42NjQwNjIgODAwLjc5Njg3NSAxMjc4LjEwMTU2MiA4MDAuNzk2ODc1IEwgNjQ0LjcwNzAzMSA4MDAuNzk2ODc1IEMgNjI4LjA4NTkzOCA4MDAuNzk2ODc1IDYxMy40OTIxODggODExLjg1NTQ2OSA2MDkuMDAzOTA2IDgyNy44NTkzNzUgWiBNIDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgTCA5NjYuNTcwMzEyIDY1Mi4xMDE1NjIgQyA5NTkuOTMzNTk0IDY3NS43NSA5NzcuNzEwOTM4IDY5OS4yMDMxMjUgMTAwMi4yNzM0MzggNjk5LjIwMzEyNSBMIDEzMjcuODcxMDk0IDY5OS4yMDMxMjUgQyAxMzQ0LjQ5MjE4OCA2OTkuMjAzMTI1IDEzNTkuMDg1OTM4IDY4OC4xNDA2MjUgMTM2My41NzQyMTkgNjcyLjEzNjcxOSBMIDE0NTMuMTA5Mzc1IDM1My4wNTQ2ODggQyAxNDU5Ljc0NjA5NCAzMjkuNDA2MjUgMTQ0MS45Njg3NSAzMDUuOTUzMTI1IDE0MTcuNDA2MjUgMzA1Ljk1MzEyNSBMIDEwOTEuODA4NTk0IDMwNS45NTMxMjUgQyAxMDc1LjE4NzUgMzA1Ljk1MzEyNSAxMDYwLjU5NzY1NiAzMTcuMDE1NjI1IDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgWiIvPg0KICAgIDwvY2xpcFBhdGg+DQogIDwvZGVmcz4NCiAgPHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGZpbGw9InVybCgjYmcpIi8+DQogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LDUxKSBzY2FsZSgwLjI2NykiPg0KICAgIDxyZWN0IHdpZHRoPSIxNTAwIiBoZWlnaHQ9IjE1MDAiIGZpbGw9IiNmZmZmZmYiIGNsaXAtcGF0aD0idXJsKCNpY29uKSIvPg0KICA8L2c+DQo8L3N2Zz4NCg==" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
|
||||
<div style="color: #ffffff; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">TREK</div>
|
||||
<div style="color: rgba(255,255,255,0.4); font-size: 10px; font-weight: 500; letter-spacing: 2px; text-transform: uppercase; margin-top: 4px;">Travel Resource & Exploration Kit</div>
|
||||
</td></tr>
|
||||
<!-- Content -->
|
||||
<tr><td style="padding: 32px 32px 16px;">
|
||||
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${safeSubject}</h1>
|
||||
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
|
||||
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${safeBody}</p>
|
||||
</td></tr>
|
||||
<!-- CTA -->
|
||||
${
|
||||
appUrl
|
||||
? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
|
||||
<a href="${ctaHref}" style="display: inline-block; padding: 12px 28px; background: #111827; color: #ffffff; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 10px; letter-spacing: 0.2px;">${s.openTrek}</a>
|
||||
</td></tr>`
|
||||
: ''
|
||||
}
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding: 20px 32px; background: #f9fafb; border-top: 1px solid #f3f4f6; text-align: center;">
|
||||
<p style="margin: 0 0 8px; font-size: 11px; color: #9ca3af; line-height: 1.5;">${s.footer}<br>${s.manage}</p>
|
||||
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">♥</span> by Maurice · <a href="https://github.com/liketrek/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function buildPasswordResetHtml(
|
||||
subject: string,
|
||||
strings: PasswordResetStrings,
|
||||
recipient: string,
|
||||
resetUrl: string,
|
||||
lang: string,
|
||||
): string {
|
||||
const safeGreeting = escapeHtml(`${strings.greeting}, ${recipient}`);
|
||||
const safeBody = escapeHtml(strings.body);
|
||||
const safeExpiry = escapeHtml(strings.expiry);
|
||||
const safeIgnore = escapeHtml(strings.ignore);
|
||||
const safeCta = escapeHtml(strings.ctaIntro);
|
||||
const block = `
|
||||
<p style="margin:0 0 16px 0; font-size:16px;">${safeGreeting},</p>
|
||||
<p style="margin:0 0 20px 0; font-size:15px; line-height:1.6;">${safeBody}</p>
|
||||
<p style="margin:28px 0;">
|
||||
<a href="${resetUrl}" style="display:inline-block;padding:14px 28px;background:#111827;color:#fff;text-decoration:none;border-radius:10px;font-weight:600;font-size:15px;">${safeCta}</a>
|
||||
</p>
|
||||
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
|
||||
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
|
||||
`;
|
||||
return buildEmailHtml(subject, block, lang, undefined, true);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MailerService } from './mailer.service';
|
||||
|
||||
/**
|
||||
* Outgoing SMTP. A leaf module on purpose: AuthModule imports it for the
|
||||
* password-reset mail, and NotificationsModule imports it for the email
|
||||
* channel. Since NotificationsModule already imports AuthModule (the demo gate
|
||||
* in NotificationsMcp), the mailer living in the notifications domain would
|
||||
* close a hard cycle — this module is what keeps it a DAG without a forwardRef.
|
||||
*/
|
||||
@Module({
|
||||
providers: [MailerService],
|
||||
exports: [MailerService],
|
||||
})
|
||||
export class MailerModule {}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { PASSWORD_RESET_I18N } from '@trek/shared/i18n/externalNotifications';
|
||||
import { readEnv } from '../../../app-config';
|
||||
import { logError, logInfo, logDebug } from '../../audit/audit-log.logger';
|
||||
import { decrypt_api_key } from '../../common/crypto/apiKeyCrypto';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { buildEmailHtml, buildPasswordResetHtml } from './email-html';
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
from: string;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outgoing mail: SMTP config resolution, the password-reset delivery and the
|
||||
* generic notification email.
|
||||
*
|
||||
* Its own leaf module rather than part of the notifications domain, and that is
|
||||
* load-bearing: AuthService sends the password-reset mail, while
|
||||
* NotificationsModule imports AuthModule for the demo gate in NotificationsMcp.
|
||||
* Putting the mailer in NotificationsModule would close a hard module cycle that
|
||||
* only a forwardRef could open again.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailerService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
private getAppSetting(key: string): string | null {
|
||||
return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
|
||||
}
|
||||
|
||||
private getSmtpConfig(): SmtpConfig | null {
|
||||
const smtpEnv = readEnv().smtp;
|
||||
const host = smtpEnv.host || this.getAppSetting('smtp_host');
|
||||
const port = smtpEnv.port || this.getAppSetting('smtp_port');
|
||||
const user = smtpEnv.user || this.getAppSetting('smtp_user');
|
||||
const pass = smtpEnv.pass || decrypt_api_key(this.getAppSetting('smtp_pass')) || '';
|
||||
const from = smtpEnv.from || this.getAppSetting('smtp_from');
|
||||
if (!host || !port || !from) return null;
|
||||
return {
|
||||
host,
|
||||
port: parseInt(port, 10),
|
||||
user: user || '',
|
||||
pass: pass || '',
|
||||
from,
|
||||
secure: parseInt(port, 10) === 465,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A transport per send, never a cached one. `skipTlsVerify` and the whole SMTP
|
||||
* config are re-read on every call, so an admin changing the host, the password
|
||||
* or the TLS setting takes effect on the next mail instead of after a restart.
|
||||
* The three call sites below each built this inline before the fold; keeping it
|
||||
* one method is the only change, and it keeps the freshness property visible.
|
||||
*/
|
||||
private createTransport(config: SmtpConfig) {
|
||||
const skipTls = readEnv().smtp.skipTlsVerify || this.getAppSetting('smtp_skip_tls_verify') === 'true';
|
||||
return nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
|
||||
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Is SMTP configured at the instance level? (Independent of any one user's address.) */
|
||||
isSmtpConfigured(): boolean {
|
||||
return !!(readEnv().smtp.host || this.getAppSetting('smtp_host'));
|
||||
}
|
||||
|
||||
getUserEmail(userId: number): string | null {
|
||||
// Defense-in-depth (#1362): a guest's synthetic email must never be emailed.
|
||||
return this.db.get<{ email: string }>(
|
||||
'SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0', userId,
|
||||
)?.email || null;
|
||||
}
|
||||
|
||||
getUserLanguage(userId: number): string {
|
||||
return this.db.get<{ value: string }>(
|
||||
"SELECT value FROM settings WHERE user_id = ? AND key = 'language'", userId,
|
||||
)?.value || 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers a password-reset link. When SMTP is configured the user
|
||||
* receives an email. When it isn't, the link is logged to stdout in a
|
||||
* clearly-fenced block so the self-hosting admin can hand it off by
|
||||
* other means. In both cases the caller always gets a boolean that
|
||||
* indicates only whether the caller should treat delivery as
|
||||
* best-effort done — the API response to the user must NOT leak it.
|
||||
*/
|
||||
async sendPasswordResetEmail(
|
||||
to: string,
|
||||
resetUrl: string,
|
||||
userId: number | null,
|
||||
): Promise<{ delivered: 'email' | 'log' | 'failed' }> {
|
||||
const lang = userId ? this.getUserLanguage(userId) : 'en';
|
||||
const strings = PASSWORD_RESET_I18N[lang] || PASSWORD_RESET_I18N.en;
|
||||
const smtpCfg = this.getSmtpConfig();
|
||||
|
||||
if (!smtpCfg) {
|
||||
// No SMTP configured — log the link in a visually distinct block so
|
||||
// the admin can relay it. Never log the associated user id/email
|
||||
// content at a lower level, only what's needed.
|
||||
|
||||
console.log(
|
||||
`\n===== PASSWORD RESET LINK =====\n` +
|
||||
`to: ${to}\n` +
|
||||
`url: ${resetUrl}\n` +
|
||||
`expires: 60 minutes\n` +
|
||||
`(SMTP is not configured — deliver this link to the user manually.)\n` +
|
||||
`================================\n`,
|
||||
);
|
||||
logInfo(`Password reset link issued (no SMTP) for=${to}`);
|
||||
return { delivered: 'log' };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.createTransport(smtpCfg).sendMail({
|
||||
from: smtpCfg.from,
|
||||
to,
|
||||
subject: `TREK — ${strings.subject}`,
|
||||
text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`,
|
||||
html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang),
|
||||
});
|
||||
logInfo(`Password reset email sent to=${to}`);
|
||||
return { delivered: 'email' };
|
||||
} catch (err) {
|
||||
logError(`Password reset email failed to=${to}: ${err instanceof Error ? err.message : err}`);
|
||||
return { delivered: 'failed' };
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
userId?: number,
|
||||
navigateTarget?: string,
|
||||
): Promise<boolean> {
|
||||
const config = this.getSmtpConfig();
|
||||
if (!config) return false;
|
||||
|
||||
const lang = userId ? this.getUserLanguage(userId) : 'en';
|
||||
|
||||
try {
|
||||
await this.createTransport(config).sendMail({
|
||||
from: config.from,
|
||||
to,
|
||||
subject: `TREK — ${subject}`,
|
||||
text: body,
|
||||
html: buildEmailHtml(subject, body, lang, navigateTarget),
|
||||
});
|
||||
logInfo(`Email sent to=${to} subject="${subject}"`);
|
||||
logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError(`Email send failed to=${to}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
|
||||
const config = this.getSmtpConfig();
|
||||
if (!config) return { success: false, error: 'SMTP not configured' };
|
||||
try {
|
||||
await this.createTransport(config).sendMail({
|
||||
from: config.from,
|
||||
to,
|
||||
subject: 'TREK — Test Notification',
|
||||
text: 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.',
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { MailerService } from './mailer/mailer.service';
|
||||
import { listChannels } from './channel-registry';
|
||||
import {
|
||||
ADMIN_SCOPED_EVENTS,
|
||||
ALL_EVENT_TYPES,
|
||||
INAPP_CHANNEL,
|
||||
isAdminGlobalChannel,
|
||||
type AdminGlobalChannel,
|
||||
type ChannelDescriptor,
|
||||
type ExternalChannel,
|
||||
type NotifChannel,
|
||||
type NotifEventType,
|
||||
} from './notification-events';
|
||||
|
||||
export interface PreferencesMatrix {
|
||||
preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
|
||||
/** The columns to render, in order. Replaces the old fixed-shape available_channels. */
|
||||
channels: ChannelDescriptor[];
|
||||
event_types: NotifEventType[];
|
||||
implemented_combos: Record<string, NotifChannel[]>;
|
||||
defaults?: { ntfyServer: string | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Who gets which notification over which channel: the admin's channel switches,
|
||||
* the per-user event opt-outs, and the matrix the settings UI renders.
|
||||
*
|
||||
* It reads the live channel set from the registry but never sends anything —
|
||||
* the dispatch loop in NotificationsService applies what is decided here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationPreferencesService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly mailer: MailerService,
|
||||
) {}
|
||||
|
||||
private getAppSetting(key: string): string | null {
|
||||
return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels implemented for an event. In-app takes everything; external channels
|
||||
* decide for themselves (today: everything except `synology_session_cleared`).
|
||||
*/
|
||||
combosFor(event: NotifEventType): NotifChannel[] {
|
||||
return [INAPP_CHANNEL, ...listChannels().filter(c => c.supportsEvent(event)).map(c => c.id)];
|
||||
}
|
||||
|
||||
private allCombos(): Record<string, NotifChannel[]> {
|
||||
const out: Record<string, NotifChannel[]> = {};
|
||||
for (const event of ALL_EVENT_TYPES) out[event] = this.combosFor(event);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Active channels (admin-configured) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Which channels the admin has enabled, as ids.
|
||||
* Reads `notification_channels` (plural) with fallback to `notification_channel` (singular).
|
||||
*
|
||||
* BUILT-INS ONLY. A plugin channel is NOT gated on this list: a built-in always exists in
|
||||
* the code and so needs an explicit switch, but a plugin channel only exists because an
|
||||
* admin installed and enabled that plugin — that IS the opt-in. Requiring a second one
|
||||
* meant a plugin channel could never be turned on at all (nothing writes a `plugin:` id
|
||||
* into this CSV, and the admin toggle rebuilds it from the three built-in booleans, which
|
||||
* would silently drop any that were).
|
||||
*/
|
||||
getActiveChannels(): NotifChannel[] {
|
||||
const raw = this.getAppSetting('notification_channels') || this.getAppSetting('notification_channel') || 'none';
|
||||
if (raw === 'none') return [];
|
||||
const builtins = new Set(listChannels().filter(c => c.source === 'builtin').map(c => c.id));
|
||||
return raw.split(',').map(c => c.trim()).filter(c => builtins.has(c));
|
||||
}
|
||||
|
||||
/** Is this channel switched on? Plugin channels are on by virtue of being live. */
|
||||
isChannelActive(channel: ExternalChannel): boolean {
|
||||
return channel.source === 'plugin' || this.getActiveChannels().includes(channel.id);
|
||||
}
|
||||
|
||||
// ── Per-user preference checks ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if the user has this event+channel enabled.
|
||||
* Default (no row) = enabled. Only returns false if there's an explicit disabled row.
|
||||
*/
|
||||
isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): boolean {
|
||||
const row = this.db.get<{ enabled: number }>(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
|
||||
userId, eventType, channel,
|
||||
);
|
||||
return row === undefined || row.enabled === 1;
|
||||
}
|
||||
|
||||
// ── Preferences matrix ─────────────────────────────────────────────────────
|
||||
|
||||
/** The in-app pseudo-channel — always active, never configurable. */
|
||||
private inAppDescriptor(): ChannelDescriptor {
|
||||
return {
|
||||
id: INAPP_CHANNEL,
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.inapp',
|
||||
active: true,
|
||||
configured: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel columns for a scope.
|
||||
* scope='user' — a column per channel the admin turned on in `notification_channels`.
|
||||
* scope='admin' — a column per channel that has admin-global credentials.
|
||||
*/
|
||||
private describeChannels(userId: number, scope: 'user' | 'admin'): ChannelDescriptor[] {
|
||||
const out: ChannelDescriptor[] = [this.inAppDescriptor()];
|
||||
|
||||
if (scope === 'admin') {
|
||||
// Admin-scoped events go out over the admin's own global credentials, which
|
||||
// are independent of the per-user `notification_channels` toggle.
|
||||
const hasSmtp = this.mailer.isSmtpConfigured();
|
||||
const hasAdminWebhook = !!this.getAppSetting('admin_webhook_url');
|
||||
const hasAdminNtfy = !!this.getAppSetting('admin_ntfy_topic');
|
||||
const adminActive: Record<string, boolean> = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy };
|
||||
for (const channel of listChannels()) {
|
||||
// Plugin channels are user-scoped only — they never carry admin-global events.
|
||||
if (channel.source !== 'builtin') continue;
|
||||
const active = adminActive[channel.id] ?? false;
|
||||
out.push({
|
||||
id: channel.id,
|
||||
source: channel.source,
|
||||
labelKey: channel.labelKey,
|
||||
label: channel.label,
|
||||
active,
|
||||
configured: active,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const channel of listChannels()) {
|
||||
out.push({
|
||||
id: channel.id,
|
||||
source: channel.source,
|
||||
labelKey: channel.labelKey,
|
||||
label: channel.label,
|
||||
settingsPath: channel.settingsPath,
|
||||
// A live plugin channel is always a column. `configured` tells the user whether
|
||||
// they still need to enter credentials — it does not hide the channel from them.
|
||||
active: this.isChannelActive(channel),
|
||||
configured: channel.isConfiguredFor(userId),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preferences matrix for a user.
|
||||
* scope='user' — excludes admin-scoped events (for user settings page)
|
||||
* scope='admin' — returns only admin-scoped events (for admin notifications tab)
|
||||
*/
|
||||
getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): PreferencesMatrix {
|
||||
const rows = this.db.all<{ event_type: string; channel: string; enabled: number }>(
|
||||
'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?', userId,
|
||||
);
|
||||
|
||||
// Build a lookup from stored rows
|
||||
const stored: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
for (const row of rows) {
|
||||
if (!stored[row.event_type]) stored[row.event_type] = {};
|
||||
stored[row.event_type]![row.channel] = row.enabled === 1;
|
||||
}
|
||||
|
||||
const implemented_combos = this.allCombos();
|
||||
|
||||
// Build the full matrix with defaults (true when no row exists)
|
||||
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
|
||||
|
||||
for (const eventType of ALL_EVENT_TYPES) {
|
||||
preferences[eventType] = {};
|
||||
for (const channel of implemented_combos[eventType]) {
|
||||
// Admin-scoped events use global settings for the built-in external channels
|
||||
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && isAdminGlobalChannel(channel)) {
|
||||
preferences[eventType]![channel] = this.getAdminGlobalPref(eventType, channel);
|
||||
} else {
|
||||
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter event types by scope
|
||||
const event_types = scope === 'admin'
|
||||
? ALL_EVENT_TYPES.filter(e => ADMIN_SCOPED_EVENTS.has(e))
|
||||
: ALL_EVENT_TYPES.filter(e => !ADMIN_SCOPED_EVENTS.has(e));
|
||||
|
||||
return {
|
||||
preferences,
|
||||
channels: this.describeChannels(userId, scope),
|
||||
event_types,
|
||||
implemented_combos,
|
||||
...(scope === 'user' && { defaults: { ntfyServer: this.getAppSetting('admin_ntfy_server') || null } }),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin global preferences (stored in app_settings) ─────────────────────
|
||||
|
||||
/**
|
||||
* Returns the global admin preference for an event+channel.
|
||||
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
|
||||
* Defaults to true (enabled) when no row exists.
|
||||
*/
|
||||
getAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel): boolean {
|
||||
const val = this.getAppSetting(`admin_notif_pref_${event}_${channel}`);
|
||||
return val !== '0';
|
||||
}
|
||||
|
||||
private setAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel, enabled: boolean): void {
|
||||
this.db.run('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)',
|
||||
`admin_notif_pref_${event}_${channel}`,
|
||||
enabled ? '1' : '0',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preferences update ─────────────────────────────────────────────────────
|
||||
|
||||
/** Shared helper for per-user channel preference upserts. */
|
||||
private applyUserChannelPrefs(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>,
|
||||
): void {
|
||||
const upsert = this.db.prepare(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const del = this.db.prepare(
|
||||
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
);
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (enabled) {
|
||||
// Remove explicit row — default is enabled
|
||||
del.run(userId, eventType, channel);
|
||||
} else {
|
||||
upsert.run(userId, eventType, channel, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-update preferences from the matrix UI.
|
||||
* Inserts disabled rows (enabled=0) and removes rows that are enabled (default).
|
||||
*/
|
||||
setPreferences(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||
): void {
|
||||
this.db.transaction(() => this.applyUserChannelPrefs(userId, prefs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-update admin notification preferences.
|
||||
* email/webhook channels are stored globally in app_settings (not per-user).
|
||||
* inapp channel remains per-user in notification_channel_preferences.
|
||||
*/
|
||||
setAdminPreferences(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||
): void {
|
||||
// Split global (email/webhook) from per-user (inapp) prefs
|
||||
const globalPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
const userPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (isAdminGlobalChannel(channel)) {
|
||||
if (!globalPrefs[eventType]) globalPrefs[eventType] = {};
|
||||
globalPrefs[eventType]![channel] = enabled;
|
||||
} else {
|
||||
if (!userPrefs[eventType]) userPrefs[eventType] = {};
|
||||
userPrefs[eventType]![channel] = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply global prefs outside the transaction (they write to app_settings)
|
||||
for (const [eventType, channels] of Object.entries(globalPrefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (!isAdminGlobalChannel(channel)) continue;
|
||||
this.setAdminGlobalPref(eventType as NotifEventType, channel, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply per-user (inapp) prefs in a transaction
|
||||
this.db.transaction(() => this.applyUserChannelPrefs(userId, userPrefs));
|
||||
}
|
||||
|
||||
// ── Instance-level readiness ──────────────────────────────────────────────
|
||||
|
||||
/** SMTP set up at all? Kept here because the settings UI asks preferences, not the mailer. */
|
||||
isSmtpConfigured(): boolean {
|
||||
return this.mailer.isSmtpConfigured();
|
||||
}
|
||||
|
||||
isWebhookConfigured(): boolean {
|
||||
return this.getActiveChannels().includes('webhook');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { MailerService } from './mailer/mailer.service';
|
||||
import { NotificationPreferencesService } from './notification-preferences.service';
|
||||
import { NtfyService } from './transports/ntfy.service';
|
||||
import { WebhookService } from './transports/webhook.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import type { NotificationPayload } from './notifications.service';
|
||||
|
||||
@@ -19,9 +23,22 @@ import type { NotificationPayload } from './notifications.service';
|
||||
* lazy senders inject.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton.
|
||||
* the shared better-sqlite3 singleton, and the channel registry is module-scoped
|
||||
* on purpose, so this instance and the DI singleton see the same channels — the
|
||||
* built-ins it registers in its constructor and the plugin channels the runtime
|
||||
* pushes in. A registry that were a provider would leave this instance empty and
|
||||
* silence every plugin channel on the scheduler's path.
|
||||
*/
|
||||
const notifications = new NotificationsService(new DatabaseService(db), new RealtimeService());
|
||||
const dbs = new DatabaseService(db);
|
||||
const mailer = new MailerService(dbs);
|
||||
const notifications = new NotificationsService(
|
||||
dbs,
|
||||
new RealtimeService(),
|
||||
mailer,
|
||||
new WebhookService(dbs),
|
||||
new NtfyService(dbs),
|
||||
new NotificationPreferencesService(dbs, mailer),
|
||||
);
|
||||
|
||||
export function send(payload: NotificationPayload): Promise<void> {
|
||||
return notifications.send(payload);
|
||||
|
||||
@@ -2,16 +2,23 @@ import { Module } from '@nestjs/common';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsMcp } from './notifications.mcp';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationPreferencesService } from './notification-preferences.service';
|
||||
import { NtfyService } from './transports/ntfy.service';
|
||||
import { WebhookService } from './transports/webhook.service';
|
||||
import { MailerModule } from './mailer/mailer.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
/** 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
|
||||
* AuthModule feeds NotificationsMcp's demo gate; MailerModule carries SMTP,
|
||||
* which lives outside this module so AuthService can send the password-reset
|
||||
* mail without AuthModule and NotificationsModule importing each other.
|
||||
* NotificationsService and NotificationPreferencesService are exported for
|
||||
* in-container consumers (AdminController's dev test send and preferences tab,
|
||||
* the plugin RPC host's PluginHostDepsFactory). */
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, MailerModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService, NotificationsMcp],
|
||||
exports: [NotificationsService],
|
||||
providers: [NotificationsService, NotificationPreferencesService, WebhookService, NtfyService, NotificationsMcp],
|
||||
exports: [NotificationsService, NotificationPreferencesService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -2,24 +2,12 @@ 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,
|
||||
testNtfy,
|
||||
getAdminWebhookUrl,
|
||||
getUserWebhookUrl,
|
||||
getUserNtfyConfig,
|
||||
getAdminNtfyConfig,
|
||||
getEventText,
|
||||
getUserLanguage,
|
||||
} from '../../services/notifications';
|
||||
import {
|
||||
getPreferencesMatrix,
|
||||
setPreferences,
|
||||
getActiveChannels,
|
||||
isEnabledForEvent,
|
||||
getAdminGlobalPref,
|
||||
} from '../../services/notificationPreferencesService';
|
||||
import { getEventText } from './mailer/email-html';
|
||||
import { MailerService } from './mailer/mailer.service';
|
||||
import { NtfyService, type NtfyConfig } from './transports/ntfy.service';
|
||||
import { WebhookService } from './transports/webhook.service';
|
||||
import { NotificationPreferencesService, type PreferencesMatrix } from './notification-preferences.service';
|
||||
import { registerBuiltinChannels } from './channels/builtins';
|
||||
import {
|
||||
ADMIN_SCOPED_EVENTS,
|
||||
isAdminGlobalChannel,
|
||||
@@ -27,14 +15,12 @@ import {
|
||||
type ExternalChannel,
|
||||
type NotifEventType,
|
||||
} from './notification-events';
|
||||
import { getChannel, listChannels } from '../../services/notifications/channelRegistry';
|
||||
import { getChannel, listChannels } from './channel-registry';
|
||||
import { getAction } from './in-app-actions';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
|
||||
type NtfyConfig = ReturnType<typeof getAdminNtfyConfig>;
|
||||
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
|
||||
@@ -276,6 +262,7 @@ function shouldSendToUser(
|
||||
event: NotifEventType,
|
||||
recipientId: number,
|
||||
activeChannels: string[],
|
||||
prefs: NotificationPreferencesService,
|
||||
): boolean {
|
||||
if (!channel.supportsEvent(event)) return false;
|
||||
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
|
||||
@@ -283,12 +270,12 @@ function shouldSendToUser(
|
||||
if (ADMIN_SCOPED_EVENTS.has(event)) {
|
||||
if (!channel.bypassesActiveToggleForAdminEvents) return false;
|
||||
if (!isAdminGlobalChannel(channel.id)) return false;
|
||||
if (!getAdminGlobalPref(event, channel.id)) return false;
|
||||
if (!prefs.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;
|
||||
if (!prefs.isEnabledForEvent(recipientId, event, channel.id)) return false;
|
||||
}
|
||||
|
||||
return channel.isConfiguredFor(recipientId);
|
||||
@@ -304,10 +291,10 @@ function shouldSendToUser(
|
||||
* 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`.
|
||||
* The channel transports and the preference matrix are injected providers
|
||||
* since the notifications fold; the channel registry stays a module singleton
|
||||
* because notifications.bridge.ts and the plugin runtime both reach it from
|
||||
* outside the container.
|
||||
*
|
||||
* Non-Nest consumers (scheduler, legacy adminService/memories, the lazy
|
||||
* fire-and-forget sends in migrated Nest services) go through
|
||||
@@ -319,10 +306,20 @@ export class NotificationsService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly realtime: RealtimeService,
|
||||
) {}
|
||||
private readonly mailer: MailerService,
|
||||
private readonly webhook: WebhookService,
|
||||
private readonly ntfy: NtfyService,
|
||||
private readonly prefs: NotificationPreferencesService,
|
||||
) {
|
||||
// The registry is a module singleton shared with notifications.bridge.ts and
|
||||
// the plugin runtime, so registering from here means every path that can
|
||||
// dispatch has the built-ins — including the bridge instance, which never
|
||||
// runs onModuleInit. registerChannel is an idempotent Map.set.
|
||||
registerBuiltinChannels({ mailer, webhook, ntfy });
|
||||
}
|
||||
|
||||
getPreferences(userId: number, role: string): PreferencesMatrix {
|
||||
return getPreferencesMatrix(userId, role, 'user');
|
||||
return this.prefs.getPreferencesMatrix(userId, role, 'user');
|
||||
}
|
||||
|
||||
/** Send a test notification over any registered channel (built-in or plugin). */
|
||||
@@ -338,36 +335,36 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
setPreferences(userId: number, body: Parameters<typeof setPreferences>[1]): void {
|
||||
setPreferences(userId, body);
|
||||
setPreferences(userId: number, body: Parameters<NotificationPreferencesService['setPreferences']>[1]): void {
|
||||
this.prefs.setPreferences(userId, body);
|
||||
}
|
||||
|
||||
testSmtp(to: string): Promise<ChannelTestResult> {
|
||||
return testSmtp(to);
|
||||
return this.mailer.testSmtp(to);
|
||||
}
|
||||
|
||||
testWebhook(url: string): Promise<ChannelTestResult> {
|
||||
return testWebhook(url);
|
||||
return this.webhook.testWebhook(url);
|
||||
}
|
||||
|
||||
testNtfy(cfg: { topic: string; server?: string | null; token?: string | null }): Promise<ChannelTestResult> {
|
||||
return testNtfy(cfg);
|
||||
return this.ntfy.testNtfy(cfg);
|
||||
}
|
||||
|
||||
userWebhookUrl(userId: number): string | null {
|
||||
return getUserWebhookUrl(userId);
|
||||
return this.webhook.getUserWebhookUrl(userId);
|
||||
}
|
||||
|
||||
adminWebhookUrl(): string | null {
|
||||
return getAdminWebhookUrl();
|
||||
return this.webhook.getAdminWebhookUrl();
|
||||
}
|
||||
|
||||
userNtfyConfig(userId: number): NtfyConfig | null {
|
||||
return getUserNtfyConfig(userId);
|
||||
return this.ntfy.getUserNtfyConfig(userId);
|
||||
}
|
||||
|
||||
adminNtfyConfig(): NtfyConfig {
|
||||
return getAdminNtfyConfig();
|
||||
return this.ntfy.getAdminNtfyConfig();
|
||||
}
|
||||
|
||||
// ── In-app notification store (from services/inAppNotifications.ts) ───────
|
||||
@@ -429,7 +426,7 @@ export class NotificationsService {
|
||||
|
||||
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')) {
|
||||
if (input.event_type && !this.prefs.isEnabledForEvent(recipientId, input.event_type, 'inapp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -699,7 +696,7 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
const config = configEntry ?? FALLBACK_EVENT_CONFIG;
|
||||
const activeChannels = getActiveChannels();
|
||||
const activeChannels = this.prefs.getActiveChannels();
|
||||
const channels = listChannels();
|
||||
const appUrl = getAppUrl();
|
||||
|
||||
@@ -719,7 +716,7 @@ export class NotificationsService {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// ── In-app ──────────────────────────────────────────────────────────
|
||||
if (isEnabledForEvent(recipientId, event, 'inapp')) {
|
||||
if (this.prefs.isEnabledForEvent(recipientId, event, 'inapp')) {
|
||||
const inAppType = inApp?.type ?? config.inAppType;
|
||||
let notifInput: NotificationInput;
|
||||
|
||||
@@ -776,9 +773,9 @@ export class NotificationsService {
|
||||
// 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));
|
||||
const deliverable = channels.filter(ch => shouldSendToUser(ch, event, recipientId, activeChannels, this.prefs));
|
||||
if (deliverable.length > 0) {
|
||||
const lang = getUserLanguage(recipientId);
|
||||
const lang = this.mailer.getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
const msg: ChannelMessage = {
|
||||
event,
|
||||
@@ -804,7 +801,7 @@ export class NotificationsService {
|
||||
// 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),
|
||||
ch => ch.sendGlobal && ch.supportsEvent(event) && isAdminGlobalChannel(ch.id) && this.prefs.getAdminGlobalPref(event, ch.id),
|
||||
);
|
||||
if (globalChannels.length > 0) {
|
||||
const { title, body } = getEventText('en', event, params);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { logDebug, logError, logInfo } from '../../audit/audit-log.logger';
|
||||
import { decrypt_api_key } from '../../common/crypto/apiKeyCrypto';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../../../utils/ssrfGuard';
|
||||
import type { NotifEventType } from '../notification-events';
|
||||
|
||||
export interface NtfyConfig {
|
||||
server: string | null;
|
||||
topic: string | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/** Priority and tags mapped to each notification event type. */
|
||||
const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4 | 5; tags: string[] }>> = {
|
||||
trip_invite: { priority: 4, tags: ['loudspeaker'] },
|
||||
booking_change: { priority: 3, tags: ['calendar'] },
|
||||
trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] },
|
||||
vacay_invite: { priority: 4, tags: ['palm_tree'] },
|
||||
todo_due: { priority: 4, tags: ['ballot_box_with_check'] },
|
||||
vacay_share: { priority: 3, tags: ['palm_tree'] },
|
||||
collection_invite: { priority: 3, tags: ['bookmark'] },
|
||||
photos_shared: { priority: 3, tags: ['camera'] },
|
||||
collab_message: { priority: 3, tags: ['speech_balloon'] },
|
||||
packing_tagged: { priority: 3, tags: ['luggage'] },
|
||||
version_available: { priority: 4, tags: ['package'] },
|
||||
synology_session_cleared: { priority: 3, tags: ['warning'] },
|
||||
};
|
||||
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
|
||||
|
||||
/**
|
||||
* Resolve the ntfy POST URL for a per-user send. The topic must come from the
|
||||
* user's own config — the admin topic is reserved for admin-scoped sends
|
||||
* (see resolveAdminNtfyUrl). Only the server falls back to the admin default.
|
||||
* Returns null if the user has no topic.
|
||||
*/
|
||||
export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null {
|
||||
const topic = userCfg?.topic;
|
||||
if (!topic) return null;
|
||||
const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
return `${base}/${encodeURIComponent(topic)}`;
|
||||
}
|
||||
|
||||
/** Resolve the ntfy POST URL for admin-scoped sends. Returns null if no admin topic. */
|
||||
export function resolveAdminNtfyUrl(adminCfg: NtfyConfig): string | null {
|
||||
if (!adminCfg.topic) return null;
|
||||
const base = (adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
return `${base}/${encodeURIComponent(adminCfg.topic)}`;
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) > 0x7f) {
|
||||
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** ntfy push: the per-user and admin topic config, and the header-based POST. */
|
||||
@Injectable()
|
||||
export class NtfyService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
private getAppSetting(key: string): string | null {
|
||||
return this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || null;
|
||||
}
|
||||
|
||||
getUserNtfyConfig(userId: number): NtfyConfig | null {
|
||||
const rows = this.db.all<{ key: string; value: string }>(
|
||||
"SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')",
|
||||
userId,
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
return {
|
||||
topic: map['ntfy_topic'] || null,
|
||||
server: map['ntfy_server'] || null,
|
||||
token: map['ntfy_token'] ? decrypt_api_key(map['ntfy_token']) : null,
|
||||
};
|
||||
}
|
||||
|
||||
getAdminNtfyConfig(): NtfyConfig {
|
||||
const topic = this.getAppSetting('admin_ntfy_topic') || null;
|
||||
const server = this.getAppSetting('admin_ntfy_server') || null;
|
||||
const rawToken = this.getAppSetting('admin_ntfy_token') || null;
|
||||
return {
|
||||
topic,
|
||||
server,
|
||||
token: rawToken ? decrypt_api_key(rawToken) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async sendNtfy(
|
||||
url: string,
|
||||
token: string | null,
|
||||
payload: { event: string; title: string; body: string; link?: string },
|
||||
): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) {
|
||||
logError(`Ntfy blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = NTFY_EVENT_META[payload.event as NotifEventType] ?? NTFY_DEFAULT_META;
|
||||
|
||||
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
|
||||
const headers: Record<string, string> = {
|
||||
Title: encodeHeaderValue(payload.title),
|
||||
Priority: String(meta.priority),
|
||||
};
|
||||
if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(',');
|
||||
if (payload.link) headers['Click'] = encodeHeaderValue(payload.link);
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload.body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
|
||||
} as RequestInit);
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
logError(`Ntfy HTTP ${res.status}: ${errBody}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logInfo(`Ntfy sent event=${payload.event}`);
|
||||
logDebug(`Ntfy url=${url} priority=${meta.priority} tags=${meta.tags.join(',')}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError(`Ntfy failed event=${payload.event}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async testNtfy(cfg: {
|
||||
topic: string;
|
||||
server?: string | null;
|
||||
token?: string | null;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const adminCfg = this.getAdminNtfyConfig();
|
||||
const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null });
|
||||
if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
|
||||
try {
|
||||
const sent = await this.sendNtfy(url, cfg.token ?? null, {
|
||||
event: 'test',
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test notification from TREK. If you received this, your ntfy configuration is working correctly.',
|
||||
});
|
||||
return sent ? { success: true } : { success: false, error: 'Failed to send ntfy notification' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { logDebug, logError, logInfo } from '../../audit/audit-log.logger';
|
||||
import { decrypt_api_key } from '../../common/crypto/apiKeyCrypto';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../../../utils/ssrfGuard';
|
||||
|
||||
/**
|
||||
* Renders the outgoing body. Discord and Slack get their native shapes; anything
|
||||
* else gets the raw payload. Pure, so the shape can be asserted without a send.
|
||||
*/
|
||||
export function buildWebhookBody(
|
||||
url: string,
|
||||
payload: { event: string; title: string; body: string; tripName?: string; link?: string },
|
||||
): string {
|
||||
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
||||
const isSlack = /hooks\.slack\.com\//.test(url);
|
||||
|
||||
if (isDiscord) {
|
||||
return JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: `📍 ${payload.title}`,
|
||||
description: payload.body,
|
||||
url: payload.link,
|
||||
color: 0x3b82f6,
|
||||
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (isSlack) {
|
||||
const trip = payload.tripName ? ` • _${payload.tripName}_` : '';
|
||||
const link = payload.link ? `\n<${payload.link}|Open in TREK>` : '';
|
||||
return JSON.stringify({
|
||||
text: `*${payload.title}*\n${payload.body}${trip}${link}`,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' });
|
||||
}
|
||||
|
||||
/** Outgoing webhooks: the per-user and the admin-global URL, and the POST itself. */
|
||||
@Injectable()
|
||||
export class WebhookService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
getUserWebhookUrl(userId: number): string | null {
|
||||
const value = this.db.get<{ value: string }>(
|
||||
"SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'", userId,
|
||||
)?.value || null;
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
getAdminWebhookUrl(): string | null {
|
||||
const value = this.db.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?', 'admin_webhook_url',
|
||||
)?.value || null;
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
async sendWebhook(
|
||||
url: string,
|
||||
payload: { event: string; title: string; body: string; tripName?: string; link?: string },
|
||||
): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) {
|
||||
logError(`Webhook blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: buildWebhookBody(url, payload),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
|
||||
} as RequestInit);
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
logError(`Webhook HTTP ${res.status}: ${errBody}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logInfo(`Webhook sent event=${payload.event} trip=${payload.tripName || '-'}`);
|
||||
logDebug(`Webhook url=${url} payload=${buildWebhookBody(url, payload).substring(0, 500)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError(`Webhook failed event=${payload.event}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async testWebhook(url: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const sent = await this.sendWebhook(url, {
|
||||
event: 'test',
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.',
|
||||
});
|
||||
return sent ? { success: true } : { success: false, error: 'Failed to send webhook' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { DatabaseService } from '../database/database.service';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { setPluginEventSink } from '../../plugin-event-sink';
|
||||
import { setUserDeletedSink } from '../../plugin-user-lifecycle';
|
||||
import { setPluginChannelSource, pluginChannelId, type ChannelMessage, type ExternalChannel } from '../../services/notifications/channelRegistry';
|
||||
import { setPluginChannelSource, pluginChannelId } from '../notifications/channel-registry';
|
||||
import type { ChannelMessage, ExternalChannel } from '../notifications/notification-events';
|
||||
import { readUserSettingsDecrypted, hasRequiredUserSettings } from './plugins.service';
|
||||
import { PLUGIN_CHANNEL_EVENTS } from './install/manifest';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { db } from '../db/database';
|
||||
import { isSmtpConfigured } from './notifications';
|
||||
import {
|
||||
ADMIN_SCOPED_EVENTS,
|
||||
ALL_EVENT_TYPES,
|
||||
INAPP_CHANNEL,
|
||||
isAdminGlobalChannel,
|
||||
type AdminGlobalChannel,
|
||||
type ChannelDescriptor,
|
||||
type ExternalChannel,
|
||||
type NotifChannel,
|
||||
type NotifEventType,
|
||||
} from '../nest/notifications/notification-events';
|
||||
import { listChannels } from './notifications/channelRegistry';
|
||||
// Side-effect import: populates the registry with email/webhook/ntfy. Safe from
|
||||
// here — builtins only reaches ../notifications, which imports this module's
|
||||
// types with `import type`, so there is no runtime cycle.
|
||||
import './notifications/builtins';
|
||||
|
||||
/**
|
||||
* Channels implemented for an event. In-app takes everything; external channels
|
||||
* decide for themselves (today: everything except `synology_session_cleared`).
|
||||
*/
|
||||
export function combosFor(event: NotifEventType): NotifChannel[] {
|
||||
return [INAPP_CHANNEL, ...listChannels().filter(c => c.supportsEvent(event)).map(c => c.id)];
|
||||
}
|
||||
|
||||
function allCombos(): Record<string, NotifChannel[]> {
|
||||
const out: Record<string, NotifChannel[]> = {};
|
||||
for (const event of ALL_EVENT_TYPES) out[event] = combosFor(event);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getAppSetting(key: string): string | null {
|
||||
return (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || null;
|
||||
}
|
||||
|
||||
// ── Active channels (admin-configured) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Which channels the admin has enabled, as ids.
|
||||
* Reads `notification_channels` (plural) with fallback to `notification_channel` (singular).
|
||||
*
|
||||
* BUILT-INS ONLY. A plugin channel is NOT gated on this list: a built-in always exists in
|
||||
* the code and so needs an explicit switch, but a plugin channel only exists because an
|
||||
* admin installed and enabled that plugin — that IS the opt-in. Requiring a second one
|
||||
* meant a plugin channel could never be turned on at all (nothing writes a `plugin:` id
|
||||
* into this CSV, and the admin toggle rebuilds it from the three built-in booleans, which
|
||||
* would silently drop any that were).
|
||||
*/
|
||||
export function getActiveChannels(): NotifChannel[] {
|
||||
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
|
||||
if (raw === 'none') return [];
|
||||
const builtins = new Set(listChannels().filter(c => c.source === 'builtin').map(c => c.id));
|
||||
return raw.split(',').map(c => c.trim()).filter(c => builtins.has(c));
|
||||
}
|
||||
|
||||
/** Is this channel switched on? Plugin channels are on by virtue of being live. */
|
||||
export function isChannelActive(channel: ExternalChannel): boolean {
|
||||
return channel.source === 'plugin' || getActiveChannels().includes(channel.id);
|
||||
}
|
||||
|
||||
// ── Per-user preference checks ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if the user has this event+channel enabled.
|
||||
* Default (no row) = enabled. Only returns false if there's an explicit disabled row.
|
||||
*/
|
||||
export function isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): boolean {
|
||||
const row = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, eventType, channel) as { enabled: number } | undefined;
|
||||
return row === undefined || row.enabled === 1;
|
||||
}
|
||||
|
||||
// ── Preferences matrix ─────────────────────────────────────────────────────
|
||||
|
||||
export interface PreferencesMatrix {
|
||||
preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
|
||||
/** The columns to render, in order. Replaces the old fixed-shape available_channels. */
|
||||
channels: ChannelDescriptor[];
|
||||
event_types: NotifEventType[];
|
||||
implemented_combos: Record<string, NotifChannel[]>;
|
||||
defaults?: { ntfyServer: string | null };
|
||||
}
|
||||
|
||||
/** The in-app pseudo-channel — always active, never configurable. */
|
||||
function inAppDescriptor(): ChannelDescriptor {
|
||||
return {
|
||||
id: INAPP_CHANNEL,
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.inapp',
|
||||
active: true,
|
||||
configured: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel columns for a scope.
|
||||
* scope='user' — a column per channel the admin turned on in `notification_channels`.
|
||||
* scope='admin' — a column per channel that has admin-global credentials.
|
||||
*/
|
||||
function describeChannels(userId: number, scope: 'user' | 'admin'): ChannelDescriptor[] {
|
||||
const out: ChannelDescriptor[] = [inAppDescriptor()];
|
||||
|
||||
if (scope === 'admin') {
|
||||
// Admin-scoped events go out over the admin's own global credentials, which
|
||||
// are independent of the per-user `notification_channels` toggle.
|
||||
const hasSmtp = isSmtpConfigured();
|
||||
const hasAdminWebhook = !!getAppSetting('admin_webhook_url');
|
||||
const hasAdminNtfy = !!getAppSetting('admin_ntfy_topic');
|
||||
const adminActive: Record<string, boolean> = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy };
|
||||
for (const channel of listChannels()) {
|
||||
// Plugin channels are user-scoped only — they never carry admin-global events.
|
||||
if (channel.source !== 'builtin') continue;
|
||||
const active = adminActive[channel.id] ?? false;
|
||||
out.push({
|
||||
id: channel.id,
|
||||
source: channel.source,
|
||||
labelKey: channel.labelKey,
|
||||
label: channel.label,
|
||||
active,
|
||||
configured: active,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const channel of listChannels()) {
|
||||
out.push({
|
||||
id: channel.id,
|
||||
source: channel.source,
|
||||
labelKey: channel.labelKey,
|
||||
label: channel.label,
|
||||
settingsPath: channel.settingsPath,
|
||||
// A live plugin channel is always a column. `configured` tells the user whether
|
||||
// they still need to enter credentials — it does not hide the channel from them.
|
||||
active: isChannelActive(channel),
|
||||
configured: channel.isConfiguredFor(userId),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preferences matrix for a user.
|
||||
* scope='user' — excludes admin-scoped events (for user settings page)
|
||||
* scope='admin' — returns only admin-scoped events (for admin notifications tab)
|
||||
*/
|
||||
export function getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): PreferencesMatrix {
|
||||
const rows = db.prepare(
|
||||
'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?'
|
||||
).all(userId) as Array<{ event_type: string; channel: string; enabled: number }>;
|
||||
|
||||
// Build a lookup from stored rows
|
||||
const stored: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
for (const row of rows) {
|
||||
if (!stored[row.event_type]) stored[row.event_type] = {};
|
||||
stored[row.event_type]![row.channel] = row.enabled === 1;
|
||||
}
|
||||
|
||||
const implemented_combos = allCombos();
|
||||
|
||||
// Build the full matrix with defaults (true when no row exists)
|
||||
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
|
||||
|
||||
for (const eventType of ALL_EVENT_TYPES) {
|
||||
preferences[eventType] = {};
|
||||
for (const channel of implemented_combos[eventType]) {
|
||||
// Admin-scoped events use global settings for the built-in external channels
|
||||
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && isAdminGlobalChannel(channel)) {
|
||||
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
|
||||
} else {
|
||||
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter event types by scope
|
||||
const event_types = scope === 'admin'
|
||||
? ALL_EVENT_TYPES.filter(e => ADMIN_SCOPED_EVENTS.has(e))
|
||||
: ALL_EVENT_TYPES.filter(e => !ADMIN_SCOPED_EVENTS.has(e));
|
||||
|
||||
return {
|
||||
preferences,
|
||||
channels: describeChannels(userId, scope),
|
||||
event_types,
|
||||
implemented_combos,
|
||||
...(scope === 'user' && { defaults: { ntfyServer: getAppSetting('admin_ntfy_server') || null } }),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin global preferences (stored in app_settings) ─────────────────────
|
||||
|
||||
/**
|
||||
* Returns the global admin preference for an event+channel.
|
||||
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
|
||||
* Defaults to true (enabled) when no row exists.
|
||||
*/
|
||||
export function getAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel): boolean {
|
||||
const val = getAppSetting(`admin_notif_pref_${event}_${channel}`);
|
||||
return val !== '0';
|
||||
}
|
||||
|
||||
function setAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel, enabled: boolean): void {
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
|
||||
`admin_notif_pref_${event}_${channel}`,
|
||||
enabled ? '1' : '0'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preferences update ─────────────────────────────────────────────────────
|
||||
|
||||
// ── Shared helper for per-user channel preference upserts ─────────────────
|
||||
|
||||
function applyUserChannelPrefs(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>,
|
||||
upsert: Database.Statement<unknown[]>,
|
||||
del: Database.Statement<unknown[]>
|
||||
): void {
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (enabled) {
|
||||
// Remove explicit row — default is enabled
|
||||
del.run(userId, eventType, channel);
|
||||
} else {
|
||||
upsert.run(userId, eventType, channel, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-update preferences from the matrix UI.
|
||||
* Inserts disabled rows (enabled=0) and removes rows that are enabled (default).
|
||||
*/
|
||||
export function setPreferences(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||
): void {
|
||||
const upsert = db.prepare(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const del = db.prepare(
|
||||
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
);
|
||||
db.transaction(() => applyUserChannelPrefs(userId, prefs, upsert, del))();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-update admin notification preferences.
|
||||
* email/webhook channels are stored globally in app_settings (not per-user).
|
||||
* inapp channel remains per-user in notification_channel_preferences.
|
||||
*/
|
||||
export function setAdminPreferences(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||
): void {
|
||||
const upsert = db.prepare(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const del = db.prepare(
|
||||
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
);
|
||||
|
||||
// Split global (email/webhook) from per-user (inapp) prefs
|
||||
const globalPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
const userPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (isAdminGlobalChannel(channel)) {
|
||||
if (!globalPrefs[eventType]) globalPrefs[eventType] = {};
|
||||
globalPrefs[eventType]![channel] = enabled;
|
||||
} else {
|
||||
if (!userPrefs[eventType]) userPrefs[eventType] = {};
|
||||
userPrefs[eventType]![channel] = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply global prefs outside the transaction (they write to app_settings)
|
||||
for (const [eventType, channels] of Object.entries(globalPrefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (!isAdminGlobalChannel(channel)) continue;
|
||||
setAdminGlobalPref(eventType as NotifEventType, channel, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply per-user (inapp) prefs in a transaction
|
||||
db.transaction(() => applyUserChannelPrefs(userId, userPrefs, upsert, del))();
|
||||
}
|
||||
|
||||
// ── SMTP availability helper (for authService) ─────────────────────────────
|
||||
// Lives in ./notifications now (the module that owns SMTP config); re-exported
|
||||
// here so existing importers keep working.
|
||||
|
||||
export { isSmtpConfigured };
|
||||
|
||||
export function isWebhookConfigured(): boolean {
|
||||
return getActiveChannels().includes('webhook');
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
import { getAppUrl, readEnv } from '../app-config';
|
||||
import { db } from '../db/database';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
|
||||
import { decrypt_api_key } from '../nest/common/crypto/apiKeyCrypto';
|
||||
import { logInfo, logDebug, logError } from '../nest/audit/audit-log.logger';
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { NotifEventType } from '../nest/notifications/notification-events';
|
||||
import { EMAIL_I18N as I18N, EVENT_TEXTS, PASSWORD_RESET_I18N } from '@trek/shared/i18n/externalNotifications';
|
||||
import type {
|
||||
EmailStrings,
|
||||
EventText,
|
||||
PasswordResetStrings,
|
||||
} from '@trek/shared/i18n/externalNotifications';
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
from: string;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
// ── HTML escaping ──────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── Settings helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function getAppSetting(key: string): string | null {
|
||||
return (
|
||||
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getSmtpConfig(): SmtpConfig | null {
|
||||
const smtpEnv = readEnv().smtp;
|
||||
const host = smtpEnv.host || getAppSetting('smtp_host');
|
||||
const port = smtpEnv.port || getAppSetting('smtp_port');
|
||||
const user = smtpEnv.user || getAppSetting('smtp_user');
|
||||
const pass = smtpEnv.pass || decrypt_api_key(getAppSetting('smtp_pass')) || '';
|
||||
const from = smtpEnv.from || getAppSetting('smtp_from');
|
||||
if (!host || !port || !from) return null;
|
||||
return {
|
||||
host,
|
||||
port: parseInt(port, 10),
|
||||
user: user || '',
|
||||
pass: pass || '',
|
||||
from,
|
||||
secure: parseInt(port, 10) === 465,
|
||||
};
|
||||
}
|
||||
|
||||
/** Is SMTP configured at the instance level? (Independent of any one user's address.) */
|
||||
export function isSmtpConfigured(): boolean {
|
||||
return !!(readEnv().smtp.host || getAppSetting('smtp_host'));
|
||||
}
|
||||
|
||||
export function getUserEmail(userId: number): string | null {
|
||||
// Defense-in-depth (#1362): a guest's synthetic email must never be emailed.
|
||||
return (
|
||||
(
|
||||
db.prepare('SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0').get(userId) as
|
||||
| { email: string }
|
||||
| undefined
|
||||
)?.email || null
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserLanguage(userId: number): string {
|
||||
return (
|
||||
(
|
||||
db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as
|
||||
| { value: string }
|
||||
| undefined
|
||||
)?.value || 'en'
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserWebhookUrl(userId: number): string | null {
|
||||
const value =
|
||||
(
|
||||
db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as
|
||||
| { value: string }
|
||||
| undefined
|
||||
)?.value || null;
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
export function getAdminWebhookUrl(): string | null {
|
||||
const value = getAppSetting('admin_webhook_url') || null;
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
// ── Email i18n strings — imported from @trek/shared/i18n/externalNotifications ──
|
||||
|
||||
// EVENT_TEXTS imported from @trek/shared/i18n/externalNotifications
|
||||
|
||||
// Get localized event text
|
||||
export function getEventText(lang: string, event: NotifEventType, params: Record<string, string>): EventText {
|
||||
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
|
||||
const fn = texts[event] ?? EVENT_TEXTS.en[event];
|
||||
if (!fn) return { title: event, body: '' };
|
||||
return fn(params);
|
||||
}
|
||||
|
||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildEmailHtml(
|
||||
subject: string,
|
||||
body: string,
|
||||
lang: string,
|
||||
navigateTarget?: string,
|
||||
rawBody = false,
|
||||
): string {
|
||||
const s = I18N[lang] || I18N.en;
|
||||
const appUrl = getAppUrl();
|
||||
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : appUrl || '');
|
||||
const safeSubject = escapeHtml(subject);
|
||||
const safeBody = rawBody ? body : escapeHtml(body);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 40px 20px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
|
||||
<!-- Header -->
|
||||
<tr><td style="background: linear-gradient(135deg, #000000 0%, #1a1a2e 100%); padding: 32px 32px 28px; text-align: center;">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4NCiAgPGRlZnM+DQogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFlMjkzYiIvPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMGYxNzJhIi8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8Y2xpcFBhdGggaWQ9Imljb24iPg0KICAgICAgPHBhdGggZD0iTSA4NTUuNjM2NzE5IDY5OS4yMDMxMjUgTCAyMjIuMjQ2MDk0IDY5OS4yMDMxMjUgQyAxOTcuNjc5Njg4IDY5OS4yMDMxMjUgMTc5LjkwNjI1IDY3NS43NSAxODYuNTM5MDYyIDY1Mi4xMDE1NjIgTCAzNjAuNDI5Njg4IDMyLjM5MDYyNSBDIDM2NC45MjE4NzUgMTYuMzg2NzE5IDM3OS41MTE3MTkgNS4zMjgxMjUgMzk2LjEzMjgxMiA1LjMyODEyNSBMIDEwMjkuNTI3MzQ0IDUuMzI4MTI1IEMgMTA1NC4wODk4NDQgNS4zMjgxMjUgMTA3MS44NjcxODggMjguNzc3MzQ0IDEwNjUuMjMwNDY5IDUyLjQyOTY4OCBMIDg5MS4zMzk4NDQgNjcyLjEzNjcxOSBDIDg4Ni44NTE1NjIgNjg4LjE0MDYyNSA4NzIuMjU3ODEyIDY5OS4yMDMxMjUgODU1LjYzNjcxOSA2OTkuMjAzMTI1IFogTSA0NDQuMjM4MjgxIDExNjYuOTgwNDY5IEwgNTMzLjc3MzQzOCA4NDcuODk4NDM4IEMgNTQwLjQxMDE1NiA4MjQuMjQ2MDk0IDUyMi42MzI4MTIgODAwLjc5Njg3NSA0OTguMDcwMzEyIDgwMC43OTY4NzUgTCAxNzIuNDcyNjU2IDgwMC43OTY4NzUgQyAxNTUuODUxNTYyIDgwMC43OTY4NzUgMTQxLjI2MTcxOSA4MTEuODU1NDY5IDEzNi43Njk1MzEgODI3Ljg1OTM3NSBMIDQ3LjIzNDM3NSAxMTQ2Ljk0MTQwNiBDIDQwLjU5NzY1NiAxMTcwLjU5Mzc1IDU4LjM3NSAxMTk0LjA0Mjk2OSA4Mi45Mzc1IDExOTQuMDQyOTY5IEwgNDA4LjUzNTE1NiAxMTk0LjA0Mjk2OSBDIDQyNS4xNTYyNSAxMTk0LjA0Mjk2OSA0MzkuNzUgMTE4Mi45ODQzNzUgNDQ0LjIzODI4MSAxMTY2Ljk4MDQ2OSBaIE0gNjA5LjAwMzkwNiA4MjcuODU5Mzc1IEwgNDM1LjExMzI4MSAxNDQ3LjU3MDMxMiBDIDQyOC40NzY1NjIgMTQ3MS4yMTg3NSA0NDYuMjUzOTA2IDE0OTQuNjcxODc1IDQ3MC44MTY0MDYgMTQ5NC42NzE4NzUgTCAxMTA0LjIxMDkzOCAxNDk0LjY3MTg3NSBDIDExMjAuODMyMDMxIDE0OTQuNjcxODc1IDExMzUuNDIxODc1IDE0ODMuNjA5Mzc1IDExMzkuOTE0MDYyIDE0NjcuNjA1NDY5IEwgMTMxMy44MDQ2ODggODQ3Ljg5ODQzOCBDIDEzMjAuNDQxNDA2IDgyNC4yNDYwOTQgMTMwMi42NjQwNjIgODAwLjc5Njg3NSAxMjc4LjEwMTU2MiA4MDAuNzk2ODc1IEwgNjQ0LjcwNzAzMSA4MDAuNzk2ODc1IEMgNjI4LjA4NTkzOCA4MDAuNzk2ODc1IDYxMy40OTIxODggODExLjg1NTQ2OSA2MDkuMDAzOTA2IDgyNy44NTkzNzUgWiBNIDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgTCA5NjYuNTcwMzEyIDY1Mi4xMDE1NjIgQyA5NTkuOTMzNTk0IDY3NS43NSA5NzcuNzEwOTM4IDY5OS4yMDMxMjUgMTAwMi4yNzM0MzggNjk5LjIwMzEyNSBMIDEzMjcuODcxMDk0IDY5OS4yMDMxMjUgQyAxMzQ0LjQ5MjE4OCA2OTkuMjAzMTI1IDEzNTkuMDg1OTM4IDY4OC4xNDA2MjUgMTM2My41NzQyMTkgNjcyLjEzNjcxOSBMIDE0NTMuMTA5Mzc1IDM1My4wNTQ2ODggQyAxNDU5Ljc0NjA5NCAzMjkuNDA2MjUgMTQ0MS45Njg3NSAzMDUuOTUzMTI1IDE0MTcuNDA2MjUgMzA1Ljk1MzEyNSBMIDEwOTEuODA4NTk0IDMwNS45NTMxMjUgQyAxMDc1LjE4NzUgMzA1Ljk1MzEyNSAxMDYwLjU5NzY1NiAzMTcuMDE1NjI1IDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgWiIvPg0KICAgIDwvY2xpcFBhdGg+DQogIDwvZGVmcz4NCiAgPHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGZpbGw9InVybCgjYmcpIi8+DQogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LDUxKSBzY2FsZSgwLjI2NykiPg0KICAgIDxyZWN0IHdpZHRoPSIxNTAwIiBoZWlnaHQ9IjE1MDAiIGZpbGw9IiNmZmZmZmYiIGNsaXAtcGF0aD0idXJsKCNpY29uKSIvPg0KICA8L2c+DQo8L3N2Zz4NCg==" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
|
||||
<div style="color: #ffffff; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">TREK</div>
|
||||
<div style="color: rgba(255,255,255,0.4); font-size: 10px; font-weight: 500; letter-spacing: 2px; text-transform: uppercase; margin-top: 4px;">Travel Resource & Exploration Kit</div>
|
||||
</td></tr>
|
||||
<!-- Content -->
|
||||
<tr><td style="padding: 32px 32px 16px;">
|
||||
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${safeSubject}</h1>
|
||||
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
|
||||
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${safeBody}</p>
|
||||
</td></tr>
|
||||
<!-- CTA -->
|
||||
${
|
||||
appUrl
|
||||
? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
|
||||
<a href="${ctaHref}" style="display: inline-block; padding: 12px 28px; background: #111827; color: #ffffff; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 10px; letter-spacing: 0.2px;">${s.openTrek}</a>
|
||||
</td></tr>`
|
||||
: ''
|
||||
}
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding: 20px 32px; background: #f9fafb; border-top: 1px solid #f3f4f6; text-align: center;">
|
||||
<p style="margin: 0 0 8px; font-size: 11px; color: #9ca3af; line-height: 1.5;">${s.footer}<br>${s.manage}</p>
|
||||
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">♥</span> by Maurice · <a href="https://github.com/liketrek/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Send functions ─────────────────────────────────────────────────────────
|
||||
|
||||
// ── Password reset email ───────────────────────────────────────────────────
|
||||
|
||||
// PASSWORD_RESET_I18N imported from @trek/shared/i18n/externalNotifications
|
||||
|
||||
function buildPasswordResetHtml(
|
||||
subject: string,
|
||||
strings: PasswordResetStrings,
|
||||
recipient: string,
|
||||
resetUrl: string,
|
||||
lang: string,
|
||||
): string {
|
||||
const safeGreeting = escapeHtml(`${strings.greeting}, ${recipient}`);
|
||||
const safeBody = escapeHtml(strings.body);
|
||||
const safeExpiry = escapeHtml(strings.expiry);
|
||||
const safeIgnore = escapeHtml(strings.ignore);
|
||||
const safeCta = escapeHtml(strings.ctaIntro);
|
||||
const block = `
|
||||
<p style="margin:0 0 16px 0; font-size:16px;">${safeGreeting},</p>
|
||||
<p style="margin:0 0 20px 0; font-size:15px; line-height:1.6;">${safeBody}</p>
|
||||
<p style="margin:28px 0;">
|
||||
<a href="${resetUrl}" style="display:inline-block;padding:14px 28px;background:#111827;color:#fff;text-decoration:none;border-radius:10px;font-weight:600;font-size:15px;">${safeCta}</a>
|
||||
</p>
|
||||
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
|
||||
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
|
||||
`;
|
||||
return buildEmailHtml(subject, block, lang, undefined, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers a password-reset link. When SMTP is configured the user
|
||||
* receives an email. When it isn't, the link is logged to stdout in a
|
||||
* clearly-fenced block so the self-hosting admin can hand it off by
|
||||
* other means. In both cases the caller always gets a boolean that
|
||||
* indicates only whether the caller should treat delivery as
|
||||
* best-effort done — the API response to the user must NOT leak it.
|
||||
*/
|
||||
export async function sendPasswordResetEmail(
|
||||
to: string,
|
||||
resetUrl: string,
|
||||
userId: number | null,
|
||||
): Promise<{ delivered: 'email' | 'log' | 'failed' }> {
|
||||
const lang = userId ? getUserLanguage(userId) : 'en';
|
||||
const strings = PASSWORD_RESET_I18N[lang] || PASSWORD_RESET_I18N.en;
|
||||
const smtpCfg = getSmtpConfig();
|
||||
|
||||
if (!smtpCfg) {
|
||||
// No SMTP configured — log the link in a visually distinct block so
|
||||
// the admin can relay it. Never log the associated user id/email
|
||||
// content at a lower level, only what's needed.
|
||||
|
||||
console.log(
|
||||
`\n===== PASSWORD RESET LINK =====\n` +
|
||||
`to: ${to}\n` +
|
||||
`url: ${resetUrl}\n` +
|
||||
`expires: 60 minutes\n` +
|
||||
`(SMTP is not configured — deliver this link to the user manually.)\n` +
|
||||
`================================\n`,
|
||||
);
|
||||
logInfo(`Password reset link issued (no SMTP) for=${to}`);
|
||||
return { delivered: 'log' };
|
||||
}
|
||||
|
||||
try {
|
||||
const skipTls = readEnv().smtp.skipTlsVerify || getAppSetting('smtp_skip_tls_verify') === 'true';
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpCfg.host,
|
||||
port: smtpCfg.port,
|
||||
secure: smtpCfg.secure,
|
||||
auth: smtpCfg.user ? { user: smtpCfg.user, pass: smtpCfg.pass } : undefined,
|
||||
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: smtpCfg.from,
|
||||
to,
|
||||
subject: `TREK — ${strings.subject}`,
|
||||
text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`,
|
||||
html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang),
|
||||
});
|
||||
logInfo(`Password reset email sent to=${to}`);
|
||||
return { delivered: 'email' };
|
||||
} catch (err) {
|
||||
logError(`Password reset email failed to=${to}: ${err instanceof Error ? err.message : err}`);
|
||||
return { delivered: 'failed' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
userId?: number,
|
||||
navigateTarget?: string,
|
||||
): Promise<boolean> {
|
||||
const config = getSmtpConfig();
|
||||
if (!config) return false;
|
||||
|
||||
const lang = userId ? getUserLanguage(userId) : 'en';
|
||||
|
||||
try {
|
||||
const skipTls = readEnv().smtp.skipTlsVerify || getAppSetting('smtp_skip_tls_verify') === 'true';
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
|
||||
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: config.from,
|
||||
to,
|
||||
subject: `TREK — ${subject}`,
|
||||
text: body,
|
||||
html: buildEmailHtml(subject, body, lang, navigateTarget),
|
||||
});
|
||||
logInfo(`Email sent to=${to} subject="${subject}"`);
|
||||
logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError(`Email send failed to=${to}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebhookBody(
|
||||
url: string,
|
||||
payload: { event: string; title: string; body: string; tripName?: string; link?: string },
|
||||
): string {
|
||||
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
||||
const isSlack = /hooks\.slack\.com\//.test(url);
|
||||
|
||||
if (isDiscord) {
|
||||
return JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: `📍 ${payload.title}`,
|
||||
description: payload.body,
|
||||
url: payload.link,
|
||||
color: 0x3b82f6,
|
||||
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (isSlack) {
|
||||
const trip = payload.tripName ? ` • _${payload.tripName}_` : '';
|
||||
const link = payload.link ? `\n<${payload.link}|Open in TREK>` : '';
|
||||
return JSON.stringify({
|
||||
text: `*${payload.title}*\n${payload.body}${trip}${link}`,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' });
|
||||
}
|
||||
|
||||
export async function sendWebhook(
|
||||
url: string,
|
||||
payload: { event: string; title: string; body: string; tripName?: string; link?: string },
|
||||
): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) {
|
||||
logError(`Webhook blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: buildWebhookBody(url, payload),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
|
||||
} as any);
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
logError(`Webhook HTTP ${res.status}: ${errBody}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logInfo(`Webhook sent event=${payload.event} trip=${payload.tripName || '-'}`);
|
||||
logDebug(`Webhook url=${url} payload=${buildWebhookBody(url, payload).substring(0, 500)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError(`Webhook failed event=${payload.event}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!getSmtpConfig()) return { success: false, error: 'SMTP not configured' };
|
||||
try {
|
||||
const config = getSmtpConfig()!;
|
||||
const skipTls = readEnv().smtp.skipTlsVerify || getAppSetting('smtp_skip_tls_verify') === 'true';
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
|
||||
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: config.from,
|
||||
to,
|
||||
subject: 'TREK — Test Notification',
|
||||
text: 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.',
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testWebhook(url: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const sent = await sendWebhook(url, {
|
||||
event: 'test',
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.',
|
||||
});
|
||||
return sent ? { success: true } : { success: false, error: 'Failed to send webhook' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ntfy ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface NtfyConfig {
|
||||
server: string | null;
|
||||
topic: string | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/** Priority and tags mapped to each notification event type. */
|
||||
const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4 | 5; tags: string[] }>> = {
|
||||
trip_invite: { priority: 4, tags: ['loudspeaker'] },
|
||||
booking_change: { priority: 3, tags: ['calendar'] },
|
||||
trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] },
|
||||
vacay_invite: { priority: 4, tags: ['palm_tree'] },
|
||||
photos_shared: { priority: 3, tags: ['camera'] },
|
||||
collab_message: { priority: 3, tags: ['speech_balloon'] },
|
||||
packing_tagged: { priority: 3, tags: ['luggage'] },
|
||||
version_available: { priority: 4, tags: ['package'] },
|
||||
synology_session_cleared: { priority: 3, tags: ['warning'] },
|
||||
};
|
||||
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
|
||||
|
||||
export function getUserNtfyConfig(userId: number): NtfyConfig | null {
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')")
|
||||
.all(userId) as { key: string; value: string }[];
|
||||
if (rows.length === 0) return null;
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
return {
|
||||
topic: map['ntfy_topic'] || null,
|
||||
server: map['ntfy_server'] || null,
|
||||
token: map['ntfy_token'] ? decrypt_api_key(map['ntfy_token']) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAdminNtfyConfig(): NtfyConfig {
|
||||
const topic = getAppSetting('admin_ntfy_topic') || null;
|
||||
const server = getAppSetting('admin_ntfy_server') || null;
|
||||
const rawToken = getAppSetting('admin_ntfy_token') || null;
|
||||
return {
|
||||
topic,
|
||||
server,
|
||||
token: rawToken ? decrypt_api_key(rawToken) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ntfy POST URL for a per-user send. The topic must come from the
|
||||
* user's own config — the admin topic is reserved for admin-scoped sends
|
||||
* (see resolveAdminNtfyUrl). Only the server falls back to the admin default.
|
||||
* Returns null if the user has no topic.
|
||||
*/
|
||||
export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null {
|
||||
const topic = userCfg?.topic;
|
||||
if (!topic) return null;
|
||||
const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
return `${base}/${encodeURIComponent(topic)}`;
|
||||
}
|
||||
|
||||
/** Resolve the ntfy POST URL for admin-scoped sends. Returns null if no admin topic. */
|
||||
export function resolveAdminNtfyUrl(adminCfg: NtfyConfig): string | null {
|
||||
if (!adminCfg.topic) return null;
|
||||
const base = (adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
return `${base}/${encodeURIComponent(adminCfg.topic)}`;
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) > 0x7f) {
|
||||
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function sendNtfy(
|
||||
url: string,
|
||||
token: string | null,
|
||||
payload: { event: string; title: string; body: string; link?: string },
|
||||
): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) {
|
||||
logError(`Ntfy blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = NTFY_EVENT_META[payload.event as NotifEventType] ?? NTFY_DEFAULT_META;
|
||||
|
||||
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
|
||||
const headers: Record<string, string> = {
|
||||
Title: encodeHeaderValue(payload.title),
|
||||
Priority: String(meta.priority),
|
||||
};
|
||||
if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(',');
|
||||
if (payload.link) headers['Click'] = encodeHeaderValue(payload.link);
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload.body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
|
||||
} as any);
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
logError(`Ntfy HTTP ${res.status}: ${errBody}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logInfo(`Ntfy sent event=${payload.event}`);
|
||||
logDebug(`Ntfy url=${url} priority=${meta.priority} tags=${meta.tags.join(',')}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError(`Ntfy failed event=${payload.event}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testNtfy(cfg: {
|
||||
topic: string;
|
||||
server?: string | null;
|
||||
token?: string | null;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const adminCfg = getAdminNtfyConfig();
|
||||
const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null });
|
||||
if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
|
||||
try {
|
||||
const sent = await sendNtfy(url, cfg.token ?? null, {
|
||||
event: 'test',
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test notification from TREK. If you received this, your ntfy configuration is working correctly.',
|
||||
});
|
||||
return sent ? { success: true } : { success: false, error: 'Failed to send ntfy notification' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import {
|
||||
sendEmail,
|
||||
sendWebhook,
|
||||
sendNtfy,
|
||||
testSmtp,
|
||||
testWebhook,
|
||||
testNtfy,
|
||||
isSmtpConfigured,
|
||||
getUserEmail,
|
||||
getUserWebhookUrl,
|
||||
getAdminWebhookUrl,
|
||||
getUserNtfyConfig,
|
||||
getAdminNtfyConfig,
|
||||
resolveNtfyUrl,
|
||||
resolveAdminNtfyUrl,
|
||||
} from '../notifications';
|
||||
import { registerChannel, type ChannelMessage, type ExternalChannel } from './channelRegistry';
|
||||
|
||||
// The three built-in external channels, wrapping the send functions and config
|
||||
// resolvers that already live in ../notifications.ts. No delivery logic is
|
||||
// rewritten here — it is only relocated behind the ExternalChannel interface so
|
||||
// notificationService.send() can iterate instead of branching.
|
||||
|
||||
/**
|
||||
* Every event except `synology_session_cleared` is deliverable externally.
|
||||
* This is the pre-registry IMPLEMENTED_COMBOS table, transposed: it listed
|
||||
* ['inapp','email','webhook','ntfy'] for every event and ['inapp'] for that one.
|
||||
*/
|
||||
function supportsAllButSynology(event: string): boolean {
|
||||
return event !== 'synology_session_cleared';
|
||||
}
|
||||
|
||||
const emailChannel: ExternalChannel = {
|
||||
id: 'email',
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.email',
|
||||
// Admin-scoped events (version_available) reach admins by email even when the
|
||||
// admin has not put `email` in notification_channels — the admin global pref
|
||||
// gates it instead. Verbatim from the pre-registry dispatch.
|
||||
bypassesActiveToggleForAdminEvents: true,
|
||||
supportsEvent: supportsAllButSynology,
|
||||
isInstanceConfigured: isSmtpConfigured,
|
||||
isConfiguredFor: (userId) => !!getUserEmail(userId),
|
||||
async sendToUser(userId, msg) {
|
||||
const email = getUserEmail(userId);
|
||||
if (!email) return false;
|
||||
return sendEmail(email, msg.title, msg.body, userId, msg.navigateTarget);
|
||||
},
|
||||
async test(userId) {
|
||||
const email = getUserEmail(userId);
|
||||
if (!email) return { success: false, error: 'No email address on file' };
|
||||
return testSmtp(email);
|
||||
},
|
||||
};
|
||||
|
||||
const webhookChannel: ExternalChannel = {
|
||||
id: 'webhook',
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.webhook',
|
||||
supportsAdminGlobal: true,
|
||||
supportsEvent: supportsAllButSynology,
|
||||
isConfiguredFor: (userId) => !!getUserWebhookUrl(userId),
|
||||
async sendToUser(userId, msg) {
|
||||
const url = getUserWebhookUrl(userId);
|
||||
if (!url) return false;
|
||||
return sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, tripName: msg.tripName, link: msg.url });
|
||||
},
|
||||
async sendGlobal(msg: ChannelMessage) {
|
||||
const url = getAdminWebhookUrl();
|
||||
if (!url) return false;
|
||||
return sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
},
|
||||
async test(userId, override) {
|
||||
const url = (typeof override?.url === 'string' && override.url) || getUserWebhookUrl(userId);
|
||||
if (!url) return { success: false, error: 'No webhook URL configured' };
|
||||
return testWebhook(url);
|
||||
},
|
||||
};
|
||||
|
||||
const ntfyChannel: ExternalChannel = {
|
||||
id: 'ntfy',
|
||||
source: 'builtin',
|
||||
labelKey: 'settings.notificationPreferences.ntfy',
|
||||
supportsAdminGlobal: true,
|
||||
supportsEvent: supportsAllButSynology,
|
||||
isConfiguredFor: (userId) => !!resolveNtfyUrl(getAdminNtfyConfig(), getUserNtfyConfig(userId)),
|
||||
async sendToUser(userId, msg) {
|
||||
const userCfg = getUserNtfyConfig(userId);
|
||||
const adminCfg = getAdminNtfyConfig();
|
||||
const url = resolveNtfyUrl(adminCfg, userCfg);
|
||||
if (!url) return false;
|
||||
return sendNtfy(url, userCfg?.token ?? adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
},
|
||||
async sendGlobal(msg: ChannelMessage) {
|
||||
const adminCfg = getAdminNtfyConfig();
|
||||
const url = resolveAdminNtfyUrl(adminCfg);
|
||||
if (!url) return false;
|
||||
return sendNtfy(url, adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
},
|
||||
async test(userId, override) {
|
||||
const topic = typeof override?.topic === 'string' ? override.topic : getUserNtfyConfig(userId)?.topic;
|
||||
if (!topic) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
|
||||
return testNtfy({
|
||||
topic,
|
||||
server: typeof override?.server === 'string' ? override.server : null,
|
||||
token: typeof override?.token === 'string' ? override.token : null,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_CHANNELS = [emailChannel, webhookChannel, ntfyChannel];
|
||||
|
||||
/** Idempotent — safe to call from every entry point that needs the registry populated. */
|
||||
export function registerBuiltinChannels(): void {
|
||||
for (const channel of BUILTIN_CHANNELS) registerChannel(channel);
|
||||
}
|
||||
|
||||
registerBuiltinChannels();
|
||||
@@ -73,7 +73,15 @@ 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/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationPreferencesService', () => ({ getPreferencesMatrix: vi.fn(() => ({})), setAdminPreferences: vi.fn() }));
|
||||
// Preferences are a provider since the notifications fold; stub the two methods
|
||||
// the admin surface calls on the prototype, so AdminService still resolves it
|
||||
// through DI.
|
||||
vi.mock('../../src/nest/notifications/notification-preferences.service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/nest/notifications/notification-preferences.service')>();
|
||||
actual.NotificationPreferencesService.prototype.getPreferencesMatrix = vi.fn(() => ({}) as never);
|
||||
actual.NotificationPreferencesService.prototype.setAdminPreferences = vi.fn();
|
||||
return actual;
|
||||
});
|
||||
vi.mock('../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { AdminModule } from '../../src/nest/admin/admin.module';
|
||||
|
||||
@@ -36,12 +36,12 @@ vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.
|
||||
// The audit domain is DI-native: 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() }));
|
||||
vi.mock('../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: true }) }));
|
||||
vi.mock('../../src/app-config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/app-config')>();
|
||||
return { ...actual, getAppUrl: () => 'https://x' };
|
||||
});
|
||||
|
||||
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createUser } from '../helpers/factories';
|
||||
@@ -58,7 +58,12 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
|
||||
let userPassword: string;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule] })
|
||||
// The mailer is a provider since the notifications fold; overriding it is
|
||||
// the DI-native replacement for the old services/notifications module mock.
|
||||
.overrideProvider(MailerService)
|
||||
.useValue({ sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: 'email' }) })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -49,22 +49,23 @@ const { db } = vi.hoisted(() => {
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
// 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(() => ({
|
||||
// The preference matrix and the channel transports are providers since the
|
||||
// fold — overridden at the container instead of mocked by module path; the
|
||||
// in-app store is real SQL.
|
||||
const { prefs, mailer, webhook, ntfy } = vi.hoisted(() => ({
|
||||
prefs: { getPreferencesMatrix: vi.fn(), setPreferences: vi.fn() },
|
||||
channels: {
|
||||
testSmtp: vi.fn(), testWebhook: vi.fn(), testNtfy: vi.fn(),
|
||||
getUserWebhookUrl: vi.fn(), getAdminWebhookUrl: vi.fn(),
|
||||
getUserNtfyConfig: vi.fn(), getAdminNtfyConfig: vi.fn(),
|
||||
},
|
||||
mailer: { testSmtp: vi.fn(), isSmtpConfigured: vi.fn(() => true), getUserEmail: vi.fn(), getUserLanguage: vi.fn(() => 'en') },
|
||||
webhook: { testWebhook: vi.fn(), getUserWebhookUrl: vi.fn(), getAdminWebhookUrl: vi.fn() },
|
||||
ntfy: { testNtfy: vi.fn(), getUserNtfyConfig: vi.fn(), getAdminNtfyConfig: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../src/services/notificationPreferencesService', () => prefs);
|
||||
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 { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
|
||||
import { NotificationPreferencesService } from '../../src/nest/notifications/notification-preferences.service';
|
||||
import { NtfyService } from '../../src/nest/notifications/transports/ntfy.service';
|
||||
import { WebhookService } from '../../src/nest/notifications/transports/webhook.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
function seedNotification(recipientId: number, overrides: { is_read?: number } = {}): number {
|
||||
@@ -82,7 +83,12 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [DatabaseModule, RealtimeModule, NotificationsModule],
|
||||
}).compile();
|
||||
})
|
||||
.overrideProvider(NotificationPreferencesService).useValue(prefs)
|
||||
.overrideProvider(MailerService).useValue(mailer)
|
||||
.overrideProvider(WebhookService).useValue(webhook)
|
||||
.overrideProvider(NtfyService).useValue(ntfy)
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
@@ -96,7 +102,7 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
prefs.getPreferencesMatrix.mockReturnValue({ preferences: {}, available_channels: {}, event_types: [], implemented_combos: {} });
|
||||
channels.testSmtp.mockResolvedValue({ success: true });
|
||||
mailer.testSmtp.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -118,7 +124,7 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
const res = await request(server).post('/api/notifications/test-smtp').set('Cookie', sessionCookie(2)).send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Admin only' });
|
||||
expect(channels.testSmtp).not.toHaveBeenCalled();
|
||||
expect(mailer.testSmtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('200 test-smtp for an admin (stays 200, not 201)', async () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ import { VacayMcp } from '../../src/nest/vacay/vacay.mcp';
|
||||
import { VacayService } from '../../src/nest/vacay/vacay.service';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from './notifications';
|
||||
|
||||
/**
|
||||
* Hand-wired counterpart of the boot-time discovery in McpRegistryService,
|
||||
@@ -104,7 +105,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),
|
||||
new NotificationsMcp(makeNotificationsService(dbService, realtimeService), authService),
|
||||
],
|
||||
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DatabaseService } from '../../src/nest/database/database.service';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
|
||||
import { NotificationPreferencesService } from '../../src/nest/notifications/notification-preferences.service';
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
import { NtfyService } from '../../src/nest/notifications/transports/ntfy.service';
|
||||
import { WebhookService } from '../../src/nest/notifications/transports/webhook.service';
|
||||
|
||||
/**
|
||||
* A NotificationsService wired the way Nest wires it.
|
||||
*
|
||||
* The domain takes six providers since the fold, and eight places used to build
|
||||
* it by hand — every added constructor parameter was an eight-file diff. One
|
||||
* helper keeps that at one.
|
||||
*/
|
||||
export function makeNotificationsService(dbs: DatabaseService, realtime = new RealtimeService()): NotificationsService {
|
||||
const mailer = new MailerService(dbs);
|
||||
return new NotificationsService(
|
||||
dbs,
|
||||
realtime,
|
||||
mailer,
|
||||
new WebhookService(dbs),
|
||||
new NtfyService(dbs),
|
||||
new NotificationPreferencesService(dbs, mailer),
|
||||
);
|
||||
}
|
||||
|
||||
/** The preferences half on its own, over the same connection. */
|
||||
export function makeNotificationPreferencesService(dbs: DatabaseService): NotificationPreferencesService {
|
||||
return new NotificationPreferencesService(dbs, new MailerService(dbs));
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from './notifications';
|
||||
|
||||
/**
|
||||
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
|
||||
@@ -75,7 +76,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto
|
||||
places,
|
||||
new CollectionsService(dbs, permissions, realtime),
|
||||
new AtlasService(dbs),
|
||||
new NotificationsService(dbs, realtime),
|
||||
makeNotificationsService(dbs, realtime),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,13 +45,18 @@ vi.mock('../../src/config', () => ({
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/notifications')>();
|
||||
return {
|
||||
...actual,
|
||||
testSmtp: vi.fn().mockResolvedValue({ success: true }),
|
||||
testWebhook: vi.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
// The two test-send endpoints must not reach a real SMTP server or URL. Since
|
||||
// the fold they are provider methods, so they are stubbed on the prototype
|
||||
// rather than by module path — everything else in the domain stays real.
|
||||
vi.mock('../../src/nest/notifications/mailer/mailer.service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/nest/notifications/mailer/mailer.service')>();
|
||||
actual.MailerService.prototype.testSmtp = vi.fn().mockResolvedValue({ success: true });
|
||||
return actual;
|
||||
});
|
||||
vi.mock('../../src/nest/notifications/transports/webhook.service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/nest/notifications/transports/webhook.service')>();
|
||||
actual.WebhookService.prototype.testWebhook = vi.fn().mockResolvedValue({ success: true });
|
||||
return actual;
|
||||
});
|
||||
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
|
||||
@@ -72,6 +72,7 @@ import { NotificationsService } from '../../../src/nest/notifications/notificati
|
||||
import { AdminService } from '../../../src/nest/admin/admin.service';
|
||||
import { checkAndNotifyVersion as bridgeCheckAndNotifyVersion } from '../../../src/nest/admin/admin.bridge';
|
||||
import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const realtime = new RealtimeService();
|
||||
@@ -87,8 +88,9 @@ const svc = new AdminService(
|
||||
new PackingService(dbs, permissions, realtime),
|
||||
auth,
|
||||
permissions,
|
||||
new NotificationsService(dbs, realtime),
|
||||
makeNotificationsService(dbs, realtime),
|
||||
userCleanup,
|
||||
makeNotificationPreferencesService(dbs),
|
||||
);
|
||||
|
||||
// Legacy free-function names bound to the service, so the moved cases below read
|
||||
|
||||
@@ -55,6 +55,7 @@ import { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import { AdminService } from '../../../src/nest/admin/admin.service';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const realtime = new RealtimeService();
|
||||
@@ -70,8 +71,9 @@ const svc = new AdminService(
|
||||
new PackingService(dbs, permissions, realtime),
|
||||
auth,
|
||||
permissions,
|
||||
new NotificationsService(dbs, realtime),
|
||||
makeNotificationsService(dbs, realtime),
|
||||
userCleanup,
|
||||
makeNotificationPreferencesService(dbs),
|
||||
);
|
||||
const checkAndNotifyVersion = () => svc.checkAndNotifyVersion();
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||||
encrypt_api_key: vi.fn((v) => v),
|
||||
}));
|
||||
vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() }));
|
||||
vi.mock('../../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn() }));
|
||||
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
|
||||
vi.mock('../../../src/scheduler', () => ({
|
||||
startTripReminders: vi.fn(),
|
||||
@@ -85,6 +84,11 @@ import { TripMembershipService } from '../../../src/nest/trip-membership/trip-me
|
||||
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
||||
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
|
||||
import { revokeUserSessions } from '../../../src/mcp/sessionManager';
|
||||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
|
||||
// MailerService is injected since the notifications fold — a stub instead of a
|
||||
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
|
||||
const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService;
|
||||
|
||||
// Stubbed rather than real: these tests assert that the invite path CALLS the
|
||||
// join, not what the join writes (TRIP-JOIN-* cover that).
|
||||
@@ -97,6 +101,7 @@ const svc = new AuthService(
|
||||
membershipStub,
|
||||
new WebauthnConfigService(new DatabaseService(testDb)),
|
||||
new UserCleanupService(new DatabaseService(testDb)),
|
||||
mailerStub,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+64
-2
@@ -30,14 +30,25 @@ import { createUser, createAdmin, setAppSetting, setNotificationChannels } from
|
||||
// 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 { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
|
||||
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
import { NotificationPreferencesService } from '../../../src/nest/notifications/notification-preferences.service';
|
||||
|
||||
import {
|
||||
setPluginChannelSource,
|
||||
listChannels,
|
||||
getChannel,
|
||||
registerChannel,
|
||||
type ExternalChannel,
|
||||
} from '../../../src/services/notifications/channelRegistry';
|
||||
} from '../../../src/nest/notifications/channel-registry';
|
||||
|
||||
const prefsDbs = new DatabaseService(testDb);
|
||||
const prefsSvc = new NotificationPreferencesService(prefsDbs, new MailerService(prefsDbs));
|
||||
const getPreferencesMatrix = prefsSvc.getPreferencesMatrix.bind(prefsSvc);
|
||||
void WebhookService; void NtfyService;
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => { resetTestDb(testDb); setPluginChannelSource(null); });
|
||||
@@ -193,3 +204,54 @@ describe('a live plugin channel needs no second opt-in', () => {
|
||||
expect(rogueSend).not.toHaveBeenCalled(); // no credentials → nothing to send with
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The seam itself, end to end.
|
||||
*
|
||||
* PluginRuntimeService pushes its channel getter into the registry at
|
||||
* onModuleInit; notifications.bridge.ts constructs its own NotificationsService
|
||||
* for the scheduler crons and the memories senders. Both only meet because the
|
||||
* registry is a module singleton. Make it a provider and the bridge gets an
|
||||
* empty one — plugin channels then go quiet with no error anywhere, which is
|
||||
* exactly the kind of failure nothing else here would catch. Every case above
|
||||
* sets the source by hand; this one goes through the runtime.
|
||||
*/
|
||||
describe('the plugin channel source reaches the bridge instance', () => {
|
||||
it('CHOVR-015 — a channel the runtime publishes is delivered by notifications.bridge.send', async () => {
|
||||
const { user } = createUser(testDb, { username: 'recipient' });
|
||||
setNotificationChannels(testDb, 'none');
|
||||
const delivered: Array<{ userId: number; title: string }> = [];
|
||||
|
||||
const runtime = createPluginRuntime(new DatabaseService(testDb));
|
||||
// Stand in for a booted supervisor: one plugin providing the hook, and an
|
||||
// invokeHook that records instead of forking a child.
|
||||
Object.defineProperty(runtime, 'supervisor', {
|
||||
value: { providersOf: (hook: string) => (hook === 'notificationChannel' ? ['gotify'] : []) },
|
||||
configurable: true,
|
||||
});
|
||||
(runtime as unknown as { invokeHook: unknown }).invokeHook = async (
|
||||
_id: string,
|
||||
_hook: string,
|
||||
_method: string,
|
||||
args: unknown[],
|
||||
) => {
|
||||
const msg = args[0] as { title: string };
|
||||
delivered.push({ userId: user.id, title: msg.title });
|
||||
return true;
|
||||
};
|
||||
testDb.prepare("INSERT INTO plugins (id, name, version, status, enabled) VALUES ('gotify', 'Gotify', '1.0.0', 'active', 1)").run();
|
||||
|
||||
// The exact line plugin-runtime.service.ts runs in onModuleInit.
|
||||
setPluginChannelSource(() => runtime.notificationChannels());
|
||||
|
||||
try {
|
||||
expect(getChannel('plugin:gotify')).toBeDefined();
|
||||
|
||||
await send({ ...TRIP_INVITE, targetId: user.id });
|
||||
|
||||
expect(delivered).toHaveLength(1);
|
||||
} finally {
|
||||
setPluginChannelSource(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
+15
-3
@@ -13,8 +13,20 @@ import {
|
||||
__resetChannelsForTest,
|
||||
type ChannelMessage,
|
||||
type ExternalChannel,
|
||||
} from '../../../src/services/notifications/channelRegistry';
|
||||
import { registerBuiltinChannels } from '../../../src/services/notifications/builtins';
|
||||
} from '../../../src/nest/notifications/channel-registry';
|
||||
import { registerBuiltinChannels } from '../../../src/nest/notifications/channels/builtins';
|
||||
import type { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
import type { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
|
||||
import type { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
|
||||
|
||||
// The built-ins take their transports as an argument now; these cases only care
|
||||
// that the three ids land in the registry with the right privileges, so the
|
||||
// transports are stubs.
|
||||
const stubTransports = {
|
||||
mailer: { isSmtpConfigured: () => true, getUserEmail: () => null } as unknown as MailerService,
|
||||
webhook: { getUserWebhookUrl: () => null, getAdminWebhookUrl: () => null } as unknown as WebhookService,
|
||||
ntfy: { getUserNtfyConfig: () => null, getAdminNtfyConfig: () => ({ server: null, topic: null, token: null }) } as unknown as NtfyService,
|
||||
};
|
||||
|
||||
function fakeChannel(id: string, over: Partial<ExternalChannel> = {}): ExternalChannel {
|
||||
return {
|
||||
@@ -32,7 +44,7 @@ const MSG: ChannelMessage = { event: 'trip_invite', title: 't', body: 'b' };
|
||||
|
||||
beforeEach(() => {
|
||||
__resetChannelsForTest();
|
||||
registerBuiltinChannels();
|
||||
registerBuiltinChannels(stubTransports);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
+23
-10
@@ -36,16 +36,29 @@ 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 {
|
||||
isEnabledForEvent,
|
||||
getPreferencesMatrix,
|
||||
setPreferences,
|
||||
setAdminPreferences,
|
||||
getAdminGlobalPref,
|
||||
getActiveChannels,
|
||||
isSmtpConfigured,
|
||||
isWebhookConfigured,
|
||||
} from '../../../src/services/notificationPreferencesService';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
import { NotificationPreferencesService } from '../../../src/nest/notifications/notification-preferences.service';
|
||||
import { registerBuiltinChannels } from '../../../src/nest/notifications/channels/builtins';
|
||||
import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
|
||||
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
|
||||
import { __resetChannelsForTest } from '../../../src/nest/notifications/channel-registry';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const mailer = new MailerService(dbs);
|
||||
registerBuiltinChannels({ mailer, webhook: new WebhookService(dbs), ntfy: new NtfyService(dbs) });
|
||||
const svc = new NotificationPreferencesService(dbs, mailer);
|
||||
|
||||
// Legacy free-function names bound to the service, so the moved cases read as before.
|
||||
const isEnabledForEvent = svc.isEnabledForEvent.bind(svc);
|
||||
const getPreferencesMatrix = svc.getPreferencesMatrix.bind(svc);
|
||||
const setPreferences = svc.setPreferences.bind(svc);
|
||||
const setAdminPreferences = svc.setAdminPreferences.bind(svc);
|
||||
const getAdminGlobalPref = svc.getAdminGlobalPref.bind(svc);
|
||||
const getActiveChannels = svc.getActiveChannels.bind(svc);
|
||||
const isSmtpConfigured = svc.isSmtpConfigured.bind(svc);
|
||||
const isWebhookConfigured = svc.isWebhookConfigured.bind(svc);
|
||||
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
+12
-1
@@ -24,7 +24,18 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
createPinnedDispatcher: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook, sendNtfy, resolveNtfyUrl, resolveAdminNtfyUrl, type NtfyConfig } from '../../../src/services/notifications';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { getEventText, buildEmailHtml } from '../../../src/nest/notifications/mailer/email-html';
|
||||
import { WebhookService, buildWebhookBody } from '../../../src/nest/notifications/transports/webhook.service';
|
||||
import { NtfyService, resolveNtfyUrl, resolveAdminNtfyUrl, type NtfyConfig } from '../../../src/nest/notifications/transports/ntfy.service';
|
||||
|
||||
// The transports are providers now; the db stub below is the same one the
|
||||
// module-level `db` mock used to supply, handed in instead of imported.
|
||||
const stubDb = { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) } as unknown as ConstructorParameters<typeof DatabaseService>[0];
|
||||
const webhookSvc = new WebhookService(new DatabaseService(stubDb));
|
||||
const ntfySvc = new NtfyService(new DatabaseService(stubDb));
|
||||
const sendWebhook = webhookSvc.sendWebhook.bind(webhookSvc);
|
||||
const sendNtfy = ntfySvc.sendNtfy.bind(ntfySvc);
|
||||
import { checkSsrf } from '../../../src/utils/ssrfGuard';
|
||||
import { logError } from '../../../src/nest/audit/audit-log.logger';
|
||||
|
||||
@@ -45,8 +45,9 @@ import { registerAction } from '../../../src/nest/notifications/in-app-actions';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
||||
|
||||
const notifications = new NotificationsService(new DatabaseService(testDb), new RealtimeService());
|
||||
const notifications = makeNotificationsService(new DatabaseService(testDb));
|
||||
const createNotification = notifications.createNotification.bind(notifications);
|
||||
const createNotificationForRecipient = notifications.createNotificationForRecipient.bind(notifications);
|
||||
const respondToBoolean = notifications.respond.bind(notifications);
|
||||
|
||||
@@ -77,9 +77,10 @@ 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';
|
||||
import { setPluginChannelSource, type ExternalChannel } from '../../../src/nest/notifications/channel-registry';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
||||
|
||||
const notifications = new NotificationsService(new DatabaseService(testDb), new RealtimeService());
|
||||
const notifications = makeNotificationsService(new DatabaseService(testDb));
|
||||
const send = (payload: NotificationPayload) => notifications.send(payload);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -62,7 +62,6 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||||
encrypt_api_key: vi.fn((v) => v),
|
||||
}));
|
||||
vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() }));
|
||||
vi.mock('../../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn() }));
|
||||
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
|
||||
vi.mock('../../../src/scheduler', () => ({
|
||||
startTripReminders: vi.fn(),
|
||||
@@ -94,6 +93,11 @@ import { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||||
import { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { OidcService } from '../../../src/nest/oidc/oidc.service';
|
||||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
|
||||
// MailerService is injected since the notifications fold — a stub instead of a
|
||||
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
|
||||
const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService;
|
||||
|
||||
const membership = new TripMembershipService(new DatabaseService(testDb));
|
||||
const auth = new AuthService(
|
||||
@@ -101,6 +105,9 @@ const auth = new AuthService(
|
||||
new PermissionsService(new DatabaseService(testDb)),
|
||||
new AtlasService(new DatabaseService(testDb)),
|
||||
membership,
|
||||
undefined as never, // webauthn — not reached from the OIDC paths
|
||||
undefined as never, // userCleanup — not reached from the OIDC paths
|
||||
mailerStub,
|
||||
);
|
||||
const svc = new OidcService(new DatabaseService(testDb), auth, membership);
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||||
encrypt_api_key: vi.fn((v) => v),
|
||||
}));
|
||||
vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() }));
|
||||
vi.mock('../../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn() }));
|
||||
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
|
||||
vi.mock('../../../src/scheduler', () => ({
|
||||
startTripReminders: vi.fn(),
|
||||
@@ -86,6 +85,11 @@ import { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||||
import { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
|
||||
import type { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
|
||||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
|
||||
// MailerService is injected since the notifications fold — a stub instead of a
|
||||
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
|
||||
const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService;
|
||||
|
||||
// The RP config resolver has its own suite (webauthn-config.service.test.ts);
|
||||
// here it is a switch for the configured / not-configured branches, handed in
|
||||
@@ -97,6 +101,10 @@ const auth = new AuthService(
|
||||
new DatabaseService(testDb),
|
||||
new PermissionsService(new DatabaseService(testDb)),
|
||||
new AtlasService(new DatabaseService(testDb)),
|
||||
undefined as never, // membership — not reached from the passkey paths
|
||||
undefined as never, // webauthn — PasskeyService gets its own stub below
|
||||
undefined as never, // userCleanup — not reached from the passkey paths
|
||||
mailerStub,
|
||||
);
|
||||
const svc = new PasskeyService(new DatabaseService(testDb), auth, webauthn);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user