diff --git a/migrate.md b/migrate.md index eab3addeb..d7d54a387 100644 --- a/migrate.md +++ b/migrate.md @@ -28,7 +28,7 @@ Wave 4 — the coupled cluster (order matters here) dayService; the budget/day coupling lives in the Nest wrapper's budget-sync seam and the MCP registrars, which keep their legacy imports until those domains migrate. Reservations went first as the frontier residue fold; dayService (done 2026-07 — the 592-line service folded into the wrapper `DaysService`, the accommodations seam in `nest/reservations/` now injects it, and the hand-rolled reorder/insert transactions became `db.transaction()`) followed; budgetService followed (done 2026-07 — the 755-line money core folded into the wrapper `BudgetService` (settlement maths, FX freeze/rebase, items/members/payers CRUD; the raw settlement writes became `insertSettlement`/`applySettlementUpdate` so the MCP paths keep skipping the freeze); the 11-tool mcp registrar + 3 budget resources moved to the decorator-driven `budget.mcp.ts`; TripsService/ReservationsService/BookingImportService now inject `BudgetService`; a 4-export `budget.bridge.ts` serves tripService, userCleanupService and the legacy trips/transports registrars; `exchange-rates.bridge.ts` was deleted with its last consumers; and the controller adopted `budget.dto.ts` over the shared schemas, clearing all nine budget allow-list entries — **tripService is now unblocked**). -9. tripService (done 2026-07 — the 1121-line hub folded into the wrapper `TripsService`: generateDays, the updateTrip date-shift transaction, members/guests (#973/#1362), the ICS export, copyTripById and getTripSummary; its six bridge imports became injected services (CollabService/VacayService joined the constructor); the 10-tool trips registrar + 3 trip resources + the trip-summary prompt moved to the decorator-driven `trips.mcp.ts` (first `@Prompt`; the static-token deprecation notice rides the `registry.attach` ctx) and the 3 share-link tools to `share.mcp.ts` on the `canShareTrips` predicate; FeedsService and the plugin RPC host inject `TripsService`; a 3-export `trips.bridge.ts` serves the legacy prompts registrar and budget.mcp.ts's owner/member seam; todo/share/collab/vacay bridges deleted with their last consumers; the controller adopted `trips.dto.ts`, clearing all seven trip allow-list entries). Then placeService (7 internal deps, mostly on things migrated by now — the remaining Wave-4 pick). +9. tripService (done 2026-07 — the 1121-line hub folded into the wrapper `TripsService`: generateDays, the updateTrip date-shift transaction, members/guests (#973/#1362), the ICS export, copyTripById and getTripSummary; its six bridge imports became injected services (CollabService/VacayService joined the constructor); the 10-tool trips registrar + 3 trip resources + the trip-summary prompt moved to the decorator-driven `trips.mcp.ts` (first `@Prompt`; the static-token deprecation notice rides the `registry.attach` ctx) and the 3 share-link tools to `share.mcp.ts` on the `canShareTrips` predicate; FeedsService and the plugin RPC host inject `TripsService`; a 3-export `trips.bridge.ts` serves the legacy prompts registrar and budget.mcp.ts's owner/member seam; todo/share/collab/vacay bridges deleted with their last consumers; the controller adopted `trips.dto.ts`, clearing all seven trip allow-list entries). placeService followed (done 2026-07 — the 1029-line place core folded into the wrapper `PlacesService` with its pure half in `places.helpers.ts`; the 10-tool registrar + the trip-places resource moved to the decorator-driven `places.mcp.ts` (`search_place` injects `MapsService`); TripsService/DaysMcp/BookingImportService/the plugin RPC host all inject it, so the domain needs **no bridge at all**; the sibling `placeEnrichment` fold absorbed the last `maps.bridge` consumer and deleted that bridge too, and the DTO ratchet cleared all seven `PlacesController` allow-list entries — **transitItineraryService is the last step-4 link**). Wave 5 — the heavyweights, last diff --git a/migration-graph.md b/migration-graph.md index 6a144bffa..e3ee0171f 100644 --- a/migration-graph.md +++ b/migration-graph.md @@ -1,37 +1,46 @@ # Legacy `src/services/` dependency graph -Generated from the actual imports in `server/src` on **2026-07-29** (after the -transitService migration — the second link of step 4 in the dependency-honest -order below: the 333-line Transitous/MOTIS proxy folded into the dep-free -DI-native `TransitService` (no `DatabaseService` — the ExchangeRatesService -precedent), the pure `deriveTransitStats` + mode whitelist + itinerary types -into `nest/transit/transit.helpers.ts`, and the whole 3-tool -`mcp/tools/transit.ts` registrar onto the decorator registry -(`transit.mcp.ts`, injecting `DaysService`/`ReservationsService` where the -registrar used the days/reservations bridges) — zero bridge files, registrar -deleted. Earlier the same day, the mapsService migration opened step 4: the -1429-line geo core folded into the DI-native `MapsService`, its pure helpers -into `nest/maps/maps.helpers.ts`, the 3 geo MCP tools onto the decorator -registry (`maps.mcp.ts` — the weather/airport tools stay in the legacy -`mapsWeather.ts` registrar), BookingImportService injects it, and a 3-export -`maps.bridge.ts` serves `placeEnrichment` + the places registrar; before that, -the tripService migration + its quirk-fix pass completed step 2: the -1121-line hub folded into the DI-native `TripsService`, its MCP surface moved -to the decorator-driven `trips.mcp.ts`/`share.mcp.ts`, the plugin RPC host -injects it, and the bridge cascade landed — `todo.bridge`, `share.bridge`, -`collab.bridge` and `vacay.bridge` deleted with their last consumers, the -days/budget bridges pruned to their surviving exports. Earlier context: the budgetService -migration (step 1) and the +Generated from the actual imports in `server/src` on **2026-07-30** (after the +placeService migration — the third and final link of step 4 in the +dependency-honest order below: the 1029-line place core (CRUD + ratings SQL, +the GPX/KML/KMZ importers, the Google/Naver list importers) folded into the +DI-native `PlacesService`; the pure pieces — the frozen XML parsers, the KMZ +unpacker, the dedup predicates, the Google hex-id parsers, `reclaimPhotoCache` +— into `nest/places/places.helpers.ts` (maps.helpers precedent); the whole +10-tool `mcp/tools/places.ts` registrar **plus** the +`trek://trips/{tripId}/places` resource onto the decorator registry +(`places.mcp.ts`, with `search_place` coming along because its gate is +`places:read` and now injecting `MapsService`); TripsService, DaysMcp, +BookingImportService and the plugin RPC host (its 21st constructor dep) all +inject `PlacesService`, so the domain needs **no bridge at all**. The sibling +`placeEnrichment` fold went further: that 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. A DTO ratchet commit cleared all seven `PlacesController` body +allow-list entries, and a trailing `fix(server)` commit repaired four verified +defects the relocation had faithfully carried (see "Quirks fixed" below). +Earlier the same week, the transitService migration opened the tail of step 4 +(the 333-line Transitous/MOTIS proxy into the dep-free `TransitService`, pure +stats/types into `nest/transit/transit.helpers.ts`, the 3-tool registrar onto +the decorator registry — zero bridge files, registrar deleted); before that the +mapsService fold (the 1429-line geo core into `MapsService`, pure helpers into +`maps.helpers.ts`, the 3 geo MCP tools onto the registry), and before that the +tripService fold completing step 2 (the 1121-line hub into `TripsService`, its +MCP surface onto `trips.mcp.ts`/`share.mcp.ts`, the plugin host injecting it, +and the bridge cascade — `todo.bridge`, `share.bridge`, `collab.bridge` and +`vacay.bridge` deleted with their last consumers, days/budget pruned to their +survivors). Earlier context: the budgetService migration (step 1) and the Phase 0 quick wins: `getAppUrl`/`getMcpSafeUrl` → `src/app-config` deleted the fake `→ notifications` edges for the identity/MCP stack and freed mapsService; the addon-enablement reads moved from adminService into `nest/addons/` (`addons.bridge.ts` for out-of-container consumers), collapsing the admin god-file's ~27-consumer fan-in to the admin routes + one `admin-2` residual -and freeing oauthService). +and freeing oauthService. This regeneration parses **static `from '...'`, `require('...')` and dynamic `import('...')`** specifiers, so the lazy edges the earlier grep-based analysis missed (scheduler jobs, the db-boot airport backfill, the fire-and-forget -notification sends) are now included. Regenerate any time by re-running that +notification sends) are included. Regenerate any time by re-running that three-pattern import scan over `server/src` (a throwaway script suffices; the patterns are the whole trick). @@ -78,10 +87,10 @@ How to read it: resolution — are plain exports in `nest/maps/maps.helpers.ts` (the DI-native TransitService imports its UA from there); the module-scoped POI cache / photo-fetch semaphore / frozen Overpass mirrors stay module-scoped on - purpose so the bridge instance and the DI singleton share them; a 3-export - `maps.bridge.ts` (getMapsKey/searchPlaces/getPlacePhoto) serves the legacy - `placeEnrichment` helper and `mcp/tools/places.ts` until the place domain - migrates), + purpose so any out-of-container instance and the DI singleton share them; + **`maps.bridge.ts` is gone** — the place fold absorbed both of its consumers, + the `placeEnrichment` helper and the places MCP registrar, and PlacesService / + PlacesMcp / BookingImportService now inject `MapsService` directly), transit (the first fully SQL-free domain fold: the Transitous/MOTIS proxy became the dep-free `TransitService` — response cache, frozen-at-import `TRANSIT_API_BASE` and lazy User-Agent memo stay module-scoped on purpose; @@ -89,23 +98,35 @@ How to read it: are plain exports in `nest/transit/transit.helpers.ts` (maps.helpers precedent) consumed by the legacy `transitItineraryService`; the 3-tool registrar moved to `transit.mcp.ts` and was deleted — no bridge ever - existed for this domain). + existed for this domain), + place (the 1029-line place core folded into `PlacesService`, its pure half into + `nest/places/places.helpers.ts`, and the whole MCP surface — 10 tools + the + trip-places resource — onto `places.mcp.ts`; the `placeEnrichment` helper was + absorbed in the same wave, taking `maps.bridge.ts` with it. **Zero bridge + files**: every consumer is in-container. The two `assignments.bridge` imports + that `places.mcp.ts` keeps are a deliberate cycle break — + AssignmentsModule imports DaysModule and DaysModule imports PlacesModule for + `days.mcp.ts`'s place creation, so injecting AssignmentsService there would + close DaysModule → PlacesModule → AssignmentsModule → DaysModule; the same + seam `reservations.mcp.ts` uses and the same trade `trips.bridge.ts` + documents). - **Domain migration targets** (the wave material): adminService, airportService, atlasService, authService, backupService, collectionsService, journeyService, journeyShareService, notificationService, oauthService, - oidcService, passkeyService, placeService, - transitItineraryService, weatherService, wikiService. + oidcService, passkeyService, transitItineraryService, weatherService, wikiService. - **Cross-cutting Wave-2 targets:** permissions and auditLog are done (2026-07) — see the DI-native list above; only tripAccess remains (delete, don't migrate). - **Helpers that stay as plain modules** (pure/infra, not wave material): avatarUrl, queryHelpers, conflictResult, cookie, demo, distanceService, ephemeralTokens, apiKeyCrypto, mfaCrypto, passwordPolicy, webauthnConfig, timezoneService, llmConfig, kmlImport, placeImage, - placePhotoCache, placeEnrichment, unsplashService, userCleanupService, + placePhotoCache, unsplashService, userCleanupService, inAppNotifications, inAppNotificationActions, notificationPreferencesService, notifications (+ `notifications/` registry), `memories/` cluster, `airtrail/` cluster. Several of these are - themselves candidates to fold *into* a domain service when its domain migrates (e.g. - placeEnrichment → places — the exchangeRateService → budget fold landed 2026-07 on - exactly this pattern). + themselves candidates to fold *into* a domain service when its domain migrates — and two + already have: exchangeRateService → budget (2026-07) and **placeEnrichment → place** + (2026-07), the latter also retiring a bridge. The four still reached only from + `nest/places/*` (kmlImport, placeImage, placePhotoCache, unsplashService) are the + next obvious fold candidates, but none of them blocks anything. ## Domain-level graph (edges = "must migrate first, or bridge"; dotted = lazy, non-blocking) @@ -122,8 +143,7 @@ flowchart TD airport["airportService (boot special case)"]:::ready wiki[wikiService]:::ready collections[collectionsService]:::ready - place["placeService (maps edge → maps.bridge since the fold)"]:::ready - transitItin["transitItineraryService (stats/types → transit.helpers since the transit fold)"]:::ready + transitItin["transitItineraryService (stats/types → transit.helpers; bridge tax zero)"]:::ready oauth[oauthService]:::ready end @@ -150,11 +170,12 @@ flowchart TD memories --> notifSvc & admin ``` -(The former `placeService → mapsService` edge (via the `placeEnrichment` helper) is a -`maps.bridge` repoint since the 2026-07 maps fold, and the former +(The `placeService` node is gone since the 2026-07-30 fold — its legacy imports were +all helpers, and the former `placeService → mapsService` edge (via `placeEnrichment`) +died with the helper rather than becoming a repoint. The `transitItineraryService → transitService` edge became the pure `nest/transit/transit.helpers` import when the transit domain went DI-native — -helpers never block — so both sit on the frontier now; +helpers never block — so it sits alone at the head of the frontier; `notificationService → notifications cluster` is a hard import — the former `mapsService/transitService/webauthnConfig/ oauthService → notifications` edges were only `getAppUrl`/`getMcpSafeUrl` and died with the @@ -176,43 +197,41 @@ already-migrated collab/packing/trips/reservations/vacay services.) | service | imports (services/) | imported by (services/) | nest consumers | out-of-container consumers | |---|---|---|---|---| -| `adminService` | apiKeyCrypto, authService, avatarUrl, llmConfig, memories/helpersService, notificationService, passwordPolicy, userCleanupService (+ `permissions.bridge`) | (none) | nest/admin/admin.service.ts, nest/packing/packing.mcp.ts (`deletePackingTemplate`, the `admin-2` residual) | scheduler.ts | -| `airportService` | (none) | (none) | nest/airports/airports.service.ts, nest/booking-import/kitinerary-mapper.ts | db/database.ts, mcp/tools/mapsWeather.ts, mcp/tools/transports.ts | +| `adminService` | apiKeyCrypto, authService, avatarUrl, llmConfig, memories/helpersService, notificationService, passwordPolicy, userCleanupService (+ `permissions.bridge`, `addons.bridge`) | (none) | nest/admin/admin.service.ts, nest/packing/packing.mcp.ts (`deletePackingTemplate`, the `admin-2` residual) | scheduler.ts (lazy) | +| `airportService` | (none) | (none) | nest/airports/airports.service.ts, nest/booking-import/kitinerary-mapper.ts | db/database.ts (lazy boot backfill), mcp/tools/mapsWeather.ts, mcp/tools/transports.ts | | `apiKeyCrypto` | (none) | adminService, airtrail/airtrailService, authService, llmConfig, memories/helpersService, memories/immichService, memories/photoResolverService, memories/synologyService, memories/unifiedService, notifications, oidcService, unsplashService | nest/maps/maps.service.ts, nest/plugins/plugin-oauth.service.ts, nest/plugins/plugin-runtime.service.ts, nest/plugins/plugins.service.ts, nest/settings/settings.service.ts | db/migrations.ts | | `atlasService` | (none) | authService | nest/atlas/atlas.service.ts, nest/plugins/host/plugin-host-deps.factory.ts | mcp/resources.ts, mcp/tools/atlas.ts | -| `authService` | apiKeyCrypto, atlasService, avatarUrl, demo, distanceService, ephemeralTokens, mfaCrypto, passwordPolicy, tripMembership, userCleanupService, webauthnConfig (+ `permissions.bridge`) | adminService, oidcService, passkeyService | nest/assignments/assignments.mcp.ts, nest/auth/auth.service.ts, nest/auth/passkey-enabled.guard.ts, nest/budget/budget.mcp.ts, nest/collab/collab.mcp.ts, nest/days/day-notes.mcp.ts, nest/days/days.mcp.ts, nest/oidc/oidc.service.ts, nest/packing/packing.mcp.ts, nest/reservations/reservations.mcp.ts, nest/share/share.mcp.ts, nest/tags/tags.mcp.ts, nest/todo/todo.mcp.ts, nest/transit/transit.mcp.ts, nest/trips/trips.mcp.ts, nest/vacay/vacay.mcp.ts | mcp/index.ts, mcp/tools/atlas.ts, mcp/tools/collections.ts, mcp/tools/journey.ts, mcp/tools/notifications.ts, mcp/tools/places.ts, mcp/tools/transports.ts | +| `authService` | apiKeyCrypto, atlasService, avatarUrl, demo, distanceService, ephemeralTokens, mfaCrypto, passwordPolicy, tripMembership, userCleanupService, webauthnConfig (+ `permissions.bridge`) | adminService, oidcService, passkeyService | nest/assignments/assignments.mcp.ts, nest/auth/auth.service.ts, nest/auth/passkey-enabled.guard.ts, nest/budget/budget.mcp.ts, nest/collab/collab.mcp.ts, nest/days/day-notes.mcp.ts, nest/days/days.mcp.ts, nest/oidc/oidc.service.ts, nest/packing/packing.mcp.ts, nest/places/places.mcp.ts, nest/reservations/reservations.mcp.ts, nest/share/share.mcp.ts, nest/tags/tags.mcp.ts, nest/todo/todo.mcp.ts, nest/transit/transit.mcp.ts, nest/trips/trips.mcp.ts, nest/vacay/vacay.mcp.ts | mcp/index.ts, mcp/tools/atlas.ts, mcp/tools/collections.ts, mcp/tools/journey.ts, mcp/tools/notifications.ts, mcp/tools/transports.ts | | `avatarUrl` | (none) | adminService, authService, inAppNotifications, journeyService | nest/budget/budget.service.ts, nest/collab/collab.service.ts, nest/files/files.service.ts, nest/packing/packing.service.ts, nest/reservations/reservations.service.ts, nest/trips/trips.service.ts | (none) | -| `backupService` | (none — `permissions.bridge`, plugin backup/paths infra only) | (none) | nest/backup/backup.controller.ts, nest/backup/backup.service.ts | (none) | +| `backupService` | (none — `permissions.bridge`, plugin backup/paths infra only) | (none) | nest/backup/backup.controller.ts, nest/backup/backup.service.ts | scheduler.ts (lazy) | | `collectionsService` | notificationService (lazy), placeImage (+ `permissions.bridge`) | (none) | nest/collections/collections.service.ts, nest/plugins/host/plugin-host-deps.factory.ts | mcp/tools/collections.ts | -| `conflictResult` | (none) | placeService | nest/packing/packing.controller.ts, nest/packing/packing.service.ts, nest/places/places.controller.ts, nest/plugins/host/plugin-host-deps.factory.ts | (none) | +| `conflictResult` | (none) | (none) | nest/packing/packing.controller.ts, nest/packing/packing.service.ts, nest/places/places.controller.ts, nest/places/places.service.ts, nest/plugins/host/plugin-host-deps.factory.ts | (none) | | `cookie` | (none) | (none) | nest/auth/auth-public.controller.ts, nest/auth/auth.service.ts, nest/auth/passkey.controller.ts, nest/oidc/oidc.controller.ts, nest/oidc/oidc.service.ts | (none) | | `demo` | (none) | authService | nest/auth/auth.controller.ts, nest/collections/collections.controller.ts, nest/files/files.controller.ts, nest/places/places.controller.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trips/trips.controller.ts | middleware/auth.ts, middleware/mfaPolicy.ts | | `distanceService` | (none) | authService, transitItineraryService | (none) | (none) | | `ephemeralTokens` | (none) | authService | nest/files/files.service.ts | index.ts, websocket.ts | | `inAppNotificationActions` | (none) | inAppNotifications | (none) | (none) | | `inAppNotifications` | avatarUrl, inAppNotificationActions, notificationPreferencesService | notificationService | nest/notifications/notifications.service.ts | mcp/resources.ts, mcp/tools/notifications.ts | -| `journeyService` | avatarUrl, memories/photoResolverService | journeyShareService | nest/assignments/assignments.service.ts, nest/journey/journey.service.ts, nest/places/places.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/plugins/journal-entry-rows.controller.ts | mcp/resources.ts, mcp/tools/journey.ts, mcp/tools/places.ts | +| `journeyService` | avatarUrl, memories/photoResolverService | journeyShareService | nest/assignments/assignments.service.ts, nest/journey/journey.service.ts, nest/places/places.mcp.ts, nest/places/places.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/plugins/journal-entry-rows.controller.ts | mcp/resources.ts, mcp/tools/journey.ts | | `journeyShareService` | journeyService | (none) | nest/journey/journey.service.ts | mcp/tools/journey.ts | -| `kmlImport` | (none) | placeService | (none) | (none) | +| `kmlImport` | (none) | (none) | nest/places/places.helpers.ts, nest/places/places.service.ts | (none) | | `llmConfig` | apiKeyCrypto | adminService | nest/llm-parse/llm-client.factory.ts, nest/llm-parse/llm-config.resolver.ts | (none) | | `mfaCrypto` | (none) | authService | (none) | (none) | | `notificationPreferencesService` | notifications, notifications/builtins, notifications/channelRegistry | inAppNotifications, notificationService, notifications, notifications/channelRegistry | nest/admin/admin.service.ts, nest/notifications/notifications.service.ts, nest/plugins/install/manifest.ts | (none) | | `notifications` | apiKeyCrypto, notificationPreferencesService (+ `audit-log.logger`) | notificationPreferencesService, notificationService, notifications/builtins | nest/auth/auth.service.ts, nest/notifications/notifications.service.ts | (none) | -| `notificationService` | inAppNotifications, notificationPreferencesService, notifications, notifications/channelRegistry (+ `audit-log.logger`) | adminService, collectionsService, memories/synologyService, memories/unifiedService | nest/admin/admin.controller.ts, nest/collab/collab.service.ts, nest/packing/packing.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/reservations/reservations.service.ts, nest/trips/trips.service.ts, nest/vacay/vacay.service.ts | scheduler.ts | +| `notificationService` | inAppNotifications, notificationPreferencesService, notifications, notifications/channelRegistry (+ `audit-log.logger`) | adminService, collectionsService, memories/synologyService, memories/unifiedService | nest/admin/admin.controller.ts, nest/collab/collab.service.ts, nest/packing/packing.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/reservations/reservations.service.ts, nest/trips/trips.service.ts, nest/vacay/vacay.service.ts | scheduler.ts (lazy) | | `oauthService` | (none — `addons.bridge` + `audit.bridge` only) | (none) | nest/oauth/oauth-api.controller.ts, nest/oauth/oauth.service.ts | mcp/index.ts, mcp/oauthProvider.ts | | `oidcService` | apiKeyCrypto, authService, tripMembership | (none) | nest/oidc/oidc.service.ts | (none) | | `passkeyService` | authService, webauthnConfig | (none) | nest/admin/admin.service.ts, nest/auth/passkey.controller.ts | (none) | | `passwordPolicy` | (none) | adminService, authService | (none) | (none) | -| `placeEnrichment` | (none — `maps.bridge` since the maps fold) | placeService | (none) | (none) | -| `placeImage` | (none) | collectionsService, placeService | nest/collections/collections.controller.ts, nest/common/place-image-upload.ts, nest/places/places.controller.ts | (none) | -| `placePhotoCache` | (none) | placeService | nest/maps/maps.service.ts, nest/share/share.service.ts | scheduler.ts | -| `placeService` | conflictResult, kmlImport, placeEnrichment, placeImage, placePhotoCache, queryHelpers, unsplashService | (none) | nest/booking-import/booking-import.service.ts, nest/days/days.mcp.ts, nest/places/places.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trips/trips.service.ts | mcp/resources.ts, mcp/tools/places.ts | -| `queryHelpers` | (none) | placeService | nest/assignments/assignments.service.ts, nest/days/days.service.ts, nest/share/share.service.ts | (none) | +| `placeImage` | (none) | collectionsService | nest/collections/collections.controller.ts, nest/common/place-image-upload.ts, nest/places/places.controller.ts, nest/places/places.service.ts | (none) | +| `placePhotoCache` | (none) | (none) | nest/maps/maps.service.ts, nest/places/places.helpers.ts, nest/share/share.service.ts | scheduler.ts (lazy) | +| `queryHelpers` | (none) | (none) | nest/assignments/assignments.service.ts, nest/days/days.service.ts, nest/places/places.service.ts, nest/share/share.service.ts | (none) | | `timezoneService` | (none) | airtrail/airtrailMapper, transitItineraryService | nest/trips/trips.service.ts | (none) | | `transitItineraryService` | distanceService, timezoneService (+ the pure `nest/transit/transit.helpers`, type-only `reservations.bridge`) | (none) | nest/transit/transit.mcp.ts | (none) | | `tripAccess` | (none) | (none) | nest/booking-import/booking-import.service.ts, nest/budget/budget.service.ts, nest/collab/collab.service.ts, nest/days/day-notes.service.ts, nest/integrations/airtrail-import.controller.ts, nest/packing/packing.service.ts, nest/reservations/reservations.service.ts, nest/todo/todo.service.ts | (none) | | `tripMembership` | (none) | authService, oidcService | nest/plugins/host/plugin-host-deps.factory.ts, nest/trip-invite/trip-invite.service.ts | (none) | -| `unsplashService` | apiKeyCrypto | placeService | nest/trips/trips.controller.ts, nest/trips/trips.service.ts | (none) | +| `unsplashService` | apiKeyCrypto | (none) | nest/places/places.service.ts, nest/trips/trips.controller.ts, nest/trips/trips.service.ts | (none) | | `userCleanupService` | (none — `budget.bridge`, plugin paths infra only) | adminService, authService | nest/trips/trips.service.ts | (none) | | `weatherService` | (none) | (none) | nest/plugins/host/plugin-host-deps.factory.ts, nest/weather/weather.controller.ts, nest/weather/weather.service.ts | mcp/tools/mapsWeather.ts | | `webauthnConfig` | (none) | authService, passkeyService | (none) | (none) | @@ -244,9 +263,8 @@ already-migrated collab/packing/trips/reservations/vacay services.) | Candidate | Why now / why not | Bridge tax (legacy dependents + out-of-container) | |---|---|---| -| **placeService** ← pick | Frontier-ready since the maps fold (the `placeEnrichment` edge is a `maps.bridge` repoint); its legacy imports are all helpers — the fold can absorb `placeEnrichment` (exchange-rates precedent) and retire `maps.bridge` with it; next in the step-4 chain | `mcp/resources.ts`, `mcp/tools/places.ts` | -| **transitItineraryService** | Frontier-ready since the transit fold (its transit edge is the pure `transit.helpers` import; distance/timezone are helpers, the reservations edge is type-only bridge) — could even jump ahead of placeService; its only consumer is the in-container `transit.mcp.ts`, so the fold is a repoint, no bridge | (none — `nest/transit/transit.mcp.ts` injects/repoints) | -| **collectionsService** | Frontier-ready — legacy deps are the placeImage helper + `permissions.bridge`, plus a call-time lazy `import()` of notificationService that a migrated service keeps (collab precedent) | `mcp/tools/collections.ts` | +| **transitItineraryService** ← pick | Frontier-ready since the transit fold (its transit edge is the pure `transit.helpers` import; distance/timezone are helpers, the reservations edge is type-only bridge) and now the **last unfinished link of step 4**; its only consumer is the in-container `transit.mcp.ts`, so the fold is a repoint | (none — `nest/transit/transit.mcp.ts` injects/repoints) | +| **collectionsService** | Frontier-ready — legacy deps are the placeImage helper + `permissions.bridge`, plus a call-time lazy `import()` of notificationService that a migrated service keeps (collab precedent). Its dedup helpers are collection-scoped ports of the ones that just moved to `places.helpers.ts` — a fold could share them | `mcp/tools/collections.ts` | | **atlasService** | Zero deps, but its legacy dependent is `authService` (Wave-5) → bridge lives long | `authService`; `mcp/tools/atlas.ts`, `mcp/resources.ts` | | **oauthService** | Dependency-free since the Phase 0 addons extraction (adminService edge was only `isAddonEnabled` → `addons.bridge`) — but the coherence order keeps it after admin so the `mcp/oauthProvider.ts` merge (`mcp-2`) lands with it | `mcp/index.ts`, `mcp/oauthProvider.ts` | | **weatherService / wikiService / airportService** | Independent leaves; airport has the `db/database.ts` boot lazy-require special case | little / none | @@ -263,7 +281,9 @@ already-migrated collab/packing/trips/reservations/vacay services.) `mcp/oauthProvider.ts` merge (`mcp-2`) lands after admin (oidc/passkey ride auth; permissions done 2026-07). - `journeyService` ← `memories/` cluster (which itself touches admin + notificationService) → - `journeyShareService` after. + `journeyShareService` after. Note the place fold added two more in-container + journeyService consumers (`places.service.ts`'s hooks and `places.mcp.ts`'s + skeleton reconcile), so this migration's repoint list grew by two. - `collectionsService`/`backupService`: unblocked since the permissions fold (their edges are `permissions.bridge` repoints now; collections' notificationService edge is lazy — see above); backup still stays last by design (owns the closeDb/reinitialize lifecycle and the @@ -282,13 +302,18 @@ Wave-2 `permissions` + `auditLog` pair were the first frontier picks — all don todo/share/collab/vacay bridges died with their last consumers) 3. notifications cluster → `notificationService` 4. `mapsService` (done 2026-07 — the geo core folded into `MapsService`, geo MCP - tools onto the decorator registry, BookingImportService injects it, and the - 3-export `maps.bridge` covers placeEnrichment + the places registrar) + tools onto the decorator registry, BookingImportService injects it, and a + 3-export `maps.bridge` covered placeEnrichment + the places registrar) → `transitService` (done 2026-07 — the SQL-free proxy folded into the dep-free `TransitService`, pure stats/types to `transit.helpers.ts`, the whole 3-tool registrar to `transit.mcp.ts`; no bridge) - → `placeService` → `transitItineraryService` (the last two are now both - frontier-ready, in either order) + → `placeService` (done 2026-07 — the place core folded into `PlacesService`, + the pure half to `places.helpers.ts`, the 10-tool registrar + trip-places + resource to `places.mcp.ts`; TripsService/DaysMcp/BookingImportService/the + plugin host inject it, so **no bridge**; the sibling `placeEnrichment` fold + deleted `maps.bridge` with its last consumer, and the DTO ratchet cleared all + seven `PlacesController` allow-list entries) + → **`transitItineraryService`** (the last link — frontier-ready, bridge tax zero) 5. `atlasService` → `authService` (+ oidc/passkey) → `adminService` → `oauthService` 6. `memories/` cluster → `journeyService` → `journeyShareService`; `collectionsService` (frontier-ready since the permissions fold — can also go any time) @@ -342,3 +367,58 @@ Wave-2 `permissions` + `auditLog` pair were the first frontier picks — all don `transit.helpers` import, and its only remaining consumer is the **in-container** `transit.mcp.ts` — so transitItineraryService joined the frontier with a bridge tax of zero, ahead of the order's placeService step. +- (2026-07-30 regeneration, post place fold) The prediction that placeService "will absorb" + both `maps.bridge` consumers held exactly: `placeEnrichment` folded into `PlacesService` + and the places registrar became `places.mcp.ts` injecting `MapsService`, so + **`maps.bridge.ts` was deleted** and the place domain needed no bridge of its own — the + fourth consecutive fold to end with zero new bridge files. Two things the earlier analysis + did not predict: (a) `search_place` travelled with the *places* registrar rather than + joining `maps.mcp.ts`, because its gate is `places:read` and not the read-only `geo` + group — the same gate-follows-the-tool rule that sent the share-link tools to + `share.mcp.ts` during the trip fold; and (b) the fold **created** a module cycle risk + the recipe had not hit before — DaysModule must import PlacesModule for + `days.mcp.ts`'s place creation, while AssignmentsModule already imports DaysModule, so + `places.mcp.ts` keeps its two `assignments.bridge` imports rather than injecting + AssignmentsService. That leaves `assignments.bridge.ts` with **only in-container + consumers** (places.mcp.ts + reservations.mcp.ts) — a bridge kept purely as a cycle + break, which is a new category the classification above should track. The four helpers + now reached only from `nest/places/*` (kmlImport, placeImage, placePhotoCache, + unsplashService) are the residue this fold could have absorbed and did not; none of them + blocks anything, so they stay as plain modules for now. + +## Quirks fixed after the place fold (the trailing `fix(server)` commit) + +The relocation itself was byte-identical; these four defects were then fixed on top, each +with a failing-first regression test, so the parity diff and the behaviour change stayed in +separate commits: + +1. **Coordinates of exactly 0 were dropped.** `createPlace` ran every optional field through + `x || null`, so a place on the equator or the prime meridian lost its `lat`/`lng`. Now + `?? null` for lat/lng/price and `?? 60` for duration_minutes. +2. **`duration_minutes: 0` was unsettable.** On create the falsy coercion replaced it with + the 60-minute default; on update `duration_minutes || null` fed `COALESCE(?, …)`, which + read the 0 as "absent" and silently kept the old value. +3. **The journey delete hook fired on unscoped ids.** `onPlaceDeleted` keys on the place id + alone, so `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 now scope first + (via the new `PlacesService.scopedIds`, mirroring the guard `PluginHostDepsFactory` + already had), and `bulk_delete_places` also moved 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 instead of removing them. +4. **The place search treated `%` and `_` as wildcards.** A bare `%` in the search box + returned the whole trip. The three LIKE clauses now carry `ESCAPE '\'` with the term + escaped by `escapeLikePattern`. + +Plus one hardening item on the same commit: the Google list-import response is now 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** (parity, not oversights): the non-COALESCE `route_color` +(an explicit null is how the picker resets a track to its category colour, #776); +`currency`/`transport_mode` still unclearable through `x || null` + COALESCE, since no client +action expresses "clear this string" and dropping COALESCE would change the absent-means- +unchanged contract; `importGpx` returning `null` (→ a 400) when a file parses but yields no +usable geometry; `update` skipping the If-Match check entirely when the stored `updated_at` +is null (old rows keep last-write-wins); and every remaining string-valued `x || null`, where +empty-string-means-absent is the intended reading. diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index 58125aaf7..f78b53fd3 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -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). diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index de14bad6f..4e52abbd8 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -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); diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts deleted file mode 100644 index 00cde2639..000000000 --- a/server/src/mcp/tools/places.ts +++ /dev/null @@ -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 }); - } - ); -} diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 385097bd2..304627d18 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -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 diff --git a/server/src/nest/booking-import/booking-import.module.ts b/server/src/nest/booking-import/booking-import.module.ts index f51644ff0..8dd697a03 100644 --- a/server/src/nest/booking-import/booking-import.module.ts +++ b/server/src/nest/booking-import/booking-import.module.ts @@ -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], }) diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index cbeba7a63..d6abee72d 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -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, diff --git a/server/src/nest/common/body-contract-allow-list.ts b/server/src/nest/common/body-contract-allow-list.ts index 7d04744d9..b870cc80e 100644 --- a/server/src/nest/common/body-contract-allow-list.ts +++ b/server/src/nest/common/body-contract-allow-list.ts @@ -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', diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts index e57e8bc58..85132ac55 100644 --- a/server/src/nest/days/days.mcp.ts +++ b/server/src/nest/days/days.mcp.ts @@ -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 }; }); diff --git a/server/src/nest/days/days.module.ts b/server/src/nest/days/days.module.ts index 5fc4b4923..b32eef0f8 100644 --- a/server/src/nest/days/days.module.ts +++ b/server/src/nest/days/days.module.ts @@ -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], diff --git a/server/src/nest/maps/maps.bridge.ts b/server/src/nest/maps/maps.bridge.ts deleted file mode 100644 index 15233b367..000000000 --- a/server/src/nest/maps/maps.bridge.ts +++ /dev/null @@ -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); -} diff --git a/server/src/nest/maps/maps.module.ts b/server/src/nest/maps/maps.module.ts index 63009f7e1..9daaff76a 100644 --- a/server/src/nest/maps/maps.module.ts +++ b/server/src/nest/maps/maps.module.ts @@ -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], diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 353137f7c..ba1d172d6 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -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 diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 19ec2bb44..e1b3cb0ff 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -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 = { 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 & { 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, + @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, + @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, + @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); } diff --git a/server/src/nest/places/places.dto.ts b/server/src/nest/places/places.dto.ts index eb18a47c9..d6b35fce4 100644 --- a/server/src/nest/places/places.dto.ts +++ b/server/src/nest/places/places.dto.ts @@ -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) {} diff --git a/server/src/nest/places/places.helpers.ts b/server/src/nest/places/places.helpers.ts new file mode 100644 index 000000000..3f700cee9 --- /dev/null +++ b/server/src/nest/places/places.helpers.ts @@ -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 as raw text so mixed-content HTML (e.g.
, ) + // 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(); + 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; + 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[], + target: { lat: number; lng: number }, + maxMeters: number = MATCH_RADIUS_METERS, +): Record | null { + let best: { c: Record; 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(items: T[], limit: number, fn: (item: T) => Promise): Promise { + 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 { + 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(); +} diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts new file mode 100644 index 000000000..9865f9de5 --- /dev/null +++ b/server/src/nest/places/places.mcp.ts @@ -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), + }], + }; + } +} diff --git a/server/src/nest/places/places.module.ts b/server/src/nest/places/places.module.ts index 143ab16cd..7e4c983b6 100644 --- a/server/src/nest/places/places.module.ts +++ b/server/src/nest/places/places.module.ts @@ -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 {} diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index a87cad58e..75946187f 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -1,22 +1,100 @@ import { Injectable } from '@nestjs/common'; +import { XMLValidator } from 'fast-xml-parser'; import { TRACK_COLORS } from '@trek/shared'; import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService } from '../database/database.service'; +import type { PlaceWithTags } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; -import type { User } from '../../types'; -import * as svc from '../../services/placeService'; +import { MapsService } from '../maps/maps.service'; +import type { Place, User } from '../../types'; +import { loadTagsByPlaceIds, loadRatingsByPlaceIds, ratingAggregate } from '../../services/queryHelpers'; +import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard'; +import { + buildCategoryNameLookup, + createKmlImportSummary, + decodeUtf8WithWarning, + extractKmlPlacemarkNodes, + parsePlacemarkNode, + resolveCategoryIdForFolder, +} from '../../services/kmlImport'; +import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashService'; +import { type UpdateConflict, isUpdateConflict } from '../../services/conflictResult'; +import { reclaimPlaceImage } from '../../services/placeImage'; import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../../services/journeyService'; +import { + COORD_DEDUP_TOLERANCE, + ENRICH_CONCURRENCY, + escapeLikePattern, + MAX_LIST_RESPONSE_BYTES, + googleMapsFeatureIdFromItem, + gpxParser, + isPlaceDuplicate, + kmlParser, + mapWithConcurrency, + pickEnrichmentMatch, + reclaimPhotoCache, + SEARCH_BIAS_RADIUS_METERS, + trackInsertedInDedupSet, + trimOrNull, + unpackKmzToKml, + type DedupSet, + type EnrichablePlace, + type GpxImportOptions, + type GpxImportResult, + type KmlImportOptions, + type ListImportError, + type ListImportOptions, + type ListImportResult, + type PlaceImportResult, + type PlaceWithCategory, +} from './places.helpers'; type Trip = { user_id: number }; type ImportedPlace = { id: number; route_geometry?: string | null; route_color?: string | null }; +/** Fields accepted when creating a place. */ +export interface PlaceCreateInput { + 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; image_url?: string; + google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string; + transport_mode?: string; route_geometry?: string; route_color?: string; tags?: number[]; +} + +/** Fields accepted when patching a place. */ +export interface PlaceUpdateInput { + 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; image_url?: string; + google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string; + transport_mode?: string; route_color?: string | null; tags?: number[]; +} + /** - * Thin Nest wrapper around the existing place service. Trip access mirrors the - * requireTripAccess middleware (canAccessTrip); mutations use 'place_edit'. The - * SQL, the GPX/map/list importers and the journey hooks reuse the legacy code - * unchanged. + * Places domain service — owns the place SQL, the GPX/KML/KMZ importers, the + * Google/Naver list importers, the list-import enrichment, the Unsplash image + * search and the collaborative ratings, all moved 1:1 from the legacy + * services/placeService.ts (+ services/placeEnrichment.ts) when the domain went + * DI-native. The COALESCE update semantics — including the deliberately + * non-COALESCE route_color (#776) — the post-write getPlaceWithTags re-selects + * and the If-Match conflict protocol (#1135) are preserved verbatim. + * + * The one deliberate departure from the legacy behaviour is the falsy-coercion + * defaults: lat/lng/price/duration_minutes use `?? fallback`, not `|| fallback`, + * because 0 is a legitimate value for all four and `||` silently threw it away + * (a place on the equator lost its coordinates). Every other `x || null` is + * string-valued, where empty-string-means-absent is the intended reading. + * + * Trip access mirrors the requireTripAccess middleware (canAccessTrip); + * mutations use 'place_edit'. Pure helpers and the frozen XML parsers live in + * places.helpers.ts. Nothing outside the Nest container consumes this domain + * any more, so there is no places.bridge.ts: the MCP surface is the + * DI-discovered places.mcp.ts, and TripsService / DaysMcp / + * BookingImportService / PluginHostDepsFactory all inject this class. */ @Injectable() export class PlacesService { @@ -24,6 +102,7 @@ export class PlacesService { private readonly dbs: DatabaseService, private readonly permissions: PermissionsService, private readonly realtime: RealtimeService, + private readonly maps: MapsService, ) {} verifyTripAccess(tripId: string, userId: number) { @@ -38,44 +117,568 @@ export class PlacesService { this.realtime.broadcast(tripId, event, payload, socketId); } - list(tripId: string, filters: { search?: string; category?: string; tag?: string }) { - return svc.listPlaces(tripId, filters); + // ------------------------------------------------------------------------- + // List places + // ------------------------------------------------------------------------- + + list( + tripId: string, + filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' }, + ) { + let query = ` + SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon + FROM places p + LEFT JOIN categories c ON p.category_id = c.id + WHERE p.trip_id = ? + `; + const params: (string | number)[] = [tripId]; + + if (filters.search) { + // ESCAPE so a `%` or `_` the user typed matches literally instead of + // acting as a LIKE wildcard (a bare '%' used to return the whole trip). + query += " AND (p.name LIKE ? ESCAPE '\\' OR p.address LIKE ? ESCAPE '\\' OR p.description LIKE ? ESCAPE '\\')"; + const searchParam = `%${escapeLikePattern(filters.search)}%`; + params.push(searchParam, searchParam, searchParam); + } + + if (filters.category) { + query += ' AND p.category_id = ?'; + params.push(filters.category); + } + + if (filters.tag) { + query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)'; + params.push(filters.tag); + } + + if (filters.assignment === 'unassigned') { + query += ` AND p.id NOT IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`; + params.push(tripId); + } else if (filters.assignment === 'assigned') { + query += ` AND p.id IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`; + params.push(tripId); + } + + query += ' ORDER BY p.created_at DESC'; + + const places = this.dbs.prepare(query).all(...params) as PlaceWithCategory[]; + + const placeIds = places.map(p => p.id); + const tagsByPlaceId = loadTagsByPlaceIds(placeIds); + const ratingsByPlaceId = loadRatingsByPlaceIds(placeIds); + + return places.map(p => ({ + ...p, + category: p.category_id ? { + id: p.category_id, + name: p.category_name, + color: p.category_color, + icon: p.category_icon, + } : null, + tags: tagsByPlaceId[p.id] || [], + ratings: ratingsByPlaceId[p.id] || [], + ...ratingAggregate(ratingsByPlaceId[p.id]), + })); } - get(tripId: string, id: string) { - return svc.getPlace(tripId, id); + // ------------------------------------------------------------------------- + // Create place + // ------------------------------------------------------------------------- + + create(tripId: string, body: PlaceCreateInput) { + const { + name, description, lat, lng, address, category_id, price, currency, + place_time, end_time, + duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, + transport_mode, route_geometry, route_color, tags = [], + } = body; + + const result = this.dbs.run(` + INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, + place_time, end_time, + duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode, + route_geometry, route_color) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + // lat/lng/price/duration_minutes use an explicit undefined check, not `||`: + // 0 is a legitimate value for all four (Null Island, a free entry, a + // drive-by stop) and the falsy coercion silently threw it away. + tripId, name, description || null, lat ?? null, lng ?? null, address || null, + category_id || null, price ?? null, currency || null, + place_time || null, end_time || null, duration_minutes ?? 60, notes || null, image_url || null, + google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking', + route_geometry || null, route_color || null, + ); + + const placeId = result.lastInsertRowid; + + if (tags && tags.length > 0) { + const insertTag = this.dbs.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); + for (const tagId of tags) { + insertTag.run(placeId, tagId); + } + } + + return this.dbs.getPlaceWithTags(Number(placeId))!; } - create(tripId: string, data: Parameters[1]) { - return svc.createPlace(tripId, data); + // ------------------------------------------------------------------------- + // Get single place + // ------------------------------------------------------------------------- + + get(tripId: string, placeId: string) { + const placeCheck = this.dbs.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); + if (!placeCheck) return null; + return this.dbs.getPlaceWithTags(placeId); } - update(tripId: string, id: string, data: Parameters[2], ifMatch?: string) { - return svc.updatePlace(tripId, id, data, ifMatch); + // ------------------------------------------------------------------------- + // Update place + // ------------------------------------------------------------------------- + + update( + tripId: string, + placeId: string, + body: PlaceUpdateInput, + ifMatch?: string, + ): PlaceWithTags | UpdateConflict | null { + const existingPlace = this.dbs.get('SELECT * FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); + if (!existingPlace) return null; + + // Optimistic concurrency (#1135): when the caller sent the version it based its + // edit on and the row has moved on since, reject instead of clobbering. Absent + // token => unconditional update (back-compat — old clients keep last-write-wins). + if (ifMatch !== undefined && existingPlace.updated_at != null && String(existingPlace.updated_at) !== ifMatch) { + return { conflict: true, server: this.dbs.getPlaceWithTags(placeId) }; + } + + const { + name, description, lat, lng, address, category_id, price, currency, + place_time, end_time, + duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, + transport_mode, route_color, tags, + } = body; + + this.dbs.run(` + UPDATE places SET + name = COALESCE(?, name), + description = ?, + lat = ?, + lng = ?, + address = ?, + category_id = ?, + price = ?, + currency = COALESCE(?, currency), + place_time = ?, + end_time = ?, + duration_minutes = COALESCE(?, duration_minutes), + notes = ?, + image_url = ?, + google_place_id = ?, + google_ftid = ?, + osm_id = ?, + website = ?, + phone = ?, + transport_mode = COALESCE(?, transport_mode), + route_color = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + name || null, + description !== undefined ? description : existingPlace.description, + lat !== undefined ? lat : existingPlace.lat, + lng !== undefined ? lng : existingPlace.lng, + address !== undefined ? address : existingPlace.address, + category_id !== undefined ? category_id : existingPlace.category_id, + price !== undefined ? price : existingPlace.price, + currency || null, + place_time !== undefined ? place_time : existingPlace.place_time, + end_time !== undefined ? end_time : existingPlace.end_time, + // `?? null` rather than `|| null`: with COALESCE(?, duration_minutes) a + // falsy-coerced 0 read as "absent" and silently kept the old duration. + duration_minutes ?? null, + notes !== undefined ? notes : existingPlace.notes, + image_url !== undefined ? image_url : existingPlace.image_url, + google_place_id !== undefined ? google_place_id : existingPlace.google_place_id, + google_ftid !== undefined ? google_ftid : existingPlace.google_ftid, + osm_id !== undefined ? osm_id : existingPlace.osm_id, + website !== undefined ? website : existingPlace.website, + phone !== undefined ? phone : existingPlace.phone, + transport_mode || null, + // Deliberately not COALESCE: an explicit null is how the picker resets a + // track back to its category colour (#776). + route_color !== undefined ? route_color : existingPlace.route_color, + placeId, + ); + + if (tags !== undefined) { + this.dbs.run('DELETE FROM place_tags WHERE place_id = ?', placeId); + if (tags.length > 0) { + const insertTag = this.dbs.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); + for (const tagId of tags) { + insertTag.run(placeId, tagId); + } + } + } + + // A custom uploaded thumbnail (#1136) that was just replaced or cleared leaves + // an orphan file behind — reclaim it once nothing references it any more. + if (image_url !== undefined && image_url !== existingPlace.image_url) { + reclaimPlaceImage(existingPlace.image_url); + } + + return this.dbs.getPlaceWithTags(placeId); } - remove(tripId: string, id: string): boolean { - return svc.deletePlace(tripId, id); + // ------------------------------------------------------------------------- + // Delete place + // ------------------------------------------------------------------------- + + remove(tripId: string, placeId: string): boolean { + const place = this.dbs.get<{ google_place_id: string | null; image_url: string | null }>( + 'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?', placeId, tripId, + ); + if (!place) return false; + this.dbs.run('DELETE FROM places WHERE id = ?', placeId); + reclaimPhotoCache(place.google_place_id, place.image_url); + reclaimPlaceImage(place.image_url); + return true; } removeMany(tripId: string, ids: number[]): number[] { - return svc.deletePlacesMany(tripId, ids); + if (ids.length === 0) return []; + const selectStmt = this.dbs.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?'); + const deleteStmt = this.dbs.prepare('DELETE FROM places WHERE id = ?'); + const deleted: number[] = []; + const reclaimable: { google_place_id: string | null; image_url: string | null }[] = []; + this.dbs.transaction(() => { + for (const id of ids) { + const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined; + if (!row) continue; + deleteStmt.run(id); + deleted.push(id); + reclaimable.push(row); + } + }); + // Reclaim after the transaction commits so isReferenced() sees the final place set. + for (const row of reclaimable) { + reclaimPhotoCache(row.google_place_id, row.image_url); + reclaimPlaceImage(row.image_url); + } + return deleted; } - updateMany(tripId: string, ids: number[], data: Parameters[2]) { - return svc.updatePlacesMany(tripId, ids, data); + /** + * Narrow a caller-supplied id list to the ones that really belong to the trip, + * in input order. Callers need this before firing journeyService's place hooks: + * those key on the place id alone, so an id from another trip would detach + * that trip's journey entries even though the delete itself refuses it. + */ + scopedIds(tripId: string, ids: number[]): number[] { + if (ids.length === 0) return []; + const placeholders = ids.map(() => '?').join(','); + const rows = this.dbs.all<{ id: number }>( + `SELECT id FROM places WHERE trip_id = ? AND id IN (${placeholders})`, tripId, ...ids, + ); + const owned = new Set(rows.map((r) => r.id)); + return ids.filter((id) => owned.has(id)); } - importGpx( + // ------------------------------------------------------------------------- + // Bulk update + // ------------------------------------------------------------------------- + + /** + * Apply the same set of fields to many places in a single transaction. Each + * place is scoped to the trip and patched via update(), so only the provided + * fields change and everything else is preserved. IDs that don't belong to the + * trip are skipped. Returns the updated places. + */ + updateMany(tripId: string, ids: number[], body: PlaceUpdateInput): PlaceWithTags[] { + if (ids.length === 0) return []; + const updated: PlaceWithTags[] = []; + this.dbs.transaction(() => { + for (const id of ids) { + // Bulk update sends no If-Match, so update() never returns a conflict + // here; the guard keeps the types honest. + const place = this.update(tripId, String(id), body); + if (place && !isUpdateConflict(place)) updated.push(place); + } + }); + return updated; + } + + // ------------------------------------------------------------------------- + // Import deduplication + // ------------------------------------------------------------------------- + + /** Build a lookup of names/coords for places already in a trip. */ + private buildDedupSet(tripId: string): DedupSet { + const rows = this.dbs.all<{ name: string | null; lat: number | null; lng: number | null }>( + 'SELECT name, lat, lng FROM places WHERE trip_id = ?', tripId, + ); + const names = new Set(); + const coords: Array<{ lat: number; lng: number }> = []; + for (const row of rows) { + if (row.name) { + names.add(row.name.trim().toLowerCase()); + } else if (row.lat != null && row.lng != null) { + coords.push({ lat: row.lat, lng: row.lng }); + } + } + return { names, coords }; + } + + private findDuplicatePlace( tripId: string, - buffer: Buffer, - opts: { importWaypoints: boolean; importRoutes: boolean; importTracks: boolean; defaultName?: string }, - ) { - return this.colorizeImportedTracks(tripId, svc.importGpx(tripId, buffer, opts)); + place: { name: string | null | undefined; lat: number | null; lng: number | null }, + ): { id: number; google_ftid: string | null } | null { + const normalizedName = place.name?.trim().toLowerCase(); + if (normalizedName) { + const duplicate = this.dbs.get<{ id: number; google_ftid: string | null }>(` + SELECT id, google_ftid FROM places + WHERE trip_id = ? AND lower(trim(name)) = ? + ORDER BY id ASC + LIMIT 1 + `, tripId, normalizedName); + if (duplicate) return duplicate; + } + if (place.lat != null && place.lng != null) { + return this.dbs.get<{ id: number; google_ftid: string | null }>(` + SELECT id, google_ftid FROM places + WHERE trip_id = ? + AND lat IS NOT NULL AND lng IS NOT NULL + AND abs(lat - ?) <= ? + AND abs(lng - ?) <= ? + ORDER BY id ASC + LIMIT 1 + `, tripId, place.lat, COORD_DEDUP_TOLERANCE, place.lng, COORD_DEDUP_TOLERANCE) || null; + } + return null; } - async importMapFile(tripId: string, buffer: Buffer, filename: string, opts: svc.KmlImportOptions) { - return this.colorizeImportedTracks(tripId, await svc.importMapFile(tripId, buffer, filename, opts)); + // ------------------------------------------------------------------------- + // Import GPX + // ------------------------------------------------------------------------- + + importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): GpxImportResult | null { + return this.colorizeImportedTracks(tripId, this.importGpxRows(tripId, fileBuffer, opts)); + } + + private importGpxRows(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): GpxImportResult | null { + const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts; + + const parsed = gpxParser.parse(fileBuffer.toString('utf-8')); + const gpx = parsed?.gpx; + if (!gpx) return null; + + const str = (v: unknown) => (v != null ? String(v).trim() : null); + const num = (v: unknown) => { const n = parseFloat(String(v)); return isNaN(n) ? null : n; }; + + // Routes and tracks rarely carry their own . Without one they all fall back to the + // same generic label, so name-based dedup drops every import after the first. Derive a + // base from the source filename (the requested behaviour) and suffix an index so multiple + // geometries from one file stay distinct. + const rawName = str(defaultName); + const baseName = rawName ? rawName.replace(/\.[^.]+$/, '').trim() || rawName : null; + let geoSeq = 0; + const geoName = (explicit: string | null, fallback: string): string => { + if (explicit) return explicit; + geoSeq++; + const base = baseName || fallback; + return geoSeq === 1 ? base : `${base} ${geoSeq}`; + }; + + type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string }; + const waypoints: WaypointEntry[] = []; + + // 1) Parse elements (named waypoints / POIs) + if (importWaypoints) { + for (const wpt of gpx.wpt ?? []) { + const lat = num(wpt['@_lat']); + const lng = num(wpt['@_lon']); + if (lat === null || lng === null) continue; + waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) }); + } + } + + // 2) Parse routes as polyline-places (one place per route with route_geometry) + if (importRoutes) { + for (const rte of gpx.rte ?? []) { + const pts = (rte.rtept ?? []) + .map((pt: Record) => ({ lat: num(pt['@_lat']), lng: num(pt['@_lon']), ele: num(pt['ele']) })) + .filter((p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null) as Array<{ lat: number; lng: number; ele: number | null }>; + if (pts.length === 0) continue; + const hasAllEle = pts.every(p => p.ele !== null); + const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); + waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: geoName(str(rte.name), 'GPX Route'), description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) }); + } + } + + // 3) Extract full track geometry from + if (importTracks) { + for (const trk of gpx.trk ?? []) { + const trackPoints: { lat: number; lng: number; ele: number | null }[] = []; + for (const seg of trk.trkseg ?? []) { + for (const pt of seg.trkpt ?? []) { + const lat = num(pt['@_lat']); + const lng = num(pt['@_lon']); + if (lat === null || lng === null) continue; + trackPoints.push({ lat, lng, ele: num(pt.ele) }); + } + } + if (trackPoints.length === 0) continue; + const start = trackPoints[0]; + const hasAllEle = trackPoints.every(p => p.ele !== null); + const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); + waypoints.push({ lat: start.lat, lng: start.lng, name: geoName(str(trk.name), 'GPX Track'), description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) }); + } + } + + if (waypoints.length === 0) return null; + + const dedup = this.buildDedupSet(tripId); + const insertStmt = this.dbs.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry) + VALUES (?, ?, ?, ?, ?, 'walking', ?) + `); + const created: PlaceWithTags[] = []; + let skipped = 0; + this.dbs.transaction(() => { + for (const wp of waypoints) { + if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) { + skipped++; + continue; + } + const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); + const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; + created.push(place); + trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup); + } + }); + + return { places: created, count: created.length, skipped }; + } + + // ------------------------------------------------------------------------- + // Import KML / KMZ + // ------------------------------------------------------------------------- + + async importMapFile(tripId: string, fileBuffer: Buffer, filename: string, opts: KmlImportOptions = {}): Promise { + return this.colorizeImportedTracks(tripId, await this.importMapFileRows(tripId, fileBuffer, filename, opts)); + } + + private async importMapFileRows(tripId: string, fileBuffer: Buffer, filename: string, opts: KmlImportOptions = {}): Promise { + const ext = filename.toLowerCase().split('.').pop(); + if (ext === 'kmz') return this.importKmzPlaces(tripId, fileBuffer, opts); + if (ext === 'kml') return this.importKmlPlaces(tripId, fileBuffer, opts); + throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`); + } + + async importKmzPlaces(tripId: string, kmzBuffer: Buffer, opts: KmlImportOptions = {}): Promise { + const kmlBuffer = await unpackKmzToKml(kmzBuffer); + return this.importKmlPlaces(tripId, kmlBuffer, opts); + } + + importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImportOptions = {}): PlaceImportResult { + const { importPoints = true, importPaths = true } = opts; + const decoded = decodeUtf8WithWarning(fileBuffer); + + const validationResult = XMLValidator.validate(decoded.text); + if (validationResult !== true) { + throw new Error('Malformed KML: invalid XML structure'); + } + + const parsed = kmlParser.parse(decoded.text); + const kmlRoot = parsed?.kml ?? parsed; + + if (!kmlRoot || typeof kmlRoot !== 'object') { + throw new Error('Malformed KML: could not parse XML'); + } + + const placemarkNodes = extractKmlPlacemarkNodes(kmlRoot); + const summary = createKmlImportSummary(placemarkNodes.length); + + if (decoded.warning) { + summary.warnings.push(decoded.warning); + } + + const categories = this.dbs.all<{ id: number; name: string }>('SELECT id, name FROM categories'); + const categoryLookup = buildCategoryNameLookup(categories); + const dedup = this.buildDedupSet(tripId); + const created: PlaceWithTags[] = []; + let dupCount = 0; + + const insertStmt = this.dbs.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode, route_geometry) + VALUES (?, ?, ?, ?, ?, ?, 'walking', ?) + `); + + this.dbs.transaction(() => { + let fallbackIndex = 1; + for (const node of placemarkNodes) { + const parsedPlacemark = parsePlacemarkNode(node); + const isPath = parsedPlacemark.routeGeometry !== null; + + // Unsupported geometry type (polygon, multi-geometry, no geometry, etc.) + if (parsedPlacemark.lat === null || parsedPlacemark.lng === null) { + summary.skippedCount += 1; + summary.errors.push(`Skipped Placemark ${fallbackIndex}: unsupported geometry type.`); + fallbackIndex += 1; + continue; + } + + // Type filtering: respect importPoints / importPaths opts + if (isPath && !importPaths) { + summary.skippedCount += 1; + fallbackIndex += 1; + continue; + } + if (!isPath && !importPoints) { + summary.skippedCount += 1; + fallbackIndex += 1; + continue; + } + + const fallbackName = `Placemark ${fallbackIndex}`; + const name = parsedPlacemark.name || fallbackName; + + if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) { + summary.skippedCount += 1; + dupCount++; + fallbackIndex += 1; + continue; + } + + const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup); + + const result = insertStmt.run( + tripId, + name, + parsedPlacemark.description, + parsedPlacemark.lat, + parsedPlacemark.lng, + categoryId, + parsedPlacemark.routeGeometry, + ); + + const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; + created.push(place); + trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup); + summary.createdCount += 1; + fallbackIndex += 1; + } + }); + + if (dupCount > 0) { + summary.warnings.push(`${dupCount} place${dupCount > 1 ? 's' : ''} skipped (already in trip).`); + } + + if (summary.totalPlacemarks === 0) { + summary.errors.push('No Placemarks found in KML file.'); + } + + return { places: created, count: created.length, summary }; } /** @@ -120,20 +723,405 @@ export class PlacesService { return result; } - importGoogleList(tripId: string, url: string, opts?: Parameters[2]) { - return svc.importGoogleList(tripId, url, opts); + // ------------------------------------------------------------------------- + // Import Google Maps list + // ------------------------------------------------------------------------- + + async importGoogleList(tripId: string, url: string, opts?: ListImportOptions): Promise { + let listId: string | null = null; + let resolvedUrl = url; + + // SSRF guard: validate user-supplied URL before fetching + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 }; + + // Follow redirects for short URLs (maps.app.goo.gl, goo.gl). Redirects are + // followed manually so every hop is re-checked against the SSRF guard — a + // short link that 302s to an internal IP is blocked even though the initial + // host is public. + if (url.includes('goo.gl') || url.includes('maps.app')) { + try { + const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) }); + resolvedUrl = redirectRes.url; + } catch (err) { + if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 }; + throw err; + } + } + + // Pattern: /placelists/list/{ID} + const plMatch = resolvedUrl.match(/placelists\/list\/([A-Za-z0-9_-]+)/); + if (plMatch) listId = plMatch[1]; + + // Pattern: !2s{ID} in data URL params + if (!listId) { + const dataMatch = resolvedUrl.match(/!2s([A-Za-z0-9_-]{15,})/); + if (dataMatch) listId = dataMatch[1]; + } + + if (!listId) { + // A single-place share link (…/maps/place/…) carries no list id — point the user at + // the place search box instead of a cryptic "could not extract list ID" (#1304). + if (resolvedUrl.includes('/maps/place/')) { + return { error: 'That link points to a single place, not a list. To add it, paste the link into the place search box instead of using the list import.', status: 400 }; + } + return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 }; + } + + // Fetch list data from Google Maps internal API + const apiUrl = `https://www.google.com/maps/preview/entitylist/getlist?authuser=0&hl=en&gl=us&pb=!1m1!1s${encodeURIComponent(listId)}!2e2!3e2!4i500!16b1`; + const apiRes = await fetch(apiUrl, { + headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, + signal: AbortSignal.timeout(15000), + }); + + if (!apiRes.ok) { + return { error: 'Failed to fetch list from Google Maps', status: 502 }; + } + + // Cap the declared body before reading it (transit.service precedent): the + // response is attacker-influenced via the list id, and buffering it whole + // used to be unbounded. + const declared = Number(apiRes.headers?.get('content-length') ?? 0); + if (declared > MAX_LIST_RESPONSE_BYTES) { + return { error: 'Failed to fetch list from Google Maps', status: 502 }; + } + + const rawText = await apiRes.text(); + if (rawText.length > MAX_LIST_RESPONSE_BYTES) { + return { error: 'Failed to fetch list from Google Maps', status: 502 }; + } + const jsonStr = rawText.substring(rawText.indexOf('\n') + 1); + // The provider hands back a JS-prefixed array; a malformed body is a + // provider problem, not a crash — surface the same 400 an unreadable + // payload already produced instead of throwing a SyntaxError. + let listData: unknown; + try { + listData = JSON.parse(jsonStr); + } catch { + return { error: 'Invalid list data received from Google Maps', status: 400 }; + } + if (!Array.isArray(listData)) { + return { error: 'Invalid list data received from Google Maps', status: 400 }; + } + + const meta = listData[0]; + if (!meta) { + return { error: 'Invalid list data received from Google Maps', status: 400 }; + } + + const listName = meta[4] || 'Google Maps List'; + const items = meta[8]; + + if (!Array.isArray(items) || items.length === 0) { + return { error: 'List is empty or could not be read', status: 400 }; + } + + // Parse place data from items + const places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[] = []; + for (const item of items) { + const coords = item?.[1]?.[5]; + const lat = coords?.[2]; + const lng = coords?.[3]; + const name = item?.[2]; + const note = item?.[3] || null; + + if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) { + places.push({ name, lat, lng, notes: note || null, googleFtid: googleMapsFeatureIdFromItem(item) }); + } + } + + if (places.length === 0) { + return { error: 'No places with coordinates found in list', status: 400 }; + } + + const dedup = this.buildDedupSet(tripId); + const insertStmt = this.dbs.prepare(` + INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode) + VALUES (?, ?, ?, ?, ?, ?, 'walking') + `); + const updateGoogleFtidStmt = this.dbs.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'); + const created: PlaceWithTags[] = []; + let skipped = 0; + this.dbs.transaction(() => { + for (const p of places) { + if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { + const duplicate = this.findDuplicatePlace(tripId, p); + if (duplicate && !duplicate.google_ftid && p.googleFtid) { + updateGoogleFtidStmt.run(p.googleFtid, duplicate.id); + } + skipped++; + continue; + } + const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid); + const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; + created.push(place); + trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); + } + }); + + if (opts?.enrich && opts.userId && created.length) { + void this.enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang); + } + + return { places: created, listName, skipped }; } - importNaverList(tripId: string, url: string, opts?: Parameters[2]) { - return svc.importNaverList(tripId, url, opts); + // ------------------------------------------------------------------------- + // Import Naver Maps list + // ------------------------------------------------------------------------- + + async importNaverList(tripId: string, url: string, opts?: ListImportOptions): Promise { + let resolvedUrl = url; + const limit = 20; + + // SSRF guard: validate user-supplied URL before fetching + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 }; + + // Resolve naver.me short links to the canonical map.naver.com folder URL. + // Redirects are followed manually so each hop is re-validated against the + // SSRF guard (a short link could otherwise 302 to an internal address). + let parsedUrl: URL; + try { parsedUrl = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; } + if (parsedUrl.hostname === 'naver.me') { + try { + const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) }); + resolvedUrl = redirectRes.url; + } catch (err) { + if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 }; + throw err; + } + } + + const folderMatch = resolvedUrl.match(/favorite\/myPlace\/folder\/([A-Za-z0-9_-]+)/i); + const folderId = folderMatch?.[1] || null; + if (!folderId) { + return { error: 'Could not extract folder ID from URL. Please use a shared Naver Maps list link.', status: 400 }; + } + + const fetchPage = async (start: number) => { + const apiUrl = `https://pages.map.naver.com/save-pages/api/maps-bookmark/v3/shares/${encodeURIComponent(folderId)}/bookmarks?placeInfo=true&start=${start}&limit=${limit}&sort=lastUseTime&mcids=ALL&createIdNo=true`; + const apiRes = await fetch(apiUrl, { + headers: { + Accept: 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + signal: AbortSignal.timeout(15000), + }); + + if (!apiRes.ok) { + return { error: 'Failed to fetch list from Naver Maps', status: 502 } as const; + } + + try { + const data = await apiRes.json() as { + folder?: { bookmarkCount?: number; name?: string }; + bookmarkList?: Record[]; + }; + return { data } as const; + } catch { + return { error: 'Invalid list data received from Naver Maps', status: 400 } as const; + } + }; + + const firstPage = await fetchPage(0); + if ('error' in firstPage) { + return { error: firstPage.error, status: firstPage.status }; + } + + const listName = firstPage.data.folder?.name || 'Naver Maps List'; + const totalCount = typeof firstPage.data.folder?.bookmarkCount === 'number' + ? firstPage.data.folder.bookmarkCount + : (firstPage.data.bookmarkList?.length || 0); + + const allItems: Record[] = [...(firstPage.data.bookmarkList || [])]; + for (let start = limit; start < totalCount; start += limit) { + const page = await fetchPage(start); + if ('error' in page) { + return { error: page.error, status: page.status }; + } + const pageItems = page.data.bookmarkList || []; + if (!Array.isArray(pageItems) || pageItems.length === 0) break; + allItems.push(...pageItems); + } + + if (allItems.length === 0) { + return { error: 'List is empty or could not be read', status: 400 }; + } + + const places: { name: string; lat: number; lng: number; notes: string | null; address: string | null }[] = []; + for (const item of allItems) { + const lat = Number(item?.py); + const lng = Number(item?.px); + const name = typeof item?.name === 'string' && item.name.trim() + ? item.name.trim() + : (typeof item?.displayName === 'string' ? item.displayName.trim() : ''); + const note = typeof item?.memo === 'string' && item.memo.trim() ? item.memo.trim() : null; + const address = typeof item?.address === 'string' && item.address.trim() ? item.address.trim() : null; + + if (name && Number.isFinite(lat) && Number.isFinite(lng)) { + places.push({ name, lat, lng, notes: note, address }); + } + } + + if (places.length === 0) { + return { error: 'No places with coordinates found in list', status: 400 }; + } + + const dedup = this.buildDedupSet(tripId); + const insertStmt = this.dbs.prepare(` + INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode) + VALUES (?, ?, ?, ?, ?, ?, 'walking') + `); + const created: PlaceWithTags[] = []; + let skipped = 0; + this.dbs.transaction(() => { + for (const p of places) { + if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { + skipped++; + continue; + } + const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes); + const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; + created.push(place); + trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); + } + }); + + if (opts?.enrich && opts.userId && created.length) { + void this.enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang); + } + + return { places: created, listName, skipped }; } - searchImage(tripId: string, id: string, userId: number) { - return svc.searchPlaceImage(tripId, id, userId); + // ------------------------------------------------------------------------- + // 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). Moved in from the legacy services/placeEnrichment.ts when the + // place domain went DI-native; the pure match selector stays in + // places.helpers.ts. + // ------------------------------------------------------------------------- + + private async enrichOne(tripId: string, userId: number, place: EnrichablePlace, lang?: string): Promise { + // 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 this.maps.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 = trimOrNull(match.google_place_id); + if (!gpid) return; + const gftid = trimOrNull(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. + this.dbs.run( + `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 = ?`, + gpid, gftid, trimOrNull(match.address), trimOrNull(match.website), trimOrNull(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 this.maps.getPlacePhoto(userId, gpid, place.lat, place.lng, place.name); + if (photo?.photoUrl) { + this.dbs.run( + 'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?', + 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 = this.dbs.getPlaceWithTags(place.id); + if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined); } - rate(tripId: string, id: string, userId: number, rating: number | null) { - return svc.ratePlace(tripId, id, userId, rating); + /** + * 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. + */ + async enrichImportedPlaces(tripId: string, userId: number, places: EnrichablePlace[], lang?: string): Promise { + try { + if (!places.length) return; + if (!this.maps.getMapsKey(userId)) return; + await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => { + try { + await this.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); + } + } + + // ------------------------------------------------------------------------- + // Search place image (Unsplash) + // ------------------------------------------------------------------------- + + async searchImage(tripId: string, placeId: string, userId: number) { + const place = this.dbs.get('SELECT * FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); + if (!place) return { error: 'Place not found', status: 404 }; + + return searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, getUnsplashKey(userId)); + } + + // ------------------------------------------------------------------------- + // Collaborative ratings (#1435) + // ------------------------------------------------------------------------- + + /** + * Set (rating 1-5) or clear (rating null) the user's own vote on a trip place. + * Ratings live in their own table so voting never bumps places.updated_at — + * a vote must not 409 another member's offline edit. Returns the refreshed + * place (with the new aggregate) or null when the place isn't in the trip. + */ + rate(tripId: string, placeId: string, userId: number, rating: number | null): PlaceWithTags | null { + const place = this.dbs.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); + if (!place) return null; + if (rating === null) { + this.dbs.run('DELETE FROM place_ratings WHERE place_id = ? AND user_id = ?', placeId, userId); + } else { + this.dbs.run(` + INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?) + ON CONFLICT(place_id, user_id) DO UPDATE SET rating = excluded.rating + `, placeId, userId, rating); + } + return this.dbs.getPlaceWithTags(placeId); } // Journey hooks — non-fatal, mirroring the route's try/catch wrappers. diff --git a/server/src/nest/plugins/DI-MIGRATION.md b/server/src/nest/plugins/DI-MIGRATION.md index 8619bc4f3..781029890 100644 --- a/server/src/nest/plugins/DI-MIGRATION.md +++ b/server/src/nest/plugins/DI-MIGRATION.md @@ -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) diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index ffedcacfc..080736c7e 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -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[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[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 }; diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index d533427f1..13a390494 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -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 diff --git a/server/src/nest/trips/trips.bridge.ts b/server/src/nest/trips/trips.bridge.ts index 2bd0990d9..0526a11da 100644 --- a/server/src/nest/trips/trips.bridge.ts +++ b/server/src/nest/trips/trips.bridge.ts @@ -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) { diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index 044b49dde..80f839e96 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -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). diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 0338a1c3f..9529d3ff8 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -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), diff --git a/server/src/services/placeEnrichment.ts b/server/src/services/placeEnrichment.ts deleted file mode 100644 index d21c88874..000000000 --- a/server/src/services/placeEnrichment.ts +++ /dev/null @@ -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[], - target: { lat: number; lng: number }, - maxMeters: number = MATCH_RADIUS_METERS, -): Record | null { - let best: { c: Record; 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(items: T[], limit: number, fn: (item: T) => Promise): Promise { - 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 { - // 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 { - 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); - } -} diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts deleted file mode 100644 index 2732d4f13..000000000 --- a/server/src/services/placeService.ts +++ /dev/null @@ -1,1029 +0,0 @@ -import { XMLParser, XMLValidator } from 'fast-xml-parser'; -import unzipper from 'unzipper'; -import { db, getPlaceWithTags } from '../db/database'; -import { loadTagsByPlaceIds, loadRatingsByPlaceIds, ratingAggregate } from './queryHelpers'; -import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard'; -import { Place } from '../types'; -import { - buildCategoryNameLookup, - createKmlImportSummary, - decodeUtf8WithWarning, - extractKmlPlacemarkNodes, - parsePlacemarkNode, - resolveCategoryIdForFolder, - type KmlImportSummary, -} from './kmlImport'; -import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment'; -import * as placePhotoCache from './placePhotoCache'; -import { searchUnsplashPhotos, getUnsplashKey } from './unsplashService'; -import { type UpdateConflict, isUpdateConflict } from './conflictResult'; -import { reclaimPlaceImage } from './placeImage'; - -// 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). -function reclaimPhotoCache(googlePlaceId: string | null, imageUrl: string | null): void { - const candidates = new Set(); - 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 */ } - } -} - -/** Opt-in Places-API enrichment for list imports (#886). */ -export interface ListImportOptions { - enrich?: boolean; - userId?: number; - lang?: string; -} - -interface PlaceWithCategory extends Place { - category_name: string | null; - category_color: string | null; - category_icon: string | null; -} - -export interface PlaceImportResult { - places: any[]; - count: number; - summary: KmlImportSummary; -} - -// --------------------------------------------------------------------------- -// List places -// --------------------------------------------------------------------------- - -export function listPlaces( - tripId: string, - filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' }, -) { - let query = ` - SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon - FROM places p - LEFT JOIN categories c ON p.category_id = c.id - WHERE p.trip_id = ? - `; - const params: (string | number)[] = [tripId]; - - if (filters.search) { - query += ' AND (p.name LIKE ? OR p.address LIKE ? OR p.description LIKE ?)'; - const searchParam = `%${filters.search}%`; - params.push(searchParam, searchParam, searchParam); - } - - if (filters.category) { - query += ' AND p.category_id = ?'; - params.push(filters.category); - } - - if (filters.tag) { - query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)'; - params.push(filters.tag); - } - - if (filters.assignment === 'unassigned') { - query += ` AND p.id NOT IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`; - params.push(tripId); - } else if (filters.assignment === 'assigned') { - query += ` AND p.id IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`; - params.push(tripId); - } - - query += ' ORDER BY p.created_at DESC'; - - const places = db.prepare(query).all(...params) as PlaceWithCategory[]; - - const placeIds = places.map(p => p.id); - const tagsByPlaceId = loadTagsByPlaceIds(placeIds); - const ratingsByPlaceId = loadRatingsByPlaceIds(placeIds); - - return places.map(p => ({ - ...p, - category: p.category_id ? { - id: p.category_id, - name: p.category_name, - color: p.category_color, - icon: p.category_icon, - } : null, - tags: tagsByPlaceId[p.id] || [], - ratings: ratingsByPlaceId[p.id] || [], - ...ratingAggregate(ratingsByPlaceId[p.id]), - })); -} - -// --------------------------------------------------------------------------- -// Create place -// --------------------------------------------------------------------------- - -export function createPlace( - tripId: string, - body: { - 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; image_url?: string; - google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string; - transport_mode?: string; route_geometry?: string; route_color?: string; tags?: number[]; - }, -) { - const { - name, description, lat, lng, address, category_id, price, currency, - place_time, end_time, - duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, - transport_mode, route_geometry, route_color, tags = [], - } = body; - - const result = db.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, - place_time, end_time, - duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode, - route_geometry, route_color) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - tripId, name, description || null, lat || null, lng || null, address || null, - category_id || null, price || null, currency || null, - place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null, - google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking', - route_geometry || null, route_color || null, - ); - - const placeId = result.lastInsertRowid; - - if (tags && tags.length > 0) { - const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); - for (const tagId of tags) { - insertTag.run(placeId, tagId); - } - } - - return getPlaceWithTags(Number(placeId)); -} - -// --------------------------------------------------------------------------- -// Get single place -// --------------------------------------------------------------------------- - -export function getPlace(tripId: string, placeId: string) { - const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); - if (!placeCheck) return null; - return getPlaceWithTags(placeId); -} - -// --------------------------------------------------------------------------- -// Update place -// --------------------------------------------------------------------------- - -export function updatePlace( - tripId: string, - placeId: string, - body: { - 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; image_url?: string; - google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string; - transport_mode?: string; route_color?: string | null; tags?: number[]; - }, - ifMatch?: string, -): ReturnType | UpdateConflict | null { - const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined; - if (!existingPlace) return null; - - // Optimistic concurrency (#1135): when the caller sent the version it based its - // edit on and the row has moved on since, reject instead of clobbering. Absent - // token => unconditional update (back-compat — old clients keep last-write-wins). - if (ifMatch !== undefined && existingPlace.updated_at != null && String(existingPlace.updated_at) !== ifMatch) { - return { conflict: true, server: getPlaceWithTags(placeId) }; - } - - const { - name, description, lat, lng, address, category_id, price, currency, - place_time, end_time, - duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, - transport_mode, route_color, tags, - } = body; - - db.prepare(` - UPDATE places SET - name = COALESCE(?, name), - description = ?, - lat = ?, - lng = ?, - address = ?, - category_id = ?, - price = ?, - currency = COALESCE(?, currency), - place_time = ?, - end_time = ?, - duration_minutes = COALESCE(?, duration_minutes), - notes = ?, - image_url = ?, - google_place_id = ?, - google_ftid = ?, - osm_id = ?, - website = ?, - phone = ?, - transport_mode = COALESCE(?, transport_mode), - route_color = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `).run( - name || null, - description !== undefined ? description : existingPlace.description, - lat !== undefined ? lat : existingPlace.lat, - lng !== undefined ? lng : existingPlace.lng, - address !== undefined ? address : existingPlace.address, - category_id !== undefined ? category_id : existingPlace.category_id, - price !== undefined ? price : existingPlace.price, - currency || null, - place_time !== undefined ? place_time : existingPlace.place_time, - end_time !== undefined ? end_time : existingPlace.end_time, - duration_minutes || null, - notes !== undefined ? notes : existingPlace.notes, - image_url !== undefined ? image_url : existingPlace.image_url, - google_place_id !== undefined ? google_place_id : existingPlace.google_place_id, - google_ftid !== undefined ? google_ftid : existingPlace.google_ftid, - osm_id !== undefined ? osm_id : existingPlace.osm_id, - website !== undefined ? website : existingPlace.website, - phone !== undefined ? phone : existingPlace.phone, - transport_mode || null, - // Deliberately not COALESCE: an explicit null is how the picker resets a - // track back to its category colour (#776). - route_color !== undefined ? route_color : existingPlace.route_color, - placeId, - ); - - if (tags !== undefined) { - db.prepare('DELETE FROM place_tags WHERE place_id = ?').run(placeId); - if (tags.length > 0) { - const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); - for (const tagId of tags) { - insertTag.run(placeId, tagId); - } - } - } - - // A custom uploaded thumbnail (#1136) that was just replaced or cleared leaves - // an orphan file behind — reclaim it once nothing references it any more. - if (image_url !== undefined && image_url !== existingPlace.image_url) { - reclaimPlaceImage(existingPlace.image_url); - } - - return getPlaceWithTags(placeId); -} - -// --------------------------------------------------------------------------- -// Delete place -// --------------------------------------------------------------------------- - -export function deletePlace(tripId: string, placeId: string): boolean { - const place = db.prepare( - 'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?' - ).get(placeId, tripId) as { google_place_id: string | null; image_url: string | null } | undefined; - if (!place) return false; - db.prepare('DELETE FROM places WHERE id = ?').run(placeId); - reclaimPhotoCache(place.google_place_id, place.image_url); - reclaimPlaceImage(place.image_url); - return true; -} - -export function deletePlacesMany(tripId: string, ids: number[]): number[] { - if (ids.length === 0) return []; - const selectStmt = db.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?'); - const deleteStmt = db.prepare('DELETE FROM places WHERE id = ?'); - const deleted: number[] = []; - const reclaimable: { google_place_id: string | null; image_url: string | null }[] = []; - const run = db.transaction((list: number[]) => { - for (const id of list) { - const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined; - if (!row) continue; - deleteStmt.run(id); - deleted.push(id); - reclaimable.push(row); - } - }); - run(ids); - // Reclaim after the transaction commits so isReferenced() sees the final place set. - for (const row of reclaimable) { - reclaimPhotoCache(row.google_place_id, row.image_url); - reclaimPlaceImage(row.image_url); - } - return deleted; -} - -// --------------------------------------------------------------------------- -// Bulk update -// --------------------------------------------------------------------------- - -/** - * Apply the same set of fields to many places in a single transaction. Each - * place is scoped to the trip and patched via updatePlace, so only the provided - * fields change and everything else is preserved. IDs that don't belong to the - * trip are skipped. Returns the updated places. - */ -export function updatePlacesMany( - tripId: string, - ids: number[], - body: Parameters[2], -): NonNullable>[] { - if (ids.length === 0) return []; - const updated: NonNullable>[] = []; - const run = db.transaction((list: number[]) => { - for (const id of list) { - // Bulk update sends no If-Match, so updatePlace never returns a conflict - // here; the guard keeps the types honest. - const place = updatePlace(tripId, String(id), body); - if (place && !isUpdateConflict(place)) updated.push(place); - } - }); - run(ids); - return updated; -} - -// --------------------------------------------------------------------------- -// Import GPX -// --------------------------------------------------------------------------- - -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 as raw text so mixed-content HTML (e.g.
, ) - // is returned as a string instead of a parsed object. - stopNodes: ['*.description'], -}); - -export const KMZ_DECOMPRESSED_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB - -// --------------------------------------------------------------------------- -// Import deduplication helpers -// --------------------------------------------------------------------------- - -const COORD_DEDUP_TOLERANCE = 0.0001; // ≈ 11 m - -interface DedupSet { - names: Set; - coords: Array<{ lat: number; lng: number }>; -} - -/** Build a lookup of names/coords for places already in a trip. */ -function buildDedupSet(tripId: string): DedupSet { - const rows = db.prepare('SELECT name, lat, lng FROM places WHERE trip_id = ?').all(tripId) as Array<{ - name: string | null; - lat: number | null; - lng: number | null; - }>; - const names = new Set(); - const coords: Array<{ lat: number; lng: number }> = []; - for (const row of rows) { - if (row.name) { - names.add(row.name.trim().toLowerCase()); - } else if (row.lat != null && row.lng != null) { - coords.push({ lat: row.lat, lng: row.lng }); - } - } - return { names, coords }; -} - -/** - * 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. - */ -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. */ -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 }); - } -} - -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; -} - -export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}) { - const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts; - - const parsed = gpxParser.parse(fileBuffer.toString('utf-8')); - const gpx = parsed?.gpx; - if (!gpx) return null; - - const str = (v: unknown) => (v != null ? String(v).trim() : null); - const num = (v: unknown) => { const n = parseFloat(String(v)); return isNaN(n) ? null : n; }; - - // Routes and tracks rarely carry their own . Without one they all fall back to the - // same generic label, so name-based dedup drops every import after the first. Derive a - // base from the source filename (the requested behaviour) and suffix an index so multiple - // geometries from one file stay distinct. - const rawName = str(defaultName); - const baseName = rawName ? rawName.replace(/\.[^.]+$/, '').trim() || rawName : null; - let geoSeq = 0; - const geoName = (explicit: string | null, fallback: string): string => { - if (explicit) return explicit; - geoSeq++; - const base = baseName || fallback; - return geoSeq === 1 ? base : `${base} ${geoSeq}`; - }; - - type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string }; - const waypoints: WaypointEntry[] = []; - - // 1) Parse elements (named waypoints / POIs) - if (importWaypoints) { - for (const wpt of gpx.wpt ?? []) { - const lat = num(wpt['@_lat']); - const lng = num(wpt['@_lon']); - if (lat === null || lng === null) continue; - waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) }); - } - } - - // 2) Parse routes as polyline-places (one place per route with route_geometry) - if (importRoutes) { - for (const rte of gpx.rte ?? []) { - const pts = (rte.rtept ?? []) - .map((pt: Record) => ({ lat: num(pt['@_lat']), lng: num(pt['@_lon']), ele: num(pt['ele']) })) - .filter((p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null) as Array<{ lat: number; lng: number; ele: number | null }>; - if (pts.length === 0) continue; - const hasAllEle = pts.every(p => p.ele !== null); - const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); - waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: geoName(str(rte.name), 'GPX Route'), description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) }); - } - } - - // 3) Extract full track geometry from - if (importTracks) { - for (const trk of gpx.trk ?? []) { - const trackPoints: { lat: number; lng: number; ele: number | null }[] = []; - for (const seg of trk.trkseg ?? []) { - for (const pt of seg.trkpt ?? []) { - const lat = num(pt['@_lat']); - const lng = num(pt['@_lon']); - if (lat === null || lng === null) continue; - trackPoints.push({ lat, lng, ele: num(pt.ele) }); - } - } - if (trackPoints.length === 0) continue; - const start = trackPoints[0]; - const hasAllEle = trackPoints.every(p => p.ele !== null); - const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); - waypoints.push({ lat: start.lat, lng: start.lng, name: geoName(str(trk.name), 'GPX Track'), description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) }); - } - } - - if (waypoints.length === 0) return null; - - const dedup = buildDedupSet(tripId); - const insertStmt = db.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry) - VALUES (?, ?, ?, ?, ?, 'walking', ?) - `); - const created: any[] = []; - let skipped = 0; - const insertAll = db.transaction(() => { - for (const wp of waypoints) { - if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) { - skipped++; - continue; - } - const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); - const place = getPlaceWithTags(Number(result.lastInsertRowid)); - created.push(place); - trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup); - } - }); - insertAll(); - - return { places: created, count: created.length, skipped }; -} - -export function importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImportOptions = {}): PlaceImportResult { - const { importPoints = true, importPaths = true } = opts; - const decoded = decodeUtf8WithWarning(fileBuffer); - - const validationResult = XMLValidator.validate(decoded.text); - if (validationResult !== true) { - throw new Error('Malformed KML: invalid XML structure'); - } - - const parsed = kmlParser.parse(decoded.text); - const kmlRoot = parsed?.kml ?? parsed; - - if (!kmlRoot || typeof kmlRoot !== 'object') { - throw new Error('Malformed KML: could not parse XML'); - } - - const placemarkNodes = extractKmlPlacemarkNodes(kmlRoot); - const summary = createKmlImportSummary(placemarkNodes.length); - - if (decoded.warning) { - summary.warnings.push(decoded.warning); - } - - const categories = db.prepare('SELECT id, name FROM categories').all() as { id: number; name: string }[]; - const categoryLookup = buildCategoryNameLookup(categories); - const dedup = buildDedupSet(tripId); - const created: any[] = []; - let dupCount = 0; - - const insertStmt = db.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode, route_geometry) - VALUES (?, ?, ?, ?, ?, ?, 'walking', ?) - `); - - const insertAll = db.transaction(() => { - let fallbackIndex = 1; - for (const node of placemarkNodes) { - const parsedPlacemark = parsePlacemarkNode(node); - const isPath = parsedPlacemark.routeGeometry !== null; - - // Unsupported geometry type (polygon, multi-geometry, no geometry, etc.) - if (parsedPlacemark.lat === null || parsedPlacemark.lng === null) { - summary.skippedCount += 1; - summary.errors.push(`Skipped Placemark ${fallbackIndex}: unsupported geometry type.`); - fallbackIndex += 1; - continue; - } - - // Type filtering: respect importPoints / importPaths opts - if (isPath && !importPaths) { - summary.skippedCount += 1; - fallbackIndex += 1; - continue; - } - if (!isPath && !importPoints) { - summary.skippedCount += 1; - fallbackIndex += 1; - continue; - } - - const fallbackName = `Placemark ${fallbackIndex}`; - const name = parsedPlacemark.name || fallbackName; - - if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) { - summary.skippedCount += 1; - dupCount++; - fallbackIndex += 1; - continue; - } - - const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup); - - const result = insertStmt.run( - tripId, - name, - parsedPlacemark.description, - parsedPlacemark.lat, - parsedPlacemark.lng, - categoryId, - parsedPlacemark.routeGeometry, - ); - - const place = getPlaceWithTags(Number(result.lastInsertRowid)); - created.push(place); - trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup); - summary.createdCount += 1; - fallbackIndex += 1; - } - }); - - insertAll(); - - if (dupCount > 0) { - summary.warnings.push(`${dupCount} place${dupCount > 1 ? 's' : ''} skipped (already in trip).`); - } - - if (summary.totalPlacemarks === 0) { - summary.errors.push('No Placemarks found in KML file.'); - } - - return { places: created, count: created.length, summary }; -} - -export async function unpackKmzToKml( - kmzBuffer: Buffer, - decompressedSizeLimit = KMZ_DECOMPRESSED_SIZE_LIMIT, -): Promise { - 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(); -} - -export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer, opts: KmlImportOptions = {}): Promise { - const kmlBuffer = await unpackKmzToKml(kmzBuffer); - return importKmlPlaces(tripId, kmlBuffer, opts); -} - -export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string, opts: KmlImportOptions = {}): Promise { - const ext = filename.toLowerCase().split('.').pop(); - if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer, opts); - if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer, opts); - throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`); -} - -// --------------------------------------------------------------------------- -// Import Google Maps list -// --------------------------------------------------------------------------- - -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; - } -} - -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; -} - -function findDuplicatePlace( - tripId: string, - place: { name: string | null | undefined; lat: number | null; lng: number | null }, -): { id: number; google_ftid: string | null } | null { - const normalizedName = place.name?.trim().toLowerCase(); - if (normalizedName) { - const duplicate = db.prepare(` - SELECT id, google_ftid FROM places - WHERE trip_id = ? AND lower(trim(name)) = ? - ORDER BY id ASC - LIMIT 1 - `).get(tripId, normalizedName) as { id: number; google_ftid: string | null } | undefined; - if (duplicate) return duplicate; - } - if (place.lat != null && place.lng != null) { - return db.prepare(` - SELECT id, google_ftid FROM places - WHERE trip_id = ? - AND lat IS NOT NULL AND lng IS NOT NULL - AND abs(lat - ?) <= ? - AND abs(lng - ?) <= ? - ORDER BY id ASC - LIMIT 1 - `).get(tripId, place.lat, COORD_DEDUP_TOLERANCE, place.lng, COORD_DEDUP_TOLERANCE) as { id: number; google_ftid: string | null } | undefined || null; - } - return null; -} - -export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) { - let listId: string | null = null; - let resolvedUrl = url; - - // SSRF guard: validate user-supplied URL before fetching - const ssrf = await checkSsrf(url); - if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 }; - - // Follow redirects for short URLs (maps.app.goo.gl, goo.gl). Redirects are - // followed manually so every hop is re-checked against the SSRF guard — a - // short link that 302s to an internal IP is blocked even though the initial - // host is public. - if (url.includes('goo.gl') || url.includes('maps.app')) { - try { - const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) }); - resolvedUrl = redirectRes.url; - } catch (err) { - if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 }; - throw err; - } - } - - // Pattern: /placelists/list/{ID} - const plMatch = resolvedUrl.match(/placelists\/list\/([A-Za-z0-9_-]+)/); - if (plMatch) listId = plMatch[1]; - - // Pattern: !2s{ID} in data URL params - if (!listId) { - const dataMatch = resolvedUrl.match(/!2s([A-Za-z0-9_-]{15,})/); - if (dataMatch) listId = dataMatch[1]; - } - - if (!listId) { - // A single-place share link (…/maps/place/…) carries no list id — point the user at - // the place search box instead of a cryptic "could not extract list ID" (#1304). - if (resolvedUrl.includes('/maps/place/')) { - return { error: 'That link points to a single place, not a list. To add it, paste the link into the place search box instead of using the list import.', status: 400 }; - } - return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 }; - } - - // Fetch list data from Google Maps internal API - const apiUrl = `https://www.google.com/maps/preview/entitylist/getlist?authuser=0&hl=en&gl=us&pb=!1m1!1s${encodeURIComponent(listId)}!2e2!3e2!4i500!16b1`; - const apiRes = await fetch(apiUrl, { - headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, - signal: AbortSignal.timeout(15000), - }); - - if (!apiRes.ok) { - return { error: 'Failed to fetch list from Google Maps', status: 502 }; - } - - const rawText = await apiRes.text(); - const jsonStr = rawText.substring(rawText.indexOf('\n') + 1); - const listData = JSON.parse(jsonStr); - - const meta = listData[0]; - if (!meta) { - return { error: 'Invalid list data received from Google Maps', status: 400 }; - } - - const listName = meta[4] || 'Google Maps List'; - const items = meta[8]; - - if (!Array.isArray(items) || items.length === 0) { - return { error: 'List is empty or could not be read', status: 400 }; - } - - // Parse place data from items - const places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[] = []; - for (const item of items) { - const coords = item?.[1]?.[5]; - const lat = coords?.[2]; - const lng = coords?.[3]; - const name = item?.[2]; - const note = item?.[3] || null; - - if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) { - places.push({ name, lat, lng, notes: note || null, googleFtid: googleMapsFeatureIdFromItem(item) }); - } - } - - if (places.length === 0) { - return { error: 'No places with coordinates found in list', status: 400 }; - } - - const dedup = buildDedupSet(tripId); - const insertStmt = db.prepare(` - INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode) - VALUES (?, ?, ?, ?, ?, ?, 'walking') - `); - const updateGoogleFtidStmt = db.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'); - const created: any[] = []; - let skipped = 0; - const insertAll = db.transaction(() => { - for (const p of places) { - if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { - const duplicate = findDuplicatePlace(tripId, p); - if (duplicate && !duplicate.google_ftid && p.googleFtid) { - updateGoogleFtidStmt.run(p.googleFtid, duplicate.id); - } - skipped++; - continue; - } - const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid); - const place = getPlaceWithTags(Number(result.lastInsertRowid)); - created.push(place); - trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); - } - }); - insertAll(); - - if (opts?.enrich && opts.userId && created.length) { - void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang); - } - - return { places: created, listName, skipped }; -} - -// --------------------------------------------------------------------------- -// Import Naver Maps list -// --------------------------------------------------------------------------- - -export async function importNaverList( - tripId: string, - url: string, - opts?: ListImportOptions, -): Promise<{ places: any[]; listName: string; skipped: number } | { error: string; status: number }> { - let resolvedUrl = url; - const limit = 20; - - // SSRF guard: validate user-supplied URL before fetching - const ssrf = await checkSsrf(url); - if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 }; - - // Resolve naver.me short links to the canonical map.naver.com folder URL. - // Redirects are followed manually so each hop is re-validated against the - // SSRF guard (a short link could otherwise 302 to an internal address). - let parsedUrl: URL; - try { parsedUrl = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; } - if (parsedUrl.hostname === 'naver.me') { - try { - const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) }); - resolvedUrl = redirectRes.url; - } catch (err) { - if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 }; - throw err; - } - } - - const folderMatch = resolvedUrl.match(/favorite\/myPlace\/folder\/([A-Za-z0-9_-]+)/i); - const folderId = folderMatch?.[1] || null; - if (!folderId) { - return { error: 'Could not extract folder ID from URL. Please use a shared Naver Maps list link.', status: 400 }; - } - - const fetchPage = async (start: number) => { - const apiUrl = `https://pages.map.naver.com/save-pages/api/maps-bookmark/v3/shares/${encodeURIComponent(folderId)}/bookmarks?placeInfo=true&start=${start}&limit=${limit}&sort=lastUseTime&mcids=ALL&createIdNo=true`; - const apiRes = await fetch(apiUrl, { - headers: { - Accept: 'application/json', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - }, - signal: AbortSignal.timeout(15000), - }); - - if (!apiRes.ok) { - return { error: 'Failed to fetch list from Naver Maps', status: 502 } as const; - } - - try { - const data = await apiRes.json() as { - folder?: { bookmarkCount?: number; name?: string }; - bookmarkList?: any[]; - }; - return { data } as const; - } catch { - return { error: 'Invalid list data received from Naver Maps', status: 400 } as const; - } - }; - - const firstPage = await fetchPage(0); - if ('error' in firstPage) { - return { error: firstPage.error, status: firstPage.status }; - } - - const listName = firstPage.data.folder?.name || 'Naver Maps List'; - const totalCount = typeof firstPage.data.folder?.bookmarkCount === 'number' - ? firstPage.data.folder.bookmarkCount - : (firstPage.data.bookmarkList?.length || 0); - - const allItems: any[] = [...(firstPage.data.bookmarkList || [])]; - for (let start = limit; start < totalCount; start += limit) { - const page = await fetchPage(start); - if ('error' in page) { - return { error: page.error, status: page.status }; - } - const pageItems = page.data.bookmarkList || []; - if (!Array.isArray(pageItems) || pageItems.length === 0) break; - allItems.push(...pageItems); - } - - if (allItems.length === 0) { - return { error: 'List is empty or could not be read', status: 400 }; - } - - const places: { name: string; lat: number; lng: number; notes: string | null; address: string | null }[] = []; - for (const item of allItems) { - const lat = Number(item?.py); - const lng = Number(item?.px); - const name = typeof item?.name === 'string' && item.name.trim() - ? item.name.trim() - : (typeof item?.displayName === 'string' ? item.displayName.trim() : ''); - const note = typeof item?.memo === 'string' && item.memo.trim() ? item.memo.trim() : null; - const address = typeof item?.address === 'string' && item.address.trim() ? item.address.trim() : null; - - if (name && Number.isFinite(lat) && Number.isFinite(lng)) { - places.push({ name, lat, lng, notes: note, address }); - } - } - - if (places.length === 0) { - return { error: 'No places with coordinates found in list', status: 400 }; - } - - const dedup = buildDedupSet(tripId); - const insertStmt = db.prepare(` - INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode) - VALUES (?, ?, ?, ?, ?, ?, 'walking') - `); - const created: any[] = []; - let skipped = 0; - const insertAll = db.transaction(() => { - for (const p of places) { - if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { - skipped++; - continue; - } - const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes); - const place = getPlaceWithTags(Number(result.lastInsertRowid)); - created.push(place); - trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); - } - }); - insertAll(); - - if (opts?.enrich && opts.userId && created.length) { - void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang); - } - - return { places: created, listName, skipped }; -} - -// --------------------------------------------------------------------------- -// Search place image (Unsplash) -// --------------------------------------------------------------------------- - -export async function searchPlaceImage(tripId: string, placeId: string, userId: number) { - const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined; - if (!place) return { error: 'Place not found', status: 404 }; - - return searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, getUnsplashKey(userId)); -} - -// --------------------------------------------------------------------------- -// Collaborative ratings (#1435) -// --------------------------------------------------------------------------- - -/** - * Set (rating 1-5) or clear (rating null) the user's own vote on a trip place. - * Ratings live in their own table so voting never bumps places.updated_at — - * a vote must not 409 another member's offline edit. Returns the refreshed - * place (with the new aggregate) or null when the place isn't in the trip. - */ -export function ratePlace(tripId: string, placeId: string, userId: number, rating: number | null): ReturnType | null { - const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); - if (!place) return null; - if (rating === null) { - db.prepare('DELETE FROM place_ratings WHERE place_id = ? AND user_id = ?').run(placeId, userId); - } else { - db.prepare(` - INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?) - ON CONFLICT(place_id, user_id) DO UPDATE SET rating = excluded.rating - `).run(placeId, userId, rating); - } - return getPlaceWithTags(placeId); -} diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index 5a10999d1..6fad99a05 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -1,7 +1,8 @@ /** * Places module e2e — exercises the migrated /api/trips/:tripId/places endpoints - * through the real JwtAuthGuard against a temp SQLite db. placeService, - * journeyService, the permission check, canAccessTrip and the WebSocket + * through the real JwtAuthGuard against a temp SQLite db. PlacesService runs its + * real (DI-native) SQL — places/categories/tags/place_tags/place_ratings DDL + * below; journeyService, the permission check, canAccessTrip and the WebSocket * broadcast are mocked. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; @@ -19,13 +20,43 @@ const { db } = vi.hoisted(() => { const tmp = new Database(':memory:'); tmp.exec('PRAGMA journal_mode = WAL'); tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0, + avatar TEXT);`); + // PlacesService runs its real SQL since the place fold — the full place column + // set the INSERT/UPDATE/SELECT paths touch, plus the joined projections. + tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, + description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT, + place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, image_url TEXT, + google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT, transport_mode TEXT, + route_geometry TEXT, route_color TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);`); + tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE place_tags (place_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, + PRIMARY KEY (place_id, tag_id));`); + tmp.exec(`CREATE TABLE place_ratings (place_id INTEGER NOT NULL, user_id INTEGER NOT NULL, rating INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(place_id, user_id));`); + // The assignment=unassigned/assigned filters join these. + tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT);'); + tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, + place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0);`); + // reclaimPlaceImage ref-counts an uploaded thumbnail across both tables. + tmp.exec(`CREATE TABLE collection_places (id INTEGER PRIMARY KEY AUTOINCREMENT, image_url TEXT);`); + // reclaimPhotoCache's removeIfUnreferenced sweeps the Google photo cache. + tmp.exec(`CREATE TABLE google_place_photo_meta (place_id TEXT PRIMARY KEY, attribution TEXT, error_at DATETIME);`); return { db: tmp }; }); const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); +const { getPlaceWithTags } = vi.hoisted(() => ({ + // The one db/database helper PlacesService still calls through (post-write + // re-selects). Real implementation over the temp db so the responses carry + // the joined category/tags/ratings the client expects. + getPlaceWithTags: vi.fn(), +})); vi.mock('../../src/db/database', () => ({ - db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, + db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags, closeDb: () => {}, reinitialize: () => {}, })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../src/services/journeyService', () => ({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn() })); @@ -36,16 +67,8 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi // PermissionsService singleton (created in beforeAll, after build()). let checkPermission: MockInstance; -const { pl } = vi.hoisted(() => ({ - pl: { - listPlaces: vi.fn(), createPlace: vi.fn(), getPlace: vi.fn(), updatePlace: vi.fn(), deletePlace: vi.fn(), - deletePlacesMany: vi.fn(), importGpx: vi.fn(), importMapFile: vi.fn(), importGoogleList: vi.fn(), - importNaverList: vi.fn(), searchPlaceImage: vi.fn(), - }, -})); -vi.mock('../../src/services/placeService', () => pl); - import { PlacesModule } from '../../src/nest/places/places.module'; +import { PlacesService } from '../../src/nest/places/places.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -67,15 +90,20 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { beforeAll(async () => { seedUser(db as never, { id: 1 }); + getPlaceWithTags.mockImplementation((placeId: number | string) => { + const place = db.prepare(`SELECT p.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon + FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId) as Record | undefined; + if (!place) return null; + const tags = db.prepare('SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?').all(placeId); + return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name } : null, tags, ratings: [], rating_avg: null, rating_count: 0 }; + }); app = await build(); checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); server = app.getHttpServer(); - pl.listPlaces.mockReturnValue([{ id: 1, name: 'Spot' }]); - pl.createPlace.mockReturnValue({ id: 9, name: 'Spot' }); - pl.deletePlacesMany.mockReturnValue([1, 2]); }); beforeEach(() => { + db.exec('DELETE FROM places; DELETE FROM place_tags; DELETE FROM place_ratings; DELETE FROM day_assignments; DELETE FROM days;'); canAccessTrip.mockReturnValue({ id: 5, user_id: 1 }); checkPermission.mockReturnValue(true); }); @@ -89,50 +117,144 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { }); it('200 list', async () => { + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (1, 5, 'Spot')").run(); const res = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body).toEqual({ places: [{ id: 1, name: 'Spot' }] }); + expect(res.body.places).toHaveLength(1); + expect(res.body.places[0]).toMatchObject({ id: 1, name: 'Spot', trip_id: 5, tags: [], ratings: [] }); + }); + + it('200 list scoped to the trip', async () => { + db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Mine')").run(); + db.prepare("INSERT INTO places (trip_id, name) VALUES (6, 'Theirs')").run(); + const res = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1)); + expect(res.body.places.map((p: { name: string }) => p.name)).toEqual(['Mine']); }); it('201 create, 403 without permission, 400 over-long name', async () => { const ok = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({ name: 'Spot' }); expect(ok.status).toBe(201); - expect(ok.body).toEqual({ place: { id: 9, name: 'Spot' } }); + expect(ok.body.place).toMatchObject({ name: 'Spot', trip_id: 5, transport_mode: 'walking', duration_minutes: 60 }); + // The row really landed. + expect(db.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = 5').get()).toEqual({ n: 1 }); + const long = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({ name: 'x'.repeat(201) }); expect(long.status).toBe(400); expect(long.body).toEqual({ error: 'name must be 200 characters or less' }); + checkPermission.mockReturnValue(false); const forbidden = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({ name: 'Spot' }); expect(forbidden.status).toBe(403); }); it('200 (not 201) bulk-delete, 400 on bad ids', async () => { - const ok = await request(server).post('/api/trips/5/places/bulk-delete').set('Cookie', sessionCookie(1)).send({ ids: [1, 2] }); + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (1, 5, 'A'), (2, 5, 'B'), (3, 6, 'Foreign')").run(); + const ok = await request(server).post('/api/trips/5/places/bulk-delete').set('Cookie', sessionCookie(1)).send({ ids: [1, 2, 3] }); expect(ok.status).toBe(200); expect(ok.body).toEqual({ deleted: [1, 2], count: 2 }); + // The foreign trip's place is untouched. + expect(db.prepare('SELECT id FROM places ORDER BY id').all()).toEqual([{ id: 3 }]); + + // The ZodValidationPipe owns this 400 since the DTO ratchet — the legacy + // 'ids must be an array of numbers' string is gone. const bad = await request(server).post('/api/trips/5/places/bulk-delete').set('Cookie', sessionCookie(1)).send({ ids: ['a'] }); expect(bad.status).toBe(400); - expect(bad.body).toEqual({ error: 'ids must be an array of numbers' }); + expect(bad.body.error).toMatch(/^ids\.0: /); + }); + + it('body DTOs: the pipe rejects a nameless create and a urlless list import', async () => { + const nameless = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({ lat: 1 }); + expect(nameless.status).toBe(400); + expect(nameless.body.error).toMatch(/^name: /); + + const urlless = await request(server).post('/api/trips/5/places/import/google-list').set('Cookie', sessionCookie(1)).send({}); + expect(urlless.status).toBe(400); + expect(urlless.body.error).toMatch(/^url: /); + + // And it fires ahead of the trip-access 404 it used to follow (documented + // parity shift of the ratchet — the todo/trips precedent). + canAccessTrip.mockReturnValue(undefined); + const noTrip = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({}); + expect(noTrip.status).toBe(400); + }); + + it('bulk-update: an empty id list still short-circuits, a bare id list still 400s', async () => { + const empty = await request(server).post('/api/trips/5/places/bulk-update').set('Cookie', sessionCookie(1)).send({ ids: [] }); + expect(empty.status).toBe(200); + expect(empty.body).toEqual({ updated: [], count: 0 }); + + // category_id absent (not undefined-valued): Zod strips absent optionals, so + // the handler's `'category_id' in body` check still discriminates. + const noField = await request(server).post('/api/trips/5/places/bulk-update').set('Cookie', sessionCookie(1)).send({ ids: [1] }); + expect(noField.status).toBe(400); + expect(noField.body).toEqual({ error: 'Provide at least one field to update' }); + }); + + it('import/google-list forwards a boolean enrich flag as the client sends it', async () => { + const spy = vi.spyOn(app.get(PlacesService), 'importGoogleList').mockResolvedValue({ places: [], listName: 'L', skipped: 0 }); + const res = await request(server) + .post('/api/trips/5/places/import/google-list') + .set('Cookie', sessionCookie(1)) + .send({ url: 'https://maps.app.goo.gl/x', enrich: true }); + expect(res.status).toBe(201); + expect(spy).toHaveBeenCalledWith('5', 'https://maps.app.goo.gl/x', { enrich: true, userId: 1 }); + spy.mockRestore(); }); it('PUT route_color: hex through, null through, garbage rejected (#776)', async () => { - pl.updatePlace.mockReturnValue({ id: 9, route_color: '#e11d48' }); + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (9, 5, 'Walk')").run(); + const ok = await request(server).put('/api/trips/5/places/9').set('Cookie', sessionCookie(1)).send({ route_color: '#e11d48' }); expect(ok.status).toBe(200); - expect(ok.body).toEqual({ place: { id: 9, route_color: '#e11d48' } }); - expect(pl.updatePlace).toHaveBeenCalledWith('5', '9', expect.objectContaining({ route_color: '#e11d48' }), undefined); + expect(ok.body.place.route_color).toBe('#e11d48'); // null is the reset back to the inherited category colour. - pl.updatePlace.mockReturnValue({ id: 9, route_color: null }); const cleared = await request(server).put('/api/trips/5/places/9').set('Cookie', sessionCookie(1)).send({ route_color: null }); expect(cleared.status).toBe(200); - expect(pl.updatePlace).toHaveBeenLastCalledWith('5', '9', expect.objectContaining({ route_color: null }), undefined); + expect(cleared.body.place.route_color).toBeNull(); const bad = await request(server).put('/api/trips/5/places/9').set('Cookie', sessionCookie(1)).send({ route_color: 'red' }); expect(bad.status).toBe(400); expect(bad.body).toEqual({ error: 'route_color must be a hex colour like #4f46e5' }); }); + it('409 on a stale If-Match token (#1135)', async () => { + db.prepare("INSERT INTO places (id, trip_id, name, updated_at) VALUES (9, 5, 'Walk', '2026-01-01 00:00:00')").run(); + const res = await request(server) + .put('/api/trips/5/places/9') + .set('Cookie', sessionCookie(1)) + .set('X-Base-Updated-At', '1999-01-01 00:00:00') + .send({ name: 'Mine' }); + expect(res.status).toBe(409); + expect(res.body.error).toBe('conflict'); + expect(db.prepare('SELECT name FROM places WHERE id = 9').get()).toEqual({ name: 'Walk' }); + }); + + it('PUT/DELETE :id/rating stores and clears the caller\'s vote', async () => { + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (9, 5, 'Rated')").run(); + + const rated = await request(server).put('/api/trips/5/places/9/rating').set('Cookie', sessionCookie(1)).send({ rating: 4 }); + expect(rated.status).toBe(200); + expect(db.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = 9').all()).toEqual([{ user_id: 1, rating: 4 }]); + + const cleared = await request(server).delete('/api/trips/5/places/9/rating').set('Cookie', sessionCookie(1)); + expect(cleared.status).toBe(200); + expect(db.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = 9').get()).toEqual({ n: 0 }); + }); + + it('DELETE :id removes the row, 404 for a foreign place', async () => { + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (9, 5, 'Gone'), (10, 6, 'Foreign')").run(); + + const ok = await request(server).delete('/api/trips/5/places/9').set('Cookie', sessionCookie(1)); + expect(ok.status).toBe(200); + expect(ok.body).toEqual({ success: true }); + expect(db.prepare('SELECT id FROM places WHERE id = 9').get()).toBeUndefined(); + + const foreign = await request(server).delete('/api/trips/5/places/10').set('Cookie', sessionCookie(1)); + expect(foreign.status).toBe(404); + expect(foreign.body).toEqual({ error: 'Place not found' }); + }); + it('404 trip when not accessible', async () => { canAccessTrip.mockReturnValue(undefined); const res = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1)); diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index e31dae3e8..daa59fa9e 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -74,7 +74,13 @@ const { db } = vi.hoisted(() => { created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT);'); tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, - image_url TEXT, address TEXT, lat REAL, lng REAL);`); + image_url TEXT, address TEXT, lat REAL, lng REAL, category_id INTEGER, description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + // PlacesService.list joins categories and batch-loads tags/ratings. + tmp.exec('CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);'); + tmp.exec('CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, created_at DATETIME);'); + tmp.exec('CREATE TABLE place_tags (place_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (place_id, tag_id));'); + tmp.exec('CREATE TABLE place_ratings (place_id INTEGER NOT NULL, user_id INTEGER NOT NULL, rating INTEGER, created_at DATETIME, UNIQUE(place_id, user_id));'); tmp.exec(`CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, place_id INTEGER, start_day_id INTEGER, end_day_id INTEGER, check_in TEXT, check_in_end TEXT, check_out TEXT, confirmation TEXT, notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); @@ -114,7 +120,8 @@ let checkPermission: MockInstance; // trips/trip_members/days DDL above serves its SQL. // bundle()'s days + accommodations now run DaysService's real SQL (DI-injected, // no mock) — the days/places/day_accommodations/reservations DDL above serves them. -vi.mock('../../src/services/placeService', () => ({ listPlaces: () => [] })); +// bundle()'s places now run PlacesService's real SQL (DI-injected since the +// place fold, no mock) — the places/categories/tags DDL above serves them. // bundle()'s budget items come from the DI-injected BudgetService since the // budget fold — stubbed via a container spy in beforeAll (no budget DDL here). diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 7f7baf9cc..7f5bff36a 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -20,6 +20,8 @@ import { MapsService } from '../../src/nest/maps/maps.service'; import { PackingMcp } from '../../src/nest/packing/packing.mcp'; import { PackingService } from '../../src/nest/packing/packing.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; +import { PlacesMcp } from '../../src/nest/places/places.mcp'; +import { PlacesService } from '../../src/nest/places/places.service'; import { ReservationsMcp } from '../../src/nest/reservations/reservations.mcp'; import { ReservationsService } from '../../src/nest/reservations/reservations.service'; import { TagsMcp } from '../../src/nest/tags/tags.mcp'; @@ -55,6 +57,8 @@ export function createMcpTestRegistry(): McpRegistry { const todoService = new TodoService(dbService, permissionsService, realtimeService); const packingService = new PackingService(dbService, permissionsService, realtimeService); const collabService = new CollabService(dbService, permissionsService, realtimeService); + const mapsService = new MapsService(dbService); + const placesService = new PlacesService(dbService, permissionsService, realtimeService, mapsService); const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService); const tripsService = new TripsService( dbService, @@ -68,6 +72,7 @@ export function createMcpTestRegistry(): McpRegistry { collabService, new VacayService(dbService, realtimeService), realtimeService, + placesService, ); return createTestRegistry( [ @@ -78,13 +83,14 @@ export function createMcpTestRegistry(): McpRegistry { new BudgetMcp(budgetService, exchangeRatesService, dbService), new ReservationsMcp(reservationsService, daysService, budgetService), new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService)), - new DaysMcp(daysService, dbService), + new DaysMcp(daysService, dbService, placesService), new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService), daysService), new CollabMcp(collabService), new VacayMcp(new VacayService(dbService, realtimeService)), new TripsMcp(tripsService, todoService, collabService), new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService)), - new MapsMcp(new MapsService(dbService)), + new MapsMcp(mapsService), + new PlacesMcp(placesService, mapsService, dbService), new TransitMcp(new TransitService(), daysService, reservationsService, dbService), ], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 448ed9b86..b61d0aece 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -19,6 +19,8 @@ import { FilesService } from '../../src/nest/files/files.service'; import { CollabService } from '../../src/nest/collab/collab.service'; import { VacayService } from '../../src/nest/vacay/vacay.service'; import { TripsService } from '../../src/nest/trips/trips.service'; +import { PlacesService } from '../../src/nest/places/places.service'; +import { MapsService } from '../../src/nest/maps/maps.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { AuditService } from '../../src/nest/audit/audit.service'; import { AddonsService } from '../../src/nest/addons/addons.service'; @@ -43,6 +45,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto const collab = new CollabService(dbs, permissions, realtime); const vacay = new VacayService(dbs, realtime); const days = new DaysService(dbs, permissions, realtime); + const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs)); return new PluginHostDepsFactory( budget, reservations, @@ -63,7 +66,8 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto exchangeRates, addons, realtime, - new TripsService(dbs, todos, packing, files, reservations, days, permissions, budget, collab, vacay, realtime), + new TripsService(dbs, todos, packing, files, reservations, days, permissions, budget, collab, vacay, realtime, places), + places, ); } diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts index 86450b1ae..df472caf2 100644 --- a/server/tests/integration/places.test.ts +++ b/server/tests/integration/places.test.ts @@ -7,7 +7,7 @@ * - PLACE-014: reordering within a day is tested in assignments.test.ts * - PLACE-019: GPX bulk import tested here using the test fixture */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll, type MockInstance } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; import type { INestApplication } from '@nestjs/common'; @@ -48,14 +48,6 @@ vi.mock('../../src/config', () => ({ DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -vi.mock('../../src/services/placeService', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - importGoogleList: vi.fn(), - searchPlaceImage: vi.fn(), - }; -}); import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; @@ -63,11 +55,17 @@ import { runMigrations } from '../../src/db/migrations'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import * as placeService from '../../src/services/placeService'; +import { PlacesService } from '../../src/nest/places/places.service'; import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions.bridge'; let nestApp: INestApplication; let app: Application; +// Since the place DI fold the two outbound-I/O paths are stubbed as spies on the +// container's PlacesService singleton (permissions precedent) instead of a path +// mock of the deleted services/placeService. Bare spies keep the real +// implementation, so only the *Once overrides below change behaviour. +let importGoogleList: MockInstance; +let searchPlaceImage: MockInstance; const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx'); const KML_FIXTURE = path.join(__dirname, '../fixtures/test.kml'); const KML_NESTED_FIXTURE = path.join(__dirname, '../fixtures/test-nested.kml'); @@ -85,6 +83,10 @@ beforeEach(() => { resetTestDb(testDb); resetRateLimits(nestApp); invalidatePermissionsCache(); + // Re-attached per test: one describe below calls vi.restoreAllMocks() in its + // afterEach, which would otherwise strip these for every later test. + importGoogleList = vi.spyOn(nestApp.get(PlacesService), 'importGoogleList'); + searchPlaceImage = vi.spyOn(nestApp.get(PlacesService), 'searchImage'); }); afterAll(async () => { @@ -894,7 +896,7 @@ describe('Google Maps list import', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - vi.mocked(placeService.importGoogleList).mockResolvedValueOnce({ + importGoogleList.mockResolvedValueOnce({ places: [{ id: 1, name: 'Mocked Place' } as any], listName: 'My List', } as any); @@ -912,7 +914,7 @@ describe('Google Maps list import', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - vi.mocked(placeService.importGoogleList).mockResolvedValueOnce({ + importGoogleList.mockResolvedValueOnce({ error: 'Invalid list URL', status: 422, } as any); @@ -929,7 +931,7 @@ describe('Google Maps list import', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - vi.mocked(placeService.importGoogleList).mockRejectedValueOnce(new Error('Network failure')); + importGoogleList.mockRejectedValueOnce(new Error('Network failure')); const res = await request(app) .post(`/api/trips/${trip.id}/places/import/google-list`) @@ -949,7 +951,7 @@ describe('Place image search', () => { const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); - vi.mocked(placeService.searchPlaceImage).mockResolvedValueOnce({ + searchPlaceImage.mockResolvedValueOnce({ photos: [{ url: 'https://example.com/photo.jpg' }], } as any); @@ -965,7 +967,7 @@ describe('Place image search', () => { const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Tower' }); - vi.mocked(placeService.searchPlaceImage).mockResolvedValueOnce({ + searchPlaceImage.mockResolvedValueOnce({ error: 'No images found', status: 404, } as any); @@ -982,7 +984,7 @@ describe('Place image search', () => { const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Bridge' }); - vi.mocked(placeService.searchPlaceImage).mockRejectedValueOnce(new Error('Unsplash down')); + searchPlaceImage.mockRejectedValueOnce(new Error('Unsplash down')); const res = await request(app) .get(`/api/trips/${trip.id}/places/${place.id}/image`) diff --git a/server/tests/unit/mcp/resources.test.ts b/server/tests/unit/mcp/resources.test.ts index dcdc0fd94..df0ac29d7 100644 --- a/server/tests/unit/mcp/resources.test.ts +++ b/server/tests/unit/mcp/resources.test.ts @@ -86,35 +86,9 @@ async function withHarness(userId: number, fn: (harness: McpHarness) => Promise< // The trek://trips/{tripId}/days resource moved to the DI-discovered // DaysMcp — see tools-days.test.ts. -describe('Resource: trek://trips/{tripId}/places', () => { - it('returns all places for a trip', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); - createPlace(testDb, trip.id, { name: 'Louvre' }); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/places` }); - const places = parseResourceResult(result) as any[]; - expect(places).toHaveLength(2); - const names = places.map((p) => p.name); - expect(names).toContain('Eiffel Tower'); - expect(names).toContain('Louvre'); - }); - }); - - it('returns access denied for unauthorized trip', async () => { - const { user } = createUser(testDb); - const { user: other } = createUser(testDb); - const trip = createTrip(testDb, other.id); - - await withHarness(user.id, async (harness) => { - const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/places` }); - const data = parseResourceResult(result) as any; - expect(data.error).toBeTruthy(); - }); - }); -}); +// The trek://trips/{tripId}/places resource moved to the DI-discovered +// PlacesMcp — its cases live in tools-places.test.ts (the registry attaches +// via registerTools, which this withTools:false harness skips). // The trek://trips/{tripId}/budget resource moved to the DI-discovered // src/nest/budget/budget.mcp.ts — its cases live in tools-budget.test.ts (the diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index b163d3309..d8b5b4aa2 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -1,6 +1,11 @@ /** * Unit tests for MCP place tools: create_place, update_place, delete_place, search_place. * (list_categories moved to tools-categories.test.ts with the CategoriesMcp migration.) + * + * Since the place DI fold these run through the decorator-driven PlacesMcp in + * src/nest/places/places.mcp.ts — the harness attaches it via the hand-wired + * registry in tests/helpers/mcp-test-controllers.ts, so the assertions below + * exercise the @Tool/@ResourceTemplate path instead of the deleted registrar. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -39,20 +44,17 @@ const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); const { searchPlacesMock } = vi.hoisted(() => ({ searchPlacesMock: vi.fn() })); -// The maps bridge is what the legacy places registrar (and placeEnrichment, in -// the same import graph) consume since the maps DI fold — mock every export it -// provides so the named imports resolve. -vi.mock('../../../src/nest/maps/maps.bridge', () => ({ - searchPlaces: searchPlacesMock, - getMapsKey: vi.fn(() => null), - getPlacePhoto: vi.fn(async () => ({ photoUrl: null, attribution: null })), -})); +// PlacesMcp and PlacesService both inject MapsService (search_place and the +// import enrichment), so the geo calls are stubbed on the prototype — see +// beforeEach — rather than through a module mock. import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createPlace, createDay, createDayAssignment, createJourney } from '../../helpers/factories'; -import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; +import { MapsService } from '../../../src/nest/maps/maps.service'; +import { PlacesService } from '../../../src/nest/places/places.service'; /** Link a journey to a trip so journey-skeleton sync has a target. */ function linkJourney(journeyId: number, tripId: number) { @@ -71,6 +73,7 @@ beforeEach(() => { resetTestDb(testDb); broadcastMock.mockClear(); searchPlacesMock.mockClear(); + vi.spyOn(MapsService.prototype, 'searchPlaces').mockImplementation(searchPlacesMock as never); delete process.env.DEMO_MODE; }); @@ -512,3 +515,310 @@ describe('Tool: list_places', () => { }); }); }); + +// --------------------------------------------------------------------------- +// rate_place (#1435) +// --------------------------------------------------------------------------- + +describe('Tool: rate_place', () => { + it('stores the acting user\'s vote and reports the aggregate', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Rated' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'rate_place', arguments: { tripId: trip.id, placeId: place.id, rating: 4 } }); + const data = parseToolResult(result) as any; + expect(data.place.id).toBe(place.id); + const rows = testDb.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(place.id); + expect(rows).toEqual([{ user_id: user.id, rating: 4 }]); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:updated', expect.any(Object)); + }); + }); + + it('an omitted rating clears the vote', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Rated' }); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'rate_place', arguments: { tripId: trip.id, placeId: place.id, rating: 5 } }); + const result = await h.client.callTool({ name: 'rate_place', arguments: { tripId: trip.id, placeId: place.id } }); + const data = parseToolResult(result) as any; + expect(data.place.id).toBe(place.id); + const rows = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number }; + expect(rows.n).toBe(0); + }); + }); + + it('returns error for a place outside the trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const place = createPlace(testDb, other.id, { name: 'Elsewhere' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'rate_place', arguments: { tripId: trip.id, placeId: place.id, rating: 3 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// bulk_delete_places +// --------------------------------------------------------------------------- + +describe('Tool: bulk_delete_places', () => { + it('deletes the trip-scoped ids, skips foreign ones and broadcasts each', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const a = createPlace(testDb, trip.id, { name: 'A' }); + const b = createPlace(testDb, trip.id, { name: 'B' }); + const foreign = createPlace(testDb, other.id, { name: 'Foreign' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'bulk_delete_places', + arguments: { tripId: trip.id, placeIds: [a.id, b.id, foreign.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.count).toBe(2); + expect(data.deleted.sort()).toEqual([a.id, b.id].sort()); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:deleted', { placeId: a.id, _source: 'mcp' }); + // The other trip's place survives. + expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(foreign.id)).toBeTruthy(); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const place = createPlace(testDb, trip.id, { name: 'Theirs' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'bulk_delete_places', arguments: { tripId: trip.id, placeIds: [place.id] } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// trek://trips/{tripId}/places resource (moved from resources.test.ts) +// --------------------------------------------------------------------------- + +describe('Resource: trek://trips/{tripId}/places', () => { + it('returns all places for a trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); + createPlace(testDb, trip.id, { name: 'Louvre' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/places` }); + const places = parseResourceResult(result) as any[]; + expect(places).toHaveLength(2); + const names = places.map((p) => p.name); + expect(names).toContain('Eiffel Tower'); + expect(names).toContain('Louvre'); + }); + }); + + // The ?assignment= filter the description advertises is read off + // uri.searchParams inside the handler, but the SDK's template matcher rejects + // a URI carrying a query string before the handler runs — same as the legacy + // resource this was moved from, so it stays untested here. + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/places` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// import_places_from_url +// --------------------------------------------------------------------------- + +describe('Tool: import_places_from_url', () => { + it('imports a google list, broadcasts each place and reports the count', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const spy = vi.spyOn(PlacesService.prototype, 'importGoogleList').mockResolvedValue({ + places: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] as never, + listName: 'Weekend', + skipped: 3, + }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'import_places_from_url', + arguments: { tripId: trip.id, url: 'https://maps.app.goo.gl/x', source: 'google-list' }, + }); + const data = parseToolResult(result) as any; + expect(data).toMatchObject({ count: 2, listName: 'Weekend', skipped: 3 }); + expect(spy).toHaveBeenCalledWith(String(trip.id), 'https://maps.app.goo.gl/x'); + expect(broadcastMock).toHaveBeenCalledTimes(2); + }); + spy.mockRestore(); + }); + + it('surfaces the naver importer\'s { error, status } as a tool error', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const spy = vi.spyOn(PlacesService.prototype, 'importNaverList').mockResolvedValue({ error: 'List is empty or could not be read', status: 400 }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'import_places_from_url', + arguments: { tripId: trip.id, url: 'https://naver.me/x', source: 'naver-list' }, + }); + expect(result.isError).toBe(true); + expect((result.content as { text: string }[])[0].text).toBe('List is empty or could not be read'); + }); + spy.mockRestore(); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'import_places_from_url', + arguments: { tripId: trip.id, url: 'https://maps.app.goo.gl/x', source: 'google-list' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// create_and_assign_place failure path +// --------------------------------------------------------------------------- + +describe('Tool: create_and_assign_place (failure paths)', () => { + it('reports a missing day without writing the place', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_and_assign_place', + arguments: { tripId: trip.id, dayId: 99999, name: 'Nowhere' }, + }); + expect(result.isError).toBe(true); + expect((result.content as { text: string }[])[0].text).toBe('Day not found.'); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 }); + }); + }); + + it('rolls the transaction back and reports failure when the place write throws', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const spy = vi.spyOn(PlacesService.prototype, 'create').mockImplementation(() => { throw new Error('db exploded'); }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_and_assign_place', + arguments: { tripId: trip.id, dayId: day.id, name: 'Doomed' }, + }); + expect(result.isError).toBe(true); + expect((result.content as { text: string }[])[0].text).toBe('Failed to create place and assignment.'); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ?').get(day.id)).toEqual({ n: 0 }); + }); + spy.mockRestore(); + }); +}); + +// --------------------------------------------------------------------------- +// Journey-hook scoping + ordering on the delete paths (#1745) +// --------------------------------------------------------------------------- + +/** Materialise a skeleton entry for an assigned place (same shape the sync writes). */ +function seedSkeleton(journeyId: number, tripId: number, placeId: number, userId: number, name: string) { + testDb.prepare( + `INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, 'skeleton', ?, ?, 0, ?, ?)`, + ).run(journeyId, tripId, placeId, userId, name, '2026-05-01', Date.now(), Date.now()); +} + +describe('journey hooks on the MCP delete paths', () => { + it("delete_place leaves a foreign trip's journey skeleton alone", async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const journey = createJourney(testDb, user.id); + linkJourney(journey.id, other.id); + const foreign = createPlace(testDb, other.id, { name: 'Theirs' }); + const day = createDay(testDb, other.id); + createDayAssignment(testDb, day.id, foreign.id); + seedSkeleton(journey.id, other.id, foreign.id, user.id, 'Theirs'); + expect(skeletonFor(journey.id, foreign.id)).toBeDefined(); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: foreign.id } }); + expect(result.isError).toBe(true); + }); + + // The other trip's skeleton entry still points at its place, and the row lives. + expect(skeletonFor(journey.id, foreign.id)).toBeDefined(); + expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(foreign.id)).toBeDefined(); + }); + + // Without the ordering fix the hook ran AFTER the DELETE, by which point + // journey_entries.source_place_id was already NULL (ON DELETE SET NULL) — so + // the entry survived as an orphan instead of being removed. Asserting on the + // entry count, not on source_place_id, is what tells the two apart. + it('bulk_delete_places detaches the skeletons before the rows go', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const journey = createJourney(testDb, user.id); + linkJourney(journey.id, trip.id); + const place = createPlace(testDb, trip.id, { name: 'Doomed' }); + const day = createDay(testDb, trip.id); + createDayAssignment(testDb, day.id, place.id); + seedSkeleton(journey.id, trip.id, place.id, user.id, 'Doomed'); + expect(skeletonFor(journey.id, place.id)).toBeDefined(); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'bulk_delete_places', arguments: { tripId: trip.id, placeIds: [place.id] } }); + expect((parseToolResult(result) as any).count).toBe(1); + }); + + // The hook ran while the row still existed, so the entry is gone — not + // lingering with a NULL source_place_id. + expect(skeletonFor(journey.id, place.id)).toBeUndefined(); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM journey_entries WHERE journey_id = ?').get(journey.id)).toEqual({ n: 0 }); + }); + + // The MCP bulk path already scoped correctly (it looped over removeMany's + // trip-scoped result), so this is a guard against the scoping regressing when + // the hook moved ahead of the delete — not a fix for a live bug. + it('bulk_delete_places leaves a foreign trip\'s skeleton alone', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const journey = createJourney(testDb, user.id); + linkJourney(journey.id, other.id); + const foreign = createPlace(testDb, other.id, { name: 'Theirs' }); + const day = createDay(testDb, other.id); + createDayAssignment(testDb, day.id, foreign.id); + seedSkeleton(journey.id, other.id, foreign.id, user.id, 'Theirs'); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'bulk_delete_places', arguments: { tripId: trip.id, placeIds: [foreign.id] } }); + expect((parseToolResult(result) as any).count).toBe(0); + }); + + expect(skeletonFor(journey.id, foreign.id)).toBeDefined(); + }); +}); diff --git a/server/tests/unit/nest/booking-import/booking-import.service.test.ts b/server/tests/unit/nest/booking-import/booking-import.service.test.ts index 4e9792498..3f00e7e00 100644 --- a/server/tests/unit/nest/booking-import/booking-import.service.test.ts +++ b/server/tests/unit/nest/booking-import/booking-import.service.test.ts @@ -9,7 +9,6 @@ import { DatabaseService } from '../../../../src/nest/database/database.service' vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() })); const permissionsStub = { checkPermission: vi.fn(() => true) }; vi.mock('../../../../src/services/tripAccess', () => ({ verifyTripAccess: vi.fn() })); -vi.mock('../../../../src/services/placeService', () => ({ createPlace: vi.fn() })); import { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service'; @@ -23,7 +22,10 @@ function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any }) // budget/addons/realtime/maps ride the confirm() path only — the preview() // tests never reach them, so stubs beyond the positional slots aren't needed. const maps = { searchNominatim: vi.fn() }; - return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn), reservations as never, permissionsStub as never, undefined as never, undefined as never, undefined as never, maps as never), extractor, llmParse, reservations, maps }; + // Places became a constructor dep with the place DI fold (was a path mock of + // services/placeService); only confirm() reaches it, so a bare create stub does. + const places = { create: vi.fn() }; + return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn), reservations as never, permissionsStub as never, undefined as never, undefined as never, undefined as never, maps as never, places as never), extractor, llmParse, reservations, maps, places }; } beforeEach(() => vi.clearAllMocks()); diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index 77b8300fb..d9b55f9b9 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -2247,42 +2247,7 @@ describe('controller-facing wrappers delegate to the folded methods', () => { }); }); -// ── maps.bridge delegation ──────────────────────────────────────────────────── - -describe('maps.bridge delegation', () => { - it('MAPS-104: getMapsKey delegates to the shared MapsService instance', async () => { - const bridge = await import('../../../src/nest/maps/maps.bridge'); - const spy = vi.spyOn(MapsService.prototype, 'getMapsKey').mockReturnValueOnce('bridged-key'); - try { - expect(bridge.getMapsKey(7)).toBe('bridged-key'); - expect(spy).toHaveBeenCalledWith(7); - } finally { - spy.mockRestore(); - } - }); - - it('MAPS-105: searchPlaces delegates with all args', async () => { - const bridge = await import('../../../src/nest/maps/maps.bridge'); - const spy = vi.spyOn(MapsService.prototype, 'searchPlaces').mockResolvedValueOnce({ places: [], source: 'osm' }); - try { - await expect(bridge.searchPlaces(7, 'berlin', 'de', { lat: 1, lng: 2, radius: 3 })).resolves.toEqual({ - places: [], - source: 'osm', - }); - expect(spy).toHaveBeenCalledWith(7, 'berlin', 'de', { lat: 1, lng: 2, radius: 3 }); - } finally { - spy.mockRestore(); - } - }); - - it('MAPS-106: getPlacePhoto delegates with coords and name', async () => { - const bridge = await import('../../../src/nest/maps/maps.bridge'); - const spy = vi.spyOn(MapsService.prototype, 'getPlacePhoto').mockResolvedValueOnce({ photoUrl: null, attribution: null }); - try { - await expect(bridge.getPlacePhoto(7, 'p1', 1.5, 2.5, 'Spot')).resolves.toEqual({ photoUrl: null, attribution: null }); - expect(spy).toHaveBeenCalledWith(7, 'p1', 1.5, 2.5, 'Spot'); - } finally { - spy.mockRestore(); - } - }); -}); +// The maps.bridge delegation cases (MAPS-104..106) died with the bridge itself: +// its last two consumers — the legacy placeEnrichment helper and the places MCP +// registrar — both folded into the DI-native places domain, which injects +// MapsService directly. diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index 32a6e23d4..6bc073a4f 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -11,6 +11,9 @@ function svc(o: Partial = {}): PlacesService { return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), onCreated: vi.fn(), onUpdated: vi.fn(), onDeleted: vi.fn(), + // Trip-scoping reads the delete paths run before firing the journey hook + // (#1745); default to "everything belongs to the trip". + get: vi.fn().mockReturnValue({ id: 9 }), scopedIds: vi.fn((_t: string, ids: number[]) => ids), ...o, } as unknown as PlacesService; } @@ -51,9 +54,11 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou expect(canEdit).not.toHaveBeenCalled(); }); - it('403 without place_edit, 400 without name, then creates + hooks', () => { + // The legacy 'Place name is required' 400 is gone: placeCreateRequestSchema + // pins `name`, so the ZodValidationPipe rejects a nameless body before the + // handler runs (see the e2e suite for the envelope it produces). + it('403 without place_edit, then creates + hooks', () => { expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) })).create(user, '5', { name: 'Spot' }))).toEqual({ status: 403, body: { error: 'No permission' } }); - expect(thrown(() => new PlacesController(svc()).create(user, '5', {}))).toEqual({ status: 400, body: { error: 'Place name is required' } }); const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); const onCreated = vi.fn(); const s = svc({ create, broadcast, onCreated } as Partial); expect(new PlacesController(s).create(user, '5', { name: 'Spot' }, 'sock')).toEqual({ place: { id: 9 } }); @@ -136,58 +141,70 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou }); describe('POST /import/google-list + naver-list', () => { - it('400 without a url', async () => { - expect(await thrownAsync(() => new PlacesController(svc()).importGoogle(user, '5', undefined))).toEqual({ status: 400, body: { error: 'URL is required' } }); - }); - it('400 when url is the wrong type (not a string)', async () => { - expect(await thrownAsync(() => new PlacesController(svc()).importNaver(user, '5', 123))).toEqual({ status: 400, body: { error: 'URL is required' } }); - }); + // The legacy 'URL is required' 400 is gone: placeImportListRequestSchema + // pins `url`, so the ZodValidationPipe rejects a urlless body before the + // handler runs. it('maps a service { error, status } to the same response', async () => { const s = svc({ importGoogleList: vi.fn().mockResolvedValue({ error: 'List is empty', status: 400 }) } as Partial); - expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({ status: 400, body: { error: 'List is empty' } }); + expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', { url: 'http://x' }))).toEqual({ status: 400, body: { error: 'List is empty' } }); }); it('imports a naver list and returns the count + listName', async () => { const s = svc({ importNaverList: vi.fn().mockResolvedValue({ places: [{ id: 1 }], listName: 'Trip', skipped: 2 }), broadcast: vi.fn() } as Partial); - expect(await new PlacesController(s).importNaver(user, '5', 'http://x')).toEqual({ places: [{ id: 1 }], count: 1, listName: 'Trip', skipped: 2 }); + expect(await new PlacesController(s).importNaver(user, '5', { url: 'http://x' })).toEqual({ places: [{ id: 1 }], count: 1, listName: 'Trip', skipped: 2 }); }); it('forwards the enrich flag + userId and broadcasts each imported place', async () => { const importGoogleList = vi.fn().mockResolvedValue({ places: [{ id: 1 }, { id: 2 }], listName: 'L', skipped: 0 }); const broadcast = vi.fn(); const s = svc({ importGoogleList, broadcast } as Partial); - expect(await new PlacesController(s).importGoogle(user, '5', 'http://x', 'true', 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, listName: 'L', skipped: 0 }); + expect(await new PlacesController(s).importGoogle(user, '5', { url: 'http://x', enrich: true }, 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, listName: 'L', skipped: 0 }); expect(importGoogleList).toHaveBeenCalledWith('5', 'http://x', { enrich: true, userId: 1 }); expect(broadcast).toHaveBeenCalledTimes(2); }); it('wraps a thrown Error in the provider-specific 400 (Google)', async () => { const s = svc({ importGoogleList: vi.fn().mockRejectedValue(new Error('network down')) } as Partial); - expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({ + expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', { url: 'http://x' }))).toEqual({ status: 400, body: { error: 'Failed to import Google Maps list. Make sure the list is shared publicly.' }, }); }); it('wraps a non-Error rejection in the provider-specific 400 (Naver)', async () => { const s = svc({ importNaverList: vi.fn().mockRejectedValue('weird') } as Partial); - expect(await thrownAsync(() => new PlacesController(s).importNaver(user, '5', 'http://x'))).toEqual({ + expect(await thrownAsync(() => new PlacesController(s).importNaver(user, '5', { url: 'http://x' }))).toEqual({ status: 400, body: { error: 'Failed to import Naver Maps list. Make sure the list is shared publicly.' }, }); }); }); describe('POST /bulk-delete', () => { - it('400 when ids is not an array of numbers', () => { - expect(thrown(() => new PlacesController(svc()).bulkDelete(user, '5', ['a']))).toEqual({ status: 400, body: { error: 'ids must be an array of numbers' } }); - }); + // The legacy 'ids must be an array of numbers' 400 is gone: + // placeBulkDeleteRequestSchema types the array, so the pipe rejects a bad + // element before the handler runs. it('returns empty for an empty list without touching the service', () => { const removeMany = vi.fn(); - expect(new PlacesController(svc({ removeMany } as Partial)).bulkDelete(user, '5', [])).toEqual({ deleted: [], count: 0 }); + expect(new PlacesController(svc({ removeMany } as Partial)).bulkDelete(user, '5', { ids: [] })).toEqual({ deleted: [], count: 0 }); expect(removeMany).not.toHaveBeenCalled(); }); it('deletes, fires hooks + broadcasts per deleted id', () => { const removeMany = vi.fn().mockReturnValue([1, 2]); const onDeleted = vi.fn(); const broadcast = vi.fn(); - const s = svc({ removeMany, onDeleted, broadcast } as Partial); - expect(new PlacesController(s).bulkDelete(user, '5', [1, 2], 'sock')).toEqual({ deleted: [1, 2], count: 2 }); + const scopedIds = vi.fn().mockReturnValue([1, 2]); + const s = svc({ removeMany, onDeleted, broadcast, scopedIds } as Partial); + expect(new PlacesController(s).bulkDelete(user, '5', { ids: [1, 2] }, 'sock')).toEqual({ deleted: [1, 2], count: 2 }); expect(onDeleted).toHaveBeenCalledTimes(2); expect(broadcast).toHaveBeenCalledTimes(2); }); + + // #1745: the hook keys on the place id alone, so an id from another trip + // would detach that trip's journey entries even though removeMany skips it. + it('fires the journey hook only for ids that belong to the trip, ahead of the delete', () => { + const removeMany = vi.fn().mockReturnValue([1]); + const scopedIds = vi.fn().mockReturnValue([1]); + const onDeleted = vi.fn(); + const s = svc({ removeMany, scopedIds, onDeleted, broadcast: vi.fn() } as Partial); + new PlacesController(s).bulkDelete(user, '5', { ids: [1, 99] }); + expect(scopedIds).toHaveBeenCalledWith('5', [1, 99]); + expect(onDeleted).toHaveBeenCalledTimes(1); + expect(onDeleted).toHaveBeenCalledWith(1); + expect(onDeleted.mock.invocationCallOrder[0]).toBeLessThan(removeMany.mock.invocationCallOrder[0]); + }); }); describe('POST /bulk-update', () => { @@ -195,9 +212,9 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou expect(thrown(() => new PlacesController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).bulkUpdate(user, '5', { ids: [1], category_id: 3 }))).toEqual({ status: 404, body: { error: 'Trip not found' } }); expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) })).bulkUpdate(user, '5', { ids: [1], category_id: 3 }))).toEqual({ status: 403, body: { error: 'No permission' } }); }); - it('400 when ids is not an array of numbers', () => { - expect(thrown(() => new PlacesController(svc()).bulkUpdate(user, '5', { ids: ['a'] }))).toEqual({ status: 400, body: { error: 'ids must be an array of numbers' } }); - }); + // Same as bulk-delete: placeBulkUpdateRequestSchema types `ids`, so the + // pipe owns that 400 now. `.min(1)` was deliberately left off the schema so + // the empty-list short-circuit below stays reachable. it('400 when no patch field is present', () => { expect(thrown(() => new PlacesController(svc()).bulkUpdate(user, '5', { ids: [1] }))).toEqual({ status: 400, body: { error: 'Provide at least one field to update' } }); }); @@ -281,12 +298,25 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou it('DELETE /:id fires the hook then 404 / success', () => { const onDeleted = vi.fn(); - expect(thrown(() => new PlacesController(svc({ remove: vi.fn().mockReturnValue(false), onDeleted } as Partial)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } }); + const remove = vi.fn().mockReturnValue(false); + expect(thrown(() => new PlacesController(svc({ remove, onDeleted } as Partial)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } }); expect(onDeleted).toHaveBeenCalledWith(9); + expect(onDeleted.mock.invocationCallOrder[0]).toBeLessThan(remove.mock.invocationCallOrder[0]); const s = svc({ remove: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial); expect(new PlacesController(s).remove(user, '5', '9')).toEqual({ success: true }); }); + // #1745: a place on another trip must 404 without the hook ever running — + // onPlaceDeleted keys on the place id alone, so it would detach that trip's + // journey entries. + it('DELETE /:id 404s a foreign place before the journey hook runs', () => { + const onDeleted = vi.fn(); const remove = vi.fn(); + const s = svc({ get: vi.fn().mockReturnValue(null), onDeleted, remove } as Partial); + expect(thrown(() => new PlacesController(s).remove(user, '5', '99'))).toEqual({ status: 404, body: { error: 'Place not found' } }); + expect(onDeleted).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + }); + it('GET /:id/image maps service error + returns photos', async () => { const s = svc({ searchImage: vi.fn().mockResolvedValue({ photos: [{ url: 'x' }] }) } as Partial); expect(await new PlacesController(s).image(user, '5', '9')).toEqual({ photos: [{ url: 'x' }] }); diff --git a/server/tests/unit/services/placeEnrichment.test.ts b/server/tests/unit/nest/places.enrichment.test.ts similarity index 69% rename from server/tests/unit/services/placeEnrichment.test.ts rename to server/tests/unit/nest/places.enrichment.test.ts index fbe60e72f..84a58aaf4 100644 --- a/server/tests/unit/services/placeEnrichment.test.ts +++ b/server/tests/unit/nest/places.enrichment.test.ts @@ -2,20 +2,14 @@ * Unit tests for the import-enrichment match selector (#886). * Covers PENRICH-001 to PENRICH-004 — the coordinate-validation guard that * prevents a name search from overwriting an imported place with the wrong POI. + * + * Moved from tests/unit/services/placeEnrichment.test.ts when the helper folded + * into the places domain: the selector is pure, so it now lives in + * places.helpers.ts and needs no DB/websocket/Maps stubbing at all. */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; -// placeEnrichment pulls in the DB, websocket and the maps bridge at import time; -// stub them so the pure match selector can be tested in isolation. -vi.mock('../../../src/db/database', () => ({ db: {}, getPlaceWithTags: () => null })); -vi.mock('../../../src/websocket', () => ({ broadcast: () => {} })); -vi.mock('../../../src/nest/maps/maps.bridge', () => ({ - getMapsKey: () => null, - searchPlaces: async () => ({ places: [], source: 'none' }), - getPlacePhoto: async () => ({ photoUrl: '', attribution: null }), -})); - -import { pickEnrichmentMatch } from '../../../src/services/placeEnrichment'; +import { pickEnrichmentMatch } from '../../../src/nest/places/places.helpers'; const target = { lat: 48.85, lng: 2.35 }; diff --git a/server/tests/unit/nest/places.helpers.test.ts b/server/tests/unit/nest/places.helpers.test.ts new file mode 100644 index 000000000..03a11c12f --- /dev/null +++ b/server/tests/unit/nest/places.helpers.test.ts @@ -0,0 +1,169 @@ +/** + * Unit tests for the pure places helpers (moved from + * tests/unit/services/kmzUnpack.test.ts when placeService went DI-native — the + * KMZ unpacker touches no DB, so it lives in places.helpers.ts). + */ +import { describe, it, expect, vi } from 'vitest'; +import path from 'path'; +import fs from 'fs'; + +vi.mock('../../../src/db/database', () => ({ + db: { prepare: vi.fn() }, + getPlaceWithTags: vi.fn(), +})); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +import { + COORD_DEDUP_TOLERANCE, + googleMapsFeatureIdFromItem, + googleMapsHexId, + isPlaceDuplicate, + KMZ_DECOMPRESSED_SIZE_LIMIT, + mapWithConcurrency, + trackInsertedInDedupSet, + trimOrNull, + unpackKmzToKml, + type DedupSet, +} from '../../../src/nest/places/places.helpers'; + +const KMZ_FIXTURE = path.join(__dirname, '../../fixtures/test.kmz'); + +describe('unpackKmzToKml', () => { + it('extracts the KML entry from a valid KMZ', async () => { + const kmzBuffer = fs.readFileSync(KMZ_FIXTURE); + const kmlBuffer = await unpackKmzToKml(kmzBuffer); + expect(kmlBuffer.length).toBeGreaterThan(0); + expect(kmlBuffer.toString('utf-8')).toContain(' { + const kmzBuffer = fs.readFileSync(KMZ_FIXTURE); + // test.kmz contains a KML with uncompressedSize 634 — set limit to 1 byte + await expect(unpackKmzToKml(kmzBuffer, 1)).rejects.toThrow('exceeds the maximum allowed decompressed size'); + }); + + it('rejects a KMZ that contains no KML file', async () => { + // Craft a minimal ZIP containing only a non-KML entry using raw ZIP bytes + // We use the test GPX fixture (a real file) re-zipped via Node's zlib/archiver + // Simplest: a KMZ whose only file has a .txt extension + const Archiver = await import('archiver'); + const archiver = Archiver.default; + const { PassThrough } = await import('stream'); + + const chunks: Buffer[] = []; + const output = new PassThrough(); + output.on('data', (chunk) => chunks.push(chunk)); + + const archive = archiver('zip', { zlib: { level: 1 } }); + archive.pipe(output); + archive.append(Buffer.from('not a kml'), { name: 'data.txt' }); + await archive.finalize(); + + const zipBuffer = Buffer.concat(chunks); + await expect(unpackKmzToKml(zipBuffer)).rejects.toThrow('does not contain a KML file'); + }); + + it('rejects a buffer that is not a valid ZIP archive', async () => { + await expect(unpackKmzToKml(Buffer.from('this is not a zip'))).rejects.toThrow('Invalid KMZ archive'); + }); + + it('exports KMZ_DECOMPRESSED_SIZE_LIMIT as 50 MB', () => { + expect(KMZ_DECOMPRESSED_SIZE_LIMIT).toBe(50 * 1024 * 1024); + }); +}); + +// ── Import dedup predicates ─────────────────────────────────────────────────── + +const emptyDedup = (): DedupSet => ({ names: new Set(), coords: [] }); + +describe('isPlaceDuplicate / trackInsertedInDedupSet', () => { + it('matches a named place case- and whitespace-insensitively', () => { + const dedup = emptyDedup(); + trackInsertedInDedupSet({ name: ' Eiffel Tower ', lat: 1, lng: 2 }, dedup); + expect(isPlaceDuplicate({ name: 'eiffel tower', lat: null, lng: null }, dedup)).toBe(true); + expect(isPlaceDuplicate({ name: 'Louvre', lat: null, lng: null }, dedup)).toBe(false); + }); + + it('falls back to coordinate proximity for unnamed places', () => { + const dedup = emptyDedup(); + // No name — tracked by coordinates instead. + trackInsertedInDedupSet({ name: null, lat: 48.85, lng: 2.35 }, dedup); + expect(dedup.names.size).toBe(0); + expect(isPlaceDuplicate({ name: undefined, lat: 48.85 + COORD_DEDUP_TOLERANCE / 2, lng: 2.35 }, dedup)).toBe(true); + expect(isPlaceDuplicate({ name: undefined, lat: 48.9, lng: 2.35 }, dedup)).toBe(false); + }); + + it('a named candidate never falls through to the coordinate check', () => { + const dedup = emptyDedup(); + trackInsertedInDedupSet({ name: null, lat: 48.85, lng: 2.35 }, dedup); + // Same spot, but it carries a name — name lookup misses, so it is not a dup. + expect(isPlaceDuplicate({ name: 'Named', lat: 48.85, lng: 2.35 }, dedup)).toBe(false); + }); + + it('a candidate with neither a name nor coordinates is never a duplicate', () => { + expect(isPlaceDuplicate({ name: null, lat: null, lng: null }, emptyDedup())).toBe(false); + }); +}); + +// ── Google Maps feature ids ─────────────────────────────────────────────────── + +describe('googleMapsHexId / googleMapsFeatureIdFromItem', () => { + it('passes an already-hex id through lower-cased', () => { + expect(googleMapsHexId('0x882BF179E806D471')).toBe('0x882bf179e806d471'); + }); + + it("converts Google's signed 64-bit decimals to unsigned hex", () => { + expect(googleMapsHexId('-8634542354666695567')).toBe('0x882bf179e806d471'); + expect(googleMapsHexId(255)).toBe('0xff'); + }); + + it('rejects anything that is not a hex or decimal id', () => { + expect(googleMapsHexId('not-an-id')).toBeNull(); + expect(googleMapsHexId(null)).toBeNull(); + expect(googleMapsHexId({})).toBeNull(); + }); + + it('reads the ftid pair from either item slot, else null', () => { + expect(googleMapsFeatureIdFromItem([null, [0, 0, 0, 0, 0, 0, ['0x1', '0x2']]])).toBe('0x1:0x2'); + expect(googleMapsFeatureIdFromItem([null, null, null, null, null, null, null, [null, ['0x3', '0x4']]])).toBe('0x3:0x4'); + expect(googleMapsFeatureIdFromItem([null, [0, 0, 0, 0, 0, 0, ['0x1']]])).toBeNull(); + expect(googleMapsFeatureIdFromItem('not an array')).toBeNull(); + }); +}); + +// ── Enrichment plumbing ─────────────────────────────────────────────────────── + +describe('mapWithConcurrency / trimOrNull', () => { + it('visits every item and never runs more than `limit` at once', async () => { + const items = [1, 2, 3, 4, 5, 6, 7]; + const seen: number[] = []; + let inFlight = 0; + let peak = 0; + await mapWithConcurrency(items, 3, async (n) => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await Promise.resolve(); + seen.push(n); + inFlight -= 1; + }); + expect(seen.sort((a, b) => a - b)).toEqual(items); + expect(peak).toBeLessThanOrEqual(3); + }); + + it('is a no-op for an empty list', async () => { + const fn = vi.fn(); + await mapWithConcurrency([], 3, fn); + expect(fn).not.toHaveBeenCalled(); + }); + + it('trimOrNull keeps real strings and nulls everything else', () => { + expect(trimOrNull(' Paris ')).toBe('Paris'); + expect(trimOrNull(' ')).toBeNull(); + expect(trimOrNull(42)).toBeNull(); + expect(trimOrNull(undefined)).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index f47d4eb26..b461edbd0 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -1,15 +1,15 @@ /** - * Unit tests for PlacesService — PLACES-SVC-001 through PLACES-SVC-005. - * - * Covers the one piece of logic the Nest wrapper owns rather than delegates: - * handing every freshly imported track its own colour (#776). The importers - * never assign a category, so without this each track in a trip renders in the - * same blue and several walks in one area are impossible to tell apart. + * Unit tests for PlacesService — the place SQL, the GPX/KML importers, the + * Google-list importer, the Unsplash image search and the collaborative + * ratings (PLACE-SVC-*, moved verbatim from tests/unit/services/placeService.test.ts + * when the domain went DI-native) plus the automatic track colours the Nest + * service already owned (PLACES-SVC-*, #776). * * Uses a real in-memory SQLite DB so the SQL is exercised faithfully; the - * service is constructed directly, no Nest container needed. + * service is constructed directly, no Nest container needed. External fetches + * are mocked where needed. */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; import { TRACK_COLORS } from '@trek/shared'; // ── DB setup ────────────────────────────────────────────────────────────────── @@ -24,9 +24,19 @@ const { testDb, dbMock } = vi.hoisted(() => { db, closeDb: () => {}, reinitialize: () => {}, - getPlaceWithTags: (placeId: any) => db.prepare('SELECT * FROM places WHERE id = ?').get(placeId) ?? null, - canAccessTrip: () => null, - isOwner: () => false, + getPlaceWithTags: (placeId: any) => { + const place: any = db.prepare(` + SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon + FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ? + `).get(placeId); + if (!place) return null; + const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId); + return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags }; + }, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; return { testDb: db, dbMock: mock }; }); @@ -38,12 +48,836 @@ vi.mock('../../../src/config', () => ({ updateJwtSecret: () => {}, })); +// Spy on the photo-cache reclaim hook so delete tests assert the wiring without +// touching disk. The removal logic itself is covered in placePhotoCache.test.ts. +const { removeIfUnreferencedSpy } = vi.hoisted(() => ({ removeIfUnreferencedSpy: vi.fn() })); +vi.mock('../../../src/services/placePhotoCache', async (importOriginal) => ({ + ...(await importOriginal()), + removeIfUnreferenced: removeIfUnreferencedSpy, +})); + import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createTrip } from '../../helpers/factories'; +import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories'; +import path from 'path'; +import fs from 'fs'; import { DatabaseService } from '../../../src/nest/database/database.service'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { PlacesService } from '../../../src/nest/places/places.service'; +import { MapsService } from '../../../src/nest/maps/maps.service'; +import { PLACE_IMAGES_DIR } from '../../../src/services/placeImage'; + +const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx'); +const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); + +const dbs = new DatabaseService(testDb); +const svc = new PlacesService(dbs, new PermissionsService(dbs), new RealtimeService(), new MapsService(dbs)); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); +}); + +afterAll(() => { + testDb.close(); +}); + +// ── list ────────────────────────────────────────────────────────────────────── + +describe('list', () => { + it('PLACE-SVC-001 — returns empty array when trip has no places', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(svc.list(String(trip.id), {})).toEqual([]); + }); + + it('PLACE-SVC-002 — returns all places for a trip', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createPlace(testDb, trip.id, { name: 'Alpha' }); + createPlace(testDb, trip.id, { name: 'Beta' }); + const places = svc.list(String(trip.id), {}) as any[]; + expect(places).toHaveLength(2); + }); + + it('PLACE-SVC-003 — does not return places from other trips', () => { + const { user } = createUser(testDb); + const t1 = createTrip(testDb, user.id); + const t2 = createTrip(testDb, user.id); + createPlace(testDb, t1.id, { name: 'T1 Place' }); + createPlace(testDb, t2.id, { name: 'T2 Place' }); + const places = svc.list(String(t1.id), {}) as any[]; + expect(places).toHaveLength(1); + expect(places[0].name).toBe('T1 Place'); + }); + + it('PLACE-SVC-004 — filters by search term (name)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); + createPlace(testDb, trip.id, { name: 'Louvre Museum' }); + const places = svc.list(String(trip.id), { search: 'Eiffel' }) as any[]; + expect(places).toHaveLength(1); + expect(places[0].name).toBe('Eiffel Tower'); + }); + + it('PLACE-SVC-005 — attaches tags array to each place (empty when none)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createPlace(testDb, trip.id, { name: 'No Tags' }); + const places = svc.list(String(trip.id), {}) as any[]; + expect(Array.isArray(places[0].tags)).toBe(true); + expect(places[0].tags).toHaveLength(0); + }); + + it('PLACE-SVC-006 — attaches category object when place has a category', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const cat = createCategory(testDb, { name: 'Museum', user_id: user.id }) as any; + const place = createPlace(testDb, trip.id, { name: 'Art Museum' }) as any; + testDb.prepare('UPDATE places SET category_id = ? WHERE id = ?').run(cat.id, place.id); + + const places = svc.list(String(trip.id), {}) as any[]; + expect(places[0].category).toBeDefined(); + expect(places[0].category!.name).toBe('Museum'); + }); +}); + +// ── create ──────────────────────────────────────────────────────────────────── + +describe('create', () => { + it('PLACE-SVC-007 — creates a place and returns it with tags array', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = svc.create(String(trip.id), { name: 'New Place', lat: 48.8, lng: 2.3 }) as any; + expect(place).toBeDefined(); + expect(place.name).toBe('New Place'); + expect(Array.isArray(place.tags)).toBe(true); + }); + + it('PLACE-SVC-008 — creates a place with tags', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const tag = createTag(testDb, user.id, { name: 'Highlight' }) as any; + const place = svc.create(String(trip.id), { name: 'Tagged Place', tags: [tag.id] }) as any; + expect(place.tags).toHaveLength(1); + expect(place.tags[0].id).toBe(tag.id); + }); + + it('PLACE-SVC-009 — place is associated with correct trip', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = svc.create(String(trip.id), { name: 'My Place' }) as any; + const row = testDb.prepare('SELECT trip_id FROM places WHERE id = ?').get(place.id) as any; + expect(row.trip_id).toBe(trip.id); + }); +}); + +// ── get ─────────────────────────────────────────────────────────────────────── + +describe('get', () => { + it('PLACE-SVC-010 — returns the place when tripId and placeId match', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Find Me' }) as any; + const found = svc.get(String(trip.id), String(place.id)) as any; + expect(found).toBeDefined(); + expect(found.name).toBe('Find Me'); + }); + + it('PLACE-SVC-011 — returns null when place belongs to different trip', () => { + const { user } = createUser(testDb); + const t1 = createTrip(testDb, user.id); + const t2 = createTrip(testDb, user.id); + const place = createPlace(testDb, t1.id, { name: 'T1 Place' }) as any; + expect(svc.get(String(t2.id), String(place.id))).toBeNull(); + }); + + it('PLACE-SVC-012 — returns null for non-existent placeId', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(svc.get(String(trip.id), '99999')).toBeNull(); + }); +}); + +// ── update ──────────────────────────────────────────────────────────────────── + +describe('update', () => { + it('PLACE-SVC-013 — updates place name and lat/lng', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Old', lat: 0, lng: 0 }) as any; + const updated = svc.update(String(trip.id), String(place.id), { name: 'New', lat: 48.8, lng: 2.3 }) as any; + expect(updated.name).toBe('New'); + expect(updated.lat).toBe(48.8); + expect(updated.lng).toBe(2.3); + }); + + it('PLACE-SVC-014 — returns null for non-existent place', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(svc.update(String(trip.id), '99999', { name: 'Ghost' })).toBeNull(); + }); + + it('PLACE-SVC-015 — updates tags (replaces old set)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const tag1 = createTag(testDb, user.id, { name: 'Old Tag' }) as any; + const tag2 = createTag(testDb, user.id, { name: 'New Tag' }) as any; + const place = svc.create(String(trip.id), { name: 'Taggable', tags: [tag1.id] }) as any; + + const updated = svc.update(String(trip.id), String(place.id), { tags: [tag2.id] }) as any; + expect(updated.tags).toHaveLength(1); + expect(updated.tags[0].id).toBe(tag2.id); + }); + + it('PLACE-SVC-016 — clears tags when tags: [] is passed', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const tag = createTag(testDb, user.id, { name: 'Temp' }) as any; + const place = svc.create(String(trip.id), { name: 'Untaggable', tags: [tag.id] }) as any; + + const updated = svc.update(String(trip.id), String(place.id), { tags: [] }) as any; + expect(updated.tags).toHaveLength(0); + }); + + // ── Track colour (#776) ───────────────────────────────────────────────────── + + it('PLACE-SVC-052 — stores a picked route_color', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Walk' }) as any; + const updated = svc.update(String(trip.id), String(place.id), { route_color: '#e11d48' }) as any; + expect(updated.route_color).toBe('#e11d48'); + }); + + it('PLACE-SVC-053 — an explicit null clears it again (the reset to auto)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Walk' }) as any; + svc.update(String(trip.id), String(place.id), { route_color: '#e11d48' }); + // Guards the COALESCE trap: name/currency/transport_mode can never be + // emptied, and route_color built that way would be a one-way door. + const cleared = svc.update(String(trip.id), String(place.id), { route_color: null }) as any; + expect(cleared.route_color).toBeNull(); + }); + + it('PLACE-SVC-054 — an unrelated update leaves the colour alone', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Walk' }) as any; + svc.update(String(trip.id), String(place.id), { route_color: '#059669' }); + const renamed = svc.update(String(trip.id), String(place.id), { name: 'Hike' }) as any; + expect(renamed.name).toBe('Hike'); + expect(renamed.route_color).toBe('#059669'); + }); + + it('PLACE-SVC-055 — create carries geometry and colour instead of dropping them', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const created = svc.create(String(trip.id), { + name: 'Restored track', + route_geometry: '[[48.0,2.0],[49.0,3.0]]', + route_color: '#7c3aed', + }) as any; + expect(created.route_geometry).toBe('[[48.0,2.0],[49.0,3.0]]'); + expect(created.route_color).toBe('#7c3aed'); + }); +}); + +// ── updateMany ──────────────────────────────────────────────────────────────── + +describe('updateMany', () => { + it('PLACE-SVC-039 — applies the same fields to many places, preserving the rest', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const a = createPlace(testDb, trip.id, { name: 'A' }) as any; + const b = createPlace(testDb, trip.id, { name: 'B' }) as any; + const c = createPlace(testDb, trip.id, { name: 'C' }) as any; + + const updated = svc.updateMany(String(trip.id), [a.id, b.id, c.id], { notes: 'visited', transport_mode: 'walking' }); + + expect(updated).toHaveLength(3); + for (const p of updated) { + expect((p as any).notes).toBe('visited'); + expect((p as any).transport_mode).toBe('walking'); + } + // Only the provided fields change — names are untouched. + expect(updated.map(p => (p as any).name).sort()).toEqual(['A', 'B', 'C']); + }); + + it('PLACE-SVC-040 — skips ids that are not in the trip and reports the rest', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const mine = createPlace(testDb, trip.id, { name: 'Mine' }) as any; + const foreign = createPlace(testDb, other.id, { name: 'Foreign' }) as any; + + const updated = svc.updateMany(String(trip.id), [mine.id, foreign.id, 99999], { notes: 'tagged' }); + + expect(updated).toHaveLength(1); + expect((updated[0] as any).id).toBe(mine.id); + // The place from the other trip stays untouched. + expect((svc.get(String(other.id), String(foreign.id)) as any).notes).toBeNull(); + }); + + it('PLACE-SVC-041 — returns [] for an empty id list', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(svc.updateMany(String(trip.id), [], { notes: 'x' })).toEqual([]); + }); +}); + +// ── remove ──────────────────────────────────────────────────────────────────── + +describe('remove', () => { + it('PLACE-SVC-017 — deletes a place and returns true', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'To Delete' }) as any; + expect(svc.remove(String(trip.id), String(place.id))).toBe(true); + expect(svc.get(String(trip.id), String(place.id))).toBeNull(); + }); + + it('PLACE-SVC-018 — returns false for non-existent place', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(svc.remove(String(trip.id), '99999')).toBe(false); + }); + + it('PLACE-SVC-019 — deleting one place does not remove others', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const p1 = createPlace(testDb, trip.id, { name: 'Keep' }) as any; + const p2 = createPlace(testDb, trip.id, { name: 'Remove' }) as any; + svc.remove(String(trip.id), String(p2.id)); + const remaining = svc.list(String(trip.id), {}) as any[]; + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe(p1.id); + }); + + it('PLACE-SVC-019b — reclaims the photo cache for the deleted place', () => { + removeIfUnreferencedSpy.mockClear(); + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'With Photo' }) as any; + testDb.prepare('UPDATE places SET google_place_id = ? WHERE id = ?').run('ChIJgid', place.id); + + svc.remove(String(trip.id), String(place.id)); + + expect(removeIfUnreferencedSpy).toHaveBeenCalledWith('ChIJgid'); + }); +}); + +// ── removeMany ──────────────────────────────────────────────────────────────── + +describe('removeMany', () => { + it('PLACE-SVC-056 — deletes the trip-scoped ids in one transaction and reports them', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const a = createPlace(testDb, trip.id, { name: 'A' }) as any; + const b = createPlace(testDb, trip.id, { name: 'B' }) as any; + const foreign = createPlace(testDb, other.id, { name: 'Foreign' }) as any; + + const deleted = svc.removeMany(String(trip.id), [a.id, b.id, foreign.id, 99999]); + + expect(deleted.sort()).toEqual([a.id, b.id].sort()); + expect(svc.get(String(other.id), String(foreign.id))).not.toBeNull(); + }); + + it('PLACE-SVC-057 — returns [] for an empty id list', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(svc.removeMany(String(trip.id), [])).toEqual([]); + }); +}); + +// ── importGpx ───────────────────────────────────────────────────────────────── + +describe('importGpx', () => { + it('PLACE-SVC-020 — returns null when buffer has no root', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const result = svc.importGpx(String(trip.id), Buffer.from('')); + expect(result).toBeNull(); + }); + + it('PLACE-SVC-021 — imports waypoints as places', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const gpx = Buffer.from(` + Paris + London + `); + const result = svc.importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(2); + expect(result.places[0].name).toBe('Paris'); + expect(result.places[1].name).toBe('London'); + }); + + it('PLACE-SVC-022 — imports as a single polyline-place with routeGeometry', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const gpx = Buffer.from(` + + My Route + Start + End + + `); + const result = svc.importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(1); + expect(result.places[0].name).toBe('My Route'); + expect(result.places[0].lat).toBe(48.8566); + expect(result.places[0].lng).toBe(2.3522); + expect(result.places[0].route_geometry).toBeTruthy(); + const coords = JSON.parse(result.places[0].route_geometry); + expect(coords).toHaveLength(2); + }); + + it('PLACE-SVC-023 — imports track as a single place with routeGeometry', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const gpx = Buffer.from(` + + My Track + + 100 + 102 + + + `); + const result = svc.importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(1); + expect(result.places[0].name).toBe('My Track'); + const geometry = JSON.parse(result.places[0].route_geometry); + expect(Array.isArray(geometry)).toBe(true); + expect(geometry).toHaveLength(2); + }); + + it('PLACE-SVC-024 — and together: waypoints plus track appended', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const gpx = Buffer.from(` + POI + + Track + + + + + + `); + const result = svc.importGpx(String(trip.id), gpx) as any; + // 1 wpt + 1 trk + expect(result.places).toHaveLength(2); + const trackPlace = result.places.find((p: any) => p.name === 'Track') as any; + expect(trackPlace).toBeDefined(); + const geometry = JSON.parse(trackPlace.route_geometry); + expect(geometry).toHaveLength(2); + }); + + it('PLACE-SVC-025 — returns null when GPX has no usable elements', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const gpx = Buffer.from(``); + const result = svc.importGpx(String(trip.id), gpx); + expect(result).toBeNull(); + }); + + it('PLACE-SVC-037 — multiple unnamed tracks in one file get distinct names instead of collapsing to one', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const gpx = Buffer.from(` + + + + + + + + + `); + const result = svc.importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(2); + const names = result.places.map((p: any) => p.name); + expect(new Set(names).size).toBe(2); + }); + + it('PLACE-SVC-038 — unnamed tracks fall back to the source filename', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const gpx = Buffer.from(` + + + + + `); + const result = svc.importGpx(String(trip.id), gpx, { defaultName: 'morning-hike.gpx' }) as any; + expect(result.places).toHaveLength(1); + expect(result.places[0].name).toBe('morning-hike'); + }); +}); + +// ── importGoogleList ────────────────────────────────────────────────────────── + +describe('importGoogleList', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('PLACE-SVC-026 — returns error when list ID cannot be extracted from URL', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const result = await svc.importGoogleList(String(trip.id), 'https://example.com/no-id-here') as any; + expect(result.error).toMatch(/Could not extract list ID/); + expect(result.status).toBe(400); + }); + + it('PLACE-SVC-026b — a single-place link gives a guiding error instead of the generic one (#1304)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const url = 'https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,17z/data=!3m1'; + const result = await svc.importGoogleList(String(trip.id), url) as any; + expect(result.status).toBe(400); + expect(result.error).toMatch(/single place/i); + }); + + it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => '', status: 502 })); + const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; + const result = await svc.importGoogleList(String(trip.id), url) as any; + expect(result.error).toMatch(/Failed to fetch list/); + expect(result.status).toBe(502); + }); + + it('PLACE-SVC-028 — imports places from a valid Google Maps list response', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const listPayload = [ + [null, null, null, null, 'My Test List', null, null, null, [ + [null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null], + [null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'], + ]], + ]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'prefix\n' + JSON.stringify(listPayload), + })); + + const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; + const result = await svc.importGoogleList(String(trip.id), url) as any; + expect(result.listName).toBe('My Test List'); + expect(result.places).toHaveLength(2); + expect(result.places[0].name).toBe('Paris'); + expect(result.places[1].name).toBe('London'); + }); + + it('PLACE-SVC-028b — stores a Google Maps ftid separately from google_place_id', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const listPayload = [ + [null, null, null, null, 'My Test List', null, null, null, [ + [null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"], + ]], + ]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'prefix\n' + JSON.stringify(listPayload), + })); + + const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; + const result = await svc.importGoogleList(String(trip.id), url) as any; + + expect(result.places).toHaveLength(1); + expect(result.places[0].google_place_id).toBeNull(); + expect(result.places[0].google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); + }); + + it('PLACE-SVC-028c — backfills google_ftid when re-import skips a duplicate', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const existing = createPlace(testDb, trip.id, { + name: "St. Jacobs Farmers' Market", + lat: 43.5118527, + lng: -80.5542617, + }) as any; + + const listPayload = [ + [null, null, null, null, 'My Test List', null, null, null, [ + [null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"], + ]], + ]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'prefix\n' + JSON.stringify(listPayload), + })); + + const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; + const result = await svc.importGoogleList(String(trip.id), url) as any; + const row = testDb.prepare('SELECT google_place_id, google_ftid FROM places WHERE id = ?').get(existing.id) as any; + + expect(result.places).toHaveLength(0); + expect(result.skipped).toBe(1); + expect(row.google_place_id).toBeNull(); + expect(row.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); + }); + + it('PLACE-SVC-029 — returns error when list items array is empty', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const listPayload = [[null, null, null, null, 'Empty List', null, null, null, []]]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'prefix\n' + JSON.stringify(listPayload), + })); + + const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; + const result = await svc.importGoogleList(String(trip.id), url) as any; + expect(result.error).toBeDefined(); + expect(result.status).toBe(400); + }); +}); + +// ── searchImage ─────────────────────────────────────────────────────────────── + +describe('searchImage', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('PLACE-SVC-030 — returns 404 when place does not exist', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const result = await svc.searchImage(String(trip.id), '99999', user.id) as any; + expect(result.error).toBeDefined(); + expect(result.status).toBe(404); + }); + + it('PLACE-SVC-031 — searches Unsplash without a stored API key', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } }, + ], + }), + status: 200, + })); + + const result = await svc.searchImage(String(trip.id), String(place.id), user.id) as any; + expect(result.photos).toHaveLength(1); + const [url] = (fetch as any).mock.calls[0]; + expect(url).toContain('https://unsplash.com/napi/search/photos?'); + expect(url).not.toContain('client_id='); + }); + + it('PLACE-SVC-032 — returns photos when Unsplash API responds successfully', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any; + + const mockPhotos = [ + { id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } }, + ]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: mockPhotos }), + status: 200, + })); + + const result = await svc.searchImage(String(trip.id), String(place.id), user.id) as any; + expect(result.photos).toHaveLength(1); + expect(result.photos[0].id).toBe('photo1'); + expect(result.photos[0].url).toBe('https://img.example.com/1'); + expect(result.photos[0].photographer).toBe('Photographer'); + }); +}); + +// ── Import deduplication ────────────────────────────────────────────────────── + +describe('importGpx deduplication', () => { + it('PLACE-SVC-033 — skips waypoints already in trip by name', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const buf = fs.readFileSync(GPX_FIXTURE); + + // First import + const first = svc.importGpx(String(trip.id), buf) as any; + expect(first.count).toBeGreaterThan(0); + + // Second import — all names already present, nothing new created + const second = svc.importGpx(String(trip.id), buf) as any; + expect(second.count).toBe(0); + expect(second.skipped).toBe(first.count); + + // Total places in DB should equal first import count + const total = (svc.list(String(trip.id), {}) as any[]).length; + expect(total).toBe(first.count); + }); + + it('PLACE-SVC-034 — imports new places while skipping existing ones', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const buf = fs.readFileSync(GPX_FIXTURE); + + const first = svc.importGpx(String(trip.id), buf) as any; + // Manually add a brand-new place so total > first.count + createPlace(testDb, trip.id, { name: 'Unique Extra Place', lat: 99, lng: 99 }); + + // Re-import: the fixture places are skipped, the extra place remains untouched + const second = svc.importGpx(String(trip.id), buf) as any; + expect(second.count).toBe(0); + + const total = (svc.list(String(trip.id), {}) as any[]).length; + expect(total).toBe(first.count + 1); + }); +}); + +describe('importKmlPlaces deduplication', () => { + it('PLACE-SVC-035 — skips placemarks already in trip by name', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const buf = fs.readFileSync(KML_FIXTURE); + + const first = svc.importKmlPlaces(String(trip.id), buf); + expect(first.count).toBeGreaterThan(0); + + const second = svc.importKmlPlaces(String(trip.id), buf); + expect(second.count).toBe(0); + expect(second.summary.skippedCount).toBeGreaterThanOrEqual(first.count); + expect(second.summary.warnings.some((w: string) => w.includes('skipped'))).toBe(true); + }); + + it('PLACE-SVC-036 — deduplicates within the same file (intra-batch)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // Craft a KML with two placemarks sharing the same name + const kml = Buffer.from(` + + Dupe Place2.0,48.0,0 + Dupe Place2.1,48.1,0 +`); + + const result = svc.importKmlPlaces(String(trip.id), kml); + expect(result.count).toBe(1); + expect(result.summary.skippedCount).toBe(1); + }); +}); + +// ── Custom place image reclaim (#1136) ────────────────────────────────────────── + +describe('custom place image reclaim', () => { + function writePlaceImage(name: string): string { + fs.mkdirSync(PLACE_IMAGES_DIR, { recursive: true }); + const filePath = path.join(PLACE_IMAGES_DIR, name); + fs.writeFileSync(filePath, 'jpeg-bytes'); + return filePath; + } + + it('PLACE-SVC-046 — replacing image_url unlinks the previous upload once unreferenced', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any; + const fileA = writePlaceImage('svc-replace-a.jpg'); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-replace-a.jpg', place.id); + expect(fs.existsSync(fileA)).toBe(true); + + svc.update(String(trip.id), String(place.id), { image_url: '/uploads/places/svc-replace-b.jpg' }); + expect(fs.existsSync(fileA)).toBe(false); + }); + + it('PLACE-SVC-047 — clearing image_url to null unlinks the previous upload', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any; + const fileA = writePlaceImage('svc-clear.jpg'); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-clear.jpg', place.id); + expect(fs.existsSync(fileA)).toBe(true); + + svc.update(String(trip.id), String(place.id), { image_url: null } as any); + expect(fs.existsSync(fileA)).toBe(false); + }); + + it('PLACE-SVC-048 — remove unlinks the uploaded image', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any; + const fileA = writePlaceImage('svc-delete.jpg'); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-delete.jpg', place.id); + expect(fs.existsSync(fileA)).toBe(true); + + svc.remove(String(trip.id), String(place.id)); + expect(fs.existsSync(fileA)).toBe(false); + }); + + it('PLACE-SVC-049 — a collection_places reference keeps the file when the trip place is deleted', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Shared Photo' }) as any; + const fileA = writePlaceImage('svc-shared.jpg'); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-shared.jpg', place.id); + // A saved-place in a collection holds the same uploaded file — the ref-count guard must protect it. + const col = testDb.prepare('INSERT INTO collections (owner_id, name) VALUES (?, ?)').run(user.id, 'Saved'); + testDb.prepare('INSERT INTO collection_places (collection_id, owner_id, name, image_url) VALUES (?, ?, ?, ?)') + .run(col.lastInsertRowid, user.id, 'Shared Photo', '/uploads/places/svc-shared.jpg'); + expect(fs.existsSync(fileA)).toBe(true); + + svc.remove(String(trip.id), String(place.id)); + expect(fs.existsSync(fileA)).toBe(true); + + // resetTestDb does not clear collections; drop what this test inserted and its file. + testDb.exec('DELETE FROM collection_places; DELETE FROM collections;'); + fs.unlinkSync(fileA); + }); + + // ── Collaborative ratings (#1435) ────────────────────────────────────────── + it('PLACE-SVC-050 — rate stores one vote per user, replaces on re-vote, clears with null', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Rated' }) as { id: number }; + + svc.rate(String(trip.id), String(place.id), user.id, 5); + let rows = testDb.prepare('SELECT rating FROM place_ratings WHERE place_id = ? AND user_id = ?').all(place.id, user.id) as { rating: number }[]; + expect(rows).toEqual([{ rating: 5 }]); + + svc.rate(String(trip.id), String(place.id), user.id, 2); // re-vote replaces via the UNIQUE upsert + rows = testDb.prepare('SELECT rating FROM place_ratings WHERE place_id = ? AND user_id = ?').all(place.id, user.id) as { rating: number }[]; + expect(rows).toEqual([{ rating: 2 }]); + + svc.rate(String(trip.id), String(place.id), user.id, null); // clear + const count = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number }; + expect(count.n).toBe(0); + }); + + it('PLACE-SVC-051 — rate returns null and writes nothing when the place is not in the trip', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const place = createPlace(testDb, otherTrip.id, { name: 'Elsewhere' }) as { id: number }; + + expect(svc.rate(String(trip.id), String(place.id), user.id, 4)).toBeNull(); + const count = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number }; + expect(count.n).toBe(0); + }); +}); + +// ── Automatic track colours (#776) ──────────────────────────────────────────── // Three tracks plus a plain waypoint — the shared fixture only has waypoints, // and the whole point here is what happens to geometry. @@ -61,8 +895,6 @@ const GPX_WITH_TRACKS = ` `; -const svc = new PlacesService(new DatabaseService(testDb)); - function importFixture(tripId: number) { return svc.importGpx(String(tripId), Buffer.from(GPX_WITH_TRACKS), { importWaypoints: true, importRoutes: true, importTracks: true, @@ -74,19 +906,6 @@ function tracksOf(tripId: number) { .all(tripId) as { id: number; route_color: string | null }[]; } -beforeAll(() => { - createTables(testDb); - runMigrations(testDb); -}); - -beforeEach(() => { - resetTestDb(testDb); -}); - -afterAll(() => { - testDb.close(); -}); - describe('PlacesService — automatic track colours (#776)', () => { it('PLACES-SVC-001 — every imported track gets a colour from the shared palette', () => { const { user } = createUser(testDb); @@ -187,3 +1006,237 @@ describe('PlacesService — automatic track colours (#776)', () => { expect((svc as any).colorizeImportedTracks(String(trip.id), { places: [] })).toEqual({ places: [] }); }); }); + +// ── Import enrichment (#886) ────────────────────────────────────────────────── + +describe('enrichImportedPlaces', () => { + function enrichSvc(maps: Partial) { + return new PlacesService(dbs, new PermissionsService(dbs), new RealtimeService(), maps as MapsService); + } + + it('PLACE-SVC-058 — no-ops when no Google Maps key is configured', async () => { + const searchPlaces = vi.fn(); + const svcNoKey = enrichSvc({ getMapsKey: vi.fn(() => null), searchPlaces }); + await svcNoKey.enrichImportedPlaces('1', 1, [{ id: 1, name: 'A', lat: 1, lng: 2 }]); + expect(searchPlaces).not.toHaveBeenCalled(); + }); + + it('PLACE-SVC-059 — no-ops for an empty batch without touching the provider', async () => { + const getMapsKey = vi.fn(() => 'key'); + await enrichSvc({ getMapsKey }).enrichImportedPlaces('1', 1, []); + expect(getMapsKey).not.toHaveBeenCalled(); + }); + + it('PLACE-SVC-060 — fills only the empty columns and persists the resolved ids', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any; + // An address the import already captured must survive the COALESCE. + testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('Imported address', place.id); + + const svcWithMaps = enrichSvc({ + getMapsKey: vi.fn(() => 'key'), + searchPlaces: vi.fn(async () => ({ + source: 'google', + places: [{ google_place_id: 'ChIJ1', google_ftid: '0x1:0x2', address: 'Google address', website: 'https://x', phone: '+33', lat: 48.85, lng: 2.35 }], + })), + getPlacePhoto: vi.fn(async () => ({ photoUrl: '/api/maps/place-photo/ChIJ1/bytes', attribution: null })), + } as never); + + await svcWithMaps.enrichImportedPlaces(String(trip.id), user.id, [{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 }]); + + const row = testDb.prepare('SELECT google_place_id, google_ftid, address, website, phone, image_url FROM places WHERE id = ?').get(place.id) as any; + expect(row.google_place_id).toBe('ChIJ1'); + expect(row.google_ftid).toBe('0x1:0x2'); + expect(row.address).toBe('Imported address'); // NOT clobbered + expect(row.website).toBe('https://x'); + expect(row.phone).toBe('+33'); + expect(row.image_url).toBe('/api/maps/place-photo/ChIJ1/bytes'); + }); + + it('PLACE-SVC-061 — leaves the place alone when no candidate is close enough', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any; + + const svcWithMaps = enrichSvc({ + getMapsKey: vi.fn(() => 'key'), + // ~1.2 km away — beyond MATCH_RADIUS_METERS. + searchPlaces: vi.fn(async () => ({ source: 'google', places: [{ google_place_id: 'ChIJfar', lat: 48.86, lng: 2.36 }] })), + } as never); + + await svcWithMaps.enrichImportedPlaces(String(trip.id), user.id, [{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 }]); + + const row = testDb.prepare('SELECT google_place_id FROM places WHERE id = ?').get(place.id) as any; + expect(row.google_place_id).toBeNull(); + }); + + it('PLACE-SVC-062 — a failed photo fetch never undoes the rest of the enrichment', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any; + + const svcWithMaps = enrichSvc({ + getMapsKey: vi.fn(() => 'key'), + searchPlaces: vi.fn(async () => ({ source: 'google', places: [{ google_place_id: 'ChIJ1', lat: 48.85, lng: 2.35 }] })), + getPlacePhoto: vi.fn(async () => { throw new Error('provider down'); }), + } as never); + + await svcWithMaps.enrichImportedPlaces(String(trip.id), user.id, [{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 }]); + + const row = testDb.prepare('SELECT google_place_id, image_url FROM places WHERE id = ?').get(place.id) as any; + expect(row.google_place_id).toBe('ChIJ1'); + expect(row.image_url).toBeNull(); + }); + + it('PLACE-SVC-063 — a per-place failure is swallowed so the batch never throws', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const svcWithMaps = enrichSvc({ + getMapsKey: vi.fn(() => 'key'), + searchPlaces: vi.fn(async () => { throw new Error('lookup exploded'); }), + } as never); + + await expect( + svcWithMaps.enrichImportedPlaces(String(trip.id), user.id, [{ id: 1, name: 'A', lat: 1, lng: 2 }]), + ).resolves.toBeUndefined(); + expect(err).toHaveBeenCalled(); + err.mockRestore(); + }); + + it('PLACE-SVC-064 — skips a place that is already linked or has no coordinates', async () => { + const searchPlaces = vi.fn(); + const svcWithMaps = enrichSvc({ getMapsKey: vi.fn(() => 'key'), searchPlaces } as never); + await svcWithMaps.enrichImportedPlaces('1', 1, [ + { id: 1, name: 'Linked', lat: 1, lng: 2, google_place_id: 'ChIJalready' }, + { id: 2, name: 'Coordless', lat: null as never, lng: null as never }, + ]); + expect(searchPlaces).not.toHaveBeenCalled(); + }); +}); + +// ── Falsy-coercion fixes (#1745) ────────────────────────────────────────────── +// +// The legacy service ran every optional field through `x || fallback`, which +// cannot tell "absent" from a legitimate zero. Coordinates on the equator or +// the prime meridian were silently dropped and a zero duration/price was +// replaced by the default. + +describe('zero-valued numeric fields', () => { + it('PLACE-SVC-065 — create keeps lat/lng of exactly 0 instead of nulling them', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = svc.create(String(trip.id), { name: 'Null Island', lat: 0, lng: 0 }) as any; + expect(place.lat).toBe(0); + expect(place.lng).toBe(0); + // A genuinely absent coordinate still lands as NULL. + const noCoords = svc.create(String(trip.id), { name: 'Unlocated' }) as any; + expect(noCoords.lat).toBeNull(); + expect(noCoords.lng).toBeNull(); + }); + + it('PLACE-SVC-066 — create keeps duration_minutes and price of 0', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = svc.create(String(trip.id), { name: 'Drive-by', duration_minutes: 0, price: 0 }) as any; + expect(place.duration_minutes).toBe(0); + expect(place.price).toBe(0); + // The 60-minute default still applies when the field is absent. + expect((svc.create(String(trip.id), { name: 'Default' }) as any).duration_minutes).toBe(60); + }); + + it('PLACE-SVC-067 — update can set duration_minutes to 0', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Stop' }) as any; + testDb.prepare('UPDATE places SET duration_minutes = 90 WHERE id = ?').run(place.id); + + const zeroed = svc.update(String(trip.id), String(place.id), { duration_minutes: 0 }) as any; + expect(zeroed.duration_minutes).toBe(0); + + // An omitted duration still leaves the stored value alone (COALESCE). + const untouched = svc.update(String(trip.id), String(place.id), { name: 'Stop 2' }) as any; + expect(untouched.duration_minutes).toBe(0); + }); +}); + +// ── LIKE metacharacter escaping (#1745) ─────────────────────────────────────── + +describe('list search escaping', () => { + it('PLACE-SVC-068 — a % or _ in the search term matches literally, not as a wildcard', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createPlace(testDb, trip.id, { name: '50% off shop' }); + createPlace(testDb, trip.id, { name: 'Bar' }); + createPlace(testDb, trip.id, { name: 'a_b cafe' }); + createPlace(testDb, trip.id, { name: 'axb cafe' }); + + // '%' used to match every row. + expect((svc.list(String(trip.id), { search: '%' }) as any[]).map(p => p.name)).toEqual(['50% off shop']); + // '_' used to match any single character. + expect((svc.list(String(trip.id), { search: 'a_b' }) as any[]).map(p => p.name)).toEqual(['a_b cafe']); + }); + + it('PLACE-SVC-069 — ordinary search terms are unaffected', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); + createPlace(testDb, trip.id, { name: 'Louvre' }); + expect((svc.list(String(trip.id), { search: 'eiff' }) as any[]).map(p => p.name)).toEqual(['Eiffel Tower']); + }); +}); + +// ── Trip-scoped id filter for the journey delete hook (#1745) ───────────────── + +describe('scopedIds', () => { + it('PLACE-SVC-070 — returns only the ids that belong to the trip, preserving input order', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const a = createPlace(testDb, trip.id, { name: 'A' }) as any; + const b = createPlace(testDb, trip.id, { name: 'B' }) as any; + const foreign = createPlace(testDb, other.id, { name: 'Foreign' }) as any; + + expect(svc.scopedIds(String(trip.id), [b.id, foreign.id, a.id, 99999])).toEqual([b.id, a.id]); + expect(svc.scopedIds(String(trip.id), [])).toEqual([]); + }); +}); + +// ── Provider-payload hardening for the Google list import (#1745) ───────────── + +describe('importGoogleList provider payload', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('PLACE-SVC-071 — a malformed provider body is the documented 400, not a thrown SyntaxError', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => 'prefix\nnot-json-at-all' })); + const result = await svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456') as any; + expect(result).toEqual({ error: 'Invalid list data received from Google Maps', status: 400 }); + }); + + it('PLACE-SVC-072 — a non-array provider payload is rejected the same way', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => 'prefix\n{"unexpected":true}' })); + const result = await svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456') as any; + expect(result).toEqual({ error: 'Invalid list data received from Google Maps', status: 400 }); + }); + + it('PLACE-SVC-073 — an over-large declared response is refused before it is read', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const text = vi.fn(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + headers: { get: (h: string) => (h.toLowerCase() === 'content-length' ? String(20 * 1024 * 1024) : null) }, + text, + })); + const result = await svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456') as any; + expect(result).toEqual({ error: 'Failed to fetch list from Google Maps', status: 502 }); + expect(text).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 74802334e..e2524d429 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -63,6 +63,8 @@ import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.se import { CollabService } from '../../../src/nest/collab/collab.service'; import { VacayService } from '../../../src/nest/vacay/vacay.service'; import { TripsService } from '../../../src/nest/trips/trips.service'; +import { PlacesService } from '../../../src/nest/places/places.service'; +import { MapsService } from '../../../src/nest/maps/maps.service'; import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nest/trips/trips.bridge'; import fs from 'fs'; @@ -71,6 +73,7 @@ import fs from 'fs'; const dbs = () => new DatabaseService(testDb); const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService()); const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService()); +const placesSvc = new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs())); const createAccommodation = daysSvc.createAccommodation.bind(daysSvc); const svc = new TripsService( @@ -85,6 +88,7 @@ const svc = new TripsService( new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()), new VacayService(dbs(), new RealtimeService()), new RealtimeService(), + placesSvc, ); beforeAll(() => { @@ -1200,6 +1204,7 @@ describe('quirk fixes', () => { new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()), new VacayService(dbs(), new RealtimeService()), new RealtimeService(), + placesSvc, ); } diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index 20f32e97a..99c8f2920 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -5,7 +5,7 @@ * host, and trip broadcasts are force-namespaced to plugin:{id}:{event}. * DI-native domains (budget/exchange-rates/reservations/tags/categories/todo/ * packing/day-notes/days/assignments/oauth/llm-config/files/collab/vacay/ - * permissions) + * permissions/trips/places) * are constructor-injected stubs; legacy services/* domains stay path-mocked * until their own DI migration lands. */ @@ -75,17 +75,22 @@ const permissionsStub = { checkPermission } as unknown as PermissionsService; const exchangeRatesStub = { getRates: vi.fn(async (base: string) => ({ [base]: 1, USD: 1.08, GBP: 0.85 })), } as unknown as ExchangeRatesService; -vi.mock('../../../src/services/placeService', () => ({ - createPlace: vi.fn((tid: string, body: Record) => ({ id: 10, trip_id: Number(tid), ...body })), - updatePlace: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })), - deletePlace: vi.fn((_tid: string, pid: string) => pid !== '99'), +// Places are a constructor-injected stub since the place fold (same behaviors +// as the old services/placeService path mock, mapped onto the PlacesService +// method names). `remove` is asserted by invocation order against the journey +// hook, so it stays a vi.fn. +const removePlaceStub = vi.fn((_tid: string, pid: string) => pid !== '99'); +const placesStub = { + create: vi.fn((tid: string, body: Record) => ({ id: 10, trip_id: Number(tid), ...body })), + update: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })), + remove: removePlaceStub, // Trip-scoped read the delete path uses to reject a foreign id before the // journey hook runs — place 99 belongs to another trip. - getPlace: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })), - // trips.service (loaded for real since the trip fold) imports listPlaces for - // its offline bundle — the mock must carry every named export it pulls. - listPlaces: vi.fn(() => []), -})); + get: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })), + // trips.service (loaded for real since the trip fold) injects this for its + // offline bundle — the stub must carry every method it calls. + list: vi.fn(() => []), +} as unknown as PlacesService; // Days are a constructor-injected stub (same behaviors as the old path mock, // mapped onto the DaysService method names). const daysStub = { @@ -297,7 +302,6 @@ import { PluginHostDepsFactory, type PluginCallRouter } from '../../../src/nest/ import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state'; import { db as mockDb } from '../../../src/db/database'; import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../../../src/services/journeyService'; -import { deletePlace as deletePlaceSvc } from '../../../src/services/placeService'; import type { BudgetService } from '../../../src/nest/budget/budget.service'; import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; @@ -314,6 +318,7 @@ import type { FilesService } from '../../../src/nest/files/files.service'; import type { CollabService } from '../../../src/nest/collab/collab.service'; import type { VacayService } from '../../../src/nest/vacay/vacay.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import type { PlacesService } from '../../../src/nest/places/places.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service'; @@ -336,7 +341,7 @@ const tripsStub = { list: () => [{ id: 1 }], removeMember: vi.fn(), } as unknown as import('../../../src/nest/trips/trips.service').TripsService; -const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub, new RealtimeService(), tripsStub); +const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub, new RealtimeService(), tripsStub, placesStub); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; const createRealRpcHost = (id: string, granted: ReadonlySet, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router); @@ -443,7 +448,7 @@ describe('host-deps factory — planner write + metadata deps', () => { const created = vi.mocked(onPlaceCreated); const updated = vi.mocked(onPlaceUpdated); const deleted = vi.mocked(onPlaceDeleted); - const removePlace = vi.mocked(deletePlaceSvc); + const removePlace = removePlaceStub; [created, updated, deleted, removePlace].forEach((m) => m.mockClear()); expect((await call(h, 'places.create', { tripId: 1, input: { name: 'P' } })).ok).toBe(true); diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index dd514863b..a7ad359f8 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -38,14 +38,16 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; -import { updatePlace, createPlace } from '../../../src/services/placeService'; import { isUpdateConflict } from '../../../src/services/conflictResult'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { PackingService } from '../../../src/nest/packing/packing.service'; +import { PlacesService } from '../../../src/nest/places/places.service'; +import { MapsService } from '../../../src/nest/maps/maps.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; const packing = new PackingService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); +const places = new PlacesService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new MapsService(new DatabaseService(testDb))); beforeAll(() => { createTables(testDb); @@ -61,17 +63,17 @@ afterAll(() => { }); function freshPlace(tripId: number) { - const place = createPlace(String(tripId), { name: 'Original' }) as { id: number; updated_at: string }; + const place = places.create(String(tripId), { name: 'Original' }) as unknown as { id: number; updated_at: string }; return place; } -describe('updatePlace — optimistic concurrency', () => { +describe('PlacesService.update — optimistic concurrency', () => { it('updates normally when no If-Match token is sent (back-compat)', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = freshPlace(trip.id); - const result = updatePlace(String(trip.id), String(place.id), { name: 'Edited' }); + const result = places.update(String(trip.id), String(place.id), { name: 'Edited' }); expect(isUpdateConflict(result)).toBe(false); expect((result as { name: string }).name).toBe('Edited'); }); @@ -81,7 +83,7 @@ describe('updatePlace — optimistic concurrency', () => { const trip = createTrip(testDb, user.id); const place = freshPlace(trip.id); - const result = updatePlace(String(trip.id), String(place.id), { name: 'Edited' }, place.updated_at); + const result = places.update(String(trip.id), String(place.id), { name: 'Edited' }, place.updated_at); expect(isUpdateConflict(result)).toBe(false); expect((result as { name: string }).name).toBe('Edited'); }); @@ -91,7 +93,7 @@ describe('updatePlace — optimistic concurrency', () => { const trip = createTrip(testDb, user.id); const place = freshPlace(trip.id); - const result = updatePlace(String(trip.id), String(place.id), { name: 'Mine' }, '1999-01-01 00:00:00'); + const result = places.update(String(trip.id), String(place.id), { name: 'Mine' }, '1999-01-01 00:00:00'); expect(isUpdateConflict(result)).toBe(true); if (isUpdateConflict(result)) { expect((result.server as { name: string }).name).toBe('Original'); @@ -104,7 +106,7 @@ describe('updatePlace — optimistic concurrency', () => { it('returns null for a place that does not exist', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - expect(updatePlace(String(trip.id), '999999', { name: 'x' }, 'whatever')).toBeNull(); + expect(places.update(String(trip.id), '999999', { name: 'x' }, 'whatever')).toBeNull(); }); }); diff --git a/server/tests/unit/services/kmzUnpack.test.ts b/server/tests/unit/services/kmzUnpack.test.ts deleted file mode 100644 index 30e9fdd3b..000000000 --- a/server/tests/unit/services/kmzUnpack.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import path from 'path'; -import fs from 'fs'; - -vi.mock('../../../src/db/database', () => ({ - db: { prepare: vi.fn() }, - getPlaceWithTags: vi.fn(), -})); -vi.mock('../../../src/config', () => ({ - JWT_SECRET: 'test', - ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', - updateJwtSecret: () => {}, -})); - -import { unpackKmzToKml, KMZ_DECOMPRESSED_SIZE_LIMIT } from '../../../src/services/placeService'; - -const KMZ_FIXTURE = path.join(__dirname, '../../fixtures/test.kmz'); - -describe('unpackKmzToKml', () => { - it('extracts the KML entry from a valid KMZ', async () => { - const kmzBuffer = fs.readFileSync(KMZ_FIXTURE); - const kmlBuffer = await unpackKmzToKml(kmzBuffer); - expect(kmlBuffer.length).toBeGreaterThan(0); - expect(kmlBuffer.toString('utf-8')).toContain(' { - const kmzBuffer = fs.readFileSync(KMZ_FIXTURE); - // test.kmz contains a KML with uncompressedSize 634 — set limit to 1 byte - await expect(unpackKmzToKml(kmzBuffer, 1)).rejects.toThrow('exceeds the maximum allowed decompressed size'); - }); - - it('rejects a KMZ that contains no KML file', async () => { - // Craft a minimal ZIP containing only a non-KML entry using raw ZIP bytes - // We use the test GPX fixture (a real file) re-zipped via Node's zlib/archiver - // Simplest: a KMZ whose only file has a .txt extension - const Archiver = await import('archiver'); - const archiver = Archiver.default; - const { PassThrough } = await import('stream'); - - const chunks: Buffer[] = []; - const output = new PassThrough(); - output.on('data', (chunk) => chunks.push(chunk)); - - const archive = archiver('zip', { zlib: { level: 1 } }); - archive.pipe(output); - archive.append(Buffer.from('not a kml'), { name: 'data.txt' }); - await archive.finalize(); - - const zipBuffer = Buffer.concat(chunks); - await expect(unpackKmzToKml(zipBuffer)).rejects.toThrow('does not contain a KML file'); - }); - - it('rejects a buffer that is not a valid ZIP archive', async () => { - await expect(unpackKmzToKml(Buffer.from('this is not a zip'))).rejects.toThrow('Invalid KMZ archive'); - }); - - it('exports KMZ_DECOMPRESSED_SIZE_LIMIT as 50 MB', () => { - expect(KMZ_DECOMPRESSED_SIZE_LIMIT).toBe(50 * 1024 * 1024); - }); -}); diff --git a/server/tests/unit/services/placeService.test.ts b/server/tests/unit/services/placeService.test.ts deleted file mode 100644 index b40274a3d..000000000 --- a/server/tests/unit/services/placeService.test.ts +++ /dev/null @@ -1,840 +0,0 @@ -/** - * Unit tests for placeService — PLACE-SVC-001 through PLACE-SVC-025. - * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. - * External fetches are mocked where needed. - */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; - -// ── DB setup ────────────────────────────────────────────────────────────────── - -const { testDb, dbMock } = vi.hoisted(() => { - const Database = require('better-sqlite3'); - const db = new Database(':memory:'); - db.exec('PRAGMA journal_mode = WAL'); - db.exec('PRAGMA foreign_keys = ON'); - db.exec('PRAGMA busy_timeout = 5000'); - const mock = { - db, - closeDb: () => {}, - reinitialize: () => {}, - getPlaceWithTags: (placeId: any) => { - const place: any = db.prepare(` - SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon - FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ? - `).get(placeId); - if (!place) return null; - const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId); - return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags }; - }, - canAccessTrip: (tripId: any, userId: number) => - db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), - isOwner: (tripId: any, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), - }; - return { testDb: db, dbMock: mock }; -}); - -vi.mock('../../../src/db/database', () => dbMock); -vi.mock('../../../src/config', () => ({ - JWT_SECRET: 'test-secret', - ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', - updateJwtSecret: () => {}, -})); - -// Spy on the photo-cache reclaim hook so delete tests assert the wiring without -// touching disk. The removal logic itself is covered in placePhotoCache.test.ts. -const { removeIfUnreferencedSpy } = vi.hoisted(() => ({ removeIfUnreferencedSpy: vi.fn() })); -vi.mock('../../../src/services/placePhotoCache', async (importOriginal) => ({ - ...(await importOriginal()), - removeIfUnreferenced: removeIfUnreferencedSpy, -})); - -import { createTables } from '../../../src/db/schema'; -import { runMigrations } from '../../../src/db/migrations'; -import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories'; -import path from 'path'; -import fs from 'fs'; -import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage, ratePlace } from '../../../src/services/placeService'; -import { PLACE_IMAGES_DIR } from '../../../src/services/placeImage'; - -const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx'); -const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); - -beforeAll(() => { - createTables(testDb); - runMigrations(testDb); -}); - -beforeEach(() => { - resetTestDb(testDb); -}); - -afterAll(() => { - testDb.close(); -}); - -// ── listPlaces ──────────────────────────────────────────────────────────────── - -describe('listPlaces', () => { - it('PLACE-SVC-001 — returns empty array when trip has no places', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - expect(listPlaces(String(trip.id), {})).toEqual([]); - }); - - it('PLACE-SVC-002 — returns all places for a trip', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - createPlace(testDb, trip.id, { name: 'Alpha' }); - createPlace(testDb, trip.id, { name: 'Beta' }); - const places = listPlaces(String(trip.id), {}) as any[]; - expect(places).toHaveLength(2); - }); - - it('PLACE-SVC-003 — does not return places from other trips', () => { - const { user } = createUser(testDb); - const t1 = createTrip(testDb, user.id); - const t2 = createTrip(testDb, user.id); - createPlace(testDb, t1.id, { name: 'T1 Place' }); - createPlace(testDb, t2.id, { name: 'T2 Place' }); - const places = listPlaces(String(t1.id), {}) as any[]; - expect(places).toHaveLength(1); - expect(places[0].name).toBe('T1 Place'); - }); - - it('PLACE-SVC-004 — filters by search term (name)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); - createPlace(testDb, trip.id, { name: 'Louvre Museum' }); - const places = listPlaces(String(trip.id), { search: 'Eiffel' }) as any[]; - expect(places).toHaveLength(1); - expect(places[0].name).toBe('Eiffel Tower'); - }); - - it('PLACE-SVC-005 — attaches tags array to each place (empty when none)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - createPlace(testDb, trip.id, { name: 'No Tags' }); - const places = listPlaces(String(trip.id), {}) as any[]; - expect(Array.isArray(places[0].tags)).toBe(true); - expect(places[0].tags).toHaveLength(0); - }); - - it('PLACE-SVC-006 — attaches category object when place has a category', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const cat = createCategory(testDb, { name: 'Museum', user_id: user.id }) as any; - const place = createPlace(testDb, trip.id, { name: 'Art Museum' }) as any; - testDb.prepare('UPDATE places SET category_id = ? WHERE id = ?').run(cat.id, place.id); - - const places = listPlaces(String(trip.id), {}) as any[]; - expect(places[0].category).toBeDefined(); - expect(places[0].category.name).toBe('Museum'); - }); -}); - -// ── createPlace (via service) ───────────────────────────────────────────────── - -describe('createPlace (service)', () => { - it('PLACE-SVC-007 — creates a place and returns it with tags array', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = svcCreatePlace(String(trip.id), { name: 'New Place', lat: 48.8, lng: 2.3 }) as any; - expect(place).toBeDefined(); - expect(place.name).toBe('New Place'); - expect(Array.isArray(place.tags)).toBe(true); - }); - - it('PLACE-SVC-008 — creates a place with tags', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const tag = createTag(testDb, user.id, { name: 'Highlight' }) as any; - const place = svcCreatePlace(String(trip.id), { name: 'Tagged Place', tags: [tag.id] }) as any; - expect(place.tags).toHaveLength(1); - expect(place.tags[0].id).toBe(tag.id); - }); - - it('PLACE-SVC-009 — place is associated with correct trip', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = svcCreatePlace(String(trip.id), { name: 'My Place' }) as any; - const row = testDb.prepare('SELECT trip_id FROM places WHERE id = ?').get(place.id) as any; - expect(row.trip_id).toBe(trip.id); - }); -}); - -// ── getPlace ────────────────────────────────────────────────────────────────── - -describe('getPlace', () => { - it('PLACE-SVC-010 — returns the place when tripId and placeId match', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Find Me' }) as any; - const found = getPlace(String(trip.id), String(place.id)) as any; - expect(found).toBeDefined(); - expect(found.name).toBe('Find Me'); - }); - - it('PLACE-SVC-011 — returns null when place belongs to different trip', () => { - const { user } = createUser(testDb); - const t1 = createTrip(testDb, user.id); - const t2 = createTrip(testDb, user.id); - const place = createPlace(testDb, t1.id, { name: 'T1 Place' }) as any; - expect(getPlace(String(t2.id), String(place.id))).toBeNull(); - }); - - it('PLACE-SVC-012 — returns null for non-existent placeId', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - expect(getPlace(String(trip.id), '99999')).toBeNull(); - }); -}); - -// ── updatePlace ─────────────────────────────────────────────────────────────── - -describe('updatePlace', () => { - it('PLACE-SVC-013 — updates place name and lat/lng', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Old', lat: 0, lng: 0 }) as any; - const updated = updatePlace(String(trip.id), String(place.id), { name: 'New', lat: 48.8, lng: 2.3 }) as any; - expect(updated.name).toBe('New'); - expect(updated.lat).toBe(48.8); - expect(updated.lng).toBe(2.3); - }); - - it('PLACE-SVC-014 — returns null for non-existent place', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - expect(updatePlace(String(trip.id), '99999', { name: 'Ghost' })).toBeNull(); - }); - - it('PLACE-SVC-015 — updates tags (replaces old set)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const tag1 = createTag(testDb, user.id, { name: 'Old Tag' }) as any; - const tag2 = createTag(testDb, user.id, { name: 'New Tag' }) as any; - const place = svcCreatePlace(String(trip.id), { name: 'Taggable', tags: [tag1.id] }) as any; - - const updated = updatePlace(String(trip.id), String(place.id), { tags: [tag2.id] }) as any; - expect(updated.tags).toHaveLength(1); - expect(updated.tags[0].id).toBe(tag2.id); - }); - - it('PLACE-SVC-016 — clears tags when tags: [] is passed', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const tag = createTag(testDb, user.id, { name: 'Temp' }) as any; - const place = svcCreatePlace(String(trip.id), { name: 'Untaggable', tags: [tag.id] }) as any; - - const updated = updatePlace(String(trip.id), String(place.id), { tags: [] }) as any; - expect(updated.tags).toHaveLength(0); - }); - - // ── Track colour (#776) ───────────────────────────────────────────────────── - - it('PLACE-SVC-052 — stores a picked route_color', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Walk' }) as any; - const updated = updatePlace(String(trip.id), String(place.id), { route_color: '#e11d48' }) as any; - expect(updated.route_color).toBe('#e11d48'); - }); - - it('PLACE-SVC-053 — an explicit null clears it again (the reset to auto)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Walk' }) as any; - updatePlace(String(trip.id), String(place.id), { route_color: '#e11d48' }); - // Guards the COALESCE trap: name/currency/transport_mode can never be - // emptied, and route_color built that way would be a one-way door. - const cleared = updatePlace(String(trip.id), String(place.id), { route_color: null }) as any; - expect(cleared.route_color).toBeNull(); - }); - - it('PLACE-SVC-054 — an unrelated update leaves the colour alone', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Walk' }) as any; - updatePlace(String(trip.id), String(place.id), { route_color: '#059669' }); - const renamed = updatePlace(String(trip.id), String(place.id), { name: 'Hike' }) as any; - expect(renamed.name).toBe('Hike'); - expect(renamed.route_color).toBe('#059669'); - }); - - it('PLACE-SVC-055 — createPlace carries geometry and colour instead of dropping them', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const created = svcCreatePlace(String(trip.id), { - name: 'Restored track', - route_geometry: '[[48.0,2.0],[49.0,3.0]]', - route_color: '#7c3aed', - }) as any; - expect(created.route_geometry).toBe('[[48.0,2.0],[49.0,3.0]]'); - expect(created.route_color).toBe('#7c3aed'); - }); -}); - -// ── updatePlacesMany ──────────────────────────────────────────────────────────── - -describe('updatePlacesMany', () => { - it('PLACE-SVC-039 — applies the same fields to many places, preserving the rest', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const a = createPlace(testDb, trip.id, { name: 'A' }) as any; - const b = createPlace(testDb, trip.id, { name: 'B' }) as any; - const c = createPlace(testDb, trip.id, { name: 'C' }) as any; - - const updated = updatePlacesMany(String(trip.id), [a.id, b.id, c.id], { notes: 'visited', transport_mode: 'walking' }); - - expect(updated).toHaveLength(3); - for (const p of updated) { - expect((p as any).notes).toBe('visited'); - expect((p as any).transport_mode).toBe('walking'); - } - // Only the provided fields change — names are untouched. - expect(updated.map(p => (p as any).name).sort()).toEqual(['A', 'B', 'C']); - }); - - it('PLACE-SVC-040 — skips ids that are not in the trip and reports the rest', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const other = createTrip(testDb, user.id); - const mine = createPlace(testDb, trip.id, { name: 'Mine' }) as any; - const foreign = createPlace(testDb, other.id, { name: 'Foreign' }) as any; - - const updated = updatePlacesMany(String(trip.id), [mine.id, foreign.id, 99999], { notes: 'tagged' }); - - expect(updated).toHaveLength(1); - expect((updated[0] as any).id).toBe(mine.id); - // The place from the other trip stays untouched. - expect((getPlace(String(other.id), String(foreign.id)) as any).notes).toBeNull(); - }); - - it('PLACE-SVC-041 — returns [] for an empty id list', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - expect(updatePlacesMany(String(trip.id), [], { notes: 'x' })).toEqual([]); - }); -}); - -// ── deletePlace ─────────────────────────────────────────────────────────────── - -describe('deletePlace', () => { - it('PLACE-SVC-017 — deletes a place and returns true', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'To Delete' }) as any; - expect(deletePlace(String(trip.id), String(place.id))).toBe(true); - expect(getPlace(String(trip.id), String(place.id))).toBeNull(); - }); - - it('PLACE-SVC-018 — returns false for non-existent place', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - expect(deletePlace(String(trip.id), '99999')).toBe(false); - }); - - it('PLACE-SVC-019 — deleting one place does not remove others', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const p1 = createPlace(testDb, trip.id, { name: 'Keep' }) as any; - const p2 = createPlace(testDb, trip.id, { name: 'Remove' }) as any; - deletePlace(String(trip.id), String(p2.id)); - const remaining = listPlaces(String(trip.id), {}) as any[]; - expect(remaining).toHaveLength(1); - expect(remaining[0].id).toBe(p1.id); - }); - - it('PLACE-SVC-019b — reclaims the photo cache for the deleted place', () => { - removeIfUnreferencedSpy.mockClear(); - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'With Photo' }) as any; - testDb.prepare('UPDATE places SET google_place_id = ? WHERE id = ?').run('ChIJgid', place.id); - - deletePlace(String(trip.id), String(place.id)); - - expect(removeIfUnreferencedSpy).toHaveBeenCalledWith('ChIJgid'); - }); -}); - -// ── importGpx ───────────────────────────────────────────────────────────────── - -describe('importGpx', () => { - it('PLACE-SVC-020 — returns null when buffer has no root', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const result = importGpx(String(trip.id), Buffer.from('')); - expect(result).toBeNull(); - }); - - it('PLACE-SVC-021 — imports waypoints as places', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const gpx = Buffer.from(` - Paris - London - `); - const result = importGpx(String(trip.id), gpx) as any; - expect(result.places).toHaveLength(2); - expect(result.places[0].name).toBe('Paris'); - expect(result.places[1].name).toBe('London'); - }); - - it('PLACE-SVC-022 — imports as a single polyline-place with routeGeometry', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const gpx = Buffer.from(` - - My Route - Start - End - - `); - const result = importGpx(String(trip.id), gpx) as any; - expect(result.places).toHaveLength(1); - expect(result.places[0].name).toBe('My Route'); - expect(result.places[0].lat).toBe(48.8566); - expect(result.places[0].lng).toBe(2.3522); - expect(result.places[0].route_geometry).toBeTruthy(); - const coords = JSON.parse(result.places[0].route_geometry); - expect(coords).toHaveLength(2); - }); - - it('PLACE-SVC-023 — imports track as a single place with routeGeometry', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const gpx = Buffer.from(` - - My Track - - 100 - 102 - - - `); - const result = importGpx(String(trip.id), gpx) as any; - expect(result.places).toHaveLength(1); - expect(result.places[0].name).toBe('My Track'); - const geometry = JSON.parse(result.places[0].route_geometry); - expect(Array.isArray(geometry)).toBe(true); - expect(geometry).toHaveLength(2); - }); - - it('PLACE-SVC-024 — and together: waypoints plus track appended', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const gpx = Buffer.from(` - POI - - Track - - - - - - `); - const result = importGpx(String(trip.id), gpx) as any; - // 1 wpt + 1 trk - expect(result.places).toHaveLength(2); - const trackPlace = result.places.find((p: any) => p.name === 'Track') as any; - expect(trackPlace).toBeDefined(); - const geometry = JSON.parse(trackPlace.route_geometry); - expect(geometry).toHaveLength(2); - }); - - it('PLACE-SVC-025 — returns null when GPX has no usable elements', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const gpx = Buffer.from(``); - const result = importGpx(String(trip.id), gpx); - expect(result).toBeNull(); - }); - - it('PLACE-SVC-037 — multiple unnamed tracks in one file get distinct names instead of collapsing to one', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const gpx = Buffer.from(` - - - - - - - - - `); - const result = importGpx(String(trip.id), gpx) as any; - expect(result.places).toHaveLength(2); - const names = result.places.map((p: any) => p.name); - expect(new Set(names).size).toBe(2); - }); - - it('PLACE-SVC-038 — unnamed tracks fall back to the source filename', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const gpx = Buffer.from(` - - - - - `); - const result = importGpx(String(trip.id), gpx, { defaultName: 'morning-hike.gpx' }) as any; - expect(result.places).toHaveLength(1); - expect(result.places[0].name).toBe('morning-hike'); - }); -}); - -// ── importGoogleList ────────────────────────────────────────────────────────── - -describe('importGoogleList', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('PLACE-SVC-026 — returns error when list ID cannot be extracted from URL', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const result = await importGoogleList(String(trip.id), 'https://example.com/no-id-here') as any; - expect(result.error).toMatch(/Could not extract list ID/); - expect(result.status).toBe(400); - }); - - it('PLACE-SVC-026b — a single-place link gives a guiding error instead of the generic one (#1304)', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const url = 'https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,17z/data=!3m1'; - const result = await importGoogleList(String(trip.id), url) as any; - expect(result.status).toBe(400); - expect(result.error).toMatch(/single place/i); - }); - - it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => '', status: 502 })); - const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; - const result = await importGoogleList(String(trip.id), url) as any; - expect(result.error).toMatch(/Failed to fetch list/); - expect(result.status).toBe(502); - }); - - it('PLACE-SVC-028 — imports places from a valid Google Maps list response', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - - const listPayload = [ - [null, null, null, null, 'My Test List', null, null, null, [ - [null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null], - [null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'], - ]], - ]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - text: async () => 'prefix\n' + JSON.stringify(listPayload), - })); - - const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; - const result = await importGoogleList(String(trip.id), url) as any; - expect(result.listName).toBe('My Test List'); - expect(result.places).toHaveLength(2); - expect(result.places[0].name).toBe('Paris'); - expect(result.places[1].name).toBe('London'); - }); - - it('PLACE-SVC-028b — stores a Google Maps ftid separately from google_place_id', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - - const listPayload = [ - [null, null, null, null, 'My Test List', null, null, null, [ - [null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"], - ]], - ]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - text: async () => 'prefix\n' + JSON.stringify(listPayload), - })); - - const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; - const result = await importGoogleList(String(trip.id), url) as any; - - expect(result.places).toHaveLength(1); - expect(result.places[0].google_place_id).toBeNull(); - expect(result.places[0].google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); - }); - - it('PLACE-SVC-028c — backfills google_ftid when re-import skips a duplicate', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const existing = createPlace(testDb, trip.id, { - name: "St. Jacobs Farmers' Market", - lat: 43.5118527, - lng: -80.5542617, - }) as any; - - const listPayload = [ - [null, null, null, null, 'My Test List', null, null, null, [ - [null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"], - ]], - ]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - text: async () => 'prefix\n' + JSON.stringify(listPayload), - })); - - const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; - const result = await importGoogleList(String(trip.id), url) as any; - const row = testDb.prepare('SELECT google_place_id, google_ftid FROM places WHERE id = ?').get(existing.id) as any; - - expect(result.places).toHaveLength(0); - expect(result.skipped).toBe(1); - expect(row.google_place_id).toBeNull(); - expect(row.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); - }); - - it('PLACE-SVC-029 — returns error when list items array is empty', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - - const listPayload = [[null, null, null, null, 'Empty List', null, null, null, []]]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - text: async () => 'prefix\n' + JSON.stringify(listPayload), - })); - - const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; - const result = await importGoogleList(String(trip.id), url) as any; - expect(result.error).toBeDefined(); - expect(result.status).toBe(400); - }); -}); - -// ── searchPlaceImage ────────────────────────────────────────────────────────── - -describe('searchPlaceImage', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('PLACE-SVC-030 — returns 404 when place does not exist', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const result = await searchPlaceImage(String(trip.id), '99999', user.id) as any; - expect(result.error).toBeDefined(); - expect(result.status).toBe(404); - }); - - it('PLACE-SVC-031 — searches Unsplash without a stored API key', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - results: [ - { id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } }, - ], - }), - status: 200, - })); - - const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any; - expect(result.photos).toHaveLength(1); - const [url] = (fetch as any).mock.calls[0]; - expect(url).toContain('https://unsplash.com/napi/search/photos?'); - expect(url).not.toContain('client_id='); - }); - - it('PLACE-SVC-032 — returns photos when Unsplash API responds successfully', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any; - - const mockPhotos = [ - { id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } }, - ]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ results: mockPhotos }), - status: 200, - })); - - const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any; - expect(result.photos).toHaveLength(1); - expect(result.photos[0].id).toBe('photo1'); - expect(result.photos[0].url).toBe('https://img.example.com/1'); - expect(result.photos[0].photographer).toBe('Photographer'); - }); -}); - -// ── Import deduplication ────────────────────────────────────────────────────── - -describe('importGpx deduplication', () => { - it('PLACE-SVC-033 — skips waypoints already in trip by name', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const buf = fs.readFileSync(GPX_FIXTURE); - - // First import - const first = importGpx(String(trip.id), buf) as any; - expect(first.count).toBeGreaterThan(0); - - // Second import — all names already present, nothing new created - const second = importGpx(String(trip.id), buf) as any; - expect(second.count).toBe(0); - expect(second.skipped).toBe(first.count); - - // Total places in DB should equal first import count - const total = (listPlaces(String(trip.id), {}) as any[]).length; - expect(total).toBe(first.count); - }); - - it('PLACE-SVC-034 — imports new places while skipping existing ones', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const buf = fs.readFileSync(GPX_FIXTURE); - - const first = importGpx(String(trip.id), buf) as any; - // Manually add a brand-new place so total > first.count - createPlace(testDb, trip.id, { name: 'Unique Extra Place', lat: 99, lng: 99 }); - - // Re-import: the fixture places are skipped, the extra place remains untouched - const second = importGpx(String(trip.id), buf) as any; - expect(second.count).toBe(0); - - const total = (listPlaces(String(trip.id), {}) as any[]).length; - expect(total).toBe(first.count + 1); - }); -}); - -describe('importKmlPlaces deduplication', () => { - it('PLACE-SVC-035 — skips placemarks already in trip by name', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const buf = fs.readFileSync(KML_FIXTURE); - - const first = importKmlPlaces(String(trip.id), buf); - expect(first.count).toBeGreaterThan(0); - - const second = importKmlPlaces(String(trip.id), buf); - expect(second.count).toBe(0); - expect(second.summary.skippedCount).toBeGreaterThanOrEqual(first.count); - expect(second.summary.warnings.some((w: string) => w.includes('skipped'))).toBe(true); - }); - - it('PLACE-SVC-036 — deduplicates within the same file (intra-batch)', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - // Craft a KML with two placemarks sharing the same name - const kml = Buffer.from(` - - Dupe Place2.0,48.0,0 - Dupe Place2.1,48.1,0 -`); - - const result = importKmlPlaces(String(trip.id), kml); - expect(result.count).toBe(1); - expect(result.summary.skippedCount).toBe(1); - }); -}); - -// ── Custom place image reclaim (#1136) ────────────────────────────────────────── - -describe('custom place image reclaim', () => { - function writePlaceImage(name: string): string { - fs.mkdirSync(PLACE_IMAGES_DIR, { recursive: true }); - const filePath = path.join(PLACE_IMAGES_DIR, name); - fs.writeFileSync(filePath, 'jpeg-bytes'); - return filePath; - } - - it('PLACE-SVC-046 — replacing image_url unlinks the previous upload once unreferenced', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any; - const fileA = writePlaceImage('svc-replace-a.jpg'); - testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-replace-a.jpg', place.id); - expect(fs.existsSync(fileA)).toBe(true); - - updatePlace(String(trip.id), String(place.id), { image_url: '/uploads/places/svc-replace-b.jpg' }); - expect(fs.existsSync(fileA)).toBe(false); - }); - - it('PLACE-SVC-047 — clearing image_url to null unlinks the previous upload', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any; - const fileA = writePlaceImage('svc-clear.jpg'); - testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-clear.jpg', place.id); - expect(fs.existsSync(fileA)).toBe(true); - - updatePlace(String(trip.id), String(place.id), { image_url: null } as any); - expect(fs.existsSync(fileA)).toBe(false); - }); - - it('PLACE-SVC-048 — deletePlace unlinks the uploaded image', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any; - const fileA = writePlaceImage('svc-delete.jpg'); - testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-delete.jpg', place.id); - expect(fs.existsSync(fileA)).toBe(true); - - deletePlace(String(trip.id), String(place.id)); - expect(fs.existsSync(fileA)).toBe(false); - }); - - it('PLACE-SVC-049 — a collection_places reference keeps the file when the trip place is deleted', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Shared Photo' }) as any; - const fileA = writePlaceImage('svc-shared.jpg'); - testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-shared.jpg', place.id); - // A saved-place in a collection holds the same uploaded file — the ref-count guard must protect it. - const col = testDb.prepare('INSERT INTO collections (owner_id, name) VALUES (?, ?)').run(user.id, 'Saved'); - testDb.prepare('INSERT INTO collection_places (collection_id, owner_id, name, image_url) VALUES (?, ?, ?, ?)') - .run(col.lastInsertRowid, user.id, 'Shared Photo', '/uploads/places/svc-shared.jpg'); - expect(fs.existsSync(fileA)).toBe(true); - - deletePlace(String(trip.id), String(place.id)); - expect(fs.existsSync(fileA)).toBe(true); - - // resetTestDb does not clear collections; drop what this test inserted and its file. - testDb.exec('DELETE FROM collection_places; DELETE FROM collections;'); - fs.unlinkSync(fileA); - }); - - // ── Collaborative ratings (#1435) ────────────────────────────────────────── - it('PLACE-SVC-050 — ratePlace stores one vote per user, replaces on re-vote, clears with null', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const place = createPlace(testDb, trip.id, { name: 'Rated' }) as { id: number }; - - ratePlace(String(trip.id), String(place.id), user.id, 5); - let rows = testDb.prepare('SELECT rating FROM place_ratings WHERE place_id = ? AND user_id = ?').all(place.id, user.id) as { rating: number }[]; - expect(rows).toEqual([{ rating: 5 }]); - - ratePlace(String(trip.id), String(place.id), user.id, 2); // re-vote replaces via the UNIQUE upsert - rows = testDb.prepare('SELECT rating FROM place_ratings WHERE place_id = ? AND user_id = ?').all(place.id, user.id) as { rating: number }[]; - expect(rows).toEqual([{ rating: 2 }]); - - ratePlace(String(trip.id), String(place.id), user.id, null); // clear - const count = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number }; - expect(count.n).toBe(0); - }); - - it('PLACE-SVC-051 — ratePlace returns null and writes nothing when the place is not in the trip', () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const otherTrip = createTrip(testDb, user.id); - const place = createPlace(testDb, otherTrip.id, { name: 'Elsewhere' }) as { id: number }; - - expect(ratePlace(String(trip.id), String(place.id), user.id, 4)).toBeNull(); - const count = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number }; - expect(count.n).toBe(0); - }); -}); diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index e0918fd94..23550c2fb 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -142,7 +142,10 @@ export const placeBulkDeleteRequestSchema = z.object({ export type PlaceBulkDeleteRequest = z.infer; export const placeBulkUpdateRequestSchema = z.object({ - ids: z.array(z.number()).min(1), + // Deliberately unbounded: the endpoint answers an empty list with + // `{ updated: [], count: 0 }` rather than a 400, so a `.min(1)` here would + // change that contract once the body validates through the Zod pipe. + ids: z.array(z.number()), // null clears the category ("No category"); a number sets it. Optional so the // field can be omitted, but the endpoint requires it to be present to act. category_id: z.number().nullable().optional(), @@ -157,6 +160,27 @@ export const placeImportListRequestSchema = z.object({ }); export type PlaceImportListRequest = z.infer; +/** + * GPX import (multipart/form-data alongside the `file` part). Form fields arrive + * as strings — the client sends `String(boolean)` — so they stay `z.string()` + * and the route keeps its own `'true'`-comparison coercion (same shape as + * fileUploadRequestSchema). Every field optional: an omitted flag defaults to + * true server-side. + */ +export const placeImportGpxRequestSchema = z.object({ + importWaypoints: z.string().optional(), + importRoutes: z.string().optional(), + importTracks: z.string().optional(), +}); +export type PlaceImportGpxRequest = z.infer; + +/** KML/KMZ import (multipart/form-data); same string-field contract as GPX. */ +export const placeImportMapRequestSchema = z.object({ + importPoints: z.string().optional(), + importPaths: z.string().optional(), +}); +export type PlaceImportMapRequest = z.infer; + /** Query filters for the place list. */ export const placeListQuerySchema = z.object({ search: z.string().optional(),