Files
TREK/server/src/nest/places/places.controller.ts
T
jubnl 4f0f80ad69 chore: move place backend to proper DI
Fold the 1029-line services/placeService.ts into the DI-native PlacesService:
the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list
importers become methods over the injected DatabaseService, byte-identical
statements, COALESCE semantics and the If-Match conflict protocol (#1135).
The pure half — frozen XML parsers, the KMZ unpacker, the dedup predicates,
the Google hex-id parsers, reclaimPhotoCache — moves to places.helpers.ts
(maps.helpers precedent).

The 10-tool mcp/tools/places.ts registrar plus the trek://trips/{tripId}/places
resource move onto the decorator registry as places.mcp.ts. search_place travels
with them because its gate is places:read, not the read-only geo group, and now
injects MapsService. TripsService, DaysMcp, BookingImportService and the plugin
RPC host (PluginHostDepsFactory's 21st constructor dep) all inject PlacesService,
so the domain needs no bridge at all. The two assignments.bridge imports that
places.mcp.ts keeps are a deliberate cycle break: AssignmentsModule imports
DaysModule and DaysModule now imports PlacesModule for days.mcp.ts's place
creation, so injecting AssignmentsService there would close
DaysModule -> PlacesModule -> AssignmentsModule -> DaysModule. Same seam
reservations.mcp.ts uses, same trade trips.bridge.ts documents.

Also folds services/placeEnrichment.ts in — its DB/websocket/Maps half becomes
PlacesService methods over the injected DatabaseService/RealtimeService/
MapsService, its pure match selector joins places.helpers.ts — which retires
nest/maps/maps.bridge.ts with its last consumer.

Adopts places.dto.ts for the seven grandfathered body contracts and clears every
PlacesController entry from body-contract-allow-list.ts. Two consequences worth
knowing: the bespoke 400s 'Place name is required', 'ids must be an array of
numbers' and 'URL is required' are now the ZodValidationPipe envelope, and
because a pipe runs before the handler a malformed body 400s ahead of the
trip-access 404 it used to follow — the same trade the todo and trips migrations
took. placeBulkUpdateRequestSchema.ids drops .min(1) so the endpoint's
empty-list short-circuit stays reachable.

Fixes four defects the relocation had faithfully carried, each with a
failing-first regression test:

- create dropped lat/lng of exactly 0, so a place on the equator or the prime
  meridian lost its coordinates (`x || null` cannot tell absent from zero).
- duration_minutes: 0 was unsettable — replaced by the 60-minute default on
  create, and read as "absent" by COALESCE on update.
- the journey delete hook fired on unscoped ids: DELETE /:id, POST /bulk-delete
  and the delete_place MCP tool could detach another trip's journey entries for
  an id they then refused. All three scope first via the new
  PlacesService.scopedIds, mirroring the guard PluginHostDepsFactory already had.
  bulk_delete_places also moves its hook ahead of the DELETE —
  journey_entries.source_place_id is ON DELETE SET NULL, so running it afterwards
  left the entries as orphans.
- the place search treated % and _ as LIKE wildcards; a bare % returned the whole
  trip. The three clauses now carry ESCAPE with the term escaped.

Plus hardening on the Google list fetch: the response is capped at 8 MB
(declared content-length and post-read length, the transit.service precedent)
and its JSON.parse is guarded, so a malformed provider payload produces the
existing 'Invalid list data received from Google Maps' 400 instead of throwing.

Quirks deliberately preserved: the non-COALESCE route_color (#776),
currency/transport_mode staying unclearable, importGpx returning null for a file
that parses but yields no usable geometry, update skipping the If-Match check
when the stored updated_at is null, and every string-valued `x || null`.

Tests move with their case IDs: placeService.test.ts and the wrapper suite merge
into unit/nest/places.service.test.ts, kmzUnpack into places.helpers.test.ts,
placeEnrichment into places.enrichment.test.ts. The places e2e converts to the
DI-native pattern (real SQL against temp-db DDL), tools-places.test.ts is kept
and extended so it exercises the decorator path through the test registry, and
the plugin-host and booking-import path mocks become constructor stubs.
2026-07-30 17:07:12 +02:00

421 lines
16 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpException,
Param,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { hexColorSchema } from '@trek/shared';
import { readEnv } from '../../app-config';
import type { User } from '../../types';
import { PlacesService } from './places.service';
import { isUpdateConflict } from '../../services/conflictResult';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { PLACE_IMAGE_UPLOAD } from '../common/place-image-upload';
import { placeImageUrl } from '../../services/placeImage';
import { isDemoEmail } from '../../services/demo';
import {
PlaceBulkDeleteDto,
PlaceBulkUpdateDto,
PlaceCreateDto,
PlaceImportGpxDto,
PlaceImportListDto,
PlaceImportMapDto,
PlaceRatingDto,
PlaceUpdateDto,
} from './places.dto';
const STRING_LIMITS: Record<string, number> = { name: 200, description: 2000, address: 500, notes: 2000 };
const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } };
function validateLengths(body: Record<string, unknown>): void {
for (const [field, max] of Object.entries(STRING_LIMITS)) {
const value = body[field];
if (value && typeof value === 'string' && value.length > max) {
throw new HttpException({ error: `${field} must be ${max} characters or less` }, 400);
}
}
}
// A bad hex makes MapLibre fail hard when it parses the paint property, and the
// update body is an open record that Zod does not police — so check it here.
function validateRouteColor(body: Record<string, unknown>): void {
const value = body.route_color;
if (value === undefined || value === null) return;
if (typeof value !== 'string' || !hexColorSchema.safeParse(value).success) {
throw new HttpException({ error: 'route_color must be a hex colour like #4f46e5' }, 400);
}
}
function parseBool(v: unknown, defaultVal: boolean): boolean {
return v === undefined || v === null ? defaultVal : String(v) === 'true';
}
/**
* /api/trips/:tripId/places — the trip's place pool + importers.
*
* Byte-identical to the legacy Express route (server/src/routes/places.ts):
* trip access (404) runs first, then the string-length guard (400), then the
* 'place_edit' permission (403); create 201 / rest 200; the bespoke 400/404
* bodies; the journey create/update/delete hooks; and WebSocket broadcasts with
* the forwarded X-Socket-Id. The /import/* and /bulk-delete routes are declared
* before /:id so the static segments win over the param.
*
* Bodies validate against the @trek/shared place schemas via the DTO classes in
* places.dto.ts + the global ZodValidationPipe (400 with the standard
* `{ error }` envelope on mismatch). That replaced three bespoke checks —
* 'Place name is required', 'ids must be an array of numbers' and
* 'URL is required' — and, because a pipe runs before the handler, a malformed
* body now 400s ahead of the trip-access 404 / permission 403 it used to follow.
* Same trade the todo and trips migrations took. The length and route_color
* guards below stay in the handler: the place body is a deliberately open
* record, so the pipe passes their inputs through untouched.
*/
@Controller('api/trips/:tripId/places')
@UseGuards(JwtAuthGuard)
export class PlacesController {
constructor(private readonly places: PlacesService) {}
private requireTrip(tripId: string, user: User) {
const trip = this.places.verifyTripAccess(tripId, user.id);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
return trip;
}
private requireEdit(trip: NonNullable<ReturnType<PlacesService['verifyTripAccess']>>, user: User): void {
if (!this.places.canEdit(trip, user)) {
throw new HttpException({ error: 'No permission' }, 403);
}
}
@Get()
list(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Query('search') search?: string,
@Query('category') category?: string,
@Query('tag') tag?: string,
) {
this.requireTrip(tripId, user);
return { places: this.places.list(tripId, { search, category, tag }) };
}
@Post()
create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PlaceCreateDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
validateLengths(body);
validateRouteColor(body);
this.requireEdit(trip, user);
const place = this.places.create(tripId, body as never);
this.places.broadcast(tripId, 'place:created', { place }, socketId);
this.places.onCreated(tripId, place.id);
return { place };
}
@Post('import/gpx')
@UseInterceptors(FileInterceptor('file', UPLOAD))
importGpx(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body() body: PlaceImportGpxDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
const importWaypoints = parseBool(body.importWaypoints, true);
const importRoutes = parseBool(body.importRoutes, true);
const importTracks = parseBool(body.importTracks, true);
if (!importWaypoints && !importRoutes && !importTracks) {
throw new HttpException({ error: 'No import types selected' }, 400);
}
const result = this.places.importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks, defaultName: file.originalname });
if (!result) {
throw new HttpException({ error: 'No matching places found in GPX file' }, 400);
}
for (const place of result.places) {
this.places.broadcast(tripId, 'place:created', { place }, socketId);
}
return { places: result.places, count: result.count, skipped: result.skipped };
}
@Post('import/map')
@UseInterceptors(FileInterceptor('file', UPLOAD))
async importMap(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body() body: PlaceImportMapDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
const importPoints = parseBool(body.importPoints, true);
const importPaths = parseBool(body.importPaths, true);
if (!importPoints && !importPaths) {
throw new HttpException({ error: 'No import types selected' }, 400);
}
try {
const result = await this.places.importMapFile(tripId, file.buffer, file.originalname, { importPoints, importPaths });
if (result.summary?.totalPlacemarks === 0) {
throw new HttpException({ error: 'No valid Placemarks found in map file', summary: result.summary }, 400);
}
for (const place of result.places) {
this.places.broadcast(tripId, 'place:created', { place }, socketId);
}
return result;
} catch (err: unknown) {
if (err instanceof HttpException) throw err;
const message = err instanceof Error ? err.message : 'Failed to import map file';
throw new HttpException({ error: message }, 400);
}
}
@Post('import/google-list')
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PlaceImportListDto, @Headers('x-socket-id') socketId?: string) {
return this.importList('google', user, tripId, body, socketId);
}
@Post('import/naver-list')
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PlaceImportListDto, @Headers('x-socket-id') socketId?: string) {
return this.importList('naver', user, tripId, body, socketId);
}
/** Shared google/naver list import — identical flow, different provider + error string. */
private async importList(provider: 'google' | 'naver', user: User, tripId: string, body: PlaceImportListDto, socketId?: string) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const { url, enrich } = body;
// Opt-in: re-resolve each imported place via the Places API to fill in
// photo / address / website / phone and persist a google_place_id (#886).
const opts = { enrich: parseBool(enrich, false), userId: user.id };
const label = provider === 'google' ? 'Google' : 'Naver';
try {
const result = provider === 'google'
? await this.places.importGoogleList(tripId, url, opts)
: await this.places.importNaverList(tripId, url, opts);
if ('error' in result) {
throw new HttpException({ error: result.error }, result.status);
}
for (const place of result.places) {
this.places.broadcast(tripId, 'place:created', { place }, socketId);
}
return { places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped };
} catch (err: unknown) {
if (err instanceof HttpException) throw err;
console.error(`[Places] ${label} list import error:`, err instanceof Error ? err.message : err);
throw new HttpException({ error: `Failed to import ${label} Maps list. Make sure the list is shared publicly.` }, 400);
}
}
@Post('bulk-delete')
@HttpCode(200) // Express answers bulk-delete with res.json (200), unlike the 201 imports.
bulkDelete(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PlaceBulkDeleteDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const { ids } = body;
if (ids.length === 0) {
return { deleted: [], count: 0 };
}
// Scope the ids to the trip before the hook: onPlaceDeleted keys on the
// place id alone, so an id from another trip would detach that trip's
// journey entries even though removeMany below refuses it. Still ahead of
// the DELETE — journey_entries.source_place_id is ON DELETE SET NULL, so
// afterwards there is nothing left to detach.
for (const id of this.places.scopedIds(tripId, ids)) this.places.onDeleted(id);
const deleted = this.places.removeMany(tripId, ids);
for (const id of deleted) {
this.places.broadcast(tripId, 'place:deleted', { placeId: id }, socketId);
}
return { deleted, count: deleted.length };
}
@Post('bulk-update')
@HttpCode(200)
bulkUpdate(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PlaceBulkUpdateDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const { ids } = body;
if (ids.length === 0) {
return { updated: [], count: 0 };
}
// `category_id` may be a number or null (null clears it), so key presence —
// not truthiness — is what signals there's something to change.
if (!('category_id' in body)) {
throw new HttpException({ error: 'Provide at least one field to update' }, 400);
}
const updated = this.places.updateMany(tripId, ids, { category_id: body.category_id as number | null });
for (const place of updated) {
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
this.places.onUpdated(place.id);
}
return { updated: updated.map((p) => p.id), count: updated.length };
}
@Get(':id')
get(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
this.requireTrip(tripId, user);
const place = this.places.get(tripId, id);
if (!place) {
throw new HttpException({ error: 'Place not found' }, 404);
}
return { place };
}
@Post(':id/image')
@HttpCode(200)
@UseInterceptors(FileInterceptor('image', PLACE_IMAGE_UPLOAD))
uploadImage(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (readEnv().demo.enabled && isDemoEmail(user.email)) {
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
}
if (!file) {
throw new HttpException({ error: 'No image uploaded' }, 400);
}
// Reuse the existing image_url slot (the top-precedence thumbnail source); the
// update path reclaims any previously uploaded file it replaces.
const result = this.places.update(tripId, id, { image_url: placeImageUrl(file.filename) } as never);
if (!result || isUpdateConflict(result)) {
throw new HttpException({ error: 'Place not found' }, 404);
}
const place = result;
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
this.places.onUpdated(place.id);
return { place };
}
@Put(':id/rating')
rate(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: PlaceRatingDto,
@Headers('x-socket-id') socketId?: string,
) {
// Deliberately no place_edit check: every trip member may cast their own
// vote (#1435), even when an admin restricts place editing.
this.requireTrip(tripId, user);
const place = this.places.rate(tripId, id, user.id, body.rating);
if (!place) {
throw new HttpException({ error: 'Place not found' }, 404);
}
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
return { place };
}
@Delete(':id/rating')
unrate(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
this.requireTrip(tripId, user);
const place = this.places.rate(tripId, id, user.id, null);
if (!place) {
throw new HttpException({ error: 'Place not found' }, 404);
}
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
return { place };
}
@Get(':id/image')
async image(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
this.requireTrip(tripId, user);
try {
const result = await this.places.searchImage(tripId, id, user.id);
if ('error' in result) {
throw new HttpException({ error: result.error }, result.status);
}
return { photos: result.photos };
} catch (err: unknown) {
if (err instanceof HttpException) throw err;
console.error('Unsplash error:', err);
throw new HttpException({ error: 'Error searching for image' }, 500);
}
}
@Put(':id')
update(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: PlaceUpdateDto,
@Headers('x-socket-id') socketId?: string,
@Headers('x-base-updated-at') ifMatch?: string,
) {
const trip = this.requireTrip(tripId, user);
validateLengths(body);
validateRouteColor(body);
this.requireEdit(trip, user);
const result = this.places.update(tripId, id, body as never, ifMatch);
if (!result) {
throw new HttpException({ error: 'Place not found' }, 404);
}
// The offline edit was based on a now-stale version — let the client resolve
// it (#1135) instead of silently overwriting the newer server state.
if (isUpdateConflict(result)) {
throw new HttpException({ error: 'conflict', server: result.server }, 409);
}
const place = result;
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
this.places.onUpdated(place.id);
return { place };
}
@Delete(':id')
remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
// Scope the id to the trip before the hook (see bulkDelete), then sync the
// journey ahead of the actual delete.
if (!this.places.get(tripId, id)) {
throw new HttpException({ error: 'Place not found' }, 404);
}
this.places.onDeleted(Number(id));
if (!this.places.remove(tripId, id)) {
throw new HttpException({ error: 'Place not found' }, 404);
}
this.places.broadcast(tripId, 'place:deleted', { placeId: Number(id) }, socketId);
return { success: true };
}
}