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
This commit is contained in:
Julien G.
2026-04-28 05:17:20 +02:00
committed by GitHub
parent ca832e8d88
commit 1f5deeba6c
32 changed files with 937 additions and 106 deletions
+35 -47
View File
@@ -2,6 +2,7 @@ import cron, { type ScheduledTask } from 'node-cron';
import archiver from 'archiver';
import path from 'node:path';
import fs from 'node:fs';
import { logInfo, logError } from './services/auditLog';
const dataDir = path.join(__dirname, '../data');
const backupsDir = path.join(dataDir, 'backups');
@@ -79,11 +80,9 @@ async function runBackup(): Promise<void> {
if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads');
archive.finalize();
});
const { logInfo: li } = require('./services/auditLog');
li(`Auto-Backup created: ${filename}`);
logInfo(`Auto-Backup created: ${filename}`);
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Auto-Backup: ${err instanceof Error ? err.message : err}`);
logError(`Auto-Backup: ${err instanceof Error ? err.message : err}`);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
return;
}
@@ -94,23 +93,28 @@ async function runBackup(): Promise<void> {
}
}
function cleanupOldBackups(keepDays: number): void {
function autoBackupTimestampMs(filename: string): number | null {
// auto-backup-2026-04-27T00-00-00.zip → 2026-04-27T00:00:00
const stamp = filename.slice('auto-backup-'.length, -'.zip'.length);
const iso = stamp.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3');
const ms = Date.parse(iso);
return Number.isNaN(ms) ? null : ms;
}
export function cleanupOldBackups(keepDays: number, now: number = Date.now()): void {
try {
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const cutoff = Date.now() - keepDays * MS_PER_DAY;
const files = fs.readdirSync(backupsDir).filter(f => f.endsWith('.zip'));
const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
const files = fs.readdirSync(backupsDir).filter(f => f.startsWith('auto-backup-') && f.endsWith('.zip'));
for (const file of files) {
const filePath = path.join(backupsDir, file);
const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) {
const ageMs = autoBackupTimestampMs(file) ?? fs.statSync(filePath).mtimeMs;
if (ageMs < cutoff) {
fs.unlinkSync(filePath);
const { logInfo: li } = require('./services/auditLog');
li(`Auto-Backup old backup deleted: ${file}`);
logInfo(`Auto-Backup old backup deleted: ${file}`);
}
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Auto-Backup cleanup: ${err instanceof Error ? err.message : err}`);
logError(`Auto-Backup cleanup: ${err instanceof Error ? err.message : err}`);
}
}
@@ -122,16 +126,14 @@ function start(): void {
const settings = loadSettings();
if (!settings.enabled) {
const { logInfo: li } = require('./services/auditLog');
li('Auto-Backup disabled');
logInfo('Auto-Backup disabled');
return;
}
const expression = buildCronExpression(settings);
const tz = process.env.TZ || 'UTC';
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
const { logInfo: li2 } = require('./services/auditLog');
li2(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
logInfo(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
}
// Demo mode: hourly reset of demo user data
@@ -139,19 +141,17 @@ let demoTask: ScheduledTask | null = null;
function startDemoReset(): void {
if (demoTask) { demoTask.stop(); demoTask = null; }
if (process.env.DEMO_MODE !== 'true') return;
if (process.env.DEMO_MODE?.toLowerCase() !== 'true') return;
demoTask = cron.schedule('0 * * * *', () => {
try {
const { resetDemoUser } = require('./demo/demo-reset');
resetDemoUser();
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Demo reset: ${err instanceof Error ? err.message : err}`);
logError(`Demo reset: ${err instanceof Error ? err.message : err}`);
}
});
const { logInfo: li3 } = require('./services/auditLog');
li3('Demo hourly reset scheduled');
logInfo('Demo hourly reset scheduled');
}
// Trip reminders: daily check at 9 AM local time for trips starting tomorrow
@@ -167,14 +167,12 @@ function startTripReminders(): void {
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
if (!reminderEnabled) {
const { logInfo: li } = require('./services/auditLog');
li('Trip reminders: disabled in settings');
logInfo('Trip reminders: disabled in settings');
return;
}
const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
const { logInfo: liSetup } = require('./services/auditLog');
liSetup(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
logInfo(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
} catch {
return;
}
@@ -196,13 +194,11 @@ function startTripReminders(): void {
await send({ event: 'trip_reminder', actorId: null, scope: 'trip', targetId: trip.id, params: { trip: trip.title, tripId: String(trip.id) } }).catch(() => {});
}
const { logInfo: li } = require('./services/auditLog');
if (trips.length > 0) {
li(`Trip reminders sent for ${trips.length} trip(s): ${trips.map(t => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`);
logInfo(`Trip reminders sent for ${trips.length} trip(s): ${trips.map(t => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`);
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
logError(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
}
@@ -222,12 +218,10 @@ function startTodoReminders(): void {
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const enabled = getSetting('notify_todo_due') !== 'false';
if (!enabled) {
const { logInfo: li } = require('./services/auditLog');
li('Todo due reminders: disabled in settings');
logInfo('Todo due reminders: disabled in settings');
return;
}
const { logInfo: liSetup } = require('./services/auditLog');
liSetup(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`);
logInfo(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`);
const tz = process.env.TZ || 'UTC';
todoReminderTask = cron.schedule('0 9 * * *', async () => {
@@ -271,13 +265,11 @@ function startTodoReminders(): void {
db.prepare('UPDATE todo_items SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?').run(todo.id);
}
const { logInfo: li } = require('./services/auditLog');
if (todos.length > 0) {
li(`Todo reminders sent for ${todos.length} item(s)`);
logInfo(`Todo reminders sent for ${todos.length} item(s)`);
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`);
logError(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
}
@@ -294,8 +286,7 @@ function startVersionCheck(): void {
const { checkAndNotifyVersion } = require('./services/adminService');
await checkAndNotifyVersion();
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Version check: ${err instanceof Error ? err.message : err}`);
logError(`Version check: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
}
@@ -313,12 +304,10 @@ function startIdempotencyCleanup(): void {
const cutoff = Math.floor(Date.now() / 1000) - 86400;
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
if (result.changes > 0) {
const { logInfo: li } = require('./services/auditLog');
li(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
logInfo(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
logError(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
}
@@ -340,8 +329,7 @@ function startTrekPhotoCacheCleanup(): void {
const { sweepExpired } = require('./services/memories/trekPhotoCache');
sweepExpired();
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
logError(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
}
});
}