import express, { Request, Response } from 'express'; import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { checkPermission } from '../services/permissions'; import { AuthRequest } from '../types'; import { verifyTripAccess, listItems, createItem, updateItem, deleteItem, bulkImport, listBags, createBag, updateBag, deleteBag, applyTemplate, saveAsTemplate, setBagMembers, getCategoryAssignees, updateCategoryAssignees, reorderItems, } from '../services/packingService'; const router = express.Router({ mergeParams: true }); router.get('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); const items = listItems(tripId); res.json({ items }); }); // Bulk import packing items (must be before /:id) router.post('/import', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const { items } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' }); const created = bulkImport(tripId, items); res.status(201).json({ items: created, count: created.length }); for (const item of created) { broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string); } }); router.post('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const { name, category, checked } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); if (!name) return res.status(400).json({ error: 'Item name is required' }); const item = createItem(tripId, { name, category, checked }); res.status(201).json({ item }); broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string); }); router.put('/reorder', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const { orderedIds } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); reorderItems(tripId, orderedIds); res.json({ success: true }); }); router.put('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; const { name, checked, category, weight_grams, bag_id, quantity } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); const updated = updateItem(tripId, id, { name, checked, category, weight_grams, bag_id, quantity }, Object.keys(req.body)); if (!updated) return res.status(404).json({ error: 'Item not found' }); res.json({ item: updated }); broadcast(tripId, 'packing:updated', { item: updated }, req.headers['x-socket-id'] as string); }); router.delete('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); if (!deleteItem(tripId, id)) return res.status(404).json({ error: 'Item not found' }); res.json({ success: true }); broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string); }); // ── Bags CRUD ─────────────────────────────────────────────────────────────── router.get('/bags', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); const bags = listBags(tripId); res.json({ bags }); }); router.post('/bags', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const { name, color } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); const bag = createBag(tripId, { name, color }); res.status(201).json({ bag }); broadcast(tripId, 'packing:bag-created', { bag }, req.headers['x-socket-id'] as string); }); router.put('/bags/:bagId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, bagId } = req.params; const { name, color, weight_limit_grams, user_id } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); const updated = updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(req.body)); if (!updated) return res.status(404).json({ error: 'Bag not found' }); res.json({ bag: updated }); broadcast(tripId, 'packing:bag-updated', { bag: updated }, req.headers['x-socket-id'] as string); }); router.delete('/bags/:bagId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, bagId } = req.params; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); if (!deleteBag(tripId, bagId)) return res.status(404).json({ error: 'Bag not found' }); res.json({ success: true }); broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, req.headers['x-socket-id'] as string); }); // ── Apply template ────────────────────────────────────────────────────────── router.post('/apply-template/:templateId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, templateId } = req.params; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); const added = applyTemplate(tripId, templateId); if (!added) return res.status(404).json({ error: 'Template not found or empty' }); res.json({ items: added, count: added.length }); broadcast(tripId, 'packing:template-applied', { items: added }, req.headers['x-socket-id'] as string); }); // ── Bag Members ──────────────────────────────────────────────────────────── router.put('/bags/:bagId/members', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, bagId } = req.params; const { user_ids } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); const members = setBagMembers(tripId, bagId, Array.isArray(user_ids) ? user_ids : []); if (!members) return res.status(404).json({ error: 'Bag not found' }); res.json({ members }); broadcast(tripId, 'packing:bag-members-updated', { bagId: Number(bagId), members }, req.headers['x-socket-id'] as string); }); // ── Save as Template ─────────────────────────────────────────────────────── router.post('/save-as-template', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const { name } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!name?.trim()) return res.status(400).json({ error: 'Template name is required' }); const template = saveAsTemplate(tripId, authReq.user.id, name.trim()); if (!template) return res.status(400).json({ error: 'No items to save' }); res.status(201).json({ template }); }); // ── Category assignees ────────────────────────────────────────────────────── router.get('/category-assignees', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); const assignees = getCategoryAssignees(tripId); res.json({ assignees }); }); router.put('/category-assignees/:categoryName', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, categoryName } = req.params; const { user_ids } = req.body; const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); const cat = decodeURIComponent(categoryName); const rows = updateCategoryAssignees(tripId, cat, user_ids); res.json({ assignees: rows }); broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string); // Notify newly assigned users if (Array.isArray(user_ids) && user_ids.length > 0) { import('../services/notificationService').then(({ send }) => { const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; // Use trip scope so the service resolves recipients — actor is excluded automatically send({ event: 'packing_tagged', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat, tripId: String(tripId) } }).catch(() => {}); }); } }); export default router;