feat: notifications, audit logging, and admin improvements

- Add centralized notification service with webhook (Discord/Slack) and
  email (SMTP) support, triggered for trip invites, booking changes,
  collab messages, and trip reminders
- Webhook sends one message per event (group channel); email sends
  individually per trip member, excluding the actor
- Discord invite notifications now include the invited user's name
- Add LOG_LEVEL env var (info/debug) controlling console and file output
- INFO logs show user email, action, and IP for audit events; errors
  for HTTP requests
- DEBUG logs show every request with full body/query (passwords redacted),
  audit details, notification params, and webhook payloads
- Add persistent trek.log file logging with 10MB rotation (5 files)
  in /app/data/logs/
- Color-coded log levels in Docker console output
- Timestamps without timezone name (user sets TZ via Docker)
- Add Test Webhook and Save buttons to admin notification settings
- Move notification event toggles to admin panel
- Add daily trip reminder scheduler (9 AM, timezone-aware)
- Wire up booking create/update/delete and collab message notifications
- Add i18n keys for notification UI across all 13 languages

Made-with: Cursor
This commit is contained in:
Andrei Brebene
2026-03-31 15:01:33 +03:00
parent f7160e6dec
commit 9b2f083e4b
35 changed files with 1004 additions and 249 deletions
+62 -9
View File
@@ -79,9 +79,11 @@ async function runBackup(): Promise<void> {
if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads');
archive.finalize();
});
console.log(`[Auto-Backup] Created: ${filename}`);
const { logInfo: li } = require('./services/auditLog');
li(`Auto-Backup created: ${filename}`);
} catch (err: unknown) {
console.error('[Auto-Backup] Error:', err instanceof Error ? err.message : err);
const { logError: le } = require('./services/auditLog');
le(`Auto-Backup: ${err instanceof Error ? err.message : err}`);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
return;
}
@@ -102,11 +104,13 @@ function cleanupOldBackups(keepDays: number): void {
const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log(`[Auto-Backup] Old backup deleted: ${file}`);
const { logInfo: li } = require('./services/auditLog');
li(`Auto-Backup old backup deleted: ${file}`);
}
}
} catch (err: unknown) {
console.error('[Auto-Backup] Cleanup error:', err instanceof Error ? err.message : err);
const { logError: le } = require('./services/auditLog');
le(`Auto-Backup cleanup: ${err instanceof Error ? err.message : err}`);
}
}
@@ -118,14 +122,16 @@ function start(): void {
const settings = loadSettings();
if (!settings.enabled) {
console.log('[Auto-Backup] Disabled');
const { logInfo: li } = require('./services/auditLog');
li('Auto-Backup disabled');
return;
}
const expression = buildCronExpression(settings);
const tz = process.env.TZ || 'UTC';
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
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'}`);
}
// Demo mode: hourly reset of demo user data
@@ -140,15 +146,62 @@ function startDemoReset(): void {
const { resetDemoUser } = require('./demo/demo-reset');
resetDemoUser();
} catch (err: unknown) {
console.error('[Demo Reset] Error:', err instanceof Error ? err.message : err);
const { logError: le } = require('./services/auditLog');
le(`Demo reset: ${err instanceof Error ? err.message : err}`);
}
});
console.log('[Demo] Hourly reset scheduled (at :00 every hour)');
const { logInfo: li3 } = require('./services/auditLog');
li3('Demo hourly reset scheduled');
}
// Trip reminders: daily check at 9 AM local time for trips starting tomorrow
let reminderTask: ScheduledTask | null = null;
function startTripReminders(): void {
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
const tz = process.env.TZ || 'UTC';
reminderTask = cron.schedule('0 9 * * *', async () => {
try {
const { db } = require('./db/database');
const { notify } = require('./services/notifications');
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateStr = tomorrow.toISOString().split('T')[0];
const trips = db.prepare(`
SELECT t.id, t.title, t.user_id FROM trips t
WHERE t.start_date = ?
`).all(dateStr) as { id: number; title: string; user_id: number }[];
for (const trip of trips) {
await notify({ userId: trip.user_id, event: 'trip_reminder', params: { trip: trip.title } }).catch(() => {});
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[];
for (const m of members) {
await notify({ userId: m.user_id, event: 'trip_reminder', params: { trip: trip.title } }).catch(() => {});
}
}
if (trips.length > 0) {
const { logInfo: li } = require('./services/auditLog');
li(`Trip reminders sent for ${trips.length} trip(s) starting ${dateStr}`);
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
const { logInfo: li4 } = require('./services/auditLog');
li4(`Trip reminders scheduled: daily at 09:00 (${tz})`);
}
function stop(): void {
if (currentTask) { currentTask.stop(); currentTask = null; }
if (demoTask) { demoTask.stop(); demoTask = null; }
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
}
export { start, stop, startDemoReset, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, startTripReminders, loadSettings, saveSettings, VALID_INTERVALS };