import { Body, Controller, Delete, Get, Headers, HttpCode, HttpException, Param, Post, Put, UseGuards, } from '@nestjs/common'; import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import type { User } from '../../types'; import { PackingService, isInvalidBagRef } from './packing.service'; import { isUpdateConflict } from '../common/conflictResult'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; import { PackingApplyTemplateDto, PackingBagMembersDto, PackingCategoryAssigneesDto, PackingCreateBagDto, PackingCreateItemDto, PackingImportDto, PackingReorderDto, PackingSaveTemplateDto, PackingSetSharingDto, PackingUpdateBagDto, PackingUpdateItemDto, } from './packing.dto'; /** A packing item row carrying the privacy fields (#858) used to scope broadcasts. */ type PackingItemRow = { is_private?: number; owner_id?: number | null; recipients?: { user_id: number }[]; [key: string]: unknown }; /** * /api/trips/:tripId/packing — trip-scoped packing list (items, bags, templates, * assignees). * * Byte-identical to the legacy Express route (server/src/routes/packing.ts): * every handler verifies trip access (404 "Trip not found"); mutations check the * 'packing_edit' permission (403 "No permission"); status codes match (201 on the * creates, 200 elsewhere — note POST /apply-template stays 200); and the bespoke * 400/404 bodies are reproduced. Mutations broadcast over WebSocket with the * forwarded X-Socket-Id. /reorder is declared before /:id so it wins over the param. * * Bodies validate via the @trek/shared Zod contracts (packing.dto.ts + the * global ZodValidationPipe). The pipe's 400 envelope replaced the legacy * bespoke name checks that the schemas now enforce (missing item name, invalid * visibility); checks the schemas cannot express (whitespace-only names, empty * import arrays, the admin template gate) keep their exact legacy strings. */ @Controller('api/trips/:tripId/packing') // TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations // add @RequirePermission('packing_edit'), the same action string the service's canEdit // passes, so the HTTP and MCP paths cannot demand different rights. @UseGuards(JwtAuthGuard, TripAccessGuard) export class PackingController { constructor(private readonly packing: PackingService) {} /** Loads the trip or throws the legacy 404; returns it for the permission check. */ @Get() async list(@CurrentUser() user: User, @Param('tripId') tripId: string) { // Pass the viewer so private items (#858) owned by other members are hidden. return { items: await this.packing.listItems(tripId, user.id) }; } @RequirePermission('packing_edit') @Post('import') async importItems( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PackingImportDto, @Headers('x-socket-id') socketId?: string, ) { // The schema guarantees an array; the empty-array rejection stays a bespoke 400. if (body.items.length === 0) { throw new HttpException({ error: 'items must be a non-empty array' }, 400); } const created = await this.packing.bulkImport(tripId, body.items, user.id); for (const item of created) { this.packing.broadcastItem(tripId, 'packing:created', { item }, item, socketId); } this.packing.broadcastBagTotals(tripId); return { items: created, count: created.length }; } @RequirePermission('packing_edit') @Post() async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PackingCreateItemDto, @Headers('x-socket-id') socketId?: string, ) { // checked arrives as boolean or legacy 0/1 — the service coerces by truthiness. const item = await this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked === undefined ? undefined : !!body.checked, weight_grams: body.weight_grams, bag_id: body.bag_id, quantity: body.quantity, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id); // A bag referenced in the body must exist on this trip (#2154). The payload // is at fault, so 400 — the 404 'Bag not found' stays with the path routes. if (isInvalidBagRef(item)) { throw new HttpException({ error: 'Bag not found' }, 400); } this.packing.emitToViewers(tripId, 'packing:created', { item }, item, socketId); this.packing.broadcastBagTotals(tripId); return { item }; } @RequirePermission('packing_edit') @Put('reorder') async reorder( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PackingReorderDto, @Headers('x-socket-id') _socketId?: string, ) { await this.packing.reorderItems(tripId, body.orderedIds); return { success: true }; } @RequirePermission('packing_edit') @Put(':id') async update( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: PackingUpdateItemDto, @Headers('x-socket-id') socketId?: string, @Headers('x-base-updated-at') ifMatch?: string, ) { // Privacy state before the change, so a public↔private toggle (#858) can route // the broadcast correctly instead of leaking a freshly-privatized item. const before = await this.packing.getItemPrivacy(tripId, id); const { name, checked, category, weight_grams, bag_id, quantity, is_private } = body; // bodyKeys carries which keys the request actually provided (the presence- // sentinel protocol); the parsed body only ever holds known schema keys. // checked arrives as boolean or legacy 0/1 — normalize to the 0/1 the SQL binds. const updated = await this.packing.updateItem(tripId, id, { name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, weight_grams, bag_id, quantity, is_private }, Object.keys(body), ifMatch, user.id); if (!updated) { throw new HttpException({ error: 'Item not found' }, 404); } // Stale offline overwrite — surface the conflict for client-side resolution (#1135). if (isUpdateConflict(updated)) { throw new HttpException({ error: 'conflict', server: updated.server }, 409); } // A bag referenced in the body must exist on this trip (#2154) — see create. if (isInvalidBagRef(updated)) { throw new HttpException({ error: 'Bag not found' }, 400); } this.packing.broadcastUpdate(tripId, id, updated as PackingItemRow, !!before?.is_private, socketId); // Only when the write could actually move a weight. Checking an item off is // the most frequent packing write there is, and every ping costs every // connected client a listBags round trip. if (['weight_grams', 'quantity', 'bag_id'].some(k => Object.keys(body).includes(k))) { this.packing.broadcastBagTotals(tripId); } return { item: updated }; } @RequirePermission('packing_edit') @Delete(':id') async remove( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { const deleted = await this.packing.deleteItem(tripId, id, user.id); if (!deleted) { throw new HttpException({ error: 'Item not found' }, 404); } // Scope the delete to the people who could see it (owner + recipients, #858). this.packing.emitToViewers(tripId, 'packing:deleted', { itemId: Number(id) }, deleted as PackingItemRow, socketId); this.packing.broadcastBagTotals(tripId); return { success: true }; } @RequirePermission('packing_edit') @Put(':id/sharing') async setSharing( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: PackingSetSharingDto, @Headers('x-socket-id') socketId?: string, ) { const updated = await this.packing.setItemSharing(tripId, id, user.id, body.visibility, Array.isArray(body.recipient_ids) ? body.recipient_ids : []); if (!updated) { throw new HttpException({ error: 'Item not found' }, 404); } if ((updated as { forbidden?: boolean }).forbidden) { throw new HttpException({ error: 'Only the owner can change sharing' }, 403); } // The viewer set just changed: drop the item from the whole room, then re-add // it for whoever can now see it (owner + recipients, or everyone if Common). this.packing.broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, socketId); this.packing.emitToViewers(tripId, 'packing:created', { item: updated }, updated as PackingItemRow, socketId); return { item: updated }; } @RequirePermission('packing_edit') @Post(':id/clone') @HttpCode(201) async clone( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { const item = await this.packing.cloneItem(tripId, id, user.id); if (!item) { throw new HttpException({ error: 'Item not found' }, 404); } // The clone is personal to the caller — only their sockets need it. this.packing.emitToViewers(tripId, 'packing:created', { item }, item, socketId); this.packing.broadcastBagTotals(tripId); return { item }; } @RequirePermission('packing_edit') @Post(':id/contributors') async addContributor( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { const item = await this.packing.addContributor(tripId, id, user.id); if (!item) { throw new HttpException({ error: 'Item not found or not a shared list item' }, 404); } // Common item — visible to all, so the contributor change broadcasts to the room. this.packing.broadcast(tripId, 'packing:updated', { item }, socketId); return { item }; } @RequirePermission('packing_edit') @Delete(':id/contributors/:userId') async removeContributor( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Param('userId') userId: string, @Headers('x-socket-id') socketId?: string, ) { // You can drop your own pledge; the owner can remove anyone's. const target = Number.parseInt(userId); const item = await this.packing.removeContributor(tripId, id, target); if (!item) { throw new HttpException({ error: 'Item not found' }, 404); } this.packing.broadcast(tripId, 'packing:updated', { item }, socketId); return { item }; } @Get('bags') async listBags(@CurrentUser() user: User, @Param('tripId') tripId: string) { // unassigned_weight_grams rides along so the "no bag" pile and the grand // total follow the same rule as the bags themselves (#2191) — a screen // mixing true totals with per-viewer ones would be worse than either. return await this.packing.listBagsWithWeights(tripId); } @RequirePermission('packing_edit') @Post('bags') async createBag( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PackingCreateBagDto, @Headers('x-socket-id') socketId?: string, ) { // The schema requires a non-empty name; whitespace-only still 400s here. if (!body.name.trim()) { throw new HttpException({ error: 'Name is required' }, 400); } const bag = await this.packing.createBag(tripId, { name: body.name, color: body.color, weight_limit_grams: body.weight_limit_grams }); this.packing.broadcast(tripId, 'packing:bag-created', { bag }, socketId); return { bag }; } @RequirePermission('packing_edit') @Put('bags/:bagId') async updateBag( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('bagId') bagId: string, @Body() body: PackingUpdateBagDto, @Headers('x-socket-id') socketId?: string, ) { const { name, color, weight_limit_grams, user_id } = body; // bodyKeys carries which keys the request actually provided (the presence- // sentinel protocol); the parsed body only ever holds known schema keys. const updated = await this.packing.updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(body)); if (!updated) { throw new HttpException({ error: 'Bag not found' }, 404); } this.packing.broadcast(tripId, 'packing:bag-updated', { bag: updated }, socketId); return { bag: updated }; } @RequirePermission('packing_edit') @Delete('bags/:bagId') async deleteBag( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('bagId') bagId: string, @Headers('x-socket-id') socketId?: string, ) { if (!(await this.packing.deleteBag(tripId, bagId))) { throw new HttpException({ error: 'Bag not found' }, 404); } this.packing.broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, socketId); // bag_id is ON DELETE SET NULL, so everything that was in it just landed in // the unassigned pile — both figures moved. this.packing.broadcastBagTotals(tripId); return { success: true }; } @Get('templates') async listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) { return { templates: await this.packing.listTemplates() }; } @RequirePermission('packing_edit') @Post('apply-template/:templateId') @HttpCode(200) async applyTemplate( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('templateId') templateId: string, @Body() body: PackingApplyTemplateDto, @Headers('x-socket-id') socketId?: string, ) { const visibility = body?.visibility === 'personal' ? 'personal' : 'common'; const added = await this.packing.applyTemplate(tripId, templateId, visibility, user.id); if (!added) { throw new HttpException({ error: 'Template not found or empty' }, 404); } this.packing.broadcastItem(tripId, 'packing:template-applied', { items: added }, added[0], socketId); this.packing.broadcastBagTotals(tripId); return { items: added, count: added.length }; } @RequirePermission('packing_edit') @Put('bags/:bagId/members') async setBagMembers( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('bagId') bagId: string, @Body() body: PackingBagMembersDto, @Headers('x-socket-id') socketId?: string, ) { const members = await this.packing.setBagMembers(tripId, bagId, body.user_ids); if (!members) { throw new HttpException({ error: 'Bag not found' }, 404); } this.packing.broadcast(tripId, 'packing:bag-members-updated', { bagId: Number(bagId), members }, socketId); return { members }; } @RequirePermission('packing_edit') @Post('save-as-template') async saveAsTemplate( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PackingSaveTemplateDto, ) { if (user.role !== 'admin') { throw new HttpException({ error: 'Admin access required' }, 403); } // The schema requires a non-empty name; whitespace-only still 400s here. if (!body.name.trim()) { throw new HttpException({ error: 'Template name is required' }, 400); } const template = await this.packing.saveAsTemplate(tripId, user.id, body.name.trim()); if (!template) { throw new HttpException({ error: 'No items to save' }, 400); } return { template }; } @Get('category-assignees') async categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) { return { assignees: await this.packing.getCategoryAssignees(tripId) }; } @RequirePermission('packing_edit') @Put('category-assignees/:categoryName') async updateCategoryAssignees( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('categoryName') categoryName: string, @Body() body: PackingCategoryAssigneesDto, @Headers('x-socket-id') socketId?: string, ) { const category = decodeURIComponent(categoryName); const rows = await this.packing.updateCategoryAssignees(tripId, category, body.user_ids); this.packing.broadcast(tripId, 'packing:assignees', { category, assignees: rows }, socketId); await this.packing.notifyTagged(tripId, user, category, body.user_ids); return { assignees: rows }; } }