refactor(server): packing templates, road trip, day and booking-import services refuse with DomainError

This commit is contained in:
Maurice
2026-10-10 16:32:26 +02:00
parent 6cff4802bf
commit c353302e3f
14 changed files with 127 additions and 147 deletions
@@ -1,4 +1,5 @@
import { Injectable, HttpException } from '@nestjs/common';
import { DomainError } from '../common/domain-error';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -166,7 +167,7 @@ export class BookingImportService {
const kitineraryAvailable = this.extractor.isAvailable();
const aiAvailable = await this.llmParse.isAvailable(userId);
if (!kitineraryAvailable && !aiAvailable) {
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
throw new DomainError(503, 'KItinerary extractor is not available on this server');
}
const allItems: ParsedBookingItem[] = [];
+5 -4
View File
@@ -1,4 +1,5 @@
import { HttpException, Injectable } from '@nestjs/common';
import { DomainError } from '../common/domain-error';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { addIsoDays, MAX_TRIP_DAYS, planDatedAppend } from '@trek/shared';
import type { RoadtripDayBoundary, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
@@ -303,7 +304,7 @@ export class DaysService {
// (it stored those through affinity and answered 201) — the same class
// of defect `PlacesService.create` was fixed for (A-M1).
const tripIdNum = toRowId(tripId);
if (tripIdNum === null) throw new HttpException({ error: 'Trip not found' }, 404);
if (tripIdNum === null) throw new DomainError(404, 'Trip not found');
// DY5: the next day number and the row that takes it in one transaction, so
// two days added at once cannot both read the same MAX. `|| 0` keeps a stored
// 0 becoming 1, as the legacy formula did.
@@ -606,7 +607,7 @@ export class DaysService {
// through affinity and answered 201 for — rather than the renumber-
// then-skip-the-invariant split H2 found for hex ids.
const tripIdNum = toRowId(tripId);
if (tripIdNum === null) throw new HttpException({ error: 'Trip not found' }, 404);
if (tripIdNum === null) throw new DomainError(404, 'Trip not found');
const rows: DayOrderRow[] = await this.daysRepo.listOrderedForReorder(tripIdNum);
const n = rows.length;
const pos = Math.min(Math.max(position ?? n + 1, 1), n + 1);
@@ -673,7 +674,7 @@ export class DaysService {
async appendDated(tripId: string | number, viewerId: number, notes?: string): Promise<DatedDayAppend> {
// The same parse-once gate `create`/`insert` use (class docstring).
const trip = toRowId(tripId);
if (trip === null) throw new HttpException({ error: 'Trip not found' }, 404);
if (trip === null) throw new DomainError(404, 'Trip not found');
const appended = await this.uow.transactional(async () => {
const range = await this.tripsRepo.findDatesById(trip);
const rows: DayOrderRow[] = await this.daysRepo.listOrderedForReorder(trip);
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { PackingService } from './packing.service';
import { AdminTemplateNameDto } from './packing.dto';
@@ -10,26 +10,6 @@ import { AuditService } from '../audit/audit.service';
import type { User } from '../../types';
/** Throw the legacy {error,status} envelope when a service call reports failure. */
function ok<T>(result: T): Exclude<T, { error: string }> {
if (result && typeof result === 'object' && 'error' in (result as Record<string, unknown>)) {
const r = result as unknown as { error: string; status?: number };
throw new HttpException({ error: r.error }, r.status ?? 400);
}
return result as Exclude<T, { error: string }>;
}
/**
* /api/admin/packing-templates — the admin CRUD for packing templates.
*
* These twelve routes sat on AdminController and reached PackingService through
* eleven pass-through methods on AdminService that did nothing but forward and cast.
* PackingService already owns all three template tables, so the routes belong here:
* the pass-through block is gone, and there is one owner instead of a controller in
* one domain calling a service in another through a third.
*
* The path, the admin gate, the {error,status} envelope, the create-201-vs-rest-200
* split and the audit-log writes are unchanged — this is a move, not a redesign.
*/
@Controller('api/admin/packing-templates')
@UseGuards(JwtAuthGuard, AdminGuard)
export class AdminPackingTemplatesController {
@@ -45,13 +25,13 @@ export class AdminPackingTemplatesController {
@Get(':id')
async get(@Param('id') id: string) {
return ok(await this.packing.getPackingTemplate(id));
return await this.packing.getPackingTemplate(id);
}
@Post()
@HttpCode(201)
async create(@CurrentUser() user: User, @Body() body: AdminTemplateNameDto, @Req() req: Request) {
const result = ok(await this.packing.createPackingTemplate(body.name, user.id));
const result = await this.packing.createPackingTemplate(body.name, user.id);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.packing_template_create',
@@ -64,12 +44,12 @@ export class AdminPackingTemplatesController {
@Put(':id')
async update(@Param('id') id: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.updatePackingTemplate(id, body));
return await this.packing.updatePackingTemplate(id, body);
}
@Delete(':id')
async remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(await this.packing.deletePackingTemplate(id));
const result = await this.packing.deletePackingTemplate(id);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.packing_template_delete',
@@ -83,34 +63,34 @@ export class AdminPackingTemplatesController {
@Post(':id/categories')
@HttpCode(201)
async createCategory(@Param('id') id: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.createTemplateCategory(id, body.name));
return await this.packing.createTemplateCategory(id, body.name);
}
@Put(':templateId/categories/:catId')
async updateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.updateTemplateCategory(templateId, catId, body));
return await this.packing.updateTemplateCategory(templateId, catId, body);
}
@Delete(':templateId/categories/:catId')
async deleteCategory(@Param('templateId') templateId: string, @Param('catId') catId: string) {
ok(await this.packing.deleteTemplateCategory(templateId, catId));
await this.packing.deleteTemplateCategory(templateId, catId);
return { success: true };
}
@Post(':templateId/categories/:catId/items')
@HttpCode(201)
async createItem(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.createTemplateItem(templateId, catId, body.name));
return await this.packing.createTemplateItem(templateId, catId, body.name);
}
@Put(':templateId/items/:itemId')
async updateItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string, @Body() body: AdminTemplateNameDto) {
return ok(await this.packing.updateTemplateItem(templateId, itemId, body));
return await this.packing.updateTemplateItem(templateId, itemId, body);
}
@Delete(':templateId/items/:itemId')
async deleteItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string) {
ok(await this.packing.deleteTemplateItem(templateId, itemId));
await this.packing.deleteTemplateItem(templateId, itemId);
return { success: true };
}
}
+6 -7
View File
@@ -62,7 +62,7 @@ export class PackingMcp {
description: 'Add an item to the packing checklist for a trip. It lands on the common list everyone shares unless visibility says otherwise; use set_packing_item_sharing to move an existing item between those tiers.',
inputSchema: {
tripId: idSchema,
name: z.string().min(1).max(200),
name: packingCreateItemRequestSchema.shape.name.max(200),
category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
bag_id: packingCreateItemRequestSchema.shape.bag_id.describe('Bag to pack the item into (ids come from list_packing_bags)'),
quantity: packingCreateItemRequestSchema.shape.quantity.describe('How many to pack, clamped to 1-999'),
@@ -138,7 +138,7 @@ export class PackingMcp {
inputSchema: {
tripId: idSchema,
itemId: idSchema,
name: z.string().min(1).max(200).optional(),
name: packingCreateItemRequestSchema.shape.name.max(200).optional(),
category: z.string().max(100).optional(),
bag_id: packingUpdateItemRequestSchema.shape.bag_id.describe('Bag to pack the item into (ids come from list_packing_bags); null takes it out of its bag'),
quantity: packingUpdateItemRequestSchema.shape.quantity.describe('How many to pack, clamped to 1-999'),
@@ -234,8 +234,8 @@ export class PackingMcp {
description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").',
inputSchema: {
tripId: idSchema,
name: z.string().min(1).max(100),
color: z.string().optional(),
name: packingCreateBagRequestSchema.shape.name.max(100),
color: packingCreateBagRequestSchema.shape.color,
weight_limit_grams: packingCreateBagRequestSchema.shape.weight_limit_grams.describe('Allowance in grams the bag is measured against (the fill bar)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -258,8 +258,8 @@ export class PackingMcp {
inputSchema: {
tripId: idSchema,
bagId: idSchema,
name: z.string().optional(),
color: z.string().optional(),
name: packingUpdateBagRequestSchema.shape.name,
color: packingUpdateBagRequestSchema.shape.color,
weight_limit_grams: packingUpdateBagRequestSchema.shape.weight_limit_grams.describe('Allowance in grams the bag is measured against (the fill bar); null lifts the limit'),
user_id: packingUpdateBagRequestSchema.shape.user_id.describe('Trip member the bag belongs to; null leaves it unassigned, and an id outside the trip roster unassigns it too'),
},
@@ -441,7 +441,6 @@ export class PackingMcp {
// Templates are global; the REST route restricts management to admins. Match it.
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
const result = await this.packing.deletePackingTemplate(String(templateId));
if ('error' in result) return errorResult(result.error);
return ok({ success: true, name: result.name });
}
+13 -12
View File
@@ -1,3 +1,4 @@
import { DomainError } from '../common/domain-error';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { resolvePackedState, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
@@ -806,14 +807,14 @@ export class PackingService {
async getPackingTemplate(id: string) {
const template = await this.templatesRepo.findById(id);
if (!template) return { error: 'Template not found', status: 404 };
if (!template) throw new DomainError(404, 'Template not found');
const categories = await this.templateCategoriesRepo.listForTemplate(id);
const items = await this.templateItemsRepo.listForTemplate(id);
return { template, categories, items };
}
async createPackingTemplate(name: string, createdBy: number) {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (!name?.trim()) throw new DomainError(400, 'Name is required');
const newId = await this.templatesRepo.insertTemplate(name.trim(), createdBy);
const template = await this.templatesRepo.findById(newId);
return { template };
@@ -821,14 +822,14 @@ export class PackingService {
async updatePackingTemplate(id: string, data: { name?: string }) {
const template = await this.templatesRepo.findById(id);
if (!template) return { error: 'Template not found', status: 404 };
if (!template) throw new DomainError(404, 'Template not found');
if (data.name?.trim()) await this.templatesRepo.updateName(id, data.name.trim());
return { template: await this.templatesRepo.findById(id) };
}
async deletePackingTemplate(id: string) {
const template = await this.templatesRepo.findById(id);
if (!template) return { error: 'Template not found', status: 404 };
if (!template) throw new DomainError(404, 'Template not found');
await this.templatesRepo.delete(id);
return { name: template.name };
}
@@ -836,9 +837,9 @@ export class PackingService {
// Template categories
async createTemplateCategory(templateId: string, name: string) {
if (!name?.trim()) return { error: 'Category name is required', status: 400 };
if (!name?.trim()) throw new DomainError(400, 'Category name is required');
const template = await this.templatesRepo.findById(templateId);
if (!template) return { error: 'Template not found', status: 404 };
if (!template) throw new DomainError(404, 'Template not found');
const newId = await this.uow.transactional(async () => {
const maxOrder = await this.templateCategoriesRepo.maxSortOrder(templateId);
return await this.templateCategoriesRepo.insertCategory(templateId, name.trim(), (maxOrder ?? -1) + 1);
@@ -848,7 +849,7 @@ export class PackingService {
async updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
const cat = await this.templateCategoriesRepo.findInTemplate(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
if (!cat) throw new DomainError(404, 'Category not found');
if (data.name?.trim())
await this.templateCategoriesRepo.updateName(catId, data.name.trim());
return { category: await this.templateCategoriesRepo.findById(catId) };
@@ -856,7 +857,7 @@ export class PackingService {
async deleteTemplateCategory(templateId: string, catId: string) {
const cat = await this.templateCategoriesRepo.findInTemplate(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
if (!cat) throw new DomainError(404, 'Category not found');
await this.templateCategoriesRepo.delete(catId);
return {};
}
@@ -864,9 +865,9 @@ export class PackingService {
// Template items
async createTemplateItem(templateId: string, catId: string, name: string) {
if (!name?.trim()) return { error: 'Item name is required', status: 400 };
if (!name?.trim()) throw new DomainError(400, 'Item name is required');
const cat = await this.templateCategoriesRepo.findInTemplate(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
if (!cat) throw new DomainError(404, 'Category not found');
const newId = await this.uow.transactional(async () => {
const maxOrder = await this.templateItemsRepo.maxSortOrder(catId);
return await this.templateItemsRepo.insertTemplateItem({ category_id: catId, name: name.trim(), sort_order: (maxOrder ?? -1) + 1 });
@@ -876,7 +877,7 @@ export class PackingService {
async updateTemplateItem(templateId: string, itemId: string, data: { name?: string }) {
const item = await this.templateItemsRepo.findScoped(itemId, templateId);
if (!item) return { error: 'Item not found', status: 404 };
if (!item) throw new DomainError(404, 'Item not found');
if (data.name?.trim())
await this.templateItemsRepo.updateName(itemId, data.name.trim());
return { item: await this.templateItemsRepo.findById(itemId) };
@@ -884,7 +885,7 @@ export class PackingService {
async deleteTemplateItem(templateId: string, itemId: string) {
const item = await this.templateItemsRepo.findScoped(itemId, templateId);
if (!item) return { error: 'Item not found', status: 404 };
if (!item) throw new DomainError(404, 'Item not found');
await this.templateItemsRepo.delete(itemId);
return {};
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { HttpException, Injectable } from '@nestjs/common';
import { DomainError } from '../common/domain-error';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { ChargingInfo } from '@trek/shared';
import { z } from 'zod';
@@ -24,7 +25,7 @@ export class ChargingService {
/** CH1 — `PlacesRepository.findChargingProbe` (trip-scoped). */
async read(tripId: number, placeId: number) {
const place = await this.placesRepo.findChargingProbe(placeId, tripId);
if (!place) throw new HttpException({ error: 'Place not found' }, 404);
if (!place) throw new DomainError(404, 'Place not found');
if (place.stop_type !== 'charging' || place.lat == null || place.lng == null) return empty('unknown');
return this.lookup(place.lat, place.lng, place.name);
}
@@ -1,5 +1,6 @@
import { DomainError } from '../common/domain-error';
import { InjectRepository } from '@mikro-orm/nestjs';
import { HttpException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { RoadtripDayBoundary } from '@trek/shared';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
@@ -30,7 +31,7 @@ export class DayBoundariesService {
// `DayAssignmentsRepository.findInTrip` (AS12) is the trip-scoped read.
const belongs = async (id: number) => !!(await this.dayAssignmentsRepo.findInTrip(id, tripIdNum));
if (!(await belongs(boundary.from_assignment_id)) || (boundary.to_assignment_id !== null && !(await belongs(boundary.to_assignment_id)))) {
throw new HttpException({ error: 'Stop not found' }, 404);
throw new DomainError(404, 'Stop not found');
}
// RB2 → RB3, un-transacted (R7 — pin, don't fix): the ownership check above and
// the upsert below are two statements, not one.
@@ -1,4 +1,5 @@
import { HttpException, Injectable } from '@nestjs/common';
import { DomainError } from '../common/domain-error';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { GoogleRouteImport, GoogleRoutePreview } from '@trek/shared';
import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps.service';
@@ -25,18 +26,18 @@ export class GoogleRouteService {
let url = new URL(raw);
if (url.protocol !== 'https:' || url.username || url.password || url.port ||
!isGoogleMapsHost(url.hostname) && !GOOGLE_SHORT_HOSTS.includes(url.hostname))
throw new HttpException({ error: 'Use a Google Maps directions link.' }, 400);
throw new DomainError(400, 'Use a Google Maps directions link.');
if (GOOGLE_SHORT_HOSTS.includes(url.hostname)) {
const reply = await safeFetchFollow(url.href, { signal: AbortSignal.timeout(10000) });
url = new URL(reply.url);
await reply.body?.cancel();
if (!isGoogleMapsHost(url.hostname)) throw new HttpException({ error: 'Use a Google Maps directions link.' }, 400);
if (!isGoogleMapsHost(url.hostname)) throw new DomainError(400, 'Use a Google Maps directions link.');
}
if (url.protocol !== 'https:' || !isDirectionsUrl(url.href))
throw new HttpException({ error: 'Use a Google Maps directions link.' }, 400);
throw new DomainError(400, 'Use a Google Maps directions link.');
const waypoints = parseDirectionsUrl(url.href, MAX_DIR_WAYPOINTS + 1);
if (waypoints.length < 2 || waypoints.length > MAX_DIR_WAYPOINTS)
throw new HttpException({ error: 'The link must contain between 2 and 30 readable stops.' }, 400);
throw new DomainError(400, 'The link must contain between 2 and 30 readable stops.');
const stops: GoogleRoutePreview['stops'] = [];
for (const waypoint of waypoints) {
let name = (waypoint.name || `${waypoint.lat}, ${waypoint.lng}`).slice(0, 200);
@@ -63,16 +64,16 @@ export class GoogleRouteService {
async import(tripId: number, userId: number, input: GoogleRouteImport, socketId?: string) {
// GR0 — `TripsRepository.findAccessible` (keeps the row: `access.user_id` feeds the permission check below).
const access = await this.tripsRepo.findAccessible(tripId, userId);
if (!access) throw new HttpException({ error: 'Trip not found' }, 404);
if (!access) throw new DomainError(404, 'Trip not found');
// GR1 — `UsersRepository.getRole`.
const role = (await this.usersRepo.getRole(userId)) ?? 'user';
// `every` cannot await the permission check, so the same all-of test runs as
// an explicit loop — same actions, same order, same short-circuit.
for (const action of ['place_edit', 'day_edit']) {
if (!(await this.permissions.checkPermission(action, role, access.user_id, userId, access.user_id !== userId)))
throw new HttpException({ error: 'Permission denied' }, 403);
throw new DomainError(403, 'Permission denied');
}
if (!(await this.assignments.dayExists(String(input.dayId), String(tripId)))) throw new HttpException({ error: 'Day not found' }, 404);
if (!(await this.assignments.dayExists(String(input.dayId), String(tripId)))) throw new DomainError(404, 'Day not found');
// `map` cannot await the now-async assignment write, so the same per-stop
// sequence runs as an explicit loop inside the transaction.
const imported = await this.uow.transactional(async () => {
@@ -1,10 +1,11 @@
import { DomainError } from '../common/domain-error';
import { InjectRepository } from '@mikro-orm/nestjs';
import { SettingsService } from '../settings/settings.service';
import { DayBoundariesService } from './day-boundaries.service';
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
import { RoadtripRouterService } from './roadtrip-router.service';
import { RoadtripService } from './roadtrip.service';
import { Injectable, HttpException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type RoadtripPreferences } from '@trek/shared';
import { Days } from '../../db/entities/Days.entity';
import type { DaysRepository } from '../../db/repositories/Days.repository';
@@ -69,7 +70,7 @@ export class RoadtripPlanService {
) {}
async context(tripId: number, userId: number) {
if (!(await this.tripsRepo.findAccessible(tripId, userId))) throw new HttpException({ error: 'Trip not found' }, 404);
if (!(await this.tripsRepo.findAccessible(tripId, userId))) throw new DomainError(404, 'Trip not found');
// RPL1 — `DaysRepository.listPlanDays`.
const days = await this.daysRepo.listPlanDays(tripId);
// RPL2 — `DayAssignmentsRepository.listRoadtripVisits`.
@@ -139,7 +140,7 @@ export class RoadtripPlanService {
preferences.roadtrip_day_end_mode,
);
if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window)
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
throw new DomainError(400, 'Day end must be later than day start.');
const seams = context.carriers.map((booking) => carrierSeam(booking)).filter((seam): seam is CarrierSeam => seam !== null);
const dayNumberOf = (dayId: number): number => context.days.find((d) => d.id === dayId)?.day_number ?? 0;
const stored: PlanDay[] = context.days.map((day) => {
@@ -196,7 +197,7 @@ export class RoadtripPlanService {
const avoid = parseAvoid(preferences.roadtrip_avoid);
const connectDays = preferences.roadtrip_connect_days || window !== null;
if (context.visits.length > 150)
throw new HttpException({ error: 'This trip exceeds the 150-visit calculation limit.' }, 400);
throw new DomainError(400, 'This trip exceeds the 150-visit calculation limit.');
const asked = new Set<string>();
const distanceUnit: DistanceUnit =
(await this.settings.getUserSettings(userId)).distance_unit === 'imperial' ? 'imperial' : 'metric';
@@ -1,7 +1,8 @@
import { DomainError } from '../common/domain-error';
import { InjectRepository } from '@mikro-orm/nestjs';
import { RealtimeService } from '../realtime/realtime.service';
import { UnitOfWork } from '../database/unit-of-work';
import { HttpException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import {
ROADTRIP_PREFERENCE_KEYS,
roadtripPreferencesSchema,
@@ -42,7 +43,7 @@ export class RoadtripPreferencesService {
const saved = await this.uow.transactional(async () => {
const next = { ...(await this.read(tripId)), ...validated };
if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) {
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
throw new DomainError(400, 'Day end must be later than day start.');
}
// RPF3 — `RoadtripPreferencesRepository.upsertValue`.
for (const [key, value] of Object.entries(validated)) {
+3 -2
View File
@@ -1,4 +1,5 @@
import { HttpException, Injectable } from '@nestjs/common';
import { DomainError } from '../common/domain-error';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { RoadtripDayTrack, RoadtripVia, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
@@ -75,7 +76,7 @@ export class RoadtripService {
*/
private requireDayId(dayId: string | number): number {
const parsed = toRowId(dayId);
if (parsed === null) throw new HttpException({ error: 'Day not found' }, 404);
if (parsed === null) throw new DomainError(404, 'Day not found');
return parsed;
}
@@ -4,6 +4,7 @@
* envelope, same create-201 split, same audit actions — these cases came over from
* admin.controller.test.ts with the routes.
*/
import { DomainError } from '../../../src/nest/common/domain-error';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import { AdminPackingTemplatesController } from '../../../src/nest/packing/admin-packing-templates.controller';
@@ -35,16 +36,6 @@ function controller(over: Partial<PackingService> = {}) {
return { c: new AdminPackingTemplatesController(packing, { writeAudit } as unknown as AuditService), packing };
}
/**
* An override whose return value the real method signature cannot produce. Every error
* branch in PackingService sets a status, so an error envelope without one is not part
* of the service's return type, yet the controller still has a fallback for it and
* PACKTPL-003 is the case that covers that fallback.
*/
function offContract<K extends keyof PackingService>(name: K, impl: () => unknown) {
return { [name]: vi.fn(impl) } as unknown as Partial<PackingService>;
}
const thrown = async (run: () => unknown) => {
try {
await run();
@@ -63,14 +54,14 @@ describe('AdminPackingTemplatesController', () => {
expect(await controller().c.list()).toEqual({ templates: [{ id: 1 }] });
});
it('PACKTPL-002 a service {error,status} becomes that HTTP status, not a 200 body', async () => {
const { c } = controller({ getPackingTemplate: vi.fn(async () => ({ error: 'not found', status: 404 })) });
it('PACKTPL-002 a service refusal becomes that HTTP status, not a 200 body', async () => {
const { c } = controller({ getPackingTemplate: vi.fn(async () => { throw new DomainError(404, 'not found'); }) });
expect(await thrown(() => c.get('9'))).toEqual({ status: 404, body: { error: 'not found' } });
});
it('PACKTPL-003 an error without a status defaults to 400', async () => {
const { c } = controller(offContract('createTemplateCategory', async () => ({ error: 'name required' })));
expect(await thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } });
it('PACKTPL-003 a 400 refusal keeps its status and text', async () => {
const { c } = controller({ createTemplateCategory: vi.fn(async () => { throw new DomainError(400, 'Category name is required'); }) });
expect(await thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'Category name is required' } });
});
it('PACKTPL-004 create audits with the new template id', async () => {
+1 -1
View File
@@ -59,7 +59,7 @@ describe('Google route import', () => {
it('rejects unrelated URLs and oversized routes without fetching or truncating', async () => {
const { service, maps } = await setup();
for (const url of ['https://example.com/maps/dir/A/B', 'http://google.com/maps/dir/A/B', 'https://user:pass@google.com/maps/dir/A/B', 'https://google.com/search?q=A']) {
await expect(service.preview(url)).rejects.toThrow('Http Exception');
await expect(service.preview(url)).rejects.toThrow('Use a Google Maps directions link.');
}
await expect(service.preview(`https://google.com/maps/dir/${Array.from({ length: 31 }, (_, i) => `A${i}`).join('/')}`)).rejects.toThrow();
expect(maps.geocodeQuery).not.toHaveBeenCalled();
+56 -55
View File
@@ -14,6 +14,7 @@
* 2026-08 admin fold, since this service already owned all three template
* tables.
*/
import { asLegacyResult } from '../../helpers/domain-error';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -1049,44 +1050,44 @@ describe('PackingService — the surface the deleted bridge exposed', () => {
describe('Packing templates', () => {
it('ADMIN-SVC-031 — createPackingTemplate returns template', async () => {
const { user: admin } = createAdmin(testDb);
const result = await svc.createPackingTemplate('Beach Trip', admin.id) as any;
const result = await asLegacyResult(svc.createPackingTemplate('Beach Trip', admin.id)) as any;
expect(result.template.name).toBe('Beach Trip');
});
it('ADMIN-SVC-032 — createPackingTemplate returns 400 for empty name', async () => {
const { user: admin } = createAdmin(testDb);
const result = await svc.createPackingTemplate('', admin.id) as any;
const result = await asLegacyResult(svc.createPackingTemplate('', admin.id)) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-033 — listPackingTemplates returns array', async () => {
const { user: admin } = createAdmin(testDb);
await svc.createPackingTemplate('Template A', admin.id);
await asLegacyResult(svc.createPackingTemplate('Template A', admin.id));
const templates = await svc.listPackingTemplates() as any[];
expect(templates.length).toBeGreaterThanOrEqual(1);
});
it('ADMIN-SVC-034 — updatePackingTemplate updates name', async () => {
const { user: admin } = createAdmin(testDb);
const created = await svc.createPackingTemplate('Old Name', admin.id) as any;
const result = await svc.updatePackingTemplate(String(created.template.id), { name: 'New Name' }) as any;
const created = await asLegacyResult(svc.createPackingTemplate('Old Name', admin.id)) as any;
const result = await asLegacyResult(svc.updatePackingTemplate(String(created.template.id), { name: 'New Name' })) as any;
expect(result.template.name).toBe('New Name');
});
it('ADMIN-SVC-035 — updatePackingTemplate returns 404 for non-existent', async () => {
const result = await svc.updatePackingTemplate('99999', { name: 'Ghost' }) as any;
const result = await asLegacyResult(svc.updatePackingTemplate('99999', { name: 'Ghost' })) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-036 — deletePackingTemplate removes template', async () => {
const { user: admin } = createAdmin(testDb);
const created = await svc.createPackingTemplate('To Delete', admin.id) as any;
const result = await svc.deletePackingTemplate(String(created.template.id)) as any;
const created = await asLegacyResult(svc.createPackingTemplate('To Delete', admin.id)) as any;
const result = await asLegacyResult(svc.deletePackingTemplate(String(created.template.id))) as any;
expect(result.name).toBe('To Delete');
});
it('ADMIN-SVC-037 — deletePackingTemplate returns 404 for non-existent', async () => {
const result = await svc.deletePackingTemplate('99999') as any;
const result = await asLegacyResult(svc.deletePackingTemplate('99999')) as any;
expect(result.status).toBe(404);
});
});
@@ -1094,50 +1095,50 @@ describe('Packing templates', () => {
describe('Template categories', () => {
it('ADMIN-SVC-038 — createTemplateCategory creates a category', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const result = await svc.createTemplateCategory(String(tpl.template.id), 'Clothing') as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const result = await asLegacyResult(svc.createTemplateCategory(String(tpl.template.id), 'Clothing')) as any;
expect(result.category.name).toBe('Clothing');
});
it('ADMIN-SVC-039 — createTemplateCategory returns 400 for empty name', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const result = await svc.createTemplateCategory(String(tpl.template.id), '') as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const result = await asLegacyResult(svc.createTemplateCategory(String(tpl.template.id), '')) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-040 — createTemplateCategory returns 404 for missing template', async () => {
const result = await svc.createTemplateCategory('99999', 'Clothing') as any;
const result = await asLegacyResult(svc.createTemplateCategory('99999', 'Clothing')) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-041 — updateTemplateCategory updates name', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Old') as any;
const result = await svc.updateTemplateCategory(String(tpl.template.id), String(cat.category.id), { name: 'New' }) as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const cat = await asLegacyResult(svc.createTemplateCategory(String(tpl.template.id), 'Old')) as any;
const result = await asLegacyResult(svc.updateTemplateCategory(String(tpl.template.id), String(cat.category.id), { name: 'New' })) as any;
expect(result.category.name).toBe('New');
});
it('ADMIN-SVC-042 — updateTemplateCategory returns 404 for missing category', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const result = await svc.updateTemplateCategory(String(tpl.template.id), '99999', { name: 'X' }) as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const result = await asLegacyResult(svc.updateTemplateCategory(String(tpl.template.id), '99999', { name: 'X' })) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-043 — deleteTemplateCategory removes category', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Remove Me') as any;
const result = await svc.deleteTemplateCategory(String(tpl.template.id), String(cat.category.id)) as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const cat = await asLegacyResult(svc.createTemplateCategory(String(tpl.template.id), 'Remove Me')) as any;
const result = await asLegacyResult(svc.deleteTemplateCategory(String(tpl.template.id), String(cat.category.id))) as any;
expect(result.error).toBeUndefined();
});
it('ADMIN-SVC-044 — deleteTemplateCategory returns 404 for missing', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const result = await svc.deleteTemplateCategory(String(tpl.template.id), '99999') as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const result = await asLegacyResult(svc.deleteTemplateCategory(String(tpl.template.id), '99999')) as any;
expect(result.status).toBe(404);
});
});
@@ -1145,11 +1146,11 @@ describe('Template categories', () => {
describe('getPackingTemplate', () => {
it('ADMIN-SVC-056 — returns template with categories and items when template exists', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Full Template', admin.id) as any;
const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Clothing') as any;
await svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'T-Shirt');
const tpl = await asLegacyResult(svc.createPackingTemplate('Full Template', admin.id)) as any;
const cat = await asLegacyResult(svc.createTemplateCategory(String(tpl.template.id), 'Clothing')) as any;
await asLegacyResult(svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'T-Shirt'));
const result = await svc.getPackingTemplate(String(tpl.template.id)) as any;
const result = await asLegacyResult(svc.getPackingTemplate(String(tpl.template.id))) as any;
expect(result.template).toBeDefined();
expect(result.template.name).toBe('Full Template');
expect(Array.isArray(result.categories)).toBe(true);
@@ -1160,7 +1161,7 @@ describe('getPackingTemplate', () => {
});
it('ADMIN-SVC-057 — returns 404 for non-existent template', async () => {
const result = await svc.getPackingTemplate('99999') as any;
const result = await asLegacyResult(svc.getPackingTemplate('99999')) as any;
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
@@ -1169,54 +1170,54 @@ describe('getPackingTemplate', () => {
describe('Template items', () => {
it('ADMIN-SVC-058 — createTemplateItem returns item with name', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const result = await svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Backpack') as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const cat = await asLegacyResult(svc.createTemplateCategory(String(tpl.template.id), 'Gear')) as any;
const result = await asLegacyResult(svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Backpack')) as any;
expect(result.item).toBeDefined();
expect(result.item.name).toBe('Backpack');
});
it('ADMIN-SVC-059 — createTemplateItem returns 400 for empty name', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const result = await svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), '') as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const cat = await asLegacyResult(svc.createTemplateCategory(String(tpl.template.id), 'Gear')) as any;
const result = await asLegacyResult(svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), '')) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-060 — createTemplateItem returns 404 for non-existent category', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const result = await svc.createTemplateItem(String(tpl.template.id), '99999', 'Item') as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const result = await asLegacyResult(svc.createTemplateItem(String(tpl.template.id), '99999', 'Item')) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-061 — updateTemplateItem updates name', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const item = await svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Old Item') as any;
const result = await svc.updateTemplateItem(String(tpl.template.id), String(item.item.id), { name: 'New Item' }) as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const cat = await asLegacyResult(svc.createTemplateCategory(String(tpl.template.id), 'Gear')) as any;
const item = await asLegacyResult(svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Old Item')) as any;
const result = await asLegacyResult(svc.updateTemplateItem(String(tpl.template.id), String(item.item.id), { name: 'New Item' })) as any;
expect(result.item.name).toBe('New Item');
});
it('ADMIN-SVC-062 — updateTemplateItem returns 404 for non-existent item', async () => {
const result = await svc.updateTemplateItem('1', '99999', { name: 'Ghost' }) as any;
const result = await asLegacyResult(svc.updateTemplateItem('1', '99999', { name: 'Ghost' })) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-063 — deleteTemplateItem removes item', async () => {
const { user: admin } = createAdmin(testDb);
const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any;
const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const item = await svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'To Delete') as any;
const result = await svc.deleteTemplateItem(String(tpl.template.id), String(item.item.id)) as any;
const tpl = await asLegacyResult(svc.createPackingTemplate('Tpl', admin.id)) as any;
const cat = await asLegacyResult(svc.createTemplateCategory(String(tpl.template.id), 'Gear')) as any;
const item = await asLegacyResult(svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'To Delete')) as any;
const result = await asLegacyResult(svc.deleteTemplateItem(String(tpl.template.id), String(item.item.id))) as any;
expect(result.error).toBeUndefined();
expect(await findRow(await orm(), PackingTemplateItems, { id: item.item.id })).toBeNull();
});
it('ADMIN-SVC-064 — deleteTemplateItem returns 404 for non-existent item', async () => {
const result = await svc.deleteTemplateItem('1', '99999') as any;
const result = await asLegacyResult(svc.deleteTemplateItem('1', '99999')) as any;
expect(result.status).toBe(404);
});
});
@@ -1226,23 +1227,23 @@ describe('Template items', () => {
describe('Template item scoping (post-fold quirk fix)', () => {
it('ADMIN-SVC-076 — update/deleteTemplateItem honour :templateId instead of ignoring it', async () => {
const { user: admin } = createAdmin(testDb);
const tplA = await svc.createPackingTemplate('A', admin.id) as any;
const tplB = await svc.createPackingTemplate('B', admin.id) as any;
const catA = await svc.createTemplateCategory(String(tplA.template.id), 'Gear') as any;
const item = await svc.createTemplateItem(String(tplA.template.id), String(catA.category.id), 'Tent') as any;
const tplA = await asLegacyResult(svc.createPackingTemplate('A', admin.id)) as any;
const tplB = await asLegacyResult(svc.createPackingTemplate('B', admin.id)) as any;
const catA = await asLegacyResult(svc.createTemplateCategory(String(tplA.template.id), 'Gear')) as any;
const item = await asLegacyResult(svc.createTemplateItem(String(tplA.template.id), String(catA.category.id), 'Tent')) as any;
// Template B does not own the item — both routes must 404 rather than act.
expect(await svc.updateTemplateItem(String(tplB.template.id), String(item.item.id), { name: 'Hijacked' }) as any)
expect(await asLegacyResult(svc.updateTemplateItem(String(tplB.template.id), String(item.item.id), { name: 'Hijacked' })) as any)
.toMatchObject({ status: 404 });
expect(await svc.deleteTemplateItem(String(tplB.template.id), String(item.item.id)) as any)
expect(await asLegacyResult(svc.deleteTemplateItem(String(tplB.template.id), String(item.item.id))) as any)
.toMatchObject({ status: 404 });
expect((await findRow(await orm(), PackingTemplateItems, { id: item.item.id }))!.name)
.toBe('Tent');
// The owning template still works.
expect((await svc.updateTemplateItem(String(tplA.template.id), String(item.item.id), { name: 'Tarp' }) as any).item.name)
expect((await asLegacyResult(svc.updateTemplateItem(String(tplA.template.id), String(item.item.id), { name: 'Tarp' })) as any).item.name)
.toBe('Tarp');
expect(await svc.deleteTemplateItem(String(tplA.template.id), String(item.item.id)) as any).toEqual({});
expect(await asLegacyResult(svc.deleteTemplateItem(String(tplA.template.id), String(item.item.id))) as any).toEqual({});
});
});