feat(notifications): add ntfy as a first-class notification channel

Adds ntfy.sh (and self-hosted instances) as a new push notification
channel with full parity to the existing webhook channel.

- Backend: NtfyConfig type, getUserNtfyConfig, getAdminNtfyConfig,
  resolveNtfyUrl, sendNtfy (header-based API with Title/Priority/Tags/
  Click headers), testNtfy, NTFY_EVENT_META (priority + emoji tags per
  event), SSRF guard via existing checkSsrf + createPinnedDispatcher
- notificationPreferencesService: ntfy added to NotifChannel union,
  IMPLEMENTED_COMBOS, getActiveChannels parser, getAvailableChannels,
  ADMIN_GLOBAL_CHANNELS, and AvailableChannels interface
- notificationService: per-user ntfy dispatch after webhook block;
  admin-scoped ntfy via getAdminGlobalPref for version_available events
- Routes: POST /api/notifications/test-ntfy with saved-token fallback
- authService: admin_ntfy_server/topic/token in ADMIN_SETTINGS_KEYS,
  masked + encrypted on read/write
- settingsService: ntfy_token added to ENCRYPTED_SETTING_KEYS
- Frontend: ntfy topic/server/token inputs + Save/Test/Clear buttons in
  NotificationsTab; admin Ntfy panel in AdminPage; testNtfy API method
- i18n: full English strings; English placeholders in 14 other locales
- Tests: resolveNtfyUrl, sendNtfy, dispatch integration, UI tests,
  MSW handler for test-ntfy endpoint
This commit is contained in:
jubnl
2026-04-15 13:59:25 +02:00
parent f349e567f8
commit bfe84b3016
30 changed files with 1241 additions and 52 deletions
@@ -153,14 +153,15 @@ describe('getPreferencesMatrix', () => {
expect(available_channels.email).toBe(false);
});
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook]', () => {
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook, ntfy]', () => {
const { user } = createAdmin(testDb);
const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin');
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook']);
// All events now support all three channels
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook', 'ntfy']);
// All events now support all four channels
expect(implemented_combos['trip_invite']).toContain('inapp');
expect(implemented_combos['trip_invite']).toContain('email');
expect(implemented_combos['trip_invite']).toContain('webhook');
expect(implemented_combos['trip_invite']).toContain('ntfy');
});
});