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:
jubnl
2026-04-05 01:20:33 +02:00
parent 179938e904
commit fc29c5f7d0
46 changed files with 21923 additions and 18383 deletions
@@ -1,40 +1,250 @@
import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto';
export function getPreferences(userId: number) {
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
if (!prefs) {
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
}
return prefs;
// ── Types ──────────────────────────────────────────────────────────────────
export type NotifChannel = 'email' | 'webhook' | 'inapp';
export type NotifEventType =
| 'trip_invite'
| 'booking_change'
| 'trip_reminder'
| 'vacay_invite'
| 'photos_shared'
| 'collab_message'
| 'packing_tagged'
| 'version_available';
export interface AvailableChannels {
email: boolean;
webhook: boolean;
inapp: boolean;
}
export function updatePreferences(
userId: number,
fields: {
notify_trip_invite?: boolean;
notify_booking_change?: boolean;
notify_trip_reminder?: boolean;
notify_webhook?: boolean;
}
) {
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(userId);
if (!existing) {
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
// Which channels are implemented for each event type.
// Only implemented combos show toggles in the user preferences UI.
const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
trip_invite: ['inapp', 'email', 'webhook'],
booking_change: ['inapp', 'email', 'webhook'],
trip_reminder: ['inapp', 'email', 'webhook'],
vacay_invite: ['inapp', 'email', 'webhook'],
photos_shared: ['inapp', 'email', 'webhook'],
collab_message: ['inapp', 'email', 'webhook'],
packing_tagged: ['inapp', 'email', 'webhook'],
version_available: ['inapp', 'email', 'webhook'],
};
/** Events that target admins only (shown in admin panel, not in user settings). */
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available']);
// ── 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) ────────────────────────────────────
/**
* Returns which channels the admin has enabled (email and/or webhook).
* Reads `notification_channels` (plural) with fallback to `notification_channel` (singular).
* In-app is always considered active at the service level.
*/
export function getActiveChannels(): NotifChannel[] {
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
if (raw === 'none') return [];
return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook');
}
/**
* Returns which channels are configured (have valid credentials/URLs set).
* In-app is always available. Email/webhook depend on configuration.
*/
export function getAvailableChannels(): AvailableChannels {
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
const hasWebhook = getActiveChannels().includes('webhook');
return { email: hasSmtp, webhook: hasWebhook, inapp: true };
}
// ── 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>>>>;
available_channels: AvailableChannels;
event_types: NotifEventType[];
implemented_combos: Record<NotifEventType, NotifChannel[]>;
}
/**
* 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;
}
db.prepare(`UPDATE notification_preferences SET
notify_trip_invite = COALESCE(?, notify_trip_invite),
notify_booking_change = COALESCE(?, notify_booking_change),
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
notify_webhook = COALESCE(?, notify_webhook)
WHERE user_id = ?`).run(
fields.notify_trip_invite !== undefined ? (fields.notify_trip_invite ? 1 : 0) : null,
fields.notify_booking_change !== undefined ? (fields.notify_booking_change ? 1 : 0) : null,
fields.notify_trip_reminder !== undefined ? (fields.notify_trip_reminder ? 1 : 0) : null,
fields.notify_webhook !== undefined ? (fields.notify_webhook ? 1 : 0) : null,
userId
// Build the full matrix with defaults (true when no row exists)
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
const allEvents = Object.keys(IMPLEMENTED_COMBOS) as NotifEventType[];
for (const eventType of allEvents) {
const channels = IMPLEMENTED_COMBOS[eventType];
preferences[eventType] = {};
for (const channel of channels) {
// Admin-scoped events use global settings for email/webhook
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook')) {
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
} else {
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
}
}
}
// Filter event types by scope
const event_types = scope === 'admin'
? allEvents.filter(e => ADMIN_SCOPED_EVENTS.has(e))
: allEvents.filter(e => !ADMIN_SCOPED_EVENTS.has(e));
// Available channels depend on scope
let available_channels: AvailableChannels;
if (scope === 'admin') {
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
const hasAdminWebhook = !!(getAppSetting('admin_webhook_url'));
available_channels = { email: hasSmtp, webhook: hasAdminWebhook, inapp: true };
} else {
const activeChannels = getActiveChannels();
available_channels = {
email: activeChannels.includes('email'),
webhook: activeChannels.includes('webhook'),
inapp: true,
};
}
return {
preferences,
available_channels,
event_types,
implemented_combos: IMPLEMENTED_COMBOS,
};
}
// ── Admin global preferences (stored in app_settings) ─────────────────────
const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook'];
/**
* 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: 'email' | 'webhook'): boolean {
const val = getAppSetting(`admin_notif_pref_${event}_${channel}`);
return val !== '0';
}
function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook', 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 ─────────────────────────────────────────────────────
/**
* 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 = ?'
);
return db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
db.transaction(() => {
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 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 = ?'
);
db.transaction(() => {
for (const [eventType, channels] of Object.entries(prefs)) {
if (!channels) continue;
for (const [channel, enabled] of Object.entries(channels)) {
if (ADMIN_GLOBAL_CHANNELS.includes(channel as NotifChannel)) {
// Global setting — stored in app_settings
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook', enabled);
} else {
// Per-user (inapp)
if (enabled) {
del.run(userId, eventType, channel);
} else {
upsert.run(userId, eventType, channel, 0);
}
}
}
}
})();
}
// ── SMTP availability helper (for authService) ─────────────────────────────
export function isSmtpConfigured(): boolean {
return !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
}
export function isWebhookConfigured(): boolean {
return getActiveChannels().includes('webhook');
}