feat: configurable trip reminders, admin full access, and enhanced audit logging

- Add configurable trip reminder days (1, 3, 9 or custom up to 30) settable by trip owner
- Grant administrators full access to edit, archive, delete, view and list all trips
- Show trip owner email in audit logs and docker logs when admin edits/deletes another user's trip
- Show target user email in audit logs when admin edits or deletes a user account
- Use email instead of username in all notifications (Discord/Slack/email) to avoid ambiguity
- Grey out notification event toggles when no SMTP/webhook is configured
- Grey out trip reminder selector when notifications are disabled
- Skip local admin account creation when OIDC_ONLY=true with OIDC configured
- Conditional scheduler logging: show disabled reason or active reminder count
- Log per-owner reminder creation/update in docker logs
- Demote 401/403 HTTP errors to DEBUG log level to reduce noise
- Hide edit/archive/delete buttons for non-owner invited users on trip cards
- Fix literal "0" rendering on trip cards from SQLite numeric is_owner field
- Add missing translation keys across all 14 language files

Made-with: Cursor
This commit is contained in:
Andrei Brebene
2026-03-31 16:42:37 +03:00
parent 9b2f083e4b
commit 7522f396e7
31 changed files with 378 additions and 83 deletions

View File

@@ -433,6 +433,9 @@ function runMigrations(db: Database.Database): void {
() => {
try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch {}
},
() => {
try { db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch {}
},
];
if (currentVersion < migrations.length) {

View File

@@ -41,6 +41,7 @@ function createTables(db: Database.Database): void {
currency TEXT DEFAULT 'EUR',
cover_image TEXT,
is_archived INTEGER DEFAULT 0,
reminder_days INTEGER DEFAULT 3,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,11 +1,26 @@
import Database from 'better-sqlite3';
import crypto from 'crypto';
function isOidcOnlyConfigured(): boolean {
if (process.env.OIDC_ONLY !== 'true') return false;
return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
}
function seedAdminAccount(db: Database.Database): void {
try {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
if (userCount > 0) return;
if (isOidcOnlyConfigured()) {
console.log('');
console.log('╔══════════════════════════════════════════════╗');
console.log('║ TREK — OIDC-Only Mode ║');
console.log('║ First SSO login will become admin. ║');
console.log('╚══════════════════════════════════════════════╝');
console.log('');
return;
}
const bcrypt = require('bcryptjs');
const password = crypto.randomBytes(12).toString('base64url');
const hash = bcrypt.hashSync(password, 12);

View File

@@ -112,6 +112,8 @@ app.use(enforceGlobalMfaPolicy);
if (res.statusCode >= 500) {
_logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
} else if (res.statusCode === 401 || res.statusCode === 403) {
_logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
} else if (res.statusCode >= 400) {
_logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
}

View File

@@ -7,7 +7,7 @@ import fs from 'fs';
import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import { revokeUserSessions } from '../mcp';
const router = express.Router();
@@ -122,8 +122,9 @@ router.put('/users/:id', (req: Request, res: Response) => {
action: 'admin.user_update',
resource: String(req.params.id),
ip: getClientIp(req),
details: { fields: changed },
details: { targetUser: user.email, fields: changed },
});
logInfo(`Admin ${authReq.user.email} edited user ${user.email} (fields: ${changed.join(', ')})`);
res.json({ user: updated });
});
@@ -133,8 +134,8 @@ router.delete('/users/:id', (req: Request, res: Response) => {
return res.status(400).json({ error: 'Cannot delete own account' });
}
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(req.params.id) as { id: number; email: string } | undefined;
if (!userToDel) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
writeAudit({
@@ -142,7 +143,9 @@ router.delete('/users/:id', (req: Request, res: Response) => {
action: 'admin.user_delete',
resource: String(req.params.id),
ip: getClientIp(req),
details: { targetUser: userToDel.email },
});
logInfo(`Admin ${authReq.user.email} deleted user ${userToDel.email}`);
res.json({ success: true });
});

View File

@@ -18,6 +18,7 @@ import { revokeUserSessions } from '../mcp';
import { AuthRequest, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto';
import { startTripReminders } from '../scheduler';
authenticator.options = { window: 1 };
@@ -185,6 +186,11 @@ router.get('/app-config', (_req: Request, res: Response) => {
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value);
const hasWebhookUrl = !!(process.env.NOTIFICATION_WEBHOOK_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_webhook_url'").get() as { value: string } | undefined)?.value);
const channelConfigured = (notifChannel === 'email' && hasSmtpHost) || (notifChannel === 'webhook' && hasWebhookUrl);
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
res.json({
allow_registration: isDemo ? false : allowRegistration,
@@ -202,6 +208,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
demo_password: isDemo ? 'demo12345' : undefined,
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
trip_reminders_enabled: tripRemindersEnabled,
});
});
@@ -684,6 +691,12 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
details: summary,
debugDetails,
});
const notifRelated = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'notify_trip_reminder'];
if (changedKeys.some(k => notifRelated.includes(k))) {
startTripReminders();
}
res.json({ success: true });
});

