Files
TREK/server/src/services/notificationService.ts
T
Julien G. 1f5deeba6c Bug fixes - April 27th 2026 (#907)
* fix: clean up dangling FK references before deleting a user

Resolves FOREIGN KEY constraint failed (500) on DELETE /api/admin/users/:id
and DELETE /api/auth/me when the target user had rows in trip_members.invited_by,
share_tokens.created_by, budget_items.paid_by_user_id, journeys.user_id,
journey_entries.author_id, journey_contributors.user_id, or
journey_share_tokens.created_by — none of which had ON DELETE clauses.

Introduces deleteUserCompletely() in userCleanupService.ts which wraps all
cleanup and the final DELETE FROM users in a single transaction. Both
adminService.deleteUser and authService.deleteAccount now call it instead of
the bare DELETE. Tests ADMIN-005b and AUTH-040 cover all reference types
including notification sender/recipient and notice dismissals.

* test: extend FK deletion tests to cover journeys, files, and photos

ADMIN-005b and AUTH-040 now also seed and assert:
- owned journey with entries (cascade-deleted via journeys.user_id cleanup)
- trip_files.uploaded_by (SET NULL — file survives, attribution cleared)
- trek_photos.owner_id (SET NULL — photo record survives, owner cleared)
- trip_photos.user_id (CASCADE — photo association removed)

* test: extend user deletion tests to cover all FK relationships

ADMIN-005b and AUTH-040 now seed and assert every user FK relationship:

CASCADE (row deleted): trips, trip_members, tags, mcp_tokens, oauth_tokens,
oauth_consents, vacay_plans, vacay_plan_members, bucket_list,
visited_countries, visited_regions, packing_templates, invite_tokens,
collab_notes, settings, password_reset_tokens, notification_channel_preferences

SET NULL (row survives, column nulled): categories, todo_items.assigned_user_id,
packing_bags, audit_log

Caught and fixed: notification_preferences was dropped in migration 72;
correct table is notification_channel_preferences.

* fix: preserve URL hash and OIDC redirect target through login flow

- Include location.hash in redirect param at all three producer sites
  (ProtectedRoute, axios 401 interceptor, OAuthAuthorizePage) so
  hash fragments survive the login bounce
- Stash redirectTarget in sessionStorage before any OIDC provider
  redirect and restore it after the code exchange, since the IdP
  strips the original ?redirect= param during the roundtrip
- Clear sessionStorage on OIDC error to avoid stale state
- Add tests covering sessionStorage stash on mount, navigate to saved
  redirect after OIDC exchange, fallback to /dashboard, and cleanup
  on error

* fix: use day position instead of ID for accommodation date range clamping

Math.min/Math.max over raw day IDs breaks the start/end picker when a
trip's day IDs are non-monotonic relative to day_number (normal after
repeated generateDays extend/shrink cycles). Replaced with findIndex
lookups so clamping is always based on positional order.

Closes #889

* fix: normalize env var comparisons to be case-insensitive

All NODE_ENV, DEMO_MODE, OIDC_ONLY, FORCE_HTTPS, COOKIE_SECURE, and
ALLOW_INTERNAL_NETWORK checks now use .toLowerCase() so values like
'Production' or 'True' behave identically to their lowercase forms.
Also adds APP_VERSION to the startup banner.

* fix: delete surplus days when shortening a trip

When shrinking a trip's date range, surplus days are now deleted along
with their assignments, notes, and accommodations (cascade). Places
remain in the trip pool; reservations keep their day reference nulled
by the existing ON DELETE SET NULL constraint (issue #909).

Updates TRIP-SVC-011 to reflect the new behaviour; adds TRIP-SVC-016
as a regression test for the empty-day case.

* fix: auto-backup retention deletes itself and manual backups on Docker

Two bugs in cleanupOldBackups:
1. Filter was .endsWith('.zip') — swept manual backup-*.zip files too.
   Now restricted to auto-backup-* prefix.
2. Age was derived from stat.birthtimeMs, which is 0 on overlayfs
   (Docker default), making every backup appear epoch-old and get
   deleted immediately. Age is now parsed from the filename timestamp
   and falls back to mtimeMs (reliable on overlayfs).

Also converts inline require('./services/auditLog') calls to a static
import throughout scheduler.ts, and adds 8 unit tests covering the
fixed retention logic including the overlayfs regression case.

* test: update TRIP-024 to match delete behavior on trip shrink

* feat: add bypass-branch-check label to skip branch enforcement
2026-04-28 05:17:20 +02:00

329 lines
13 KiB
TypeScript

import { db } from '../db/database';
import { logDebug, logError } from './auditLog';
import {
getActiveChannels,
isEnabledForEvent,
getAdminGlobalPref,
isSmtpConfigured,
ADMIN_SCOPED_EVENTS,
type NotifEventType,
type NotifChannel,
} from './notificationPreferencesService';
import {
getEventText,
sendEmail,
sendWebhook,
sendNtfy,
getUserEmail,
getUserLanguage,
getUserWebhookUrl,
getAdminWebhookUrl,
getUserNtfyConfig,
getAdminNtfyConfig,
resolveNtfyUrl,
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),
},
todo_due: {
inAppType: 'navigate',
titleKey: 'notif.todo_due.title',
textKey: 'notif.todo_due.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',
},
synology_session_cleared: {
inAppType: 'simple',
titleKey: 'notifications.synologySessionCleared.title',
textKey: 'notifications.synologySessionCleared.text',
navigateTarget: () => null,
},
};
// ── 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?.toLowerCase() === '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 }));
}
}
// ── Ntfy (per-user) — skip for admin-scoped events (handled globally below) ──
if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('ntfy') && isEnabledForEvent(recipientId, event, 'ntfy' as NotifChannel)) {
const userNtfyCfg = getUserNtfyConfig(recipientId);
const adminNtfyCfg = getAdminNtfyConfig();
const ntfyUrl = resolveNtfyUrl(adminNtfyCfg, userNtfyCfg);
if (ntfyUrl) {
const lang = getUserLanguage(recipientId);
const { title, body } = getEventText(lang, event, params);
const token = userNtfyCfg?.token ?? adminNtfyCfg.token;
promises.push(sendNtfy(ntfyUrl, token, { event, title, body, link: fullLink }));
}
}
const results = await Promise.allSettled(promises);
for (const result of results) {
if (result.status === 'rejected') {
logError(`notificationService.send channel dispatch failed event=${event} recipient=${recipientId}: ${result.reason}`);
}
}
}));
// ── 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);
await sendWebhook(adminWebhookUrl, { event, title, body, link: fullLink }).catch((err: unknown) => {
logError(`notificationService.send admin webhook failed event=${event}: ${err instanceof Error ? err.message : err}`);
});
}
}
// ── Admin ntfy (scope: admin) — global, respects global pref ─────────
if (scope === 'admin' && getAdminGlobalPref(event, 'ntfy')) {
const adminNtfyCfg = getAdminNtfyConfig();
const adminNtfyUrl = resolveNtfyUrl(adminNtfyCfg, null);
if (adminNtfyUrl) {
const { title, body } = getEventText('en', event, params);
await sendNtfy(adminNtfyUrl, adminNtfyCfg.token, { event, title, body, link: fullLink }).catch((err: unknown) => {
logError(`notificationService.send admin ntfy failed event=${event}: ${err instanceof Error ? err.message : err}`);
});
}
}
}