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:
@@ -428,9 +428,11 @@ function runMigrations(db: Database.Database): void {
|
||||
} catch {}
|
||||
},
|
||||
() => {
|
||||
// GPX full route geometry stored as JSON array of [lat,lng] pairs
|
||||
try { db.exec('ALTER TABLE places ADD COLUMN route_geometry TEXT'); } catch {}
|
||||
},
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch {}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -18,6 +18,7 @@ function createTables(db: Database.Database): void {
|
||||
mfa_enabled INTEGER DEFAULT 0,
|
||||
mfa_secret TEXT,
|
||||
mfa_backup_codes TEXT,
|
||||
must_change_password INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import crypto from 'crypto';
|
||||
|
||||
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;
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const password = crypto.randomBytes(12).toString('base64url');
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
const email = 'admin@trek.local';
|
||||
const username = 'admin';
|
||||
|
||||
db.prepare('INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)').run(username, email, hash, 'admin');
|
||||
|
||||
console.log('');
|
||||
console.log('╔══════════════════════════════════════════════╗');
|
||||
console.log('║ TREK — First Run: Admin Account Created ║');
|
||||
console.log('╠══════════════════════════════════════════════╣');
|
||||
console.log(`║ Email: ${email.padEnd(33)}║`);
|
||||
console.log(`║ Password: ${password.padEnd(33)}║`);
|
||||
console.log('╚══════════════════════════════════════════════╝');
|
||||
console.log('');
|
||||
} catch (err: unknown) {
|
||||
console.error('[ERROR] Error seeding admin account:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
function seedCategories(db: Database.Database): void {
|
||||
try {
|
||||
@@ -45,6 +72,7 @@ function seedAddons(db: Database.Database): void {
|
||||
}
|
||||
|
||||
function runSeeds(db: Database.Database): void {
|
||||
seedAdminAccount(db);
|
||||
seedCategories(db);
|
||||
seedAddons(db);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import fs from 'fs';
|
||||
|
||||
const app = express();
|
||||
const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true';
|
||||
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||
|
||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
||||
@@ -29,21 +30,18 @@ const tmpDir = path.join(__dirname, '../data/tmp');
|
||||
|
||||
// Middleware
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',')
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
let corsOrigin: cors.CorsOptions['origin'];
|
||||
if (allowedOrigins) {
|
||||
// Explicit whitelist from env var
|
||||
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||
else callback(new Error('Not allowed by CORS'));
|
||||
};
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
// Production: same-origin only (Express serves the static client)
|
||||
corsOrigin = false;
|
||||
} else {
|
||||
// Development: allow all origins (needed for Vite dev server)
|
||||
corsOrigin = true;
|
||||
}
|
||||
|
||||
@@ -92,30 +90,36 @@ app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use(enforceGlobalMfaPolicy);
|
||||
|
||||
if (DEBUG) {
|
||||
{
|
||||
const { logInfo: _logInfo, logDebug: _logDebug, logWarn: _logWarn, logError: _logError } = require('./services/auditLog');
|
||||
const SENSITIVE_KEYS = new Set(['password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass']);
|
||||
const _redact = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(_redact);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : _redact(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path === '/api/health') return next();
|
||||
|
||||
const startedAt = Date.now();
|
||||
const requestId = Math.random().toString(36).slice(2, 10);
|
||||
const redact = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(redact);
|
||||
const hidden = new Set(['password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code']);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = hidden.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const safeQuery = redact(req.query);
|
||||
const safeBody = redact(req.body);
|
||||
console.log(`[DEBUG][REQ ${requestId}] ${req.method} ${req.originalUrl} ip=${req.ip} query=${JSON.stringify(safeQuery)} body=${JSON.stringify(safeBody)}`);
|
||||
|
||||
res.on('finish', () => {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
console.log(`[DEBUG][RES ${requestId}] ${req.method} ${req.originalUrl} status=${res.statusCode} elapsed_ms=${elapsedMs}`);
|
||||
});
|
||||
const ms = Date.now() - startedAt;
|
||||
|
||||
if (res.statusCode >= 500) {
|
||||
_logError(`${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}`);
|
||||
}
|
||||
|
||||
const q = Object.keys(req.query).length ? ` query=${JSON.stringify(_redact(req.query))}` : '';
|
||||
const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(_redact(req.body))}` : '';
|
||||
_logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
@@ -246,17 +250,32 @@ import * as scheduler from './scheduler';
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`TREK API running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`);
|
||||
const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog');
|
||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
|
||||
const banner = [
|
||||
'──────────────────────────────────────',
|
||||
' TREK API started',
|
||||
` Port: ${PORT}`,
|
||||
` Environment: ${process.env.NODE_ENV || 'development'}`,
|
||||
` Timezone: ${tz}`,
|
||||
` Origins: ${origins}`,
|
||||
` Log level: ${LOG_LVL}`,
|
||||
` Log file: /app/data/logs/trek.log`,
|
||||
` PID: ${process.pid}`,
|
||||
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
||||
'──────────────────────────────────────',
|
||||
];
|
||||
banner.forEach(l => console.log(l));
|
||||
if (JWT_SECRET_IS_GENERATED) {
|
||||
console.warn('[SECURITY WARNING] JWT_SECRET was auto-generated. Sessions will not persist across restarts. Set JWT_SECRET env var for production use.');
|
||||
sLogWarn('[SECURITY WARNING] JWT_SECRET was auto-generated. Sessions will not persist across restarts. Set JWT_SECRET env var for production use.');
|
||||
}
|
||||
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
|
||||
if (process.env.DEMO_MODE === 'true') sLogInfo('Demo mode: ENABLED');
|
||||
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
|
||||
console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.');
|
||||
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
||||
}
|
||||
scheduler.start();
|
||||
scheduler.startTripReminders();
|
||||
scheduler.startDemoReset();
|
||||
import('./websocket').then(({ setupWebSocket }) => {
|
||||
setupWebSocket(server);
|
||||
@@ -265,19 +284,19 @@ const server = app.listen(PORT, () => {
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown(signal: string): void {
|
||||
console.log(`\n${signal} received — shutting down gracefully...`);
|
||||
const { logInfo: sLogInfo, logError: sLogError } = require('./services/auditLog');
|
||||
sLogInfo(`${signal} received — shutting down gracefully...`);
|
||||
scheduler.stop();
|
||||
closeMcpSessions();
|
||||
server.close(() => {
|
||||
console.log('HTTP server closed');
|
||||
sLogInfo('HTTP server closed');
|
||||
const { closeDb } = require('./db/database');
|
||||
closeDb();
|
||||
console.log('Shutdown complete');
|
||||
sLogInfo('Shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
// Force exit after 10s if connections don't close
|
||||
setTimeout(() => {
|
||||
console.error('Forced shutdown after timeout');
|
||||
sLogError('Forced shutdown after timeout');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
@@ -189,7 +189,8 @@ router.get('/audit-log', (req: Request, res: Response) => {
|
||||
details = { _parse_error: true };
|
||||
}
|
||||
}
|
||||
return { ...r, details };
|
||||
const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
|
||||
return { ...r, created_at, details };
|
||||
}),
|
||||
total,
|
||||
limit,
|
||||
|
||||
@@ -83,6 +83,7 @@ function stripUserForClient(user: User): Record<string, unknown> {
|
||||
updated_at: utcSuffix(rest.updated_at),
|
||||
last_login: utcSuffix(rest.last_login),
|
||||
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
|
||||
must_change_password: !!(user.must_change_password === 1 || user.must_change_password === true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,9 +184,12 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
||||
const oidcOnlySetting = process.env.OIDC_ONLY || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
|
||||
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 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,
|
||||
has_users: userCount > 0,
|
||||
setup_complete: setupComplete,
|
||||
version,
|
||||
has_maps_key: hasGoogleKey,
|
||||
oidc_configured: oidcConfigured,
|
||||
@@ -197,6 +201,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
||||
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
notification_channel: notifChannel,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,6 +295,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
writeAudit({ userId: Number(result.lastInsertRowid), action: 'user.register', ip: getClientIp(req), details: { username, email, role } });
|
||||
res.status(201).json({ token, user: { ...user, avatar_url: null } });
|
||||
} catch (err: unknown) {
|
||||
res.status(500).json({ error: 'Error creating user' });
|
||||
@@ -309,11 +315,13 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
|
||||
if (!user) {
|
||||
writeAudit({ userId: null, action: 'user.login_failed', ip: getClientIp(req), details: { email, reason: 'unknown_email' } });
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
const validPassword = bcrypt.compareSync(password, user.password_hash!);
|
||||
if (!validPassword) {
|
||||
writeAudit({ userId: Number(user.id), action: 'user.login_failed', ip: getClientIp(req), details: { email, reason: 'wrong_password' } });
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
@@ -330,13 +338,14 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
|
||||
const token = generateToken(user);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
|
||||
writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { email } });
|
||||
res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } });
|
||||
});
|
||||
|
||||
router.get('/me', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled FROM users WHERE id = ?'
|
||||
'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?'
|
||||
).get(authReq.user.id) as User | undefined;
|
||||
|
||||
if (!user) {
|
||||
@@ -370,7 +379,8 @@ router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(new_password, 12);
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, authReq.user.id);
|
||||
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, authReq.user.id);
|
||||
writeAudit({ userId: authReq.user.id, action: 'user.password_change', ip: getClientIp(req) });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -385,6 +395,7 @@ router.delete('/me', authenticate, (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: 'Cannot delete the last admin account' });
|
||||
}
|
||||
}
|
||||
writeAudit({ userId: authReq.user.id, action: 'user.account_delete', ip: getClientIp(req) });
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(authReq.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
@@ -606,7 +617,7 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notification_webhook_url', 'app_url'];
|
||||
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notification_webhook_url', 'notification_channel', 'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder', 'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged'];
|
||||
|
||||
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
@@ -626,7 +637,7 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const { allow_registration, allowed_file_types, require_mfa } = req.body as Record<string, unknown>;
|
||||
const { require_mfa } = req.body as Record<string, unknown>;
|
||||
|
||||
if (require_mfa === true || require_mfa === 'true') {
|
||||
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined;
|
||||
@@ -648,15 +659,30 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||
}
|
||||
}
|
||||
const changedKeys = ADMIN_SETTINGS_KEYS.filter(k => req.body[k] !== undefined && !(k === 'smtp_pass' && String(req.body[k]) === '••••••••'));
|
||||
|
||||
const summary: Record<string, unknown> = {};
|
||||
const smtpChanged = changedKeys.some(k => k.startsWith('smtp_'));
|
||||
const eventsChanged = changedKeys.some(k => k.startsWith('notify_'));
|
||||
if (changedKeys.includes('notification_channel')) summary.notification_channel = req.body.notification_channel;
|
||||
if (changedKeys.includes('notification_webhook_url')) summary.webhook_url_updated = true;
|
||||
if (smtpChanged) summary.smtp_settings_updated = true;
|
||||
if (eventsChanged) summary.notification_events_updated = true;
|
||||
if (changedKeys.includes('allow_registration')) summary.allow_registration = req.body.allow_registration;
|
||||
if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true;
|
||||
if (changedKeys.includes('require_mfa')) summary.require_mfa = req.body.require_mfa;
|
||||
|
||||
const debugDetails: Record<string, unknown> = {};
|
||||
for (const k of changedKeys) {
|
||||
debugDetails[k] = k === 'smtp_pass' ? '***' : req.body[k];
|
||||
}
|
||||
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'settings.app_update',
|
||||
ip: getClientIp(req),
|
||||
details: {
|
||||
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
|
||||
allowed_file_types_changed: allowed_file_types !== undefined,
|
||||
require_mfa: require_mfa !== undefined ? (require_mfa === true || require_mfa === 'true') : undefined,
|
||||
},
|
||||
details: summary,
|
||||
debugDetails,
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
@@ -768,6 +794,7 @@ router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => {
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||
const sessionToken = generateToken(user);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
||||
res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } });
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired verification token' });
|
||||
|
||||
@@ -126,6 +126,11 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
|
||||
const formatted = formatNote(note);
|
||||
res.status(201).json({ note: formatted });
|
||||
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string);
|
||||
|
||||
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(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/notes/:id', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import express, { Request, Response } from 'express';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { testSmtp } from '../services/notifications';
|
||||
import { testSmtp, testWebhook } from '../services/notifications';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -55,4 +55,13 @@ router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Admin: test webhook configuration
|
||||
router.post('/test-webhook', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||
|
||||
const result = await testWebhook();
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -222,6 +222,11 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
res.json({ reservation: updated });
|
||||
broadcast(tripId, 'reservation:updated', { reservation: updated }, req.headers['x-socket-id'] as string);
|
||||
|
||||
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(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
@@ -231,10 +236,9 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const reservation = db.prepare('SELECT id, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; accommodation_id: number | null } | undefined;
|
||||
const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined;
|
||||
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
|
||||
|
||||
// Delete linked accommodation if exists
|
||||
if (reservation.accommodation_id) {
|
||||
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id);
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string);
|
||||
@@ -243,6 +247,11 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
|
||||
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(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -7,6 +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';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -147,6 +148,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
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 } });
|
||||
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId });
|
||||
res.status(201).json({ trip });
|
||||
});
|
||||
@@ -229,6 +231,7 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
if (!isOwner(req.params.id, authReq.user.id))
|
||||
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 } });
|
||||
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);
|
||||
@@ -287,7 +290,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 } }).catch(() => {});
|
||||
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, invitee: target.username } }).catch(() => {});
|
||||
});
|
||||
|
||||
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,5 +1,80 @@
|
||||
import { Request } from 'express';
|
||||
import { db } from '../db/database';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
const MAX_LOG_FILES = 5;
|
||||
|
||||
const C = {
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
reset: '\x1b[0m',
|
||||
};
|
||||
|
||||
// ── File logger with rotation ─────────────────────────────────────────────
|
||||
|
||||
const logsDir = path.join(process.cwd(), 'data/logs');
|
||||
try { fs.mkdirSync(logsDir, { recursive: true }); } catch {}
|
||||
const logFilePath = path.join(logsDir, 'trek.log');
|
||||
|
||||
function rotateIfNeeded(): void {
|
||||
try {
|
||||
if (!fs.existsSync(logFilePath)) return;
|
||||
const stat = fs.statSync(logFilePath);
|
||||
if (stat.size < MAX_LOG_SIZE) return;
|
||||
|
||||
for (let i = MAX_LOG_FILES - 1; i >= 1; i--) {
|
||||
const src = i === 1 ? logFilePath : `${logFilePath}.${i - 1}`;
|
||||
const dst = `${logFilePath}.${i}`;
|
||||
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function writeToFile(line: string): void {
|
||||
try {
|
||||
rotateIfNeeded();
|
||||
fs.appendFileSync(logFilePath, line + '\n');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Public log helpers ────────────────────────────────────────────────────
|
||||
|
||||
function formatTs(): string {
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
return new Date().toLocaleString('sv-SE', { timeZone: tz }).replace(' ', 'T');
|
||||
}
|
||||
|
||||
function logInfo(msg: string): void {
|
||||
const ts = formatTs();
|
||||
console.log(`${C.blue}[INFO]${C.reset} ${ts} ${msg}`);
|
||||
writeToFile(`[INFO] ${ts} ${msg}`);
|
||||
}
|
||||
|
||||
function logDebug(msg: string): void {
|
||||
if (LOG_LEVEL !== 'debug') return;
|
||||
const ts = formatTs();
|
||||
console.log(`${C.cyan}[DEBUG]${C.reset} ${ts} ${msg}`);
|
||||
writeToFile(`[DEBUG] ${ts} ${msg}`);
|
||||
}
|
||||
|
||||
function logError(msg: string): void {
|
||||
const ts = formatTs();
|
||||
console.error(`${C.red}[ERROR]${C.reset} ${ts} ${msg}`);
|
||||
writeToFile(`[ERROR] ${ts} ${msg}`);
|
||||
}
|
||||
|
||||
function logWarn(msg: string): void {
|
||||
const ts = formatTs();
|
||||
console.warn(`${C.yellow}[WARN]${C.reset} ${ts} ${msg}`);
|
||||
writeToFile(`[WARN] ${ts} ${msg}`);
|
||||
}
|
||||
|
||||
// ── IP + audit ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getClientIp(req: Request): string | null {
|
||||
const xff = req.headers['x-forwarded-for'];
|
||||
@@ -11,12 +86,37 @@ export function getClientIp(req: Request): string | null {
|
||||
return req.socket?.remoteAddress || null;
|
||||
}
|
||||
|
||||
function resolveUserEmail(userId: number | null): string {
|
||||
if (!userId) return 'anonymous';
|
||||
try {
|
||||
const row = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
|
||||
return row?.email || `uid:${userId}`;
|
||||
} catch { return `uid:${userId}`; }
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
'user.register': 'registered',
|
||||
'user.login': 'logged in',
|
||||
'user.login_failed': 'login failed',
|
||||
'user.password_change': 'changed password',
|
||||
'user.account_delete': 'deleted account',
|
||||
'user.mfa_enable': 'enabled MFA',
|
||||
'user.mfa_disable': 'disabled MFA',
|
||||
'settings.app_update': 'updated settings',
|
||||
'trip.create': 'created trip',
|
||||
'trip.delete': 'deleted trip',
|
||||
'admin.user_role_change': 'changed user role',
|
||||
'admin.user_delete': 'deleted user',
|
||||
'admin.invite_create': 'created invite',
|
||||
};
|
||||
|
||||
/** Best-effort; never throws — failures are logged only. */
|
||||
export function writeAudit(entry: {
|
||||
userId: number | null;
|
||||
action: string;
|
||||
resource?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
debugDetails?: Record<string, unknown>;
|
||||
ip?: string | null;
|
||||
}): void {
|
||||
try {
|
||||
@@ -24,7 +124,41 @@ export function writeAudit(entry: {
|
||||
db.prepare(
|
||||
`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null);
|
||||
|
||||
const email = resolveUserEmail(entry.userId);
|
||||
const label = ACTION_LABELS[entry.action] || entry.action;
|
||||
const brief = buildInfoSummary(entry.action, entry.details);
|
||||
logInfo(`${email} ${label}${brief} ip=${entry.ip || '-'}`);
|
||||
|
||||
if (entry.debugDetails && Object.keys(entry.debugDetails).length > 0) {
|
||||
logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${JSON.stringify(entry.debugDetails)}`);
|
||||
} else if (detailsJson) {
|
||||
logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${detailsJson}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[audit] write failed:', e instanceof Error ? e.message : e);
|
||||
logError(`Audit write failed: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildInfoSummary(action: string, details?: Record<string, unknown>): string {
|
||||
if (!details || Object.keys(details).length === 0) return '';
|
||||
if (action === 'trip.create') return ` "${details.title}"`;
|
||||
if (action === 'trip.delete') return ` tripId=${details.tripId}`;
|
||||
if (action === 'user.register') return ` ${details.email}`;
|
||||
if (action === 'user.login') return '';
|
||||
if (action === 'user.login_failed') return ` reason=${details.reason}`;
|
||||
if (action === 'settings.app_update') {
|
||||
const parts: string[] = [];
|
||||
if (details.notification_channel) parts.push(`channel=${details.notification_channel}`);
|
||||
if (details.smtp_settings_updated) parts.push('smtp');
|
||||
if (details.notification_events_updated) parts.push('events');
|
||||
if (details.webhook_url_updated) parts.push('webhook_url');
|
||||
if (details.allowed_file_types_updated) parts.push('file_types');
|
||||
if (details.allow_registration !== undefined) parts.push(`registration=${details.allow_registration}`);
|
||||
if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`);
|
||||
return parts.length ? ` (${parts.join(', ')})` : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export { LOG_LEVEL, logInfo, logDebug, logError, logWarn };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import fetch from 'node-fetch';
|
||||
import { db } from '../db/database';
|
||||
import { logInfo, logDebug, logError } from './auditLog';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,7 +43,13 @@ function getWebhookUrl(): string | null {
|
||||
}
|
||||
|
||||
function getAppUrl(): string {
|
||||
return process.env.APP_URL || getAppSetting('app_url') || '';
|
||||
const origins = process.env.ALLOWED_ORIGINS;
|
||||
if (origins) {
|
||||
const first = origins.split(',')[0]?.trim();
|
||||
if (first) return first.replace(/\/+$/, '');
|
||||
}
|
||||
const port = process.env.PORT || '3000';
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
function getUserEmail(userId: number): string | null {
|
||||
@@ -53,9 +60,11 @@ function getUserLanguage(userId: number): string {
|
||||
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
|
||||
}
|
||||
|
||||
function getUserPrefs(userId: number): Record<string, number> {
|
||||
const row = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as any;
|
||||
return row || { notify_trip_invite: 1, notify_booking_change: 1, notify_trip_reminder: 1, notify_vacay_invite: 1, notify_photos_shared: 1, notify_collab_message: 1, notify_packing_tagged: 1, notify_webhook: 0 };
|
||||
function getAdminEventEnabled(event: EventType): boolean {
|
||||
const prefKey = EVENT_PREF_MAP[event];
|
||||
if (!prefKey) return true;
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(prefKey) as { value: string } | undefined;
|
||||
return !row || row.value !== 'false';
|
||||
}
|
||||
|
||||
// Event → preference column mapping
|
||||
@@ -90,7 +99,7 @@ type EventTextFn = (params: Record<string, string>) => EventText
|
||||
|
||||
const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
en: {
|
||||
trip_invite: p => ({ title: `You've been invited to "${p.trip}"`, body: `${p.actor} invited you to the trip "${p.trip}". Open TREK to view and start planning!` }),
|
||||
trip_invite: p => ({ title: `Trip invite: "${p.trip}"`, body: `${p.actor} invited ${p.invitee || 'a member'} to the trip "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }),
|
||||
@@ -99,7 +108,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
|
||||
},
|
||||
de: {
|
||||
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat dich zur Reise "${p.trip}" eingeladen. Öffne TREK um die Planung zu starten!` }),
|
||||
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
|
||||
booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }),
|
||||
trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }),
|
||||
@@ -108,7 +117,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
|
||||
},
|
||||
fr: {
|
||||
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} vous a invité au voyage "${p.trip}". Ouvrez TREK pour commencer la planification !` }),
|
||||
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }),
|
||||
vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }),
|
||||
@@ -117,7 +126,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
|
||||
},
|
||||
es: {
|
||||
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} te invitó al viaje "${p.trip}". ¡Abre TREK para comenzar a planificar!` }),
|
||||
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }),
|
||||
vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }),
|
||||
@@ -126,7 +135,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
|
||||
},
|
||||
nl: {
|
||||
trip_invite: p => ({ title: `Uitgenodigd voor "${p.trip}"`, body: `${p.actor} heeft je uitgenodigd voor de reis "${p.trip}". Open TREK om te beginnen met plannen!` }),
|
||||
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }),
|
||||
@@ -135,7 +144,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
|
||||
},
|
||||
ru: {
|
||||
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил вас в поездку "${p.trip}". Откройте TREK чтобы начать планирование!` }),
|
||||
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }),
|
||||
vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }),
|
||||
@@ -144,7 +153,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
|
||||
},
|
||||
zh: {
|
||||
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请你加入旅行"${p.trip}"。打开 TREK 开始规划!` }),
|
||||
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
|
||||
booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"(${p.type})。` }),
|
||||
trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }),
|
||||
vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }),
|
||||
@@ -153,7 +162,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
|
||||
},
|
||||
ar: {
|
||||
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعاك إلى الرحلة "${p.trip}". افتح TREK لبدء التخطيط!` }),
|
||||
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }),
|
||||
vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }),
|
||||
@@ -236,50 +245,109 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
|
||||
text: body,
|
||||
html: buildEmailHtml(subject, body, lang),
|
||||
});
|
||||
logInfo(`Email sent to=${to} subject="${subject}"`);
|
||||
logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Notifications] Email send failed:', err instanceof Error ? err.message : err);
|
||||
logError(`Email send failed to=${to}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string }): string {
|
||||
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
||||
const isSlack = /hooks\.slack\.com\//.test(url);
|
||||
|
||||
if (isDiscord) {
|
||||
return JSON.stringify({
|
||||
embeds: [{
|
||||
title: `📍 ${payload.title}`,
|
||||
description: payload.body,
|
||||
color: 0x3b82f6,
|
||||
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
if (isSlack) {
|
||||
const trip = payload.tripName ? ` • _${payload.tripName}_` : '';
|
||||
return JSON.stringify({
|
||||
text: `*${payload.title}*\n${payload.body}${trip}`,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' });
|
||||
}
|
||||
|
||||
async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise<boolean> {
|
||||
const url = getWebhookUrl();
|
||||
if (!url) return false;
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }),
|
||||
body: buildWebhookBody(url, payload),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
logError(`Webhook HTTP ${res.status}: ${errBody}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logInfo(`Webhook sent event=${payload.event} trip=${payload.tripName || '-'}`);
|
||||
logDebug(`Webhook url=${url} payload=${buildWebhookBody(url, payload).substring(0, 500)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Notifications] Webhook failed:', err instanceof Error ? err.message : err);
|
||||
logError(`Webhook failed event=${payload.event}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getNotificationChannel(): string {
|
||||
return getAppSetting('notification_channel') || 'none';
|
||||
}
|
||||
|
||||
export async function notify(payload: NotificationPayload): Promise<void> {
|
||||
const prefs = getUserPrefs(payload.userId);
|
||||
const prefKey = EVENT_PREF_MAP[payload.event];
|
||||
if (prefKey && !prefs[prefKey]) return;
|
||||
const channel = getNotificationChannel();
|
||||
if (channel === 'none') return;
|
||||
|
||||
if (!getAdminEventEnabled(payload.event)) return;
|
||||
|
||||
const lang = getUserLanguage(payload.userId);
|
||||
const { title, body } = getEventText(lang, payload.event, payload.params);
|
||||
|
||||
const email = getUserEmail(payload.userId);
|
||||
if (email) await sendEmail(email, title, body, payload.userId);
|
||||
if (prefs.notify_webhook) await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip });
|
||||
logDebug(`Notification event=${payload.event} channel=${channel} userId=${payload.userId} params=${JSON.stringify(payload.params)}`);
|
||||
|
||||
if (channel === 'email') {
|
||||
const email = getUserEmail(payload.userId);
|
||||
if (email) await sendEmail(email, title, body, payload.userId);
|
||||
} else if (channel === 'webhook') {
|
||||
await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip });
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record<string, string>): Promise<void> {
|
||||
const channel = getNotificationChannel();
|
||||
if (channel === 'none') return;
|
||||
if (!getAdminEventEnabled(event)) return;
|
||||
|
||||
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
|
||||
if (!trip) return;
|
||||
|
||||
if (channel === 'webhook') {
|
||||
const lang = getUserLanguage(actorUserId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
logDebug(`notifyTripMembers event=${event} channel=webhook tripId=${tripId} actor=${actorUserId}`);
|
||||
await sendWebhook({ event, title, body, tripName: params.trip });
|
||||
return;
|
||||
}
|
||||
|
||||
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[];
|
||||
const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId);
|
||||
const unique = [...new Set(allIds)];
|
||||
@@ -297,3 +365,12 @@ export async function testSmtp(to: string): Promise<{ success: boolean; error?:
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testWebhook(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const sent = await sendWebhook({ event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' });
|
||||
return sent ? { success: true } : { success: false, error: 'Webhook URL not configured' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface User {
|
||||
mfa_enabled?: number | boolean;
|
||||
mfa_secret?: string | null;
|
||||
mfa_backup_codes?: string | null;
|
||||
must_change_password?: number | boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user