Files
TREK/server/src/nest/packing/packing.controller.ts
T

477 lines
17 KiB
TypeScript

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 };
}
}