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
+23
View File
@@ -9,6 +9,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { revokeUserSessions } from '../mcp';
import { validatePassword } from './passwordPolicy';
import { send as sendNotification } from './notificationService';
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -312,6 +313,28 @@ export async function checkVersion() {
}
}
export async function checkAndNotifyVersion(): Promise<void> {
try {
const result = await checkVersion();
if (!result.update_available) return;
const lastNotified = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
if (lastNotified === result.latest) return;
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', result.latest);
await sendNotification({
event: 'version_available',
actorId: null,
scope: 'admin',
targetId: 0,
params: { version: result.latest as string },
});
} catch {
// Silently ignore — version check is non-critical
}
}
// ── Invite Tokens ──────────────────────────────────────────────────────────
export function listInvites() {