- Gate permissions in /app-config behind optionalAuth so unauthenticated requests don't receive admin configuration - Fix trip_delete isMember parameter (was hardcoded false) - Return skipped keys from savePermissions for admin visibility - Add disabled prop to CustomSelect, use in BudgetPanel currency picker - Fix CollabChat reaction handler returning false instead of void - Pass canUploadFiles as prop to NoteFormModal instead of internal store read - Make edit-only NoteFormModal props optional (onDeleteFile, note, tripId) - Add missing trailing newlines to .gitignore and it.ts
477 lines
23 KiB
TypeScript
477 lines
23 KiB
TypeScript
import express, { Request, Response } from 'express';
|
|
import multer from 'multer';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { db, canAccessTrip } 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';
|
|
import { checkPermission } from '../services/permissions';
|
|
|
|
const router = express.Router();
|
|
|
|
const MS_PER_DAY = 86400000;
|
|
const MAX_TRIP_DAYS = 365;
|
|
const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
|
|
|
|
const coversDir = path.join(__dirname, '../../uploads/covers');
|
|
const coverStorage = multer.diskStorage({
|
|
destination: (_req, _file, cb) => {
|
|
if (!fs.existsSync(coversDir)) fs.mkdirSync(coversDir, { recursive: true });
|
|
cb(null, coversDir);
|
|
},
|
|
filename: (_req, file, cb) => {
|
|
const ext = path.extname(file.originalname);
|
|
cb(null, `${uuidv4()}${ext}`);
|
|
},
|
|
});
|
|
const uploadCover = multer({
|
|
storage: coverStorage,
|
|
limits: { fileSize: MAX_COVER_SIZE },
|
|
fileFilter: (_req, file, cb) => {
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
|
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Only jpg, png, gif, webp images allowed'));
|
|
}
|
|
},
|
|
});
|
|
|
|
const TRIP_SELECT = `
|
|
SELECT t.*,
|
|
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
|
|
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
|
|
CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
|
|
u.username as owner_username,
|
|
(SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
|
|
FROM trips t
|
|
JOIN users u ON u.id = t.user_id
|
|
`;
|
|
|
|
function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null) {
|
|
const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[];
|
|
|
|
if (!startDate || !endDate) {
|
|
const datelessExisting = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number);
|
|
const withDates = existing.filter(d => d.date);
|
|
if (withDates.length > 0) {
|
|
db.prepare(`DELETE FROM days WHERE trip_id = ? AND date IS NOT NULL`).run(tripId);
|
|
}
|
|
const needed = 7 - datelessExisting.length;
|
|
if (needed > 0) {
|
|
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)');
|
|
for (let i = 0; i < needed; i++) insert.run(tripId, datelessExisting.length + i + 1);
|
|
} else if (needed < 0) {
|
|
const toRemove = datelessExisting.slice(7);
|
|
const del = db.prepare('DELETE FROM days WHERE id = ?');
|
|
for (const d of toRemove) del.run(d.id);
|
|
}
|
|
const remaining = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
|
|
const tmpUpd = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
|
remaining.forEach((d, i) => tmpUpd.run(-(i + 1), d.id));
|
|
remaining.forEach((d, i) => tmpUpd.run(i + 1, d.id));
|
|
return;
|
|
}
|
|
|
|
const [sy, sm, sd] = startDate.split('-').map(Number);
|
|
const [ey, em, ed] = endDate.split('-').map(Number);
|
|
const startMs = Date.UTC(sy, sm - 1, sd);
|
|
const endMs = Date.UTC(ey, em - 1, ed);
|
|
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS);
|
|
|
|
const targetDates: string[] = [];
|
|
for (let i = 0; i < numDays; i++) {
|
|
const d = new Date(startMs + i * MS_PER_DAY);
|
|
const yyyy = d.getUTCFullYear();
|
|
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
const dd = String(d.getUTCDate()).padStart(2, '0');
|
|
targetDates.push(`${yyyy}-${mm}-${dd}`);
|
|
}
|
|
|
|
const existingByDate = new Map<string, { id: number; day_number: number; date: string | null }>();
|
|
for (const d of existing) {
|
|
if (d.date) existingByDate.set(d.date, d);
|
|
}
|
|
|
|
const targetDateSet = new Set(targetDates);
|
|
|
|
const toDelete = existing.filter(d => d.date && !targetDateSet.has(d.date));
|
|
const datelessToDelete = existing.filter(d => !d.date);
|
|
const del = db.prepare('DELETE FROM days WHERE id = ?');
|
|
for (const d of [...toDelete, ...datelessToDelete]) del.run(d.id);
|
|
|
|
const setTemp = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
|
const kept = existing.filter(d => d.date && targetDateSet.has(d.date));
|
|
for (let i = 0; i < kept.length; i++) setTemp.run(-(i + 1), kept[i].id);
|
|
|
|
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
|
|
const update = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
|
|
|
for (let i = 0; i < targetDates.length; i++) {
|
|
const date = targetDates[i];
|
|
const ex = existingByDate.get(date);
|
|
if (ex) {
|
|
update.run(i + 1, ex.id);
|
|
} else {
|
|
insert.run(tripId, i + 1, date);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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;
|
|
if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false))
|
|
return res.status(403).json({ error: 'No permission to create trips' });
|
|
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, 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 });
|
|
});
|
|
|
|
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const userId = authReq.user.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 tripOwnerId = access.user_id;
|
|
const isMember = access.user_id !== authReq.user.id;
|
|
|
|
// Archive check
|
|
if (req.body.is_archived !== undefined) {
|
|
if (!checkPermission('trip_archive', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
|
|
return res.status(403).json({ error: 'No permission to archive/unarchive this trip' });
|
|
}
|
|
// Cover image check
|
|
if (req.body.cover_image !== undefined) {
|
|
if (!checkPermission('trip_cover_upload', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
|
|
return res.status(403).json({ error: 'No permission to change cover image' });
|
|
}
|
|
// General edit check (title, description, dates, currency, reminder_days)
|
|
const editFields = ['title', 'description', 'start_date', 'end_date', 'currency', 'reminder_days'];
|
|
if (editFields.some(f => req.body[f] !== undefined)) {
|
|
if (!checkPermission('trip_edit', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
|
|
return res.status(403).json({ error: 'No permission to edit this trip' });
|
|
}
|
|
|
|
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, 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' });
|
|
|
|
const newTitle = title || trip.title;
|
|
const newDesc = description !== undefined ? description : trip.description;
|
|
const newStart = start_date !== undefined ? start_date : trip.start_date;
|
|
const newEnd = end_date !== undefined ? end_date : trip.end_date;
|
|
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=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP
|
|
WHERE 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);
|
|
});
|
|
|
|
router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const access = canAccessTrip(req.params.id, authReq.user.id);
|
|
const tripOwnerId = access?.user_id;
|
|
if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
|
|
const isMember = tripOwnerId !== authReq.user.id;
|
|
if (!checkPermission('trip_cover_upload', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
|
|
return res.status(403).json({ error: 'No permission to change the cover image' });
|
|
|
|
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' });
|
|
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
|
|
|
if (trip.cover_image) {
|
|
const oldPath = path.join(__dirname, '../../', trip.cover_image.replace(/^\//, ''));
|
|
const resolvedPath = path.resolve(oldPath);
|
|
const uploadsDir = path.resolve(__dirname, '../../uploads');
|
|
if (resolvedPath.startsWith(uploadsDir) && fs.existsSync(resolvedPath)) {
|
|
fs.unlinkSync(resolvedPath);
|
|
}
|
|
}
|
|
|
|
const coverUrl = `/uploads/covers/${req.file.filename}`;
|
|
db.prepare('UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?').run(coverUrl, req.params.id);
|
|
res.json({ cover_image: coverUrl });
|
|
});
|
|
|
|
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id) as { user_id: number } | undefined;
|
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
|
const tripOwnerId = trip.user_id;
|
|
const isMemberDel = tripOwnerId !== authReq.user.id;
|
|
if (!checkPermission('trip_delete', authReq.user.role, tripOwnerId, authReq.user.id, isMemberDel))
|
|
return res.status(403).json({ error: 'No permission to delete this 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);
|
|
});
|
|
|
|
router.get('/:id/members', 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 tripOwnerId = access.user_id;
|
|
const members = db.prepare(`
|
|
SELECT u.id, u.username, u.email, u.avatar,
|
|
CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role,
|
|
m.added_at,
|
|
ib.username as invited_by_username
|
|
FROM trip_members m
|
|
JOIN users u ON u.id = m.user_id
|
|
LEFT JOIN users ib ON ib.id = m.invited_by
|
|
WHERE m.trip_id = ?
|
|
ORDER BY m.added_at ASC
|
|
`).all(tripOwnerId, req.params.id) as { id: number; username: string; email: string; avatar: string | null; role: string; added_at: string; invited_by_username: string | null }[];
|
|
|
|
const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(tripOwnerId) as Pick<User, 'id' | 'username' | 'email' | 'avatar'>;
|
|
|
|
res.json({
|
|
owner: { ...owner, role: 'owner', avatar_url: owner.avatar ? `/uploads/avatars/${owner.avatar}` : null },
|
|
members: members.map(m => ({ ...m, avatar_url: m.avatar ? `/uploads/avatars/${m.avatar}` : null })),
|
|
current_user_id: authReq.user.id,
|
|
});
|
|
});
|
|
|
|
router.post('/:id/members', 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 tripOwnerId = access.user_id;
|
|
const isMember = tripOwnerId !== authReq.user.id;
|
|
if (!checkPermission('member_manage', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
|
|
return res.status(403).json({ error: 'No permission to manage members' });
|
|
|
|
const { identifier } = req.body;
|
|
if (!identifier) return res.status(400).json({ error: 'Email or username required' });
|
|
|
|
const target = db.prepare(
|
|
'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?'
|
|
).get(identifier.trim(), identifier.trim()) as Pick<User, 'id' | 'username' | 'email' | 'avatar'> | undefined;
|
|
|
|
if (!target) return res.status(404).json({ error: 'User not found' });
|
|
|
|
if (target.id === tripOwnerId)
|
|
return res.status(400).json({ error: 'Trip owner is already a member' });
|
|
|
|
const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(req.params.id, target.id);
|
|
if (existing) return res.status(400).json({ error: 'User already has access' });
|
|
|
|
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, authReq.user.id);
|
|
|
|
// 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.email, invitee: target.email } }).catch(() => {});
|
|
});
|
|
|
|
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
|
|
});
|
|
|
|
router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
if (!canAccessTrip(req.params.id, authReq.user.id))
|
|
return res.status(404).json({ error: 'Trip not found' });
|
|
|
|
const targetId = parseInt(req.params.userId);
|
|
const isSelf = targetId === authReq.user.id;
|
|
if (!isSelf) {
|
|
const access = canAccessTrip(req.params.id, authReq.user.id);
|
|
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
|
const memberCheck = access.user_id !== authReq.user.id;
|
|
if (!checkPermission('member_manage', authReq.user.role, access.user_id, authReq.user.id, memberCheck))
|
|
return res.status(403).json({ error: 'No permission to remove members' });
|
|
}
|
|
|
|
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(req.params.id, targetId);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// ICS calendar export
|
|
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
if (!canAccessTrip(req.params.id, authReq.user.id))
|
|
return res.status(404).json({ error: 'Trip not found' });
|
|
|
|
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as any;
|
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
|
|
|
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[];
|
|
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
|
|
|
|
const esc = (s: string) => s.replace(/[\\;,\n]/g, m => m === '\n' ? '\\n' : '\\' + m);
|
|
const fmtDate = (d: string) => d.replace(/-/g, '');
|
|
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
|
const uid = (id: number, type: string) => `trek-${type}-${id}@trek`;
|
|
|
|
// Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00"
|
|
const fmtDateTime = (d: string, refDate?: string) => {
|
|
if (d.includes('T')) return d.replace(/[-:]/g, '').split('.')[0];
|
|
// Time-only: combine with reference date
|
|
if (refDate && d.match(/^\d{2}:\d{2}/)) {
|
|
const datePart = refDate.split('T')[0];
|
|
return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, '');
|
|
}
|
|
return d.replace(/[-:]/g, '');
|
|
};
|
|
|
|
let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
|
|
ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`;
|
|
|
|
// Trip as all-day event
|
|
if (trip.start_date && trip.end_date) {
|
|
const endNext = new Date(trip.end_date + 'T00:00:00');
|
|
endNext.setDate(endNext.getDate() + 1);
|
|
const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
|
|
ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`;
|
|
if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`;
|
|
ics += `END:VEVENT\r\n`;
|
|
}
|
|
|
|
// Reservations as events
|
|
for (const r of reservations) {
|
|
if (!r.reservation_time) continue;
|
|
const hasTime = r.reservation_time.includes('T');
|
|
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
|
|
|
|
ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
|
|
if (hasTime) {
|
|
ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
|
|
if (r.reservation_end_time) {
|
|
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
|
|
if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`;
|
|
}
|
|
} else {
|
|
ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
|
|
}
|
|
ics += `SUMMARY:${esc(r.title)}\r\n`;
|
|
|
|
let desc = r.type ? `Type: ${r.type}` : '';
|
|
if (r.confirmation_number) desc += `\\nConfirmation: ${r.confirmation_number}`;
|
|
if (meta.airline) desc += `\\nAirline: ${meta.airline}`;
|
|
if (meta.flight_number) desc += `\\nFlight: ${meta.flight_number}`;
|
|
if (meta.departure_airport) desc += `\\nFrom: ${meta.departure_airport}`;
|
|
if (meta.arrival_airport) desc += `\\nTo: ${meta.arrival_airport}`;
|
|
if (meta.train_number) desc += `\\nTrain: ${meta.train_number}`;
|
|
if (r.notes) desc += `\\n${r.notes}`;
|
|
if (desc) ics += `DESCRIPTION:${desc}\r\n`;
|
|
if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`;
|
|
ics += `END:VEVENT\r\n`;
|
|
}
|
|
|
|
ics += 'END:VCALENDAR\r\n';
|
|
|
|
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${esc(trip.title || 'trek-trip')}.ics"`);
|
|
res.send(ics);
|
|
});
|
|
|
|
export default router;
|