Files
TREK/server/src/routes/packing.ts
T
Maurice 48bf149d01 feat(packing): item quantity, bag rename, multi-user bags, save as template
- Add quantity field to packing items (persisted, visible per item)
- Bags are now renamable (click to edit in sidebar)
- Bags support multiple user assignments with avatar display
- New packing_bag_members table for multi-user bag ownership
- Save current packing list as reusable template
- Add bag members API endpoint (PUT /bags/:bagId/members)
- Migration 74: quantity on packing_items, user_id on packing_bags, packing_bag_members table
2026-04-05 19:28:33 +02:00

272 lines
12 KiB
TypeScript

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;