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