mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Merge pull request #225 from andreibrebene/improvements/various-improvements
Improvements/various improvements
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -189,7 +192,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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -83,6 +84,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 +185,17 @@ 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 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,
|
||||
has_users: userCount > 0,
|
||||
setup_complete: setupComplete,
|
||||
version,
|
||||
has_maps_key: hasGoogleKey,
|
||||
oidc_configured: oidcConfigured,
|
||||
@@ -197,6 +207,8 @@ 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,
|
||||
trip_reminders_enabled: tripRemindersEnabled,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,6 +302,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 +322,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 +345,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 +386,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 +402,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 +624,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 +644,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,16 +666,37 @@ 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,
|
||||
});
|
||||
|
||||
const notifRelated = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'notify_trip_reminder'];
|
||||
if (changedKeys.some(k => notifRelated.includes(k))) {
|
||||
startTripReminders();
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -768,6 +807,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.email }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/notes/:id', authenticate, (req: Request, res: Response) => {
|
||||
@@ -425,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(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.email, 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.email, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
+71
-26
@@ -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, logInfo } from '../services/auditLog';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -124,29 +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, 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 });
|
||||
});
|
||||
@@ -154,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' });
|
||||
@@ -186,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);
|
||||
@@ -226,9 +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);
|
||||
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);
|
||||
@@ -287,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 } }).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 } });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user