feat(notifications): add unified multi-channel notification system
Introduces a fully featured notification system with three delivery channels (in-app, email, webhook), normalized per-user/per-event/ per-channel preferences, admin-scoped notifications, scheduled trip reminders and version update alerts. - New notificationService.send() as the single orchestration entry point - In-app notifications with simple/boolean/navigate types and WebSocket push - Per-user preference matrix with normalized notification_channel_preferences table - Admin notification preferences stored globally in app_settings - Migration 69 normalizes legacy notification_preferences table - Scheduler hooks for daily trip reminders and version checks - DevNotificationsPanel for testing in dev mode - All new tests passing, covering dispatch, preferences, migration, boolean responses, resilience, and full API integration (NSVC, NPREF, INOTIF, MIGR, VNOTIF, NROUTE series) - Previous tests passing
This commit is contained in:
278
server/src/services/notificationService.ts
Normal file
278
server/src/services/notificationService.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { db } from '../db/database';
|
||||
import { logDebug } from './auditLog';
|
||||
import {
|
||||
getActiveChannels,
|
||||
isEnabledForEvent,
|
||||
getAdminGlobalPref,
|
||||
isSmtpConfigured,
|
||||
ADMIN_SCOPED_EVENTS,
|
||||
type NotifEventType,
|
||||
} from './notificationPreferencesService';
|
||||
import {
|
||||
getEventText,
|
||||
sendEmail,
|
||||
sendWebhook,
|
||||
getUserEmail,
|
||||
getUserLanguage,
|
||||
getUserWebhookUrl,
|
||||
getAdminWebhookUrl,
|
||||
getAppUrl,
|
||||
} from './notifications';
|
||||
import {
|
||||
resolveRecipients,
|
||||
createNotificationForRecipient,
|
||||
type NotificationInput,
|
||||
} from './inAppNotifications';
|
||||
|
||||
// ── Event config map ───────────────────────────────────────────────────────
|
||||
|
||||
interface EventNotifConfig {
|
||||
inAppType: 'simple' | 'navigate';
|
||||
titleKey: string;
|
||||
textKey: string;
|
||||
navigateTextKey?: string;
|
||||
navigateTarget: (params: Record<string, string>) => string | null;
|
||||
}
|
||||
|
||||
const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
|
||||
// ── Dev-only test events ──────────────────────────────────────────────────
|
||||
test_simple: {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.simple.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
test_boolean: {
|
||||
inAppType: 'simple', // overridden by inApp.type at call site
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.boolean.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
test_navigate: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.navigate.text',
|
||||
navigateTextKey: 'notif.action.view',
|
||||
navigateTarget: () => '/dashboard',
|
||||
},
|
||||
// ── Production events ─────────────────────────────────────────────────────
|
||||
trip_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.trip_invite.title',
|
||||
textKey: 'notif.trip_invite.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
booking_change: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.booking_change.title',
|
||||
textKey: 'notif.booking_change.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
trip_reminder: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.trip_reminder.title',
|
||||
textKey: 'notif.trip_reminder.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
vacay_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.vacay_invite.title',
|
||||
textKey: 'notif.vacay_invite.text',
|
||||
navigateTextKey: 'notif.action.view_vacay',
|
||||
navigateTarget: p => (p.planId ? `/vacay/${p.planId}` : null),
|
||||
},
|
||||
photos_shared: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.photos_shared.title',
|
||||
textKey: 'notif.photos_shared.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
collab_message: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.collab_message.title',
|
||||
textKey: 'notif.collab_message.text',
|
||||
navigateTextKey: 'notif.action.view_collab',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
packing_tagged: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.packing_tagged.title',
|
||||
textKey: 'notif.packing_tagged.text',
|
||||
navigateTextKey: 'notif.action.view_packing',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
version_available: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.version_available.title',
|
||||
textKey: 'notif.version_available.text',
|
||||
navigateTextKey: 'notif.action.view_admin',
|
||||
navigateTarget: () => '/admin',
|
||||
},
|
||||
};
|
||||
|
||||
// ── Fallback config for unknown event types ────────────────────────────────
|
||||
|
||||
const FALLBACK_EVENT_CONFIG: EventNotifConfig = {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notif.generic.title',
|
||||
textKey: 'notif.generic.text',
|
||||
navigateTarget: () => null,
|
||||
};
|
||||
|
||||
// ── Unified send() API ─────────────────────────────────────────────────────
|
||||
|
||||
export interface NotificationPayload {
|
||||
event: NotifEventType;
|
||||
actorId: number | null;
|
||||
params: Record<string, string>;
|
||||
scope: 'trip' | 'user' | 'admin';
|
||||
targetId: number; // tripId for trip scope, userId for user scope, 0 for admin
|
||||
/** Optional in-app overrides (e.g. boolean type with callbacks) */
|
||||
inApp?: {
|
||||
type?: 'simple' | 'boolean' | 'navigate';
|
||||
positiveTextKey?: string;
|
||||
negativeTextKey?: string;
|
||||
positiveCallback?: { action: string; payload: Record<string, unknown> };
|
||||
negativeCallback?: { action: string; payload: Record<string, unknown> };
|
||||
navigateTarget?: string; // override the auto-generated navigate target
|
||||
};
|
||||
}
|
||||
|
||||
export async function send(payload: NotificationPayload): Promise<void> {
|
||||
const { event, actorId, params, scope, targetId, inApp } = payload;
|
||||
|
||||
// Resolve recipients based on scope
|
||||
const recipients = resolveRecipients(scope, targetId, actorId);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
|
||||
if (!configEntry) {
|
||||
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
|
||||
if (process.env.NODE_ENV === 'development' && actorId != null) {
|
||||
const devSender = (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null;
|
||||
createNotificationForRecipient({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: actorId,
|
||||
sender_id: null,
|
||||
title_key: 'notif.dev.unknown_event.title',
|
||||
text_key: 'notif.dev.unknown_event.text',
|
||||
text_params: { event },
|
||||
}, actorId, devSender);
|
||||
}
|
||||
}
|
||||
const config = configEntry ?? FALLBACK_EVENT_CONFIG;
|
||||
const activeChannels = getActiveChannels();
|
||||
const appUrl = getAppUrl();
|
||||
|
||||
// Build navigate target (used by email/webhook CTA and in-app navigate)
|
||||
const navigateTarget = inApp?.navigateTarget ?? config.navigateTarget(params);
|
||||
const fullLink = navigateTarget ? `${appUrl}${navigateTarget}` : undefined;
|
||||
|
||||
// Fetch sender info once for in-app WS payloads
|
||||
const sender = actorId
|
||||
? (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null
|
||||
: null;
|
||||
|
||||
logDebug(`notificationService.send event=${event} scope=${scope} targetId=${targetId} recipients=${recipients.length} channels=inapp,${activeChannels.join(',')}`);
|
||||
|
||||
// Dispatch to each recipient in parallel
|
||||
await Promise.all(recipients.map(async (recipientId) => {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// ── In-app ──────────────────────────────────────────────────────────
|
||||
if (isEnabledForEvent(recipientId, event, 'inapp')) {
|
||||
const inAppType = inApp?.type ?? config.inAppType;
|
||||
let notifInput: NotificationInput;
|
||||
|
||||
if (inAppType === 'boolean' && inApp?.positiveCallback && inApp?.negativeCallback) {
|
||||
notifInput = {
|
||||
type: 'boolean',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
positive_text_key: inApp.positiveTextKey ?? 'notif.action.accept',
|
||||
negative_text_key: inApp.negativeTextKey ?? 'notif.action.decline',
|
||||
positive_callback: inApp.positiveCallback,
|
||||
negative_callback: inApp.negativeCallback,
|
||||
};
|
||||
} else if (inAppType === 'navigate' && navigateTarget) {
|
||||
notifInput = {
|
||||
type: 'navigate',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
navigate_text_key: config.navigateTextKey ?? 'notif.action.view',
|
||||
navigate_target: navigateTarget,
|
||||
};
|
||||
} else {
|
||||
notifInput = {
|
||||
type: 'simple',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
};
|
||||
}
|
||||
|
||||
promises.push(
|
||||
Promise.resolve().then(() => createNotificationForRecipient(notifInput, recipientId, sender ?? null))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Email ────────────────────────────────────────────────────────────
|
||||
// Admin-scoped events: use global pref + SMTP check (bypass notification_channels toggle)
|
||||
// Regular events: use active channels + per-user pref
|
||||
const emailEnabled = ADMIN_SCOPED_EVENTS.has(event)
|
||||
? isSmtpConfigured() && getAdminGlobalPref(event, 'email')
|
||||
: activeChannels.includes('email') && isEnabledForEvent(recipientId, event, 'email');
|
||||
|
||||
if (emailEnabled) {
|
||||
const email = getUserEmail(recipientId);
|
||||
if (email) {
|
||||
const lang = getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
promises.push(sendEmail(email, title, body, recipientId, navigateTarget ?? undefined));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Webhook (per-user) — skip for admin-scoped events (handled globally below) ──
|
||||
if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('webhook') && isEnabledForEvent(recipientId, event, 'webhook')) {
|
||||
const webhookUrl = getUserWebhookUrl(recipientId);
|
||||
if (webhookUrl) {
|
||||
const lang = getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
promises.push(sendWebhook(webhookUrl, { event, title, body, tripName: params.trip, link: fullLink }));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}));
|
||||
|
||||
// ── Admin webhook (scope: admin) — global, respects global pref ──────
|
||||
if (scope === 'admin' && getAdminGlobalPref(event, 'webhook')) {
|
||||
const adminWebhookUrl = getAdminWebhookUrl();
|
||||
if (adminWebhookUrl) {
|
||||
const { title, body } = getEventText('en', event, params);
|
||||
sendWebhook(adminWebhookUrl, { event, title, body, link: fullLink }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user