import type { User } from '../../types'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { toRowId } from '../common/row-id'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; import { PackingWritesService } from './packing-writes.service'; import { PackingApplyTemplateDto, PackingBagMembersDto, PackingCategoryAssigneesDto, PackingCreateBagDto, PackingCreateItemDto, PackingImportDto, PackingReorderDto, PackingSaveTemplateDto, PackingSetSharingDto, PackingUpdateBagDto, PackingUpdateItemDto, } from './packing.dto'; import { PackingService } from './packing.service'; import { Body, Controller, Delete, Get, Headers, HttpCode, HttpException, Param, Post, Put, UseGuards, } from '@nestjs/common'; import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; /** * /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, private readonly writes: PackingWritesService, ) {} /** 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. // A bag the body names must exist on this trip (#2154); the use case answers // that 400, the 404 'Bag not found' stays with the path routes. const item = await this.writes.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, }, this.writes.restWriter(tripId, user, socketId), ); 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.writes.reorder(tripId, body.orderedIds, this.writes.restWriter(tripId, user, socketId)); 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, ) { // Plan 4 Task 8b (U6) — :id is parsed ONCE here (toRowId, not Number(): // rule 15's NaN-into-SQL trap), and the parsed number is what flows into // the service instead of the raw route string reaching the repository. // A malformed id never matched under the legacy affinity CAST either, // so it 404s with the same body this handler's own not-found branch // already produces below. const itemId = toRowId(id); if (itemId === null) { throw new HttpException({ error: 'Item not found' }, 404); } const { name, checked, category, weight_grams, bag_id, quantity, packed_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. // The use case answers 404, the 409 conflict (#1135) and the 400 bag (#2154). const updated = await this.writes.updateItem( tripId, itemId, { name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, weight_grams, bag_id, quantity, packed_quantity, is_private, }, Object.keys(body), this.writes.restWriter(tripId, user, socketId), ifMatch, ); 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, ) { // Plan 4 Task 8b (U6) — same single gate-level parse as update above. const itemId = toRowId(id); if (itemId === null) { throw new HttpException({ error: 'Item not found' }, 404); } await this.writes.deleteItem(tripId, itemId, this.writes.restWriter(tripId, user, socketId)); 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, ) { // Plan 4 Task 8b (U6) — same single gate-level parse as update above. const itemId = toRowId(id); if (itemId === null) { throw new HttpException({ error: 'Item not found' }, 404); } // The use case answers the 404, the owner-only 403 and both events. const updated = await this.writes.setSharing( tripId, itemId, body.visibility, Array.isArray(body.recipient_ids) ? body.recipient_ids : [], this.writes.restWriter(tripId, user, 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, ) { // Plan 4 Task 8b (U6) — same single gate-level parse as update above. const itemId = toRowId(id); if (itemId === null) { throw new HttpException({ error: 'Item not found' }, 404); } const item = await this.packing.cloneItem(tripId, itemId, 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, ) { // Plan 4 Task 8b (U6) — :id parsed ONCE here (toRowId). #858's // contributors/sharing routes are native Nest code with no pre-ORM // Express precedent (introduced in 7eabf6066, after the migration), so // there is no legacy affinity-seam behavior to match — a malformed id // just 404s the same way an unknown one already does below. const itemId = toRowId(id); if (itemId === null) { throw new HttpException({ error: 'Item not found or not a shared list item' }, 404); } const item = await this.packing.addContributor(tripId, itemId, 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, ) { // Plan 4 Task 8b (U6) — :id/:userId both parsed ONCE here (toRowId, not // Number.parseInt(): the NaN-into-SQL trap row-id.ts documents — a // malformed userId used to reach contributorsRepo.deleteOne as a bare // NaN parameter). Same "no legacy precedent" note as addContributor above. const itemId = toRowId(id); const target = toRowId(userId); if (itemId === null || target === null) { throw new HttpException({ error: 'Item not found' }, 404); } // You can drop your own pledge; the owner can remove anyone's. const item = await this.packing.removeContributor(tripId, itemId, 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, ) { // Plan 4 Task 8b (U6) — :bagId is parsed ONCE here (toRowId, not // Number(): rule 15's NaN-into-SQL trap). A malformed id never matched // under the legacy affinity CAST either, so it 404s with the same body // this handler's own not-found branch already produces below. const bagIdNum = toRowId(bagId); if (bagIdNum === null) { throw new HttpException({ error: 'Bag not found' }, 404); } 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, bagIdNum, { 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, ) { // Plan 4 Task 8b (U6) — same single gate-level parse as updateBag above. const bagIdNum = toRowId(bagId); if (bagIdNum === null) { throw new HttpException({ error: 'Bag not found' }, 404); } // The use case answers an unknown bag with the same 404 and sends nothing then. await this.writes.deleteBag(tripId, bagIdNum, this.writes.restWriter(tripId, user, socketId)); 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, ) { // Plan 4 Task 8b (U6) — :templateId is parsed ONCE here (toRowId, not // Number(): rule 15's NaN-into-SQL trap). A malformed id never matched // under the legacy affinity CAST either, so it 404s with the same body // the use case's own not-found refusal produces below. const templateIdNum = toRowId(templateId); if (templateIdNum === null) { throw new HttpException({ error: 'Template not found or empty' }, 404); } const visibility = body?.visibility === 'personal' ? 'personal' : 'common'; const added = await this.writes.applyTemplate( tripId, templateIdNum, visibility, this.writes.restWriter(tripId, user, socketId), ); 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, ) { // Plan 4 Task 8b (U6) — same single gate-level parse as updateBag above. const bagIdNum = toRowId(bagId); if (bagIdNum === null) { throw new HttpException({ error: 'Bag not found' }, 404); } const members = await this.packing.setBagMembers(tripId, bagIdNum, body.user_ids); if (!members) { throw new HttpException({ error: 'Bag not found' }, 404); } this.packing.broadcast(tripId, 'packing:bag-members-updated', { bagId: bagIdNum, 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.writes.setCategoryAssignees( tripId, category, body.user_ids, this.writes.restWriter(tripId, user, socketId), ); return { assignees: rows }; } }