View File

@@ -129,7 +129,7 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username }).catch(() => {});
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email }).catch(() => {});
});
});
@@ -430,7 +430,7 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, preview }).catch(() => {});
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview }).catch(() => {});
});
});

View File

@@ -186,7 +186,7 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
if (shared && added > 0) {
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, count: String(added) }).catch(() => {});
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {});
});
}
});

View File

@@ -285,7 +285,7 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
for (const uid of user_ids) {
if (uid !== authReq.user.id) {
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, category: cat } }).catch(() => {});
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat } }).catch(() => {});
}
}
});

View File

@@ -105,7 +105,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
// Notify trip members about new booking
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: title, type: type || 'booking' }).catch(() => {});
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking' }).catch(() => {});
});
});
@@ -225,7 +225,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: title || reservation.title, type: type || reservation.type || 'booking' }).catch(() => {});
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || reservation.title, type: type || reservation.type || 'booking' }).catch(() => {});
});
});
@@ -250,7 +250,7 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
});
});

View File

@@ -7,7 +7,7 @@ import { db, canAccessTrip, isOwner } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest, Trip, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
const router = express.Router();
@@ -125,30 +125,42 @@ router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const archived = req.query.archived === '1' ? 1 : 0;
const userId = authReq.user.id;
const trips = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
ORDER BY t.created_at DESC
`).all({ userId, archived });
const isAdminUser = authReq.user.role === 'admin';
const trips = isAdminUser
? db.prepare(`
${TRIP_SELECT}
WHERE t.is_archived = :archived
ORDER BY t.created_at DESC
`).all({ userId, archived })
: db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
ORDER BY t.created_at DESC
`).all({ userId, archived });
res.json({ trips });
});
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { title, description, start_date, end_date, currency } = req.body;
const { title, description, start_date, end_date, currency, reminder_days } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
if (start_date && end_date && new Date(end_date) < new Date(start_date))
return res.status(400).json({ error: 'End date must be after start date' });
const rd = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : 3) : 3;
const result = db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency)
VALUES (?, ?, ?, ?, ?, ?)
`).run(authReq.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR');
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(authReq.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR', rd);
const tripId = result.lastInsertRowid;
generateDays(tripId, start_date, end_date);
writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId: Number(tripId), title } });
writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId: Number(tripId), title, reminder_days: rd === 0 ? 'none' : `${rd} days` } });
if (rd > 0) {
logInfo(`${authReq.user.email} set ${rd}-day reminder for trip "${title}"`);
}
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId });
res.status(201).json({ trip });
});
@@ -156,27 +168,26 @@ router.post('/', authenticate, (req: Request, res: Response) => {
router.get('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const userId = authReq.user.id;
const trip = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
`).get({ userId, tripId: req.params.id });
const isAdminUser = authReq.user.role === 'admin';
const trip = isAdminUser
? db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: req.params.id })
: db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
`).get({ userId, tripId: req.params.id });
if (!trip) return res.status(404).json({ error: 'Trip not found' });
res.json({ trip });
});
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const access = canAccessTrip(req.params.id, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
const ownerOnly = req.body.is_archived !== undefined || req.body.cover_image !== undefined;
if (ownerOnly && !isOwner(req.params.id, authReq.user.id))
return res.status(403).json({ error: 'Only the owner can change this setting' });
if (!isOwner(req.params.id, authReq.user.id) && authReq.user.role !== 'admin')
return res.status(403).json({ error: 'Only the trip owner can edit trip details' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const { title, description, start_date, end_date, currency, is_archived, cover_image } = req.body;
const { title, description, start_date, end_date, currency, is_archived, cover_image, reminder_days } = req.body;
if (start_date && end_date && new Date(end_date) < new Date(start_date))
return res.status(400).json({ error: 'End date must be after start date' });
@@ -188,16 +199,41 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const newCurrency = currency || trip.currency;
const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
const newCover = cover_image !== undefined ? cover_image : trip.cover_image;
const newReminder = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : (trip as any).reminder_days) : (trip as any).reminder_days;
db.prepare(`
UPDATE trips SET title=?, description=?, start_date=?, end_date=?,
currency=?, is_archived=?, cover_image=?, updated_at=CURRENT_TIMESTAMP
currency=?, is_archived=?, cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, req.params.id);
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, req.params.id);
if (newStart !== trip.start_date || newEnd !== trip.end_date)
generateDays(req.params.id, newStart, newEnd);
const changes: Record<string, unknown> = {};
if (title && title !== trip.title) changes.title = title;
if (newStart !== trip.start_date) changes.start_date = newStart;
if (newEnd !== trip.end_date) changes.end_date = newEnd;
if (newReminder !== (trip as any).reminder_days) changes.reminder_days = newReminder === 0 ? 'none' : `${newReminder} days`;
if (is_archived !== undefined && newArchived !== trip.is_archived) changes.archived = !!newArchived;
const isAdminEdit = authReq.user.role === 'admin' && trip.user_id !== authReq.user.id;
if (Object.keys(changes).length > 0) {
const ownerEmail = isAdminEdit ? (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email : undefined;
writeAudit({ userId: authReq.user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(req.params.id), trip: newTitle, ...(ownerEmail ? { owner: ownerEmail } : {}), ...changes } });
if (isAdminEdit && ownerEmail) {
logInfo(`Admin ${authReq.user.email} edited trip "${newTitle}" owned by ${ownerEmail}`);
}
}
if (newReminder !== (trip as any).reminder_days) {
if (newReminder > 0) {
logInfo(`${authReq.user.email} set ${newReminder}-day reminder for trip "${newTitle}"`);
} else {
logInfo(`${authReq.user.email} removed reminder for trip "${newTitle}"`);
}
}
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: req.params.id });
res.json({ trip: updatedTrip });
broadcast(req.params.id, 'trip:updated', { trip: updatedTrip }, req.headers['x-socket-id'] as string);
@@ -228,10 +264,16 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!isOwner(req.params.id, authReq.user.id))
if (!isOwner(req.params.id, authReq.user.id) && authReq.user.role !== 'admin')
return res.status(403).json({ error: 'Only the owner can delete the trip' });
const deletedTripId = Number(req.params.id);
writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: deletedTripId } });
const delTrip = db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(req.params.id) as { title: string; user_id: number } | undefined;
const isAdminDel = authReq.user.role === 'admin' && delTrip && delTrip.user_id !== authReq.user.id;
const ownerEmail = isAdminDel ? (db.prepare('SELECT email FROM users WHERE id = ?').get(delTrip!.user_id) as { email: string } | undefined)?.email : undefined;
writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: deletedTripId, trip: delTrip?.title, ...(ownerEmail ? { owner: ownerEmail } : {}) } });
if (isAdminDel && ownerEmail) {
logInfo(`Admin ${authReq.user.email} deleted trip "${delTrip!.title}" owned by ${ownerEmail}`);
}
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
res.json({ success: true });
broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id'] as string);
@@ -290,7 +332,7 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
// Notify invited user
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
import('../services/notifications').then(({ notify }) => {
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, invitee: target.username } }).catch(() => {});
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, invitee: target.email } }).catch(() => {});
});
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });

