mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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.
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { canAccessTrip } from '../db/database';
|
||||
import { listPlaces } from '../services/placeService';
|
||||
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
|
||||
import { getNotifications } from '../services/inAppNotifications';
|
||||
import { isAddonEnabled } from '../nest/addons/addons.bridge';
|
||||
@@ -51,19 +49,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
// The trip-days resource moved to the DI-discovered
|
||||
// src/nest/days/days.mcp.ts (@ResourceTemplate).
|
||||
|
||||
// Places in a trip
|
||||
if (canRead(scopes, 'places')) server.registerResource(
|
||||
'trip-places',
|
||||
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
|
||||
{ description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null;
|
||||
const places = listPlaces(String(id), { assignment: assignment ?? undefined });
|
||||
return jsonContent(uri.href, places);
|
||||
}
|
||||
);
|
||||
// The trip-places resource moved to the DI-discovered
|
||||
// src/nest/places/places.mcp.ts (@ResourceTemplate).
|
||||
|
||||
// The trip-budget resource moved to the DI-discovered
|
||||
// src/nest/budget/budget.mcp.ts (@ResourceTemplate).
|
||||
|
||||
@@ -3,7 +3,6 @@ import { registerJourneyTools } from './tools/journey';
|
||||
import { registerMapsWeatherTools } from './tools/mapsWeather';
|
||||
import { registerNotificationTools } from './tools/notifications';
|
||||
import { registerAtlasTools } from './tools/atlas';
|
||||
import { registerPlaceTools } from './tools/places';
|
||||
import { registerCollectionTools } from './tools/collections';
|
||||
import { registerTransportTools } from './tools/transports';
|
||||
import { registerMcpPrompts } from './tools/prompts';
|
||||
@@ -15,7 +14,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
// attached via the nest-mcp registry below — getDeprecationNotice rides the
|
||||
// attach ctx).
|
||||
|
||||
registerPlaceTools(server, userId, scopes);
|
||||
// The place tools moved to the DI-discovered src/nest/places/places.mcp.ts
|
||||
// (@McpController, attached via the nest-mcp registry below).
|
||||
|
||||
registerCollectionTools(server, userId, scopes);
|
||||
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace, ratePlace } from '../../services/placeService';
|
||||
import { createAssignment, dayExists } from '../../nest/assignments/assignments.bridge';
|
||||
import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService';
|
||||
import { searchPlaces } from '../../nest/maps/maps.bridge';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
export function registerPlaceTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'places');
|
||||
const W = canWrite(scopes, 'places');
|
||||
|
||||
// --- PLACES ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_place',
|
||||
{
|
||||
description: 'Add a new place/POI to a trip. Set google_place_id, google_ftid, or osm_id (from search_place) so the app can show opening hours, ratings, and direct Google Maps links. Set price + currency to record the cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency });
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_and_assign_place',
|
||||
{
|
||||
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive().describe('Day to assign the place to'),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
|
||||
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||
return { place, assignment };
|
||||
});
|
||||
const result = run();
|
||||
safeBroadcast(tripId, 'place:created', { place: result.place });
|
||||
safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_place',
|
||||
{
|
||||
description: 'Update an existing place in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
|
||||
price: z.number().optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
|
||||
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
|
||||
duration_minutes: z.number().int().positive().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
|
||||
google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid });
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'rate_place',
|
||||
{
|
||||
description: "Set or clear the current user's 1-5 star rating on a trip place (#1435). Every trip member rates independently; the place shows the average. Omit rating (or pass null) to remove the user's vote. Use the ratings to capture the user's preferences and shape the itinerary around highly-rated places.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
rating: z.number().int().min(1).max(5).nullable().optional().describe('1-5 stars; null/omitted clears the vote'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, placeId, rating }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
// Rating is a personal vote — any trip member may cast one, place_edit not required.
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const place = ratePlace(String(tripId), String(placeId), userId, rating ?? null);
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_place',
|
||||
{
|
||||
description: 'Delete a place from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, placeId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
try { onPlaceDeleted(placeId); } catch {} // sync journeys before the row is gone
|
||||
const deleted = deletePlace(String(tripId), String(placeId));
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:deleted', { placeId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_places',
|
||||
{
|
||||
description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
search: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all').describe('Filter by assignment status: "all" (default), "unassigned" (not on any day), or "assigned" (scheduled on a day)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId, search, category, tag, assignment }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const places = listPlaces(String(tripId), { search, category, tag, assignment });
|
||||
return ok({ places });
|
||||
}
|
||||
);
|
||||
|
||||
// The list_categories tool moved to the DI-discovered
|
||||
// src/nest/categories/categories.mcp.ts (@McpController).
|
||||
|
||||
// --- SEARCH ---
|
||||
|
||||
if (R) server.registerTool(
|
||||
'search_place',
|
||||
{
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ query }) => {
|
||||
try {
|
||||
const result = await searchPlaces(userId, query);
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'import_places_from_url',
|
||||
{
|
||||
description: 'Import places from a shared Google Maps or Naver Maps list URL. Returns the imported places and count. The list must be shared publicly.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
url: z.string().url().describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'),
|
||||
source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, url, source }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
|
||||
const result = source === 'google-list'
|
||||
? await importGoogleList(String(tripId), url)
|
||||
: await importNaverList(String(tripId), url);
|
||||
|
||||
if ('error' in result) {
|
||||
return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
}
|
||||
|
||||
for (const place of result.places) {
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
}
|
||||
return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'bulk_delete_places',
|
||||
{
|
||||
description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well. Warn the user before calling this — it cannot be undone.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(200),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, placeIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
|
||||
const deleted = deletePlacesMany(String(tripId), placeIds);
|
||||
for (const id of deleted) {
|
||||
safeBroadcast(tripId, 'place:deleted', { placeId: id });
|
||||
try { onPlaceDeleted(id); } catch {}
|
||||
}
|
||||
return ok({ deleted, count: deleted.length });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'bulk_update_places',
|
||||
{
|
||||
description: 'Update many places in a trip at once, applying the SAME field values to every listed place. Use this for sweeping edits — e.g. re-categorising a batch of POIs (set category_id for 80 places) — in a single call instead of one update_place per place. Only the fields you set are changed; everything else on each place is preserved. Use list_categories for category_id.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(500).describe('IDs of the places to update (from list_places)'),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
|
||||
price: z.number().optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
|
||||
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
|
||||
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
|
||||
duration_minutes: z.number().int().positive().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, placeIds, category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, description }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
|
||||
const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, description };
|
||||
if (Object.values(fields).every(v => v === undefined)) {
|
||||
return { content: [{ type: 'text' as const, text: 'Provide at least one field to update.' }], isError: true };
|
||||
}
|
||||
|
||||
const updated = updatePlacesMany(String(tripId), placeIds, fields);
|
||||
for (const place of updated) safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ count: updated.length, updatedIds: updated.map(p => p.id), skipped: placeIds.length - updated.length });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
|
||||
- **DI-native services (legacy `src/services/*` deleted):** tags, categories,
|
||||
todo, packing, day-notes, trip-invite, assignments, share, settings, files,
|
||||
collab, vacay, reservations, day, permissions, audit, budget, trip, maps,
|
||||
transit — see the migration recipe below.
|
||||
transit, place — see the migration recipe below.
|
||||
|
||||
## Cross-cutting Foundation pieces
|
||||
|
||||
@@ -231,13 +231,13 @@ wrapper `MapsService`; the pure parser/UA/POI-category helpers moved to
|
||||
`maps.helpers.ts` as plain exports (files.constants/client-ip precedent —
|
||||
the DI-native TransitService's User-Agent imports from there, not a bridge), the module-scoped
|
||||
POI cache / photo-fetch semaphore / frozen Overpass mirrors stayed module-scoped
|
||||
on purpose (permissions-cache precedent: the bridge instance and the DI
|
||||
singleton share them); the 3 geo tools left the mixed `mcp/tools/mapsWeather.ts`
|
||||
on purpose (permissions-cache precedent: any out-of-container instance and the
|
||||
DI singleton share them); the 3 geo tools left the mixed `mcp/tools/mapsWeather.ts`
|
||||
registrar for the decorator-driven `maps.mcp.ts` (the registrar file survives —
|
||||
its weather + airport tools await their own migrations); BookingImportService
|
||||
injects `MapsService` for its Nominatim geocoding, and a 3-export
|
||||
`maps.bridge.ts` serves the legacy placeEnrichment helper and the places
|
||||
registrar until the place domain migrates).
|
||||
`maps.bridge.ts` served the legacy placeEnrichment helper and the places
|
||||
registrar — both absorbed by the 2026-07 place fold, which deleted the bridge).
|
||||
transitService followed (the first fully SQL-free domain fold — the 333-line
|
||||
Transitous/MOTIS proxy became a dep-free `TransitService` (no
|
||||
`DatabaseService`; the ExchangeRatesService precedent), its response cache,
|
||||
@@ -251,8 +251,35 @@ search tools on `access: { group: 'geo', mode: 'read' }` and
|
||||
bridge imports becoming injected `DaysService`/`ReservationsService` (+
|
||||
`DatabaseService` for `canAccessTrip`) — leaving **zero bridge files** and the
|
||||
transports registrar as `days.bridge.ts`'s last consumer).
|
||||
placeService followed (the step-4 tail: the 1029-line place core — the
|
||||
CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list
|
||||
importers — folded into the wrapper `PlacesService`, with the pure pieces
|
||||
(frozen XML parsers, the KMZ unpacker, the dedup predicates, the Google
|
||||
hex-id parsers, `reclaimPhotoCache`) moving to `places.helpers.ts` on the
|
||||
maps.helpers precedent; the 10-tool `mcp/tools/places.ts` registrar + the
|
||||
`trek://trips/{tripId}/places` resource moved to `places.mcp.ts` —
|
||||
`search_place` came along because its gate is `places:read`, not the geo
|
||||
group, and now injects `MapsService`; TripsService, DaysMcp,
|
||||
BookingImportService and the plugin host (its 21st constructor dep) inject
|
||||
`PlacesService`, leaving **zero bridge files** for the domain; the two
|
||||
`assignments.bridge` imports stay in `places.mcp.ts` on purpose —
|
||||
AssignmentsModule imports DaysModule and DaysModule now imports
|
||||
PlacesModule, so injecting would close a
|
||||
DaysModule → PlacesModule → AssignmentsModule → DaysModule cycle
|
||||
(reservations.mcp.ts uses the same seam for the same reason). The sibling
|
||||
`placeEnrichment` fold went further than the recipe's minimum: the 168-line
|
||||
helper's DB/websocket/Maps half became `PlacesService` methods over the
|
||||
injected `DatabaseService`/`RealtimeService`/`MapsService` and its pure
|
||||
match selector joined `places.helpers.ts`, which retired **`maps.bridge.ts`**
|
||||
with its last consumer. The DTO ratchet for its 7 grandfathered body
|
||||
contracts landed as a third commit, which also loosened
|
||||
`placeBulkUpdateRequestSchema.ids` (`.min(1)` dropped) so the endpoint's
|
||||
empty-list short-circuit stays reachable, and retired three bespoke 400
|
||||
strings — 'Place name is required', 'ids must be an array of numbers' and
|
||||
'URL is required' — in favour of the pipe envelope, accepting that a
|
||||
malformed body now 400s ahead of the trip-access 404 (the todo/trips trade).
|
||||
Repeat these steps per
|
||||
service (next up: **placeService → transitItineraryService** —
|
||||
service (next up: **transitItineraryService** —
|
||||
or the notifications fan-in — per the dependency-honest order in
|
||||
`migration-graph.md`). This is a
|
||||
**pure relocation** — byte-identical
|
||||
|
||||
@@ -10,9 +10,10 @@ import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { BudgetModule } from '../budget/budget.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { MapsModule } from '../maps/maps.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
|
||||
@Module({
|
||||
imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule, MapsModule],
|
||||
imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule, MapsModule, PlacesModule],
|
||||
controllers: [BookingImportController, FeaturesController],
|
||||
providers: [BookingImportService, KitineraryExtractorService, ImportJobsService],
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { ReservationsService } from '../reservations/reservations.service';
|
||||
import { createPlace } from '../../services/placeService';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
@@ -29,6 +29,7 @@ export class BookingImportService {
|
||||
private readonly addons: AddonsService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly maps: MapsService,
|
||||
private readonly places: PlacesService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
@@ -174,7 +175,7 @@ export class BookingImportService {
|
||||
}
|
||||
}
|
||||
|
||||
const place = createPlace(tripId, {
|
||||
const place = this.places.create(tripId, {
|
||||
name: _venue.name,
|
||||
lat,
|
||||
lng,
|
||||
|
||||
@@ -84,14 +84,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
|
||||
'PasskeyController.registerOptions',
|
||||
'PasskeyController.registerVerify',
|
||||
'PasskeyController.rename',
|
||||
'PlacesController.bulkDelete',
|
||||
'PlacesController.bulkUpdate',
|
||||
'PlacesController.create',
|
||||
'PlacesController.importGoogle',
|
||||
'PlacesController.importGpx',
|
||||
'PlacesController.importMap',
|
||||
'PlacesController.importNaver',
|
||||
'PlacesController.update',
|
||||
'PluginRoutesController.route',
|
||||
'PluginUserSettingsController.update',
|
||||
'PluginsController.activate',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { createPlace } from '../../services/placeService';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { DaysService } from './days.service';
|
||||
@@ -33,6 +33,7 @@ export class DaysMcp {
|
||||
constructor(
|
||||
private readonly days: DaysService,
|
||||
private readonly db: DatabaseService,
|
||||
private readonly places: PlacesService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
@@ -186,7 +187,7 @@ export class DaysMcp {
|
||||
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
||||
try {
|
||||
const result = this.db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const accommodation = this.days.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
|
||||
return { place, accommodation };
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DayNotesController } from './day-notes.controller';
|
||||
import { DayNotesService } from './day-notes.service';
|
||||
import { DayNotesMcp } from './day-notes.mcp';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
|
||||
/**
|
||||
* Days + day-notes domain (S6 — Phase 2 trip sub-domain). The single prefix
|
||||
@@ -17,7 +18,7 @@ import { PermissionsModule } from '../permissions/permissions.module';
|
||||
* controllers).
|
||||
*/
|
||||
@Module({
|
||||
imports: [PermissionsModule],
|
||||
imports: [PermissionsModule, PlacesModule],
|
||||
controllers: [DaysController, DayNotesController],
|
||||
providers: [DaysService, DaysMcp, DayNotesService, DayNotesMcp],
|
||||
exports: [DaysService, DayNotesService],
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { MapsService } from './maps.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the maps domain — for code running OUTSIDE the Nest
|
||||
* container (the legacy placeService's placeEnrichment helper and the legacy
|
||||
* places MCP registrar in src/mcp/tools/places.ts; the geo MCP tools moved to
|
||||
* the DI-discovered maps.mcp.ts, and transitService's User-Agent comes from the
|
||||
* pure maps.helpers.ts). Exports only the legacy services/mapsService names
|
||||
* still consumed outside the container, 1:1, so repointing a consumer is an
|
||||
* import-path-only diff. Inside the container, inject MapsService instead.
|
||||
* Delete this file when the place domain migrates.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton.
|
||||
*/
|
||||
const maps = new MapsService(new DatabaseService(db));
|
||||
|
||||
export function getMapsKey(userId: number) {
|
||||
return maps.getMapsKey(userId);
|
||||
}
|
||||
|
||||
export function searchPlaces(
|
||||
userId: number,
|
||||
query: string,
|
||||
lang?: string,
|
||||
locationBias?: { lat: number; lng: number; radius?: number },
|
||||
) {
|
||||
return maps.searchPlaces(userId, query, lang, locationBias);
|
||||
}
|
||||
|
||||
export function getPlacePhoto(userId: number, placeId: string, lat: number, lng: number, name?: string) {
|
||||
return maps.getPlacePhoto(userId, placeId, lat, lng, name);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { MapsMcp } from './maps.mcp';
|
||||
/**
|
||||
* Maps / geo domain (L3 leaf module). Registered in AppModule. Exports
|
||||
* MapsService for the in-container consumers (BookingImportModule's Nominatim
|
||||
* geocoding); out-of-container code goes through maps.bridge.ts.
|
||||
* geocoding, PlacesModule's search_place tool and list-import enrichment).
|
||||
* Nothing outside the container consumes this domain, so there is no bridge.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [MapsController],
|
||||
|
||||
@@ -229,8 +229,8 @@ type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; ln
|
||||
* (Nominatim/Overpass/Google), the place-details/photo caches and the SSRF
|
||||
* guard on every outbound URL. DI-native since the maps fold: the legacy
|
||||
* services/mapsService.ts functions live here as methods over the injected
|
||||
* DatabaseService (byte-identical SQL and behaviour). Out-of-container
|
||||
* consumers go through maps.bridge.ts; pure helpers live in maps.helpers.ts.
|
||||
* DatabaseService (byte-identical SQL and behaviour). Every consumer injects
|
||||
* this class; pure helpers live in maps.helpers.ts.
|
||||
*
|
||||
* The per-endpoint kill-switches are settings reads the legacy route does
|
||||
* inline; they're encapsulated here as `*Disabled()` helpers over the same
|
||||
|
||||
@@ -26,7 +26,16 @@ 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 { PlaceRatingDto } from './places.dto';
|
||||
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 } };
|
||||
@@ -63,6 +72,16 @@ function parseBool(v: unknown, defaultVal: boolean): boolean {
|
||||
* 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)
|
||||
@@ -99,16 +118,13 @@ export class PlacesController {
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: Record<string, unknown> & { name?: string },
|
||||
@Body() body: PlaceCreateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
validateLengths(body);
|
||||
validateRouteColor(body);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.name) {
|
||||
throw new HttpException({ error: 'Place name is required' }, 400);
|
||||
}
|
||||
const place = this.places.create(tripId, body as never);
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
this.places.onCreated(tripId, place.id);
|
||||
@@ -121,7 +137,7 @@ export class PlacesController {
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Body() body: PlaceImportGpxDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
@@ -151,7 +167,7 @@ export class PlacesController {
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Body() body: PlaceImportMapDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
@@ -181,22 +197,20 @@ export class PlacesController {
|
||||
}
|
||||
|
||||
@Post('import/google-list')
|
||||
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('google', user, tripId, url, enrich, socketId);
|
||||
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('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('naver', user, tripId, url, enrich, socketId);
|
||||
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, url: unknown, enrich: unknown, socketId?: 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);
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new HttpException({ error: 'URL is required' }, 400);
|
||||
}
|
||||
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 };
|
||||
@@ -224,18 +238,21 @@ export class PlacesController {
|
||||
bulkDelete(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body('ids') ids: unknown,
|
||||
@Body() body: PlaceBulkDeleteDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!Array.isArray(ids) || ids.some((v) => typeof v !== 'number')) {
|
||||
throw new HttpException({ error: 'ids must be an array of numbers' }, 400);
|
||||
}
|
||||
const { ids } = body;
|
||||
if (ids.length === 0) {
|
||||
return { deleted: [], count: 0 };
|
||||
}
|
||||
for (const id of ids) this.places.onDeleted(id);
|
||||
// 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);
|
||||
@@ -248,15 +265,12 @@ export class PlacesController {
|
||||
bulkUpdate(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { ids?: unknown; category_id?: unknown },
|
||||
@Body() body: PlaceBulkUpdateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { ids } = body;
|
||||
if (!Array.isArray(ids) || ids.some((v) => typeof v !== 'number')) {
|
||||
throw new HttpException({ error: 'ids must be an array of numbers' }, 400);
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
return { updated: [], count: 0 };
|
||||
}
|
||||
@@ -364,7 +378,7 @@ export class PlacesController {
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Body() body: PlaceUpdateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
@Headers('x-base-updated-at') ifMatch?: string,
|
||||
) {
|
||||
@@ -391,7 +405,12 @@ export class PlacesController {
|
||||
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);
|
||||
this.places.onDeleted(Number(id)); // sync before actual delete
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { placeRatingRequestSchema } from '@trek/shared';
|
||||
import {
|
||||
placeBulkDeleteRequestSchema,
|
||||
placeBulkUpdateRequestSchema,
|
||||
placeCreateRequestSchema,
|
||||
placeImportGpxRequestSchema,
|
||||
placeImportListRequestSchema,
|
||||
placeImportMapRequestSchema,
|
||||
placeRatingRequestSchema,
|
||||
placeUpdateRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Server-side createZodDto wrappers over the @trek/shared places contracts.
|
||||
* Typing a controller parameter with one of these classes is what lets the
|
||||
* global ZodValidationPipe (APP_PIPE) validate it by metatype — the Zod
|
||||
* schemas in shared/ remain the single source of truth for the contract.
|
||||
*
|
||||
* Create/update wrap the deliberately open place body (place rows are wide and
|
||||
* provider-derived): the record passes unknown keys straight through, so the
|
||||
* handler's own length and route_color guards still see — and still reject —
|
||||
* exactly what they did before the ratchet. The two import DTOs describe
|
||||
* multipart form fields, which arrive as strings (fileUploadRequestSchema
|
||||
* precedent), so the handlers keep their own 'true'-comparison coercion.
|
||||
*/
|
||||
|
||||
export class PlaceCreateDto extends createZodDto(placeCreateRequestSchema) {}
|
||||
export class PlaceUpdateDto extends createZodDto(placeUpdateRequestSchema) {}
|
||||
export class PlaceRatingDto extends createZodDto(placeRatingRequestSchema) {}
|
||||
export class PlaceBulkDeleteDto extends createZodDto(placeBulkDeleteRequestSchema) {}
|
||||
export class PlaceBulkUpdateDto extends createZodDto(placeBulkUpdateRequestSchema) {}
|
||||
export class PlaceImportListDto extends createZodDto(placeImportListRequestSchema) {}
|
||||
export class PlaceImportGpxDto extends createZodDto(placeImportGpxRequestSchema) {}
|
||||
export class PlaceImportMapDto extends createZodDto(placeImportMapRequestSchema) {}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import unzipper from 'unzipper';
|
||||
import type { Place } from '../../types';
|
||||
import type { PlaceWithTags } from '../database/database.service';
|
||||
import type { KmlImportSummary } from '../../services/kmlImport';
|
||||
import * as placePhotoCache from '../../services/placePhotoCache';
|
||||
|
||||
/**
|
||||
* Pure helpers and module-scope constants of the places domain, moved verbatim
|
||||
* out of the legacy services/placeService.ts when it went DI-native. Same
|
||||
* split as maps.helpers.ts / transit.helpers.ts / files.constants.ts: nothing
|
||||
* here touches the DB, so it stays plain exports rather than becoming methods
|
||||
* on PlacesService — which also keeps the frozen-at-import XML parsers a
|
||||
* single shared instance and lets the KMZ unpacker be unit-tested on its own.
|
||||
*/
|
||||
|
||||
const gpxParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
isArray: (name) => ['wpt', 'trkpt', 'rtept', 'trk', 'trkseg', 'rte'].includes(name),
|
||||
});
|
||||
|
||||
const kmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
removeNSPrefix: true,
|
||||
isArray: (name) => ['Placemark', 'Folder', 'Document'].includes(name),
|
||||
// Treat <description> as raw text so mixed-content HTML (e.g. <br/>, <i>)
|
||||
// is returned as a string instead of a parsed object.
|
||||
stopNodes: ['*.description'],
|
||||
});
|
||||
|
||||
export { gpxParser, kmlParser };
|
||||
|
||||
export const KMZ_DECOMPRESSED_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
export const COORD_DEDUP_TOLERANCE = 0.0001; // ≈ 11 m
|
||||
|
||||
/** Cap on a provider list-import response body — the payload is attacker-influenced via the list id. */
|
||||
export const MAX_LIST_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||
|
||||
/**
|
||||
* Escape the LIKE metacharacters in a user-supplied search term so `%` and `_`
|
||||
* match literally. Pairs with `LIKE ? ESCAPE '\\'` at the call site; the
|
||||
* backslash itself is escaped first so a trailing one cannot orphan the escape.
|
||||
*/
|
||||
export function escapeLikePattern(term: string): string {
|
||||
return term.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
||||
}
|
||||
|
||||
/** Opt-in Places-API enrichment for list imports (#886). */
|
||||
export interface ListImportOptions {
|
||||
enrich?: boolean;
|
||||
userId?: number;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export interface PlaceWithCategory extends Place {
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
category_icon: string | null;
|
||||
}
|
||||
|
||||
export interface PlaceImportResult {
|
||||
places: PlaceWithTags[];
|
||||
count: number;
|
||||
summary: KmlImportSummary;
|
||||
}
|
||||
|
||||
export interface GpxImportResult {
|
||||
places: PlaceWithTags[];
|
||||
count: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export interface ListImportResult {
|
||||
places: PlaceWithTags[];
|
||||
listName: string;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export interface ListImportError {
|
||||
error: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface GpxImportOptions {
|
||||
importWaypoints?: boolean;
|
||||
importRoutes?: boolean;
|
||||
importTracks?: boolean;
|
||||
/** Source filename used to name unnamed routes/tracks (keeps multiple imports distinct). */
|
||||
defaultName?: string;
|
||||
}
|
||||
|
||||
export interface KmlImportOptions {
|
||||
importPoints?: boolean;
|
||||
importPaths?: boolean;
|
||||
}
|
||||
|
||||
// Reclaim a deleted place's cached marker photo if nothing else references it.
|
||||
// The cache key is the Google place_id, or — for coordinate-only places — the
|
||||
// pseudo-id embedded in the stored proxy URL (/api/maps/place-photo/{id}/bytes).
|
||||
export function reclaimPhotoCache(googlePlaceId: string | null, imageUrl: string | null): void {
|
||||
const candidates = new Set<string>();
|
||||
if (googlePlaceId) candidates.add(googlePlaceId);
|
||||
const m = imageUrl?.match(/^\/api\/maps\/place-photo\/(.+)\/bytes$/);
|
||||
if (m) { try { candidates.add(decodeURIComponent(m[1])); } catch { /* malformed url */ } }
|
||||
for (const id of candidates) {
|
||||
try { placePhotoCache.removeIfUnreferenced(id); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import deduplication helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DedupSet {
|
||||
names: Set<string>;
|
||||
coords: Array<{ lat: number; lng: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a candidate place is already represented in the dedup set.
|
||||
* Named places match by case-insensitive name; unnamed places fall back to
|
||||
* coordinate proximity.
|
||||
*/
|
||||
export function isPlaceDuplicate(
|
||||
candidate: { name: string | null | undefined; lat: number | null; lng: number | null },
|
||||
dedup: DedupSet,
|
||||
): boolean {
|
||||
const normalizedName = candidate.name?.trim().toLowerCase();
|
||||
if (normalizedName) return dedup.names.has(normalizedName);
|
||||
if (candidate.lat != null && candidate.lng != null) {
|
||||
return dedup.coords.some(
|
||||
(c) =>
|
||||
Math.abs(c.lat - candidate.lat!) <= COORD_DEDUP_TOLERANCE &&
|
||||
Math.abs(c.lng - candidate.lng!) <= COORD_DEDUP_TOLERANCE,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
|
||||
export function trackInsertedInDedupSet(
|
||||
place: { name: string | null | undefined; lat: number | null; lng: number | null },
|
||||
dedup: DedupSet,
|
||||
): void {
|
||||
const normalizedName = place.name?.trim().toLowerCase();
|
||||
if (normalizedName) {
|
||||
dedup.names.add(normalizedName);
|
||||
} else if (place.lat != null && place.lng != null) {
|
||||
dedup.coords.push({ lat: place.lat, lng: place.lng });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google Maps list id parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function googleMapsHexId(value: unknown): string | null {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
||||
const raw = String(value).trim();
|
||||
if (/^0x[0-9a-f]+$/i.test(raw)) return raw.toLowerCase();
|
||||
if (!/^-?\d+$/.test(raw)) return null;
|
||||
try {
|
||||
const parsed = BigInt(raw);
|
||||
const unsigned = parsed < 0n ? (1n << 64n) + parsed : parsed;
|
||||
return `0x${unsigned.toString(16)}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function googleMapsFeatureIdFromItem(item: unknown): string | null {
|
||||
if (!Array.isArray(item)) return null;
|
||||
const candidates = [
|
||||
Array.isArray(item[1]) ? item[1][6] : null,
|
||||
Array.isArray(item[7]) ? item[7][1] : null,
|
||||
];
|
||||
|
||||
for (const ids of candidates) {
|
||||
if (!Array.isArray(ids) || ids.length < 2) continue;
|
||||
const first = googleMapsHexId(ids[0]);
|
||||
const second = googleMapsHexId(ids[1]);
|
||||
if (first && second) return `${first}:${second}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import enrichment (#886) — the pure half of the former
|
||||
// services/placeEnrichment.ts; the DB/websocket/Maps half is PlacesService.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A place the import produced — only the fields enrichment reads/writes. */
|
||||
export interface EnrichablePlace {
|
||||
id: number;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
google_place_id?: string | null;
|
||||
google_ftid?: string | null;
|
||||
address?: string | null;
|
||||
website?: string | null;
|
||||
phone?: string | null;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
/** How close a search hit must be to the imported coordinates to be trusted. */
|
||||
export const MATCH_RADIUS_METERS = 250;
|
||||
/** Bias the text search to roughly the imported area. */
|
||||
export const SEARCH_BIAS_RADIUS_METERS = 2000;
|
||||
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
|
||||
export const ENRICH_CONCURRENCY = 3;
|
||||
|
||||
function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371000;
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLng = toRad(b.lng - a.lng);
|
||||
const lat1 = toRad(a.lat);
|
||||
const lat2 = toRad(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the search result that is the same place as the import: it must be a
|
||||
* Google result (have a google_place_id) with coordinates within
|
||||
* MATCH_RADIUS_METERS of the imported point. Returns the closest such hit, or
|
||||
* null when nothing is close enough — in which case the place is left as
|
||||
* imported rather than risking a wrong-place overwrite (common-name / romanized
|
||||
* lists). Exported for unit testing.
|
||||
*/
|
||||
export function pickEnrichmentMatch(
|
||||
candidates: Record<string, unknown>[],
|
||||
target: { lat: number; lng: number },
|
||||
maxMeters: number = MATCH_RADIUS_METERS,
|
||||
): Record<string, unknown> | null {
|
||||
let best: { c: Record<string, unknown>; dist: number } | null = null;
|
||||
for (const c of candidates || []) {
|
||||
const gpid = c.google_place_id;
|
||||
const lat = c.lat;
|
||||
const lng = c.lng;
|
||||
if (typeof gpid !== 'string' || !gpid) continue;
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number') continue;
|
||||
const dist = haversineMeters(target, { lat, lng });
|
||||
if (dist > maxMeters) continue;
|
||||
if (!best || dist < best.dist) best = { c, dist };
|
||||
}
|
||||
return best?.c ?? null;
|
||||
}
|
||||
|
||||
export async function mapWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const item = items[cursor++];
|
||||
await fn(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
/** Trim to a non-empty string, else null — the enrichment "only fill real values" guard. */
|
||||
export const trimOrNull = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KMZ unpacking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function unpackKmzToKml(
|
||||
kmzBuffer: Buffer,
|
||||
decompressedSizeLimit = KMZ_DECOMPRESSED_SIZE_LIMIT,
|
||||
): Promise<Buffer> {
|
||||
let zip;
|
||||
try {
|
||||
zip = await unzipper.Open.buffer(kmzBuffer);
|
||||
} catch {
|
||||
throw new Error('Invalid KMZ archive.');
|
||||
}
|
||||
|
||||
const kmlEntries = zip.files.filter((entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml'));
|
||||
if (kmlEntries.length === 0) {
|
||||
throw new Error('KMZ archive does not contain a KML file.');
|
||||
}
|
||||
|
||||
const preferredEntry = kmlEntries.find((entry) => entry.path.toLowerCase().endsWith('doc.kml')) || kmlEntries[0];
|
||||
|
||||
if (preferredEntry.uncompressedSize > decompressedSizeLimit) {
|
||||
throw new Error('KMZ archive exceeds the maximum allowed decompressed size.');
|
||||
}
|
||||
|
||||
return preferredEntry.buffer();
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import {
|
||||
McpController, Tool, ResourceTemplate, type McpContext,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, ok,
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { createAssignment, dayExists } from '../assignments/assignments.bridge';
|
||||
import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService';
|
||||
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { MapsService } from '../maps/maps.service';
|
||||
import { PlacesService } from './places.service';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places MCP surface — ported 1:1 from the legacy registrar
|
||||
* src/mcp/tools/places.ts (ten tools) plus the trek://trips/{tripId}/places
|
||||
* resource from src/mcp/resources.ts: identical names, descriptions, input
|
||||
* schemas, annotations, error/payload shapes and safeBroadcast events. The
|
||||
* registration-time `canWrite/canRead(scopes, 'places')` gates became the
|
||||
* declarative access markers, resolved by trekMcpAccessPolicy.
|
||||
*
|
||||
* `search_place` stays in this controller even though it calls MapsService:
|
||||
* its legacy gate is `places:read`, not the geo group that maps.mcp.ts carries.
|
||||
*
|
||||
* The two assignments imports stay on assignments.bridge rather than injecting
|
||||
* AssignmentsService: AssignmentsModule imports DaysModule, and DaysModule now
|
||||
* imports PlacesModule for days.mcp.ts's place creation, so injecting here
|
||||
* would close a DaysModule → PlacesModule → AssignmentsModule → DaysModule
|
||||
* cycle. Same reason reservations.mcp.ts uses that bridge from inside the
|
||||
* container, and the same trade trips.bridge.ts documents.
|
||||
*/
|
||||
@McpController()
|
||||
export class PlacesMcp {
|
||||
constructor(
|
||||
private readonly places: PlacesService,
|
||||
private readonly maps: MapsService,
|
||||
private readonly db: DatabaseService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'create_place',
|
||||
description: 'Add a new place/POI to a trip. Set google_place_id, google_ftid, or osm_id (from search_place) so the app can show opening hours, ratings, and direct Google Maps links. Set price + currency to record the cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async createPlace(
|
||||
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency }: {
|
||||
tripId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
|
||||
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string;
|
||||
notes?: string; website?: string; phone?: string; price?: number; currency?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency });
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_and_assign_place',
|
||||
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive().describe('Day to assign the place to'),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
|
||||
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async createAndAssignPlace(
|
||||
{ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, assignment_notes, price, currency }: {
|
||||
tripId: number; dayId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
|
||||
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string;
|
||||
place_notes?: string; website?: string; phone?: string; assignment_notes?: string;
|
||||
price?: number; currency?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
try {
|
||||
const result = this.db.transaction(() => {
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||
return { place, assignment };
|
||||
});
|
||||
safeBroadcast(tripId, 'place:created', { place: result.place });
|
||||
safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_place',
|
||||
description: 'Update an existing place in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
|
||||
price: z.number().optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
|
||||
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
|
||||
duration_minutes: z.number().int().positive().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
|
||||
google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async updatePlace(
|
||||
{ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid }: {
|
||||
tripId: number; placeId: number; name?: string; description?: string; lat?: number; lng?: number;
|
||||
address?: string; category_id?: number; price?: number; currency?: string; place_time?: string;
|
||||
end_time?: string; duration_minutes?: number; notes?: string; website?: string; phone?: string;
|
||||
transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; osm_id?: string;
|
||||
google_place_id?: string; google_ftid?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const place = this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid });
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'rate_place',
|
||||
description: "Set or clear the current user's 1-5 star rating on a trip place (#1435). Every trip member rates independently; the place shows the average. Omit rating (or pass null) to remove the user's vote. Use the ratings to capture the user's preferences and shape the itinerary around highly-rated places.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
rating: z.number().int().min(1).max(5).nullable().optional().describe('1-5 stars; null/omitted clears the vote'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async ratePlace(
|
||||
{ tripId, placeId, rating }: { tripId: number; placeId: number; rating?: number | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
// Rating is a personal vote — any trip member may cast one, place_edit not required.
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
const place = this.places.rate(String(tripId), String(placeId), ctx.userId, rating ?? null);
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_place',
|
||||
description: 'Delete a place from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async deletePlace({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
// Scope the id to the trip before the hook: onPlaceDeleted keys on the place
|
||||
// id alone, so a foreign id would detach that trip's journey entries even
|
||||
// though the delete below refuses it.
|
||||
if (!this.places.get(String(tripId), String(placeId))) {
|
||||
return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
}
|
||||
try { onPlaceDeleted(placeId); } catch { /* non-fatal */ } // sync journeys before the row is gone
|
||||
const deleted = this.places.remove(String(tripId), String(placeId));
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:deleted', { placeId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_places',
|
||||
description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
search: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all').describe('Filter by assignment status: "all" (default), "unassigned" (not on any day), or "assigned" (scheduled on a day)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'places', mode: 'read' },
|
||||
})
|
||||
async listPlaces(
|
||||
{ tripId, search, category, tag, assignment }: {
|
||||
tripId: number; search?: string; category?: string; tag?: string;
|
||||
assignment?: 'all' | 'unassigned' | 'assigned';
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
const places = this.places.list(String(tripId), { search, category, tag, assignment });
|
||||
return ok({ places });
|
||||
}
|
||||
|
||||
// The list_categories tool moved to the DI-discovered
|
||||
// src/nest/categories/categories.mcp.ts (@McpController).
|
||||
|
||||
// --- SEARCH ---
|
||||
|
||||
@Tool({
|
||||
name: 'search_place',
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'places', mode: 'read' },
|
||||
})
|
||||
async searchPlace({ query }: { query: string }, ctx: McpContext) {
|
||||
try {
|
||||
const result = await this.maps.searchPlaces(ctx.userId, query);
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'import_places_from_url',
|
||||
description: 'Import places from a shared Google Maps or Naver Maps list URL. Returns the imported places and count. The list must be shared publicly.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
url: z.string().url().describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'),
|
||||
source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async importPlacesFromUrl(
|
||||
{ tripId, url, source }: { tripId: number; url: string; source: 'google-list' | 'naver-list' },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
|
||||
const result = source === 'google-list'
|
||||
? await this.places.importGoogleList(String(tripId), url)
|
||||
: await this.places.importNaverList(String(tripId), url);
|
||||
|
||||
if ('error' in result) {
|
||||
return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
}
|
||||
|
||||
for (const place of result.places) {
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
}
|
||||
return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'bulk_delete_places',
|
||||
description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well. Warn the user before calling this — it cannot be undone.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(200),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async bulkDeletePlaces({ tripId, placeIds }: { tripId: number; placeIds: number[] }, ctx: McpContext) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
|
||||
// Trip-scoped, and ahead of the DELETE: journey_entries.source_place_id is
|
||||
// ON DELETE SET NULL, so a hook that ran afterwards found nothing left to
|
||||
// detach and left the entries as orphans.
|
||||
for (const id of this.places.scopedIds(String(tripId), placeIds)) {
|
||||
try { onPlaceDeleted(id); } catch { /* non-fatal */ }
|
||||
}
|
||||
const deleted = this.places.removeMany(String(tripId), placeIds);
|
||||
for (const id of deleted) safeBroadcast(tripId, 'place:deleted', { placeId: id });
|
||||
return ok({ deleted, count: deleted.length });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'bulk_update_places',
|
||||
description: 'Update many places in a trip at once, applying the SAME field values to every listed place. Use this for sweeping edits — e.g. re-categorising a batch of POIs (set category_id for 80 places) — in a single call instead of one update_place per place. Only the fields you set are changed; everything else on each place is preserved. Use list_categories for category_id.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(500).describe('IDs of the places to update (from list_places)'),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
|
||||
price: z.number().optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
|
||||
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
|
||||
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
|
||||
duration_minutes: z.number().int().positive().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async bulkUpdatePlaces(
|
||||
{ tripId, placeIds, category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, description }: {
|
||||
tripId: number; placeIds: number[]; category_id?: number; price?: number; currency?: string;
|
||||
transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; place_time?: string;
|
||||
end_time?: string; duration_minutes?: number; notes?: string; website?: string; phone?: string;
|
||||
description?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
|
||||
const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, description };
|
||||
if (Object.values(fields).every(v => v === undefined)) {
|
||||
return { content: [{ type: 'text' as const, text: 'Provide at least one field to update.' }], isError: true };
|
||||
}
|
||||
|
||||
const updated = this.places.updateMany(String(tripId), placeIds, fields);
|
||||
for (const place of updated) safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ count: updated.length, updatedIds: updated.map(p => p.id), skipped: placeIds.length - updated.length });
|
||||
}
|
||||
|
||||
@ResourceTemplate({
|
||||
name: 'trip-places',
|
||||
uriTemplate: 'trek://trips/{tripId}/places',
|
||||
description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)',
|
||||
mimeType: 'application/json',
|
||||
access: { group: 'places', mode: 'read' },
|
||||
})
|
||||
async tripPlacesResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !this.db.canAccessTrip(id, ctx.userId)) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ error: 'Trip not found or access denied' }),
|
||||
}],
|
||||
};
|
||||
}
|
||||
const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null;
|
||||
const places = this.places.list(String(id), { assignment: assignment ?? undefined });
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(places, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlacesController } from './places.controller';
|
||||
import { PlacesService } from './places.service';
|
||||
import { PlacesMcp } from './places.mcp';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { MapsModule } from '../maps/maps.module';
|
||||
|
||||
/** Places domain (S8 — Phase 2 trip sub-domain). Depends on L4 Categories + L5 Tags. */
|
||||
/**
|
||||
* Places domain (S8 — Phase 2 trip sub-domain). Depends on L4 Categories + L5
|
||||
* Tags for the joined projections, and on MapsModule for places.mcp.ts's
|
||||
* search_place tool. Exports PlacesService for the in-container consumers —
|
||||
* TripsService's trip summary, DaysMcp's place-accommodation creation,
|
||||
* BookingImportService and the plugin RPC host's PluginHostDepsFactory. There
|
||||
* is no places.bridge.ts: nothing outside the container consumes this domain.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PermissionsModule],
|
||||
imports: [PermissionsModule, MapsModule],
|
||||
controllers: [PlacesController],
|
||||
providers: [PlacesService],
|
||||
providers: [PlacesService, PlacesMcp],
|
||||
exports: [PlacesService],
|
||||
})
|
||||
export class PlacesModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -123,8 +123,11 @@ budget migration — its `listBudgetItems` import became the already-injected
|
||||
2026-07 with the trip fold — its six imported symbols (`listTrips`,
|
||||
`updateTrip`, `createTrip`, `removeMember`, `NotFoundError`, `ValidationError`)
|
||||
became the injected `TripsService`, with the error classes now imported from
|
||||
`nest/trips/trips.service`; the remaining factory
|
||||
imports are all Wave-4+/5 domains: places, journeys, atlas, collections).
|
||||
`nest/trips/trips.service`; placeService swapped in 2026-07 with the place
|
||||
fold — its four imported symbols (`createPlace`, `updatePlace`, `deletePlace`,
|
||||
`getPlace`) became the injected `PlacesService`, the factory's 21st constructor
|
||||
dep; the remaining factory
|
||||
imports are all Wave-5 domains: journeys, atlas, collections).
|
||||
|
||||
### Test impact (as landed)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import pathMod from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PermissionsService } from '../../permissions/permissions.service';
|
||||
import { TripsService, NotFoundError, ValidationError } from '../../trips/trips.service';
|
||||
import { createPlace, updatePlace, deletePlace, getPlace } from '../../../services/placeService';
|
||||
import { PlacesService, type PlaceCreateInput, type PlaceUpdateInput } from '../../places/places.service';
|
||||
import { AddonsService } from '../../addons/addons.service';
|
||||
import { isDemoEmail } from '../../../services/demo';
|
||||
import { ADDON_IDS } from '../../../addons';
|
||||
@@ -161,6 +161,7 @@ export class PluginHostDepsFactory {
|
||||
private readonly addons: AddonsService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly trips: TripsService,
|
||||
private readonly places: PlacesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -467,7 +468,7 @@ export class PluginHostDepsFactory {
|
||||
this.realtime.broadcast(tripId, 'budget:deleted', { itemId });
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Places (place_edit). Delegate to the same placeService the REST/MCP paths
|
||||
// --- Places (place_edit). Delegate to the same PlacesService the REST/MCP paths
|
||||
// use, then broadcast the same events so open web sessions update live, and run
|
||||
// the journey hooks places.controller runs on the same three writes: a journey
|
||||
// linked to the trip carries a skeleton entry per day-assigned place, and
|
||||
@@ -475,13 +476,13 @@ export class PluginHostDepsFactory {
|
||||
// somebody reloads it. ---
|
||||
canEditPlaces: (tripId, userId) => this.canEditTripAs('place_edit', tripId, userId),
|
||||
createPlace: (tripId, input) => {
|
||||
const place = createPlace(String(tripId), input as Parameters<typeof createPlace>[1]);
|
||||
const place = this.places.create(String(tripId), input as unknown as PlaceCreateInput);
|
||||
this.realtime.broadcast(tripId, 'place:created', { place });
|
||||
mirrorJourneys(() => onPlaceCreated(Number(tripId), place.id));
|
||||
return place;
|
||||
},
|
||||
updatePlace: (tripId, placeId, input) => {
|
||||
const place = updatePlace(String(tripId), String(placeId), input as Parameters<typeof updatePlace>[2]);
|
||||
const place = this.places.update(String(tripId), String(placeId), input as PlaceUpdateInput);
|
||||
if (place === null) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
|
||||
this.realtime.broadcast(tripId, 'place:updated', { place });
|
||||
mirrorJourneys(() => onPlaceUpdated(Number(placeId)));
|
||||
@@ -491,12 +492,12 @@ export class PluginHostDepsFactory {
|
||||
// Scope the id to the trip before anything else: onPlaceDeleted keys on the
|
||||
// place alone, so an id belonging to a foreign trip would detach that trip's
|
||||
// journey entries even though the delete below refuses it.
|
||||
if (!getPlace(String(tripId), String(placeId))) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
|
||||
if (!this.places.get(String(tripId), String(placeId))) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
|
||||
// Ahead of the DELETE, like the REST route and the MCP tool: journey_entries
|
||||
// .source_place_id is ON DELETE SET NULL, so afterwards the hook finds nothing
|
||||
// left to detach and the entries linger as orphans.
|
||||
mirrorJourneys(() => onPlaceDeleted(Number(placeId)));
|
||||
const deleted = deletePlace(String(tripId), String(placeId));
|
||||
const deleted = this.places.remove(String(tripId), String(placeId));
|
||||
if (!deleted) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
|
||||
this.realtime.broadcast(tripId, 'place:deleted', { placeId });
|
||||
return { deleted: true };
|
||||
|
||||
@@ -37,6 +37,7 @@ import { FilesModule } from '../files/files.module';
|
||||
import { CollabModule } from '../collab/collab.module';
|
||||
import { VacayModule } from '../vacay/vacay.module';
|
||||
import { TripsModule } from '../trips/trips.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
@@ -51,7 +52,7 @@ import { AddonsModule } from '../addons/addons.module';
|
||||
@Module({
|
||||
// The DI-native domain services the plugin host wiring injects
|
||||
// (PluginHostDepsFactory); DatabaseModule is @Global, so not listed.
|
||||
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule],
|
||||
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule, PlacesModule],
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
|
||||
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory],
|
||||
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
|
||||
|
||||
@@ -12,6 +12,8 @@ import { BudgetService } from '../budget/budget.service';
|
||||
import { ExchangeRatesService } from '../budget/exchange-rates.service';
|
||||
import { CollabService } from '../collab/collab.service';
|
||||
import { VacayService } from '../vacay/vacay.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { MapsService } from '../maps/maps.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the trip domain — for the two consumers that cannot
|
||||
@@ -42,6 +44,7 @@ const trips = new TripsService(
|
||||
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
|
||||
new VacayService(dbs(), new RealtimeService()),
|
||||
new RealtimeService(),
|
||||
new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs())),
|
||||
);
|
||||
|
||||
export function getTripOwner(tripId: string | number) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TodoModule } from '../todo/todo.module';
|
||||
import { VacayModule } from '../vacay/vacay.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { TripsController } from './trips.controller';
|
||||
import { TripsService } from './trips.service';
|
||||
import { TripsMcp } from './trips.mcp';
|
||||
@@ -16,7 +17,7 @@ import { TripsMcp } from './trips.mcp';
|
||||
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
|
||||
* not capture the nested sub-domain mounts (collab, files, ...). */
|
||||
@Module({
|
||||
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule],
|
||||
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule],
|
||||
controllers: [TripsController],
|
||||
providers: [TripsService, TripsMcp],
|
||||
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
|
||||
|
||||
@@ -11,7 +11,7 @@ import { avatarUrl } from '../../services/avatarUrl';
|
||||
import { erasePluginUserData } from '../../services/userCleanupService';
|
||||
import { emitUserDeleted } from '../../plugin-user-lifecycle';
|
||||
import { resolveTimeZone } from '../../services/timezoneService';
|
||||
import { listPlaces } from '../../services/placeService';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { DaysService, addDays } from '../days/days.service';
|
||||
import { PackingService } from '../packing/packing.service';
|
||||
import { TodoService } from '../todo/todo.service';
|
||||
@@ -226,6 +226,7 @@ export class TripsService {
|
||||
private readonly collab: CollabService,
|
||||
private readonly vacay: VacayService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly places: PlacesService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
@@ -1209,7 +1210,7 @@ export class TripsService {
|
||||
return {
|
||||
trip,
|
||||
days,
|
||||
places: listPlaces(String(tripId), {}),
|
||||
places: this.places.list(String(tripId), {}),
|
||||
// Scope to the requesting member so other members' private packing items
|
||||
// (#858) never land in this viewer's offline cache.
|
||||
packingItems: this.packing.listItems(tripId, viewerId),
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { broadcast } from '../websocket';
|
||||
import { getMapsKey, searchPlaces, getPlacePhoto } from '../nest/maps/maps.bridge';
|
||||
|
||||
/**
|
||||
* Background enrichment for list-imported places (#886).
|
||||
*
|
||||
* Google/Naver list imports only carry name + coordinates, so the imported
|
||||
* places open as bare pins (the Maps tab jumps to coordinates, no photo, no
|
||||
* open/closed). When the importer opts in and a Google Maps key is configured,
|
||||
* we re-resolve each place by name — biased to and validated against the
|
||||
* imported coordinates — to a real Google place, then fill in the empty fields
|
||||
* and persist the resolved `google_place_id` plus `google_ftid` (which power
|
||||
* on-demand opening hours and proper Maps links going forward).
|
||||
*
|
||||
* This runs detached from the import request (fire-and-forget) so a long list
|
||||
* never blocks the response, and pushes each enriched row over the websocket so
|
||||
* the sidebar fills in progressively. It only ever fills EMPTY columns, so it
|
||||
* can never clobber data the import already captured (e.g. a Naver address).
|
||||
*/
|
||||
|
||||
/** A place the import produced — only the fields enrichment reads/writes. */
|
||||
export interface EnrichablePlace {
|
||||
id: number;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
google_place_id?: string | null;
|
||||
google_ftid?: string | null;
|
||||
address?: string | null;
|
||||
website?: string | null;
|
||||
phone?: string | null;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
/** How close a search hit must be to the imported coordinates to be trusted. */
|
||||
const MATCH_RADIUS_METERS = 250;
|
||||
/** Bias the text search to roughly the imported area. */
|
||||
const SEARCH_BIAS_RADIUS_METERS = 2000;
|
||||
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
|
||||
const ENRICH_CONCURRENCY = 3;
|
||||
|
||||
function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371000;
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLng = toRad(b.lng - a.lng);
|
||||
const lat1 = toRad(a.lat);
|
||||
const lat2 = toRad(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the search result that is the same place as the import: it must be a
|
||||
* Google result (have a google_place_id) with coordinates within
|
||||
* MATCH_RADIUS_METERS of the imported point. Returns the closest such hit, or
|
||||
* null when nothing is close enough — in which case the place is left as
|
||||
* imported rather than risking a wrong-place overwrite (common-name / romanized
|
||||
* lists). Exported for unit testing.
|
||||
*/
|
||||
export function pickEnrichmentMatch(
|
||||
candidates: Record<string, unknown>[],
|
||||
target: { lat: number; lng: number },
|
||||
maxMeters: number = MATCH_RADIUS_METERS,
|
||||
): Record<string, unknown> | null {
|
||||
let best: { c: Record<string, unknown>; dist: number } | null = null;
|
||||
for (const c of candidates || []) {
|
||||
const gpid = c.google_place_id;
|
||||
const lat = c.lat;
|
||||
const lng = c.lng;
|
||||
if (typeof gpid !== 'string' || !gpid) continue;
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number') continue;
|
||||
const dist = haversineMeters(target, { lat, lng });
|
||||
if (dist > maxMeters) continue;
|
||||
if (!best || dist < best.dist) best = { c, dist };
|
||||
}
|
||||
return best?.c ?? null;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const item = items[cursor++];
|
||||
await fn(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||
|
||||
async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, lang?: string): Promise<void> {
|
||||
// Already linked (shouldn't happen for list imports) — nothing to resolve.
|
||||
if (place.google_place_id) return;
|
||||
if (typeof place.lat !== 'number' || typeof place.lng !== 'number') return;
|
||||
|
||||
const { places: results } = await searchPlaces(userId, place.name, lang, {
|
||||
lat: place.lat,
|
||||
lng: place.lng,
|
||||
radius: SEARCH_BIAS_RADIUS_METERS,
|
||||
});
|
||||
const match = pickEnrichmentMatch(results, { lat: place.lat, lng: place.lng });
|
||||
if (!match) return;
|
||||
|
||||
const gpid = str(match.google_place_id);
|
||||
if (!gpid) return;
|
||||
const gftid = str(match.google_ftid);
|
||||
|
||||
// COALESCE so enrichment only fills empty columns — never overwrites data the
|
||||
// import already captured (e.g. Naver's address) or anything the user edited.
|
||||
db.prepare(
|
||||
`UPDATE places
|
||||
SET google_place_id = COALESCE(google_place_id, ?),
|
||||
google_ftid = COALESCE(google_ftid, ?),
|
||||
address = COALESCE(address, ?),
|
||||
website = COALESCE(website, ?),
|
||||
phone = COALESCE(phone, ?),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND trip_id = ?`,
|
||||
).run(gpid, gftid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
|
||||
|
||||
// Photo is best-effort: Google often has none, in which case getPlacePhoto
|
||||
// resolves with photoUrl: null. A missing photo (or a provider outage, which
|
||||
// still throws) must never abort the rest of the enrichment.
|
||||
try {
|
||||
const photo = await getPlacePhoto(userId, gpid, place.lat, place.lng, place.name);
|
||||
if (photo?.photoUrl) {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
|
||||
).run(photo.photoUrl, place.id, tripId);
|
||||
}
|
||||
} catch {
|
||||
/* no photo — leave image_url as-is */
|
||||
}
|
||||
|
||||
// Push the enriched row to every connected client (no socket exclusion: the
|
||||
// importer's own client should also receive the late update).
|
||||
const updated = getPlaceWithTags(place.id);
|
||||
if (updated) broadcast(tripId, 'place:updated', { place: updated }, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a batch of just-imported places in the background. Never throws —
|
||||
* any per-place failure is swallowed so one bad lookup can't take down the
|
||||
* detached task or the process. No-ops when no Google Maps key is configured.
|
||||
*/
|
||||
export async function enrichImportedPlaces(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
places: EnrichablePlace[],
|
||||
lang?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!places.length) return;
|
||||
if (!getMapsKey(userId)) return;
|
||||
await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => {
|
||||
try {
|
||||
await enrichOne(tripId, userId, place, lang);
|
||||
} catch (err) {
|
||||
console.error(`[Places] enrichment failed for place ${place.id}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Places] import enrichment pass failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user