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
This commit is contained in:
@@ -826,6 +826,23 @@ function runMigrations(db: Database.Database): void {
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE budget_items ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
// Migration 74: Add quantity to packing_items + user_id to packing_bags + bag_members table
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE packing_items ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec('ALTER TABLE packing_bags ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS packing_bag_members (
|
||||
bag_id INTEGER NOT NULL REFERENCES packing_bags(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (bag_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_packing_bag_members_bag ON packing_bag_members(bag_id);
|
||||
`);
|
||||
// Migrate existing single user_id to bag_members
|
||||
const bagsWithUser = db.prepare('SELECT id, user_id FROM packing_bags WHERE user_id IS NOT NULL').all() as { id: number; user_id: number }[];
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
||||
for (const b of bagsWithUser) ins.run(b.id, b.user_id);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
updateBag,
|
||||
deleteBag,
|
||||
applyTemplate,
|
||||
saveAsTemplate,
|
||||
setBagMembers,
|
||||
getCategoryAssignees,
|
||||
updateCategoryAssignees,
|
||||
reorderItems,
|
||||
@@ -92,7 +94,7 @@ router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
||||
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 } = req.body;
|
||||
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' });
|
||||
@@ -100,7 +102,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
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 }, Object.keys(req.body));
|
||||
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 });
|
||||
@@ -151,12 +153,12 @@ router.post('/bags', authenticate, (req: Request, res: Response) => {
|
||||
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 } = req.body;
|
||||
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 });
|
||||
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);
|
||||
@@ -193,6 +195,40 @@ router.post('/apply-template/:templateId', authenticate, (req: Request, res: Res
|
||||
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) => {
|
||||
|
||||
@@ -14,13 +14,14 @@ export function listItems(tripId: string | number) {
|
||||
).all(tripId);
|
||||
}
|
||||
|
||||
export function createItem(tripId: string | number, data: { name: string; category?: string; checked?: boolean }) {
|
||||
export function createItem(tripId: string | number, data: { name: string; category?: string; checked?: boolean; quantity?: number }) {
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
const qty = Math.max(1, Math.min(999, Number(data.quantity) || 1));
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, data.name, data.checked ? 1 : 0, data.category || 'Allgemein', sortOrder);
|
||||
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order, quantity) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, data.name, data.checked ? 1 : 0, data.category || 'Allgemein', sortOrder, qty);
|
||||
|
||||
return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
}
|
||||
@@ -28,7 +29,7 @@ export function createItem(tripId: string | number, data: { name: string; catego
|
||||
export function updateItem(
|
||||
tripId: string | number,
|
||||
id: string | number,
|
||||
data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null },
|
||||
data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null; quantity?: number },
|
||||
bodyKeys: string[]
|
||||
) {
|
||||
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
@@ -40,7 +41,8 @@ export function updateItem(
|
||||
checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END,
|
||||
category = COALESCE(?, category),
|
||||
weight_grams = CASE WHEN ? THEN ? ELSE weight_grams END,
|
||||
bag_id = CASE WHEN ? THEN ? ELSE bag_id END
|
||||
bag_id = CASE WHEN ? THEN ? ELSE bag_id END,
|
||||
quantity = CASE WHEN ? THEN ? ELSE quantity END
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
data.name || null,
|
||||
@@ -51,6 +53,8 @@ export function updateItem(
|
||||
data.weight_grams ?? null,
|
||||
bodyKeys.includes('bag_id') ? 1 : 0,
|
||||
data.bag_id ?? null,
|
||||
bodyKeys.includes('quantity') ? 1 : 0,
|
||||
data.quantity ? Math.max(1, Math.min(999, Number(data.quantity))) : 1,
|
||||
id
|
||||
);
|
||||
|
||||
@@ -114,7 +118,33 @@ export function bulkImport(tripId: string | number, items: ImportItem[]) {
|
||||
// ── Bags ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function listBags(tripId: string | number) {
|
||||
return db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId);
|
||||
const bags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId) as any[];
|
||||
const members = db.prepare(`
|
||||
SELECT bm.bag_id, bm.user_id, u.username, u.avatar
|
||||
FROM packing_bag_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
JOIN packing_bags b ON bm.bag_id = b.id
|
||||
WHERE b.trip_id = ?
|
||||
`).all(tripId) as { bag_id: number; user_id: number; username: string; avatar: string | null }[];
|
||||
const membersByBag = new Map<number, typeof members>();
|
||||
for (const m of members) {
|
||||
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
|
||||
membersByBag.get(m.bag_id)!.push(m);
|
||||
}
|
||||
return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] }));
|
||||
}
|
||||
|
||||
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
|
||||
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
|
||||
if (!bag) return null;
|
||||
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of userIds) ins.run(bagId, uid);
|
||||
return db.prepare(`
|
||||
SELECT bm.user_id, u.username, u.avatar
|
||||
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.bag_id = ?
|
||||
`).all(bagId);
|
||||
}
|
||||
|
||||
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
|
||||
@@ -128,15 +158,26 @@ export function createBag(tripId: string | number, data: { name: string; color?:
|
||||
export function updateBag(
|
||||
tripId: string | number,
|
||||
bagId: string | number,
|
||||
data: { name?: string; color?: string; weight_limit_grams?: number | null }
|
||||
data: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null },
|
||||
bodyKeys?: string[]
|
||||
) {
|
||||
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
|
||||
if (!bag) return null;
|
||||
|
||||
db.prepare('UPDATE packing_bags SET name = COALESCE(?, name), color = COALESCE(?, color), weight_limit_grams = ? WHERE id = ?').run(
|
||||
data.name?.trim() || null, data.color || null, data.weight_limit_grams ?? null, bagId
|
||||
db.prepare(`UPDATE packing_bags SET
|
||||
name = COALESCE(?, name),
|
||||
color = COALESCE(?, color),
|
||||
weight_limit_grams = ?,
|
||||
user_id = CASE WHEN ? THEN ? ELSE user_id END
|
||||
WHERE id = ?`).run(
|
||||
data.name?.trim() || null,
|
||||
data.color || null,
|
||||
data.weight_limit_grams ?? (bag as any).weight_limit_grams ?? null,
|
||||
bodyKeys?.includes('user_id') ? 1 : 0,
|
||||
data.user_id ?? null,
|
||||
bagId
|
||||
);
|
||||
return db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(bagId);
|
||||
return db.prepare('SELECT b.*, u.username as assigned_username FROM packing_bags b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = ?').get(bagId);
|
||||
}
|
||||
|
||||
export function deleteBag(tripId: string | number, bagId: string | number) {
|
||||
@@ -174,6 +215,37 @@ export function applyTemplate(tripId: string | number, templateId: string | numb
|
||||
return added;
|
||||
}
|
||||
|
||||
// ── Save as Template ──────────────────────────────────────────────────────
|
||||
|
||||
export function saveAsTemplate(tripId: string | number, userId: number, templateName: string) {
|
||||
const items = db.prepare(
|
||||
'SELECT name, category FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC'
|
||||
).all(tripId) as { name: string; category: string }[];
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(templateName, userId);
|
||||
const templateId = result.lastInsertRowid;
|
||||
|
||||
const categories = [...new Set(items.map(i => i.category || 'Other'))];
|
||||
const catIdMap = new Map<string, number | bigint>();
|
||||
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
const catResult = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, categories[i], i);
|
||||
catIdMap.set(categories[i], catResult.lastInsertRowid);
|
||||
}
|
||||
|
||||
const itemsByCategory = new Map<string, number>();
|
||||
for (const item of items) {
|
||||
const catId = catIdMap.get(item.category || 'Other')!;
|
||||
const order = itemsByCategory.get(item.category || 'Other') || 0;
|
||||
db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, item.name, order);
|
||||
itemsByCategory.set(item.category || 'Other', order + 1);
|
||||
}
|
||||
|
||||
return { id: Number(templateId), name: templateName, categoryCount: categories.length, itemCount: items.length };
|
||||
}
|
||||
|
||||
// ── Category Assignees ─────────────────────────────────────────────────────
|
||||
|
||||
export function getCategoryAssignees(tripId: string | number) {
|
||||
|
||||
Reference in New Issue
Block a user