View File

@@ -351,7 +351,7 @@ router.post('/invite', (req: Request, res: Response) => {
// Notify invited user
import('../services/notifications').then(({ notify }) => {
notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.username } }).catch(() => {});
notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.email } }).catch(() => {});
});
res.json({ success: true });

View File

@@ -160,42 +160,55 @@ let reminderTask: ScheduledTask | null = null;
function startTripReminders(): void {
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
try {
const { db } = require('./db/database');
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const channel = getSetting('notification_channel') || 'none';
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
const hasSmtp = !!(getSetting('smtp_host') || '').trim();
const hasWebhook = !!(getSetting('notification_webhook_url') || '').trim();
const channelReady = (channel === 'email' && hasSmtp) || (channel === 'webhook' && hasWebhook);
if (!channelReady || !reminderEnabled) {
const { logInfo: li } = require('./services/auditLog');
const reason = !channelReady ? `no ${channel === 'none' ? 'notification channel' : channel} configuration` : 'trip reminders disabled in settings';
li(`Trip reminders: disabled (${reason})`);
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 ${channel}${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
} catch {
return;
}
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 { notifyTripMembers } = require('./services/notifications');
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 }[];
SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
WHERE t.reminder_days > 0
AND t.start_date IS NOT NULL
AND t.start_date = date('now', '+' || t.reminder_days || ' days')
`).all() as { id: number; title: string; user_id: number; reminder_days: 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(() => {});
}
await notifyTripMembers(trip.id, 0, 'trip_reminder', { trip: trip.title }).catch(() => {});
}
const { logInfo: li } = require('./services/auditLog');
if (trips.length > 0) {
const { logInfo: li } = require('./services/auditLog');
li(`Trip reminders sent for ${trips.length} trip(s) starting ${dateStr}`);
li(`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}`);
}
}, { timezone: tz });
const { logInfo: li4 } = require('./services/auditLog');
li4(`Trip reminders scheduled: daily at 09:00 (${tz})`);
}
function stop(): void {