feat(server): adopt zod DTOs for the packing body contracts

This commit is contained in:
jubnl
2026-07-26 16:55:53 +02:00
parent f1960dc364
commit 5da2abf4f9
6 changed files with 173 additions and 99 deletions
@@ -115,17 +115,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'NotificationsController.testWebhook',
'OauthApiController.authorize',
'OauthApiController.createClient',
'PackingController.applyTemplate',
'PackingController.create',
'PackingController.createBag',
'PackingController.importItems',
'PackingController.reorder',
'PackingController.saveAsTemplate',
'PackingController.setBagMembers',
'PackingController.setSharing',
'PackingController.update',
'PackingController.updateBag',
'PackingController.updateCategoryAssignees',
'PasskeyController.loginVerify',
'PasskeyController.registerOptions',
'PasskeyController.registerVerify',
+52 -30
View File
@@ -16,6 +16,19 @@ import { PackingService } from './packing.service';
import { isUpdateConflict } from '../../services/conflictResult';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
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 };
@@ -30,6 +43,12 @@ type PackingItemRow = { is_private?: number; owner_id?: number | null; recipient
* 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')
@UseGuards(JwtAuthGuard)
@@ -62,15 +81,16 @@ export class PackingController {
importItems(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body('items') items: unknown,
@Body() body: PackingImportDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!Array.isArray(items) || items.length === 0) {
// 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 = this.packing.bulkImport(tripId, items, user.id);
const created = this.packing.bulkImport(tripId, body.items, user.id);
for (const item of created) {
this.packing.broadcastItem(tripId, 'packing:created', { item }, item, socketId);
}
@@ -81,15 +101,13 @@ export class PackingController {
create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { name?: string; category?: string; checked?: boolean; is_private?: boolean; visibility?: 'common' | 'personal' | 'shared'; recipient_ids?: number[] },
@Body() body: PackingCreateItemDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!body.name) {
throw new HttpException({ error: 'Item name is required' }, 400);
}
const item = this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id);
// checked arrives as boolean or legacy 0/1 — the service coerces by truthiness.
const item = this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked === undefined ? undefined : !!body.checked, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id);
this.emitToViewers(tripId, 'packing:created', { item }, item, socketId);
return { item };
}
@@ -109,12 +127,12 @@ export class PackingController {
reorder(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body('orderedIds') orderedIds: number[],
@Body() body: PackingReorderDto,
@Headers('x-socket-id') _socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
this.packing.reorderItems(tripId, orderedIds);
this.packing.reorderItems(tripId, body.orderedIds);
return { success: true };
}
@@ -123,7 +141,7 @@ export class PackingController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: Record<string, unknown>,
@Body() body: PackingUpdateItemDto,
@Headers('x-socket-id') socketId?: string,
@Headers('x-base-updated-at') ifMatch?: string,
) {
@@ -132,8 +150,11 @@ export class PackingController {
// 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 = this.packing.getItemPrivacy(tripId, id);
const { name, checked, category, weight_grams, bag_id, quantity, is_private } = body as Record<string, never>;
const updated = this.packing.updateItem(tripId, id, { name, checked, category, weight_grams, bag_id, quantity, is_private }, Object.keys(body), ifMatch, user.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 = 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);
}
@@ -199,14 +220,11 @@ export class PackingController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { visibility?: 'common' | 'personal' | 'shared'; recipient_ids?: number[] },
@Body() body: PackingSetSharingDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (body.visibility !== 'common' && body.visibility !== 'personal' && body.visibility !== 'shared') {
throw new HttpException({ error: 'Invalid visibility' }, 400);
}
const updated = 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);
@@ -288,12 +306,13 @@ export class PackingController {
createBag(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { name?: string; color?: string },
@Body() body: PackingCreateBagDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!body.name?.trim()) {
// 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 = this.packing.createBag(tripId, { name: body.name, color: body.color });
@@ -306,12 +325,14 @@ export class PackingController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('bagId') bagId: string,
@Body() body: Record<string, unknown>,
@Body() body: PackingUpdateBagDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const { name, color, weight_limit_grams, user_id } = body as Record<string, never>;
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 = 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);
@@ -348,7 +369,7 @@ export class PackingController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('templateId') templateId: string,
@Body() body: { visibility?: 'common' | 'personal' },
@Body() body: PackingApplyTemplateDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
@@ -367,12 +388,12 @@ export class PackingController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('bagId') bagId: string,
@Body('user_ids') userIds: unknown,
@Body() body: PackingBagMembersDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const members = this.packing.setBagMembers(tripId, bagId, Array.isArray(userIds) ? userIds : []);
const members = this.packing.setBagMembers(tripId, bagId, body.user_ids);
if (!members) {
throw new HttpException({ error: 'Bag not found' }, 404);
}
@@ -384,16 +405,17 @@ export class PackingController {
saveAsTemplate(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body('name') name?: string,
@Body() body: PackingSaveTemplateDto,
) {
this.requireTrip(tripId, user);
if (user.role !== 'admin') {
throw new HttpException({ error: 'Admin access required' }, 403);
}
if (!name?.trim()) {
// 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 = this.packing.saveAsTemplate(tripId, user.id, name.trim());
const template = this.packing.saveAsTemplate(tripId, user.id, body.name.trim());
if (!template) {
throw new HttpException({ error: 'No items to save' }, 400);
}
@@ -411,15 +433,15 @@ export class PackingController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('categoryName') categoryName: string,
@Body('user_ids') userIds: number[],
@Body() body: PackingCategoryAssigneesDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const category = decodeURIComponent(categoryName);
const rows = this.packing.updateCategoryAssignees(tripId, category, userIds);
const rows = this.packing.updateCategoryAssignees(tripId, category, body.user_ids);
this.packing.broadcast(tripId, 'packing:assignees', { category, assignees: rows }, socketId);
this.packing.notifyTagged(tripId, user, category, userIds);
this.packing.notifyTagged(tripId, user, category, body.user_ids);
return { assignees: rows };
}
}
+32
View File
@@ -0,0 +1,32 @@
import { createZodDto } from 'nestjs-zod';
import {
packingCreateItemRequestSchema,
packingUpdateItemRequestSchema,
packingSetSharingRequestSchema,
packingImportRequestSchema,
packingReorderRequestSchema,
packingCreateBagRequestSchema,
packingUpdateBagRequestSchema,
packingBagMembersRequestSchema,
packingSaveTemplateRequestSchema,
packingApplyTemplateRequestSchema,
packingCategoryAssigneesRequestSchema,
} from '@trek/shared';
/**
* Server-side createZodDto wrappers over the @trek/shared packing contracts.
* The global ZodValidationPipe (APP_PIPE in app.module.ts) validates any
* @Body() parameter typed with one of these classes by metatype — the Zod
* schemas in shared/ remain the single source of truth for the wire contract.
*/
export class PackingCreateItemDto extends createZodDto(packingCreateItemRequestSchema) {}
export class PackingUpdateItemDto extends createZodDto(packingUpdateItemRequestSchema) {}
export class PackingSetSharingDto extends createZodDto(packingSetSharingRequestSchema) {}
export class PackingImportDto extends createZodDto(packingImportRequestSchema) {}
export class PackingReorderDto extends createZodDto(packingReorderRequestSchema) {}
export class PackingCreateBagDto extends createZodDto(packingCreateBagRequestSchema) {}
export class PackingUpdateBagDto extends createZodDto(packingUpdateBagRequestSchema) {}
export class PackingBagMembersDto extends createZodDto(packingBagMembersRequestSchema) {}
export class PackingSaveTemplateDto extends createZodDto(packingSaveTemplateRequestSchema) {}
export class PackingApplyTemplateDto extends createZodDto(packingApplyTemplateRequestSchema) {}
export class PackingCategoryAssigneesDto extends createZodDto(packingCategoryAssigneesRequestSchema) {}