From 287dceb532ddcddb4b5aedcb9f267dd3a52d3b4f Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 29 Jul 2026 01:53:54 +0200 Subject: [PATCH] chore: wire TripsService into the plugin RPC host and retire the legacy tripService Swap the factory's six tripService imports for the injected TripsService; delete services/tripService.ts and the collab/vacay bridges with it; prune the days/budget bridges to their surviving exports; update the migration docs and regenerate migration-graph.md. --- migrate.md | 2 +- migration-graph.md | 72 +- server/src/nest/README.md | 29 +- server/src/nest/budget/budget.bridge.ts | 27 +- server/src/nest/collab/collab.bridge.ts | 33 - server/src/nest/collab/collab.service.ts | 5 +- server/src/nest/days/days.bridge.ts | 40 +- server/src/nest/plugins/DI-MIGRATION.md | 22 +- .../plugins/host/plugin-host-deps.factory.ts | 19 +- server/src/nest/plugins/plugins.module.ts | 3 +- server/src/nest/vacay/vacay.bridge.ts | 28 - server/src/nest/vacay/vacay.service.ts | 5 +- server/src/services/tripService.ts | 1121 ----------------- server/tests/helpers/plugin-host.ts | 23 +- server/tests/unit/nest/collab.service.test.ts | 35 +- server/tests/unit/nest/days.service.test.ts | 20 +- server/tests/unit/nest/vacay.service.test.ts | 20 +- .../plugins/plugin-host-deps.factory.test.ts | 45 +- 18 files changed, 179 insertions(+), 1370 deletions(-) delete mode 100644 server/src/nest/collab/collab.bridge.ts delete mode 100644 server/src/nest/vacay/vacay.bridge.ts delete mode 100644 server/src/services/tripService.ts diff --git a/migrate.md b/migrate.md index d6956fb59..5a1dd0291 100644 --- a/migrate.md +++ b/migrate.md @@ -28,7 +28,7 @@ Wave 4 — the coupled cluster (order matters here) dayService; the budget/day coupling lives in the Nest wrapper's budget-sync seam and the MCP registrars, which keep their legacy imports until those domains migrate. Reservations went first as the frontier residue fold; dayService (done 2026-07 — the 592-line service folded into the wrapper `DaysService`, the accommodations seam in `nest/reservations/` now injects it, and the hand-rolled reorder/insert transactions became `db.transaction()`) followed; budgetService followed (done 2026-07 — the 755-line money core folded into the wrapper `BudgetService` (settlement maths, FX freeze/rebase, items/members/payers CRUD; the raw settlement writes became `insertSettlement`/`applySettlementUpdate` so the MCP paths keep skipping the freeze); the 11-tool mcp registrar + 3 budget resources moved to the decorator-driven `budget.mcp.ts`; TripsService/ReservationsService/BookingImportService now inject `BudgetService`; a 4-export `budget.bridge.ts` serves tripService, userCleanupService and the legacy trips/transports registrars; `exchange-rates.bridge.ts` was deleted with its last consumers; and the controller adopted `budget.dto.ts` over the shared schemas, clearing all nine budget allow-list entries — **tripService is now unblocked**). -9. Then placeService (7 internal deps, mostly on things migrated by now) and tripService (10 deps — the biggest hub; last in this wave, since nearly everything it needs will already be injectable). +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). Wave 5 — the heavyweights, last diff --git a/migration-graph.md b/migration-graph.md index 81fce3ca2..eff5da897 100644 --- a/migration-graph.md +++ b/migration-graph.md @@ -1,8 +1,14 @@ # Legacy `src/services/` dependency graph -Generated from the actual imports in `server/src` on **2026-07-28** (after the -budgetService migration + its quirk-fix pass — completing step 1 of the -dependency-honest order below and unblocking the tripService hub — and the +Generated from the actual imports in `server/src` on **2026-07-29** (after the +tripService migration + its quirk-fix pass — completing step 2 of the +dependency-honest order below: 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 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/` @@ -44,14 +50,20 @@ How to read it: `ExchangeRatesService` — module-scoped rate cache retained on purpose, permissions-style, so out-of-container instances and the DI singleton share one cached upstream feed; its `exchange-rates.bridge` was deleted with the budgetService migration), - budget (the 755-line money core folded into `BudgetService`; `budget.bridge.ts` carries - the four exports still consumed outside the container — tripService, userCleanupService - and the legacy trips/transports MCP registrars). + budget (the 755-line money core folded into `BudgetService`; `budget.bridge.ts` is down + to the two exports still consumed outside the container — userCleanupService and the + legacy transports registrar), + trip (the 1121-line hub folded into `TripsService`; its six bridge imports became + injected services, and the fold deleted `todo.bridge`, `share.bridge`, `collab.bridge` + and `vacay.bridge` with their last consumers; a 3-export `trips.bridge.ts` serves the + legacy prompts registrar's getTripSummary and `budget.mcp.ts`'s getTripOwner/listMembers + seam — injecting there would need a forwardRef'd TripsModule↔BudgetModule cycle; + `days.bridge.ts` shrank to getDay/listDays for the transit/transports registrars). - **Domain migration targets** (the wave material): adminService, airportService, atlasService, authService, backupService, collectionsService, journeyService, journeyShareService, mapsService, notificationService, oauthService, oidcService, passkeyService, placeService, transitService, - transitItineraryService, tripService, weatherService, wikiService. + 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, @@ -74,7 +86,6 @@ flowchart TD classDef infra fill:#455a64,color:#fff subgraph frontier["READY FRONTIER (no unmigrated domain deps)"] - trip["tripService (the hub)"]:::hub atlas[atlasService]:::ready weather[weatherService]:::ready airport["airportService (boot special case)"]:::ready @@ -99,7 +110,6 @@ flowchart TD memories["memories/ cluster"]:::infra cleanup[userCleanupService]:::infra - trip --> cleanup collections -.-> notifSvc place --> maps transit --> maps @@ -122,10 +132,11 @@ journey/memories corner tangle with the admin corner. The former `auth/collections/backup → permissions` and `notifSvc/oauth → auditLog` edges are gone since the 2026-07 Wave-2 pair: the permissions consumers repointed to `nest/permissions/permissions.bridge`, the writeAudit consumers to `nest/audit/audit.bridge`, and the log*-only consumers to the plain -`nest/audit/audit-log.logger` — none of them block a migration anymore. The former -`trip → budget` and `cleanup → budget` edges are gone since the 2026-07 budgetService -migration: both repointed to `nest/budget/budget.bridge`, leaving tripService with no -unmigrated domain dependency — the hub is frontier-ready. The dotted +`nest/audit/audit-log.logger` — none of them block a migration anymore. The +tripService hub node is gone since the 2026-07 trip fold — the DI-native +`TripsService` injects its former bridge targets and keeps the legacy +avatarUrl/timezoneService/userCleanupService helpers as plain imports (helpers +never block). The dotted `collections -.-> notifSvc` edge is a call-time `import()` for the collection-invite notification: it stays as-is through a migration, exactly like the identical lazy sends in the already-migrated collab/packing/trips/reservations/vacay services.) @@ -138,8 +149,8 @@ already-migrated collab/packing/trips/reservations/vacay services.) | `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 | | `apiKeyCrypto` | (none) | adminService, airtrail/airtrailService, authService, llmConfig, mapsService, memories/helpersService, memories/immichService, memories/photoResolverService, memories/synologyService, memories/unifiedService, notifications, oidcService, unsplashService | 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/tags/tags.mcp.ts, nest/todo/todo.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/transit.ts, mcp/tools/transports.ts, mcp/tools/trips.ts | -| `avatarUrl` | (none) | adminService, authService, inAppNotifications, journeyService, tripService | 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 | (none) | +| `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/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/transit.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) | | `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) | @@ -167,14 +178,13 @@ already-migrated collab/packing/trips/reservations/vacay services.) | `placePhotoCache` | (none) | mapsService, 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) | -| `timezoneService` | (none) | airtrail/airtrailMapper, transitItineraryService, tripService | (none) | (none) | +| `timezoneService` | (none) | airtrail/airtrailMapper, transitItineraryService | nest/trips/trips.service.ts | (none) | | `transitItineraryService` | distanceService, timezoneService, transitService (+ type-only `reservations.bridge`) | (none) | (none) | mcp/tools/transit.ts | | `transitService` | mapsService | transitItineraryService | nest/transit/transit.controller.ts | mcp/tools/transit.ts | -| `tripAccess` | (none) | tripService | nest/booking-import/booking-import.service.ts, nest/budget/budget.service.ts, nest/collab/collab.service.ts, nest/integrations/airtrail-import.controller.ts, nest/packing/packing.service.ts, nest/reservations/reservations.service.ts, nest/todo/todo.service.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) | -| `tripService` | avatarUrl, timezoneService, tripAccess, userCleanupService (+ `budget.bridge`, `collab.bridge`, `days.bridge`, `packing.bridge`, `reservations.bridge`, `vacay.bridge`) | (none) | nest/budget/budget.mcp.ts, nest/feeds/feeds.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trips/trips.controller.ts, nest/trips/trips.service.ts | mcp/resources.ts, mcp/tools/prompts.ts, mcp/tools/trips.ts | | `unsplashService` | apiKeyCrypto | placeService | nest/trips/trips.controller.ts, nest/trips/trips.service.ts | (none) | -| `userCleanupService` | (none — `budget.bridge`, plugin paths infra only) | adminService, authService, tripService | (none) | (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) | | `wikiService` | (none) | (none) | nest/help/help.controller.ts | (none) | @@ -205,19 +215,14 @@ already-migrated collab/packing/trips/reservations/vacay services.) | Candidate | Why now / why not | Bridge tax (legacy dependents + out-of-container) | |---|---|---| -| **tripService** ← pick | budgetService landed (2026-07), so the hub's last unmigrated domain edge is gone (its remaining legacy deps are helpers + the budget/collab/days/packing/reservations/vacay bridges); every other migration keeps paying it a repoint tax until it moves | no legacy dependents; mcp resources/prompts/trips registrars + `budget.mcp.ts`'s getTripOwner/listMembers seam | +| **mapsService** ← pick | Frontier-ready since the Phase 0 `getAppUrl` → app-config move; heads the step-4 chain that unblocks the placeService Wave-4 remainder (tripService — the hub — landed 2026-07) | `placeEnrichment`, `transitService`; `mcp/tools/mapsWeather.ts`, `mcp/tools/places.ts` | | **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` | | **atlasService** | Zero deps, but its legacy dependent is `authService` (Wave-5) → bridge lives long | `authService`; `mcp/tools/atlas.ts`, `mcp/resources.ts` | -| **mapsService** | Frontier-ready since the Phase 0 `getAppUrl` → app-config move (remaining legacy deps are the apiKeyCrypto/placePhotoCache helpers); unblocks transitService → placeService → transitItineraryService | `placeEnrichment`, `transitService`; `mcp/tools/mapsWeather.ts`, `mcp/tools/places.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 | **Blocked, and by what (shortest unblock path):** -- `tripService` (the hub) — **unblocked since the 2026-07 budgetService - migration** (its budget edge and `userCleanupService`'s are now - `budget.bridge` repoints, joining the collab/vacay/reservations/days ones). - See the frontier table above. - `placeService` ← `mapsService` (via placeEnrichment) — mapsService itself is frontier-ready since the Phase 0 app-url move deleted its notifications edge. - `notificationService` ← notifications cluster (its auditLog edge is now the plain @@ -246,7 +251,9 @@ Wave-2 `permissions` + `auditLog` pair were the first frontier picks — all don 1. `exchangeRateService` fold → `budgetService` (both done 2026-07 — `ExchangeRatesService` then the full `BudgetService` fold in `nest/budget/`; `userCleanupService` is free, repointed to `budget.bridge`) -2. `tripService` — all remaining domain edges + userCleanup now gone (frontier `← pick`) +2. `tripService` (done 2026-07 — the hub folded into `TripsService`; the trips/share MCP + surfaces moved to the decorator registry, the plugin host injects it, and the + todo/share/collab/vacay bridges died with their last consumers) 3. notifications cluster → `notificationService` 4. `mapsService` → `transitService` → `placeService` → `transitItineraryService` 5. `atlasService` → `authService` (+ oidc/passkey) → `adminService` → `oauthService` @@ -262,8 +269,10 @@ Wave-2 `permissions` + `auditLog` pair were the first frontier picks — all don frontier-ready. **Borne out by the 2026-07 fold**: the budget/day coupling lives in the Nest wrapper's budget-sync seam and the MCP surface, which keep their legacy imports until those domains migrate. -- Every remaining Wave-3/4 domain has legacy `tripService` as a dependent — each migration - before tripService pays a small bridge/repoint tax the files migration didn't have. +- Every remaining Wave-3/4 domain had legacy `tripService` as a dependent — each migration + before tripService paid a small bridge/repoint tax the files migration didn't have. + **Resolved by the 2026-07 trip fold**: that recurring tax is gone, and the fold cashed in + the accumulated bridges (todo/share/collab/vacay deleted, days/budget pruned). - (2026-07-28 regeneration) The earlier grep-based analysis only saw static imports, so it missed every lazy edge: the scheduler's `require()` jobs (admin, notificationService, placePhotoCache, airtrailSync, trekPhotoCache), the `db/database.ts` boot backfill's @@ -272,3 +281,10 @@ Wave-2 `permissions` + `auditLog` pair were the first frontier picks — all don also under-reported `tripService`'s bridge repoints (six, not three: budget, collab, days, packing, reservations, vacay). None of this changes the order — lazy edges don't block — but the bridge-pressure column was undercounting scheduler/system-notice consumers. +- (2026-07-29 regeneration, post trip fold) The predicted bridge tax was right in shape but + the fold went further than a bridge: the whole trips MCP surface (10 tools + 3 resources + + the first `@Prompt`) and the 3 share-link tools moved onto the decorator registry, so + the only bridge left is the 3-export `trips.bridge.ts` for the legacy prompts registrar + and `budget.mcp.ts`'s owner/member seam. `verifyTripAccess`'s `tripAccess` re-export died + with the legacy file, leaving `services/tripAccess` with zero legacy importers — it is + now purely Nest-consumed (Wave-2 "delete, don't migrate" is down to those repoints). diff --git a/server/src/nest/README.md b/server/src/nest/README.md index c404b25c1..049e1c3dc 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -28,8 +28,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express. - **Phase 2 (trip sub-domains):** vacay (addon), packing, todo. - **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 — see the migration - recipe below. + collab, vacay, reservations, day, permissions, audit, budget, trip — see the + migration recipe below. ## Cross-cutting Foundation pieces @@ -203,9 +203,30 @@ MCP class) and BookingImportService inject `BudgetService`; a 4-export trips/transports registrars; `exchange-rates.bridge.ts` was deleted with its last consumers, and the controller adopted `budget.dto.ts` — all nine allow-list entries removed). +tripService followed (the 1121-line Wave-4 hub — the biggest fold — moved into +the wrapper `TripsService`: TRIP_SELECT + list/create/get, the +`generateDays` two-phase renumber engine, the updateTrip date-shift +transaction, the member/guest lifecycle (#973/#1362), the ICS export with its +module-scoped tz-validity cache, `copyTripById` and `getTripSummary`; its six +bridge imports became injected services (CollabService + VacayService joined +the constructor); the 10-tool trips registrar + 3 `resources.ts` trip +resources + the trip-summary prompt moved to `trips.mcp.ts` — the first +`@Prompt` use, with the fire-once static-token deprecation notice now riding +the `registry.attach` ctx — and the 3 share-link tools it carried moved to +`share.mcp.ts` on the `canShareTrips` predicate (delete_trip and the +canReadTrips reads are predicates too — the broadened legacy gates have no +declarative equivalent); FeedsService and the plugin host inject +`TripsService` (its 20th constructor dep); a 3-export `trips.bridge.ts` +serves the legacy prompts registrar and budget.mcp.ts's owner/member seam +(injecting there would need a forwardRef'd TripsModule↔BudgetModule cycle); +`todo.bridge.ts`, `share.bridge.ts`, `collab.bridge.ts` and `vacay.bridge.ts` +were deleted with their last consumers and the unused days/budget bridge +exports pruned; the controller adopted `trips.dto.ts` — all seven allow-list +entries removed). Repeat these steps per -service (next up: **tripService** — the budget fold was its unblock, per the -dependency-honest order in `migration-graph.md`). This is a +service (next up: the **mapsService → transitService → placeService** chain — +or the notifications fan-in — per the dependency-honest order in +`migration-graph.md`). This is a **pure relocation** — byte-identical SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it diff --git a/server/src/nest/budget/budget.bridge.ts b/server/src/nest/budget/budget.bridge.ts index 88b84a256..9404de576 100644 --- a/server/src/nest/budget/budget.bridge.ts +++ b/server/src/nest/budget/budget.bridge.ts @@ -7,15 +7,16 @@ import { ExchangeRatesService } from './exchange-rates.service'; /** * Non-Nest entry point for the budget domain — for code running OUTSIDE the - * Nest container (the legacy services/tripService.ts and - * services/userCleanupService.ts, and the legacy update_trip / create_transport - * registrars in src/mcp/tools/trips.ts and src/mcp/tools/transports.ts; the - * budget MCP tools and resources moved to the DI-discovered budget.mcp.ts, and - * the plugin RPC host injects BudgetService via PluginHostDepsFactory). - * Exports only the legacy services/budgetService names still consumed outside - * the container, 1:1, so repointing a consumer is an import-path-only diff. - * Inside the container, inject BudgetService instead. Delete this file when - * tripService/userCleanupService and the legacy MCP registrars migrate. + * Nest container (services/userCleanupService.ts and the legacy + * create_transport registrar in src/mcp/tools/transports.ts; the budget MCP + * tools and resources moved to the DI-discovered budget.mcp.ts, the plugin + * RPC host injects BudgetService via PluginHostDepsFactory, and the trip + * domain folded into the DI-native TripsService, which injects BudgetService — + * its bridge exports were pruned with it). Exports only the legacy + * services/budgetService names still consumed outside the container, 1:1, so + * repointing a consumer is an import-path-only diff. Inside the container, + * inject BudgetService instead. Delete this file when + * userCleanupService and the transports registrar migrate. * * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton, and ExchangeRatesService keeps its FX @@ -24,18 +25,10 @@ import { ExchangeRatesService } from './exchange-rates.service'; */ const budget = new BudgetService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new ExchangeRatesService(), new RealtimeService()); -export function listBudgetItems(tripId: string | number) { - return budget.listBudgetItems(tripId); -} - export function removeUserFromBudgetItems(userId: number): void { budget.removeUserFromBudgetItems(userId); } -export function rebaseTripCurrency(tripId: string | number, newCurrency: string | null | undefined): Promise { - return budget.rebaseTripCurrency(tripId, newCurrency); -} - export function linkBudgetItemToReservation( tripId: string | number, reservationId: number, diff --git a/server/src/nest/collab/collab.bridge.ts b/server/src/nest/collab/collab.bridge.ts deleted file mode 100644 index 7fb7ad7b5..000000000 --- a/server/src/nest/collab/collab.bridge.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { db } from '../../db/database'; -import { DatabaseService } from '../database/database.service'; -import { RealtimeService } from '../realtime/realtime.service'; -import { CollabService } from './collab.service'; -import { PermissionsService } from '../permissions/permissions.service'; - -/** - * Non-Nest entry point for the collab domain — for code running OUTSIDE the - * Nest container (the legacy trip-summary builder in src/services/tripService.ts - * and the legacy get_trip_summary registrar in src/mcp/tools/trips.ts; the - * collab MCP tools and resources moved to the DI-discovered collab.mcp.ts, and - * the plugin RPC host injects CollabService via PluginHostDepsFactory). Exports - * only the legacy services/collabService names still consumed outside the - * container, 1:1, so repointing a consumer is an import-path-only diff. Inside - * the container, inject CollabService instead. Delete this file when - * tripService and the legacy MCP trips registrar migrate. - * - * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto - * the shared better-sqlite3 singleton. - */ -const collab = new CollabService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService()); - -export function listNotes(tripId: string | number) { - return collab.listNotes(tripId); -} - -export function listPolls(tripId: string | number) { - return collab.listPolls(tripId); -} - -export function countMessages(tripId: string | number): number { - return collab.countMessages(tripId); -} diff --git a/server/src/nest/collab/collab.service.ts b/server/src/nest/collab/collab.service.ts index 9c42e90a6..8937769bd 100644 --- a/server/src/nest/collab/collab.service.ts +++ b/server/src/nest/collab/collab.service.ts @@ -58,8 +58,9 @@ export interface LinkPreviewResult { * run in db.transaction(), getFormattedNoteById is trip-scoped and null-safe, * votePoll rejects non-integer indexes, and linkPreview absorbs malformed URLs * instead of throwing. - * Non-Nest consumers (legacy tripService, the legacy MCP trips registrar) go - * through collab.bridge.ts instead of importing this class directly. + * All consumers are in-container since the trip fold (TripsService and + * TripsMcp inject this class); collab.bridge.ts was deleted with its last + * outside-container consumers. */ @Injectable() export class CollabService { diff --git a/server/src/nest/days/days.bridge.ts b/server/src/nest/days/days.bridge.ts index 3d3865e50..accb7bf7a 100644 --- a/server/src/nest/days/days.bridge.ts +++ b/server/src/nest/days/days.bridge.ts @@ -1,27 +1,26 @@ import { db } from '../../db/database'; import { DatabaseService } from '../database/database.service'; import { RealtimeService } from '../realtime/realtime.service'; -import { DaysService, addDays } from './days.service'; +import { DaysService } from './days.service'; import { PermissionsService } from '../permissions/permissions.service'; /** * Non-Nest entry point for the day domain — for code running OUTSIDE the - * Nest container (the legacy services/tripService.ts and the legacy - * transit/transports MCP registrars in src/mcp/tools/; the day MCP tools and - * resources moved to the DI-discovered days.mcp.ts, and the plugin RPC host - * injects DaysService via PluginHostDepsFactory). Exports only the legacy - * services/dayService names still consumed outside the container, 1:1, so - * repointing a consumer is an import-path-only diff. Inside the container, - * inject DaysService instead. Delete this file when tripService and the - * transit/transports registrars migrate. + * Nest container (the legacy transit/transports MCP registrars in + * src/mcp/tools/; the day MCP tools and resources moved to the DI-discovered + * days.mcp.ts, the plugin RPC host injects DaysService via + * PluginHostDepsFactory, and the trip domain folded into the DI-native + * TripsService, which injects DaysService — its bridge exports were pruned + * with it). Exports only the legacy services/dayService names still consumed + * outside the container, 1:1, so repointing a consumer is an import-path-only + * diff. Inside the container, inject DaysService instead. Delete this file + * when the transit/transports registrars migrate. * * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton. */ const days = new DaysService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService()); -export { addDays }; - export function getDay(id: string | number, tripId: string | number) { return days.getDay(id, tripId); } @@ -29,22 +28,3 @@ export function getDay(id: string | number, tripId: string | number) { export function listDays(tripId: string | number) { return days.list(tripId); } - -export function listAccommodations(tripId: string | number) { - return days.listAccommodations(tripId); -} - -export function restampReservationDates( - tripId: string | number, - oldDateById: Map, - newDateById: Map, -): void { - days.restampReservationDates(tripId, oldDateById, newDateById); -} - -export function resyncAccommodationDays( - tripId: string | number, - prevDateByDayId: Map, -): void { - days.resyncAccommodationDays(tripId, prevDateByDayId); -} diff --git a/server/src/nest/plugins/DI-MIGRATION.md b/server/src/nest/plugins/DI-MIGRATION.md index 04b6b05be..8619bc4f3 100644 --- a/server/src/nest/plugins/DI-MIGRATION.md +++ b/server/src/nest/plugins/DI-MIGRATION.md @@ -36,10 +36,11 @@ DI-only service and the legacy function module was deleted, but the wiring layer didn't inject — so a module-level shim re-exported the legacy names over a hand-built instance. Option A deleted `tags.bridge.ts` and `categories.bridge.ts` (the plugin host was their only consumer). -**`todo.bridge.ts` survives** for exactly one remaining consumer that genuinely -runs outside the container: the legacy `get_trip_summary` registrar in -`src/mcp/tools/trips.ts`. Delete it when that registrar migrates to the -DI-discovered MCP registry. New bridges should only be added for genuinely +`todo.bridge.ts`, `share.bridge.ts`, `collab.bridge.ts` and `vacay.bridge.ts` +followed with the 2026-07 trip fold — their last consumers (the legacy +`src/mcp/tools/trips.ts` registrar and the legacy tripService itself) migrated +into the DI-discovered `trips.mcp.ts`/`share.mcp.ts` and the DI-native +`TripsService`. New bridges should only be added for genuinely non-Nest consumers (legacy MCP registrars, scheduler, websocket) — the plugin host no longer needs them, ever. @@ -118,8 +119,12 @@ host's audit trail is the separate `plugin-audit.ts`); exchangeRateService swapped in 2026-07 with the budget-domain fold — its `getRates` import became the injected `ExchangeRatesService`; budgetService swapped in 2026-07 with the budget migration — its `listBudgetItems` import became the already-injected -`BudgetService`, so no constructor change at all; the remaining factory -imports are all Wave-4+ domains: trips, places, journeys, atlas, collections). +`BudgetService`, so no constructor change at all; tripService swapped in +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). ### Test impact (as landed) @@ -127,7 +132,10 @@ imports are all Wave-4+ domains: trips, places, journeys, atlas, collections). `PluginRpcHost` directly with hand-built `HostDeps`). - `tests/unit/plugins/plugin-host-deps.factory.test.ts` (was `create-rpc-host.test.ts`) — the six DI-domain path mocks became constructor - stubs (sixteen stubs as of the 2026-07 exchange-rates fold); the ~22 + stubs (sixteen stubs as of the 2026-07 exchange-rates fold, plus `tripsStub` + with the 2026-07 trip fold — the tripService path mock's sentinel behaviors + moved onto the stub, its throws now built from the real + `nest/trips/trips.service` error classes); the ~22 legacy-service path mocks shrank by one with the 2026-07 budget migration (the budgetService satisfy-the-import mock died with the legacy file — the `budgetStub` gained `listBudgetItems` for the two swapped closures); a diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index e3e5c42d4..78adcf4e4 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -14,7 +14,7 @@ import fsMod from 'node:fs'; import pathMod from 'node:path'; import { randomUUID } from 'node:crypto'; import { PermissionsService } from '../../permissions/permissions.service'; -import { listTrips, updateTrip, createTrip, removeMember as removeTripMemberSvc, NotFoundError, ValidationError } from '../../../services/tripService'; +import { TripsService, NotFoundError, ValidationError } from '../../trips/trips.service'; import { createPlace, updatePlace, deletePlace } from '../../../services/placeService'; import { AddonsService } from '../../addons/addons.service'; import { isDemoEmail } from '../../../services/demo'; @@ -125,7 +125,7 @@ export interface PluginCallRouter { * * DI-native domain services (budget, exchange-rates, reservations, tags, * categories, todo, packing, day-notes, assignments, oauth, files, collab, - * vacay, the LLM config resolver) and + * vacay, trips, the LLM config resolver) and * the DatabaseService (all inline SQL + the trip-access helper) are * constructor-injected; legacy `services/*` domains stay plain function * imports until their own migration lands (DI-MIGRATION.md), at which point @@ -153,6 +153,7 @@ export class PluginHostDepsFactory { private readonly exchangeRates: ExchangeRatesService, private readonly addons: AddonsService, private readonly realtime: RealtimeService, + private readonly trips: TripsService, ) {} /** @@ -347,7 +348,7 @@ export class PluginHostDepsFactory { // transfer is a separate, deliberate action, not a member-management side effect. const trip = this.db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; if (trip && trip.user_id === targetUserId) throw new ForbiddenResource('cannot remove the trip owner'); - removeTripMemberSvc(tripId, targetUserId); + this.trips.removeMember(tripId, targetUserId); return { removed: true }; }, // --- Host-mediated notifications. Recipient resolution + channel fan-out + @@ -431,7 +432,7 @@ export class PluginHostDepsFactory { // Cross-trip: every accessible trip's budget items (membership predicate is // baked into listTrips). Reuses the hydrated list so members/payers come too. listCostsForUser: (userId) => { - const trips = listTrips(userId, null) as Array<{ id: number }>; + const trips = this.trips.list(userId, null) as Array<{ id: number }>; return trips.flatMap((t) => this.budget.listBudgetItems(t.id)); }, // Reuses BudgetService.create (frozen FX + members/payers), then broadcasts @@ -528,7 +529,7 @@ export class PluginHostDepsFactory { }, createTripForUser: (userId, input) => { try { - const result = createTrip(userId, input as unknown as Parameters[1]); + const result = this.trips.create(userId, input as unknown as Parameters[1]); return result.trip; } catch (e) { if (e instanceof ValidationError) throw new BadParams(e.message); @@ -552,7 +553,9 @@ export class PluginHostDepsFactory { } const u = this.db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined; try { - const result = updateTrip(tripId, userId, input as Parameters[2], u?.role ?? 'user'); + // The no-rebase updateTrip core — parity with the legacy host path, + // which never re-anchored the budget currency. + const result = this.trips.updateTrip(tripId, userId, input as Parameters[2], u?.role ?? 'user'); this.realtime.broadcast(tripId, 'trip:updated', { trip: result.updatedTrip }); return result.updatedTrip; } catch (e) { @@ -564,9 +567,9 @@ export class PluginHostDepsFactory { // --- Cross-trip reads. The membership predicate is baked into listTrips, so a // plugin only ever sees the acting user's own trips/reservations (no raw // cross-tenant SELECT). Reuses the same hydrated services as the REST paths. --- - listTripsForUser: (userId) => listTrips(userId, null), + listTripsForUser: (userId) => this.trips.list(userId, null), listReservationsForUser: (userId) => { - const trips = listTrips(userId, null) as Array<{ id: number }>; + const trips = this.trips.list(userId, null) as Array<{ id: number }>; return trips.flatMap((t) => this.reservations.list(String(t.id))); }, // --- Trip-scoped hydrated reads (membership already checked by tripRead). Same diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index cbfde0071..d533427f1 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -36,6 +36,7 @@ import { LlmParseModule } from '../llm-parse/llm-parse.module'; 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 { PermissionsModule } from '../permissions/permissions.module'; import { AuditModule } from '../audit/audit.module'; import { AddonsModule } from '../addons/addons.module'; @@ -50,7 +51,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, PermissionsModule, AuditModule, AddonsModule], + imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule], controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController], providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory], // Exported so the admin addon-toggle handler can cascade-disable plugins whose diff --git a/server/src/nest/vacay/vacay.bridge.ts b/server/src/nest/vacay/vacay.bridge.ts deleted file mode 100644 index 7212725cd..000000000 --- a/server/src/nest/vacay/vacay.bridge.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { db } from '../../db/database'; -import { DatabaseService } from '../database/database.service'; -import { RealtimeService } from '../realtime/realtime.service'; -import { VacayService } from './vacay.service'; - -/** - * Non-Nest entry point for the vacay domain — for code running OUTSIDE the - * Nest container (the legacy services/tripService.ts, which shifts a trip - * owner's vacation entries when the trip window moves; the vacay MCP tools and - * resources moved to the DI-discovered vacay.mcp.ts, and the plugin RPC host - * injects VacayService via PluginHostDepsFactory). Exports only the legacy - * services/vacayService names still consumed outside the container, 1:1, so - * repointing a consumer is an import-path-only diff. Inside the container, - * inject VacayService instead. Delete this file when tripService migrates. - * - * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto - * the shared better-sqlite3 singleton. - */ -const vacay = new VacayService(new DatabaseService(db), new RealtimeService()); - -export function shiftOwnerEntriesForTripWindow( - ownerId: number, - oldStart: string, - oldEnd: string, - newStart: string -): void { - return vacay.shiftOwnerEntriesForTripWindow(ownerId, oldStart, oldEnd, newStart); -} diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index 40db71209..94cd27e04 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -155,8 +155,9 @@ function windowEndYear(end: string): number { * an AbortSignal timeout and the nager.at responses are ok-checked; * applyHolidayCalendars honors the cache TTL; addYear no longer swallows real * errors; the holiday cache is instance state instead of a module-level map. - * The only consumer outside the Nest container is the legacy tripService, - * which goes through vacay.bridge.ts instead of importing this class. + * All consumers are in-container since the trip fold (TripsService injects + * this class); vacay.bridge.ts was deleted with its last outside-container + * consumer. */ @Injectable() export class VacayService { diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts deleted file mode 100644 index f5bc99d28..000000000 --- a/server/src/services/tripService.ts +++ /dev/null @@ -1,1121 +0,0 @@ -import path from 'path'; -import { avatarUrl } from './avatarUrl'; -import fs from 'fs'; -import { randomUUID } from 'crypto'; -import { db, isOwner } from '../db/database'; -import { erasePluginUserData } from './userCleanupService'; -import { emitUserDeleted } from '../plugin-user-lifecycle'; -import { Trip, User } from '../types'; -import { listDays, listAccommodations, addDays, resyncAccommodationDays, restampReservationDates } from '../nest/days/days.bridge'; -import { listBudgetItems, removeUserFromBudgetItems } from '../nest/budget/budget.bridge'; -import { listItems as listPackingItems } from '../nest/packing/packing.bridge'; -import { listReservations, loadEndpointsByTrip, resyncReservationDays } from '../nest/reservations/reservations.bridge'; -import { listNotes as listCollabNotes } from '../nest/collab/collab.bridge'; -import { shiftOwnerEntriesForTripWindow } from '../nest/vacay/vacay.bridge'; -import { resolveTimeZone } from './timezoneService'; - -export const MS_PER_DAY = 86400000; -export const MAX_TRIP_DAYS = 365; - -export const TRIP_SELECT = ` - SELECT t.*, - (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count, - (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count, - CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner, - u.username as owner_username, - (SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count - FROM trips t - JOIN users u ON u.id = t.user_id -`; - -// ── Access helpers ──────────────────────────────────────────────────────── - -export { verifyTripAccess } from './tripAccess'; -export { isOwner }; - -// ── Day generation ──────────────────────────────────────────────────────── - -export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, maxDays?: number, dayCount?: number) { - const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[]; - const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); - - // Helper: two-phase renumber to avoid UNIQUE(trip_id, day_number) collisions - function renumber(days: { id: number }[]) { - days.forEach((d, i) => setDayNumber.run(-(i + 1), d.id)); - days.forEach((d, i) => setDayNumber.run(i + 1, d.id)); - } - - if (!startDate || !endDate) { - // Nullify all dated days instead of deleting them — preserves assignments/notes/accommodations - const withDates = existing.filter(d => d.date); - if (withDates.length > 0) { - const nullify = db.prepare('UPDATE days SET date = NULL WHERE id = ?'); - for (const d of withDates) nullify.run(d.id); - } - // Now all days are dateless — adjust count toward dayCount target - const allDays = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]; - const targetCount = Math.min(Math.max(dayCount ?? (allDays.length || 7), 1), MAX_TRIP_DAYS); - const needed = targetCount - allDays.length; - if (needed > 0) { - const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)'); - for (let i = 0; i < needed; i++) insert.run(tripId, allDays.length + i + 1); - } else if (needed < 0) { - // Only trim trailing empty days to avoid destroying content - const candidates = db.prepare( - `SELECT d.id FROM days d - WHERE d.trip_id = ? - AND NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = d.id) - AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = d.id) - AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = d.id OR dac.end_day_id = d.id) - ORDER BY d.day_number DESC - LIMIT ?` - ).all(tripId, -needed) as { id: number }[]; - const del = db.prepare('DELETE FROM days WHERE id = ?'); - for (const d of candidates) del.run(d.id); - } - const remaining = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]; - renumber(remaining); - return; - } - - const [sy, sm, sd] = startDate.split('-').map(Number); - const [ey, em, ed] = endDate.split('-').map(Number); - const startMs = Date.UTC(sy, sm - 1, sd); - const endMs = Date.UTC(ey, em - 1, ed); - const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, maxDays ?? MAX_TRIP_DAYS); - - const targetDates: string[] = []; - for (let i = 0; i < numDays; i++) { - const d = new Date(startMs + i * MS_PER_DAY); - const yyyy = d.getUTCFullYear(); - const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); - const dd = String(d.getUTCDate()).padStart(2, '0'); - targetDates.push(`${yyyy}-${mm}-${dd}`); - } - - // Split into dated (sorted by day_number = position) and dateless (spare pool) - const dated = existing.filter(d => d.date).sort((a, b) => a.day_number - b.day_number); - const dateless = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number); - - // Phase 1: stamp all existing days with negative day_numbers to free up slots - const allExisting = [...dated, ...dateless]; - allExisting.forEach((d, i) => setDayNumber.run(-(i + 1), d.id)); - - const assignDay = db.prepare('UPDATE days SET date = ?, day_number = ? WHERE id = ?'); - const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); - - let datelessIdx = 0; - - for (let i = 0; i < targetDates.length; i++) { - const date = targetDates[i]; - if (i < dated.length) { - // Positional remap: existing dated day i gets new date — keeps all children - assignDay.run(date, i + 1, dated[i].id); - } else if (datelessIdx < dateless.length) { - // Reuse a dateless day — keeps its assignments, notes, etc. - assignDay.run(date, i + 1, dateless[datelessIdx].id); - datelessIdx++; - } else { - insert.run(tripId, i + 1, date); - } - } - - // Overflow dated days (trip shrunk): delete them (issue #909). - // Cascade removes their assignments, notes, and accommodations. - const del = db.prepare('DELETE FROM days WHERE id = ?'); - for (let i = targetDates.length; i < dated.length; i++) { - del.run(dated[i].id); - } - - // Any remaining unused dateless days: drop the empty placeholders so day_count - // reflects the dated range, but keep ones that still hold content (assignments, - // notes, accommodations) — mirrors the dateless-path trimming above (#1083). - // Base must be max(targetDates.length, dated.length) to avoid colliding with - // positives already assigned by the main loop or the overflow loop above. - const isEmptyDay = db.prepare( - `SELECT NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = @id) - AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = @id) - AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = @id OR dac.end_day_id = @id) AS empty` - ); - const maxAssigned = Math.max(targetDates.length, dated.length); - let keptDateless = 0; - for (let i = datelessIdx; i < dateless.length; i++) { - const empty = (isEmptyDay.get({ id: dateless[i].id }) as { empty: number }).empty; - if (empty) { - del.run(dateless[i].id); - } else { - setDayNumber.run(maxAssigned + keptDateless + 1, dateless[i].id); - keptDateless++; - } - } - - // Final renumber to compact and eliminate any gaps/negatives - const remaining = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]; - renumber(remaining); -} - -// ── Trip CRUD ───────────────────────────────────────────────────────────── - -export function listTrips(userId: number, archived: number | null) { - if (archived === null) { - return db.prepare(` - ${TRIP_SELECT} - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId - WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) - ORDER BY t.created_at DESC - `).all({ userId }); - } - return db.prepare(` - ${TRIP_SELECT} - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId - WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived - ORDER BY t.created_at DESC - `).all({ userId, archived }); -} - -interface CreateTripData { - title: string; - description?: string | null; - start_date?: string | null; - end_date?: string | null; - currency?: string; - reminder_days?: number; - day_count?: number; -} - -export function createTrip(userId: number, data: CreateTripData, maxDays?: number) { - const rd = data.reminder_days !== undefined - ? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3) - : 3; - - const result = db.prepare(` - INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd); - - const tripId = result.lastInsertRowid; - generateDays(tripId, data.start_date || null, data.end_date || null, maxDays, data.day_count); - - const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId }); - return { trip, tripId: Number(tripId), reminderDays: rd }; -} - -export function getTrip(tripId: string | number, userId: number) { - return db.prepare(` - ${TRIP_SELECT} - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId - WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL) - `).get({ userId, tripId }) as Trip | undefined; -} - -interface UpdateTripData { - title?: string; - description?: string; - start_date?: string; - end_date?: string; - currency?: string; - is_archived?: boolean | number; - cover_image?: string; - reminder_days?: number; - day_count?: number; - date_shift_mode?: 'keep_bookings' | 'shift_all'; -} - -export interface UpdateTripResult { - updatedTrip: any; - changes: Record; - isAdminEdit: boolean; - ownerEmail?: string; - newTitle: string; - newReminder: number; - oldReminder: number; -} - -export function updateTrip(tripId: string | number, userId: number, data: UpdateTripData, userRole: string): UpdateTripResult { - const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip & { reminder_days?: number } | undefined; - if (!trip) throw new NotFoundError('Trip not found'); - - const { title, description, start_date, end_date, currency, is_archived, cover_image, reminder_days } = data; - - if (start_date && end_date && new Date(end_date) < new Date(start_date)) - throw new ValidationError('End date must be after start date'); - - const newTitle = title || trip.title; - const newDesc = description !== undefined ? description : trip.description; - const newStart = start_date !== undefined ? start_date : trip.start_date; - const newEnd = end_date !== undefined ? end_date : trip.end_date; - const newCurrency = currency || trip.currency; - const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived; - const newCover = cover_image !== undefined ? cover_image : trip.cover_image; - const oldReminder = (trip as any).reminder_days ?? 3; - const newReminder = reminder_days !== undefined - ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : oldReminder) - : oldReminder; - - db.prepare(` - UPDATE trips SET title=?, description=?, start_date=?, end_date=?, - currency=?, is_archived=?, cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP - WHERE id=? - `).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId); - - if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date) - shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); - - const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined; - if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) { - db.transaction(() => { - // Accommodations have no absolute date columns, so their pre-change dates must be - // snapshotted before generateDays re-dates the day rows in place. - const prevDateByDayId = new Map( - (db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[]) - .map(d => [d.id, d.date]), - ); - generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount); - if (data.date_shift_mode === 'shift_all') { - // Explicit "shift everything": bookings stay glued to their (re-dated) day rows, - // so re-stamp reservation_time to follow — same rules as reorderDays/insertDay. - const newDateByDayId = new Map( - (db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[]) - .map(d => [d.id, d.date]), - ); - restampReservationDates(tripId, prevDateByDayId, newDateByDayId); - } else { - // Default: generateDays re-dates day rows positionally; re-anchor dated bookings to - // the day matching their absolute reservation_time, and accommodations (+ their - // linked hotel reservations) to the days now holding their pre-change dates (#1288). - resyncReservationDays(tripId); - resyncAccommodationDays(tripId, prevDateByDayId); - } - })(); - } - - const changes: Record = {}; - if (title && title !== trip.title) changes.title = title; - if (newStart !== trip.start_date) changes.start_date = newStart; - if (newEnd !== trip.end_date) changes.end_date = newEnd; - if (newReminder !== oldReminder) changes.reminder_days = newReminder === 0 ? 'none' : `${newReminder} days`; - if (is_archived !== undefined && newArchived !== trip.is_archived) changes.archived = !!newArchived; - - const isAdminEdit = userRole === 'admin' && trip.user_id !== userId; - let ownerEmail: string | undefined; - if (Object.keys(changes).length > 0 && isAdminEdit) { - ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email; - } - - const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId }); - - return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder }; -} - -// ── Delete ───────────────────────────────────────────────────────────────── - -export interface DeleteTripInfo { - tripId: number; - title: string; - ownerId: number; - isAdminDelete: boolean; - ownerEmail?: string; -} - -export function deleteTrip(tripId: string | number, userId: number, userRole: string): DeleteTripInfo { - const trip = db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(tripId) as { title: string; user_id: number } | undefined; - if (!trip) throw new NotFoundError('Trip not found'); - - const isAdminDelete = userRole === 'admin' && trip.user_id !== userId; - let ownerEmail: string | undefined; - if (isAdminDelete) { - ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email; - } - - // Clean up journey entries synced from this trip before deleting - // Delete skeleton entries (unfilled synced places) - db.prepare(` - DELETE FROM journey_entries - WHERE source_trip_id = ? AND type = 'skeleton' - `).run(tripId); - // Detach filled entries (keep user's written content, just remove trip link) - db.prepare(` - UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL - WHERE source_trip_id = ? - `).run(tripId); - - db.prepare('DELETE FROM trips WHERE id = ?').run(tripId); - - return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail }; -} - -// ── Cover image ─────────────────────────────────────────────────────────── - -export function deleteOldCover(coverImage: string | null | undefined) { - if (!coverImage) return; - // cover_image is client-supplied, so treat it as untrusted: covers live in - // uploads/covers as a flat filename — use basename() and confine the unlink - // to that directory. - const coversDir = path.resolve(__dirname, '../../uploads/covers'); - const resolvedPath = path.resolve(path.join(coversDir, path.basename(coverImage))); - if (resolvedPath.startsWith(coversDir + path.sep) && fs.existsSync(resolvedPath)) { - fs.unlinkSync(resolvedPath); - } -} - -export function updateCoverImage(tripId: string | number, coverUrl: string) { - db.prepare('UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?').run(coverUrl, tripId); -} - -export function getTripRaw(tripId: string | number): Trip | undefined { - return db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip | undefined; -} - -export function getTripOwner(tripId: string | number): { user_id: number } | undefined { - return db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; -} - -// ── Members ─────────────────────────────────────────────────────────────── - -export function listMembers(tripId: string | number, tripOwnerId: number) { - // u.is_guest rides along (#1362) so guests stay assignable everywhere a member is, - // while the UI can badge them and suppress owner-only actions. The owner is never a guest. - const members = db.prepare(` - SELECT u.id, COALESCE(u.display_name, u.username) AS username, u.email, u.avatar, u.is_guest, - CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role, - m.added_at, - COALESCE(ib.display_name, ib.username) as invited_by_username - FROM trip_members m - JOIN users u ON u.id = m.user_id - LEFT JOIN users ib ON ib.id = m.invited_by - WHERE m.trip_id = ? - ORDER BY m.added_at ASC - `).all(tripOwnerId, tripId) as { id: number; username: string; email: string; avatar: string | null; is_guest: number; role: string; added_at: string; invited_by_username: string | null }[]; - - const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(tripOwnerId) as Pick; - - return { - owner: { ...owner, role: 'owner', is_guest: false, avatar_url: avatarUrl(owner) }, - members: members.map(m => ({ ...m, is_guest: !!m.is_guest, avatar_url: avatarUrl(m) })), - }; -} - -export interface AddMemberResult { - member: { id: number; username: string; email: string; avatar?: string | null; role: string; avatar_url: string | null }; - targetUserId: number; - tripTitle: string; -} - -export function addMember(tripId: string | number, identifier: string, tripOwnerId: number, invitedByUserId: number): AddMemberResult { - if (!identifier) throw new ValidationError('Email or username required'); - - // Guests (#1362) are not invitable accounts — exclude them so a trip-scoped guest - // can never be resolved (and re-attached to another trip) through the invite box. - const target = db.prepare( - 'SELECT id, username, email, avatar FROM users WHERE (email = ? OR username = ?) AND COALESCE(is_guest, 0) = 0' - ).get(identifier.trim(), identifier.trim()) as Pick | undefined; - - if (!target) throw new NotFoundError('User not found'); - - if (target.id === tripOwnerId) - throw new ValidationError('Trip owner is already a member'); - - const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, target.id); - if (existing) throw new ValidationError('User already has access'); - - db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, target.id, invitedByUserId); - - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - - return { - member: { ...target, role: 'member', avatar_url: avatarUrl(target) }, - targetUserId: target.id, - tripTitle: tripInfo?.title || 'Untitled', - }; -} - -export function removeMember(tripId: string | number, targetUserId: number) { - db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, targetUserId); -} - -export interface TransferOwnershipResult { - tripTitle: string; - fromEmail: string; - toEmail: string; -} - -/** - * Hand a trip over to one of its existing members (#973). The new owner must - * already be a member; afterwards they hold `trips.user_id` and the former owner - * becomes a regular member, so nobody loses access. Runs in a transaction so the - * owner pointer and the membership rows never diverge. - */ -export function transferOwnership( - tripId: string | number, - newOwnerId: number, - currentOwnerId: number, -): TransferOwnershipResult { - const trip = db.prepare('SELECT id, title, user_id FROM trips WHERE id = ?').get(tripId) as { id: number; title: string; user_id: number } | undefined; - if (!trip) throw new NotFoundError('Trip not found'); - if (trip.user_id !== currentOwnerId) throw new ValidationError('Only the owner can transfer ownership'); - if (newOwnerId === currentOwnerId) throw new ValidationError('You already own this trip'); - - const newOwner = db.prepare('SELECT id, email, is_guest FROM users WHERE id = ?').get(newOwnerId) as { id: number; email: string; is_guest?: number } | undefined; - if (!newOwner) throw new NotFoundError('User not found'); - // A guest (#1362) can never log in, so it must never become the owner of a trip. - if (newOwner.is_guest) throw new ValidationError('Cannot transfer ownership to a guest'); - - const isMember = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, newOwnerId); - if (!isMember) throw new ValidationError('New owner must be a trip member'); - - const fromEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(currentOwnerId) as { email: string } | undefined)?.email || ''; - - const run = db.transaction(() => { - db.prepare('UPDATE trips SET user_id = ? WHERE id = ?').run(newOwnerId, tripId); - // The new owner is no longer a plain member… - db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, newOwnerId); - // …and the former owner keeps access as a member. - db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, currentOwnerId, newOwnerId); - }); - run(); - - return { tripTitle: trip.title, fromEmail, toEmail: newOwner.email }; -} - -// ── Guest members (#1362) ─────────────────────────────────────────────────── -// -// A guest is a credential-less users row (is_guest=1) joined into trip_members, so -// it is assignable everywhere a real member is (budget splits, packing, to-dos, day -// participants) yet can never authenticate (the auth/global-list guards exclude -// is_guest=1). The display name lives in users.username so every existing JOIN that -// renders a member name shows the guest correctly; a synthetic, non-deliverable -// email keeps the UNIQUE/NOT NULL constraints satisfied. - -export interface GuestMember { - id: number; - username: string; - email: string; - role: 'member'; - is_guest: true; - avatar_url: null; -} - -/** username is UNIQUE across all users — keep the typed name but disambiguate guests - * that happen to share it (e.g. two "Anna"s) with a numeric suffix. */ -export function createGuest(tripId: string | number, name: string, invitedByUserId: number): { member: GuestMember } { - const display = (name || '').trim(); - if (!display) throw new ValidationError('Guest name is required'); - if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer'); - - // The human name lives in display_name (not unique — two trips can each have a - // "Jake", #1446); username is a uuid handle only for the UNIQUE constraint and is - // never shown (member views COALESCE display_name over it). - const email = `guest-${randomUUID()}@guests.invalid`; - const username = `guest-${randomUUID()}`; - - const create = db.transaction(() => { - const res = db.prepare( - "INSERT INTO users (username, email, password_hash, role, is_guest, display_name) VALUES (?, ?, '', 'user', 1, ?)" - ).run(username, email, display); - const guestId = Number(res.lastInsertRowid); - db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, guestId, invitedByUserId); - return guestId; - }); - const guestId = create(); - - return { member: { id: guestId, username: display, email, role: 'member', is_guest: true, avatar_url: null } }; -} - -/** Confirms a user id is a guest of THIS trip, so guest mutations stay trip-scoped. */ -function guestOfTrip(tripId: string | number, guestUserId: number): boolean { - return !!db.prepare( - 'SELECT u.id FROM users u JOIN trip_members m ON m.user_id = u.id WHERE u.id = ? AND m.trip_id = ? AND u.is_guest = 1' - ).get(guestUserId, tripId); -} - -export function renameGuest(tripId: string | number, guestUserId: number, name: string): boolean { - const display = (name || '').trim(); - if (!display) throw new ValidationError('Guest name is required'); - if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer'); - if (!guestOfTrip(tripId, guestUserId)) return false; - - // Rename only the display name — no global-uniqueness dedup, so a rename to a name - // another trip's guest already uses no longer produces "Name 2" (#1446). - db.prepare('UPDATE users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_guest = 1').run(display, guestUserId); - return true; -} - -export function deleteGuest(tripId: string | number, guestUserId: number): boolean { - if (!guestOfTrip(tripId, guestUserId)) return false; - // A guest is still a user id a plugin may hold data for, so erase that too — the - // host-side per-user tables + a durable own-db erasure per granted plugin — exactly - // like a full account deletion (otherwise a deleted guest's plugin data lingers). - erasePluginUserData(guestUserId); - // Re-split the expenses they were part of before the cascade takes their member - // rows away — the divisor is denormalized and cannot follow a foreign key (#1553). - removeUserFromBudgetItems(guestUserId); - // Deleting the guest's users row cascades its membership and every assignment join - // (trip_members, budget/packing/assignment links) via the ON DELETE foreign keys. - db.prepare('DELETE FROM users WHERE id = ? AND is_guest = 1').run(guestUserId); - emitUserDeleted(guestUserId); // deliver the erasure to any active plugin now - return true; -} - -// ── ICS export ──────────────────────────────────────────────────────────── - -// RFC 5545 §3.1: content lines longer than 75 octets must be folded with a CRLF -// followed by a single leading space. We fold on UTF-8 *octet* boundaries and -// never split a multi-byte codepoint, so non-ASCII titles/notes (accents, CJK, -// emoji) stay intact. Applied to the whole calendar, so both the one-time -// download and the subscribable feed emit spec-compliant output. -function foldICS(ics: string): string { - const foldLine = (line: string): string => { - const bytes = Buffer.from(line, 'utf8'); - if (bytes.length <= 75) return line; - const parts: Buffer[] = []; - let start = 0; - let limit = 75; // first physical line may use 75 octets - while (start < bytes.length) { - let end = Math.min(start + limit, bytes.length); - // Back off so we never cut a multi-byte UTF-8 sequence (0x80–0xBF = continuation byte). - while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--; - parts.push(bytes.subarray(start, end)); - start = end; - limit = 74; // continuation lines spend one octet on the leading space - } - return parts.map((b, i) => (i === 0 ? '' : ' ') + b.toString('utf8')).join('\r\n'); - }; - return ics.split('\r\n').map(foldLine).join('\r\n'); -} - -// ── ICS time-zone helpers ──────────────────────────────────────────────────── -// Timed events must carry an explicit IANA zone; a bare "YYYYMMDDTHHMMSS" is an -// RFC 5545 "floating" time that clients render in the *subscriber's* zone (#1453). - -// A stored/plugin-provided timezone (e.g. a transport endpoint's `timezone`) is a -// free string that need not be a real IANA zone. Intl.DateTimeFormat throws a -// RangeError on an unknown zone, which — via buildVTimezone → tzOffsetString — -// would crash the whole ICS export (and drop the trip from the all-trips feed). -// Validate once so an invalid zone degrades to a floating local time instead. -const _tzValidCache = new Map(); -function isValidTimeZone(zone: string): boolean { - const cached = _tzValidCache.get(zone); - if (cached !== undefined) return cached; - let ok = false; - try { - new Intl.DateTimeFormat('en-US', { timeZone: zone }); - ok = true; - } catch { - // Unknown/invalid zone → ok stays false. - } - // Bound the cache — the key is a free-form (plugin/importer-written) zone string, - // so cap distinct entries rather than growing for the process lifetime. - if (_tzValidCache.size >= 1000) _tzValidCache.clear(); - _tzValidCache.set(zone, ok); - return ok; -} - -// UTC offset ("+0200") the zone uses on the given YYYYMMDD date. Only feeds the -// fallback VTIMEZONE offset; iOS/Google resolve the named zone from their own -// IANA database, so a single representative offset is sufficient. -function tzOffsetString(zone: string, yyyymmdd: string): string { - const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}T12:00:00Z`; - const probe = new Date(iso); - if (Number.isNaN(probe.getTime())) return '+0000'; - const parts = new Intl.DateTimeFormat('en-US', { - timeZone: zone, - timeZoneName: 'longOffset', - }).formatToParts(probe); - const raw = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT'; - const m = raw.match(/GMT([+-])(\d{2}):?(\d{2})?/); - if (!m) return '+0000'; // "GMT" (UTC) has no offset digits - return `${m[1]}${m[2]}${m[3] ?? '00'}`; -} - -// Minimal but RFC-valid VTIMEZONE. Smart clients override it with their own tz -// rules; dumb clients fall back to this fixed offset. -function buildVTimezone(zone: string, yyyymmdd: string): string { - const off = tzOffsetString(zone, yyyymmdd); - return ( - 'BEGIN:VTIMEZONE\r\n' + - `TZID:${zone}\r\n` + - 'BEGIN:STANDARD\r\n' + - 'DTSTART:19700101T000000\r\n' + - `TZOFFSETFROM:${off}\r\n` + - `TZOFFSETTO:${off}\r\n` + - `TZNAME:${zone}\r\n` + - 'END:STANDARD\r\n' + - 'END:VTIMEZONE\r\n' - ); -} - -export function exportICS(tripId: string | number): { ics: string; filename: string } { - const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any; - if (!trip) throw new NotFoundError('Trip not found'); - - const reservations = db - .prepare( - `SELECT r.*, pl.lat AS place_lat, pl.lng AS place_lng - FROM reservations r - LEFT JOIN places pl ON r.place_id = pl.id - WHERE r.trip_id = ?`, - ) - .all(tripId) as any[]; - - const esc = (s: string) => s - .replace(/\\/g, '\\\\') - .replace(/;/g, '\\;') - .replace(/,/g, '\\,') - .replace(/\r?\n/g, '\\n') - .replace(/\r/g, ''); - const fmtDate = (d: string) => d.replace(/-/g, ''); - const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; - const uid = (id: number, type: string) => `trek-${type}-${id}@trek`; - - // Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00" - // iCal requires exactly YYYYMMDDTHHMMSS format - const fmtDateTime = (d: string, refDate?: string) => { - if (d.includes('T')) { - const raw = d.replace(/[-:]/g, '').split('.')[0]; - // Pad to 15 chars (YYYYMMDDTHHMMSS) — add missing seconds - return raw.length === 13 ? raw + '00' : raw; - } - // Time-only: combine with reference date - if (refDate && d.match(/^\d{2}:\d{2}/)) { - const datePart = refDate.split('T')[0]; - return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, ''); - } - return d.replace(/[-:]/g, ''); - }; - - // Zones referenced by timed events → representative YYYYMMDD (for the fallback - // VTIMEZONE offset). Populated by dtLine; emitted once as VTIMEZONE blocks. - const usedZones = new Map(); - - // Emit a DTSTART/DTEND line, attaching TZID when the event's zone is known so - // subscribers see the time in TREK's zone. Falls back to a floating local time - // (unchanged behavior) when no zone resolves or the value is not a date-time. - const dtLine = ( - prop: 'DTSTART' | 'DTEND', - wallClock: string, - zone: string | null, - refDate?: string, - ): string => { - const val = fmtDateTime(wallClock, refDate); - if (zone && isValidTimeZone(zone) && /^\d{8}T\d{6}$/.test(val)) { - if (!usedZones.has(zone)) usedZones.set(zone, val.slice(0, 8)); - return `${prop};TZID=${zone}:${val}\r\n`; - } - return `${prop}:${val}\r\n`; - }; - - let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; - ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`; - - // Trip as all-day event. DTEND is exclusive, so it must be the day *after* the last - // day. addDays() stays in UTC — building a local-time Date here dropped the trip's - // last day on any server east of Greenwich (#1453). - if (trip.start_date && trip.end_date) { - const endStr = fmtDate(addDays(trip.end_date, 1)); - ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`; - if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`; - ics += `END:VEVENT\r\n`; - } - - // Days with assignments and notes - const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[]; - for (const day of days) { - if (!day.date) continue; - - const assignments = db.prepare(` - SELECT da.*, p.name as place_name, p.address as place_address, - p.lat as place_lat, p.lng as place_lng, - COALESCE(da.assignment_time, p.place_time) as effective_time, - COALESCE(da.assignment_end_time, p.end_time) as effective_end_time - FROM day_assignments da - JOIN places p ON da.place_id = p.id - WHERE da.day_id = ? - ORDER BY da.order_index ASC, da.created_at ASC - `).all(day.id) as any[]; - - const notes = db.prepare( - 'SELECT * FROM day_notes WHERE day_id = ? ORDER BY sort_order ASC, created_at ASC' - ).all(day.id) as any[]; - - const timed = assignments.filter(a => a.effective_time); - const untimed = assignments.filter(a => !a.effective_time); - - // Timed assignments → individual events - for (const a of timed) { - const zone = resolveTimeZone(a.place_lat, a.place_lng); - ics += `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`; - ics += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00'); - if (a.effective_end_time) { - ics += dtLine('DTEND', a.effective_end_time, zone, day.date + 'T00:00'); - } - ics += `SUMMARY:${esc(a.place_name)}\r\n`; - let desc = ''; - if (a.notes) desc += a.notes; - if (a.place_address) desc += (desc ? '\n' : '') + a.place_address; - if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; - if (a.place_address) ics += `LOCATION:${esc(a.place_address)}\r\n`; - ics += `END:VEVENT\r\n`; - } - - // Build all-day summary event if there are untimed activities or notes - if (untimed.length > 0 || notes.length > 0) { - const dayTitle = day.title || `Day ${day.day_number}`; - const endStr = fmtDate(addDays(day.date, 1)); - - ics += `BEGIN:VEVENT\r\nUID:${uid(day.id, 'day')}\r\nDTSTAMP:${now}\r\n`; - ics += `DTSTART;VALUE=DATE:${fmtDate(day.date)}\r\nDTEND;VALUE=DATE:${endStr}\r\n`; - ics += `SUMMARY:${esc(dayTitle)}\r\n`; - - let desc = ''; - if (untimed.length > 0) { - desc += untimed.map(a => { - let line = `• ${a.place_name}`; - if (a.place_address) line += ` (${a.place_address})`; - if (a.notes) line += ` — ${a.notes}`; - return line; - }).join('\n'); - } - if (notes.length > 0) { - if (desc) desc += '\n\n'; - desc += 'Notes:\n' + notes.map(n => { - const line = n.time ? `${n.time} — ${n.text}` : `• ${n.text}`; - return line; - }).join('\n'); - } - if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; - ics += `END:VEVENT\r\n`; - } - } - - // Transport/flight reservations carry no top-level reservation_time; their - // times live per endpoint (local_date + local_time) in reservation_endpoints. - const endpointsMap = loadEndpointsByTrip(tripId); - const isDate = (s: string | null | undefined) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s); - const isTime = (s: string | null | undefined) => !!s && /^\d{2}:\d{2}/.test(s); - - // Build the DTSTART/DTEND lines for a reservation, or null when it has no - // calendar-placeable time. Hotels/restaurants use reservation_time; flights - // fall back to their first/last endpoint. - const buildReservationTimeLines = (r: any): string | null => { - if (r.reservation_time) { - const datePart = r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time; - if (!isDate(datePart)) return null; // time-only (relative "Day N" trips) - if (r.reservation_time.includes('T')) { - // Hotels/restaurants: derive the zone from the linked place, if any. - const zone = resolveTimeZone(r.place_lat, r.place_lng); - let out = dtLine('DTSTART', r.reservation_time, zone); - if (r.reservation_end_time) { - const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time); - if (endDt.length >= 15) out += dtLine('DTEND', r.reservation_end_time, zone, r.reservation_time); - } - return out; - } - return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`; - } - - const eps = endpointsMap.get(r.id); - if (!eps || eps.length === 0) return null; - const ordered = [...eps].sort((a, b) => a.sequence - b.sequence); - const first = ordered[0]; - const last = ordered[ordered.length - 1]; - if (!isDate(first.local_date)) return null; - if (isTime(first.local_time)) { - // Transport: departure endpoint zone drives DTSTART, arrival drives DTEND. - // Prefer the stored IANA zone; fall back to the endpoint's coordinates. - const startZone = first.timezone || resolveTimeZone(first.lat, first.lng); - let out = dtLine('DTSTART', `${first.local_date}T${first.local_time}`, startZone); - if (last !== first && isDate(last.local_date) && isTime(last.local_time)) { - const endZone = last.timezone || resolveTimeZone(last.lat, last.lng); - out += dtLine('DTEND', `${last.local_date}T${last.local_time}`, endZone); - } - return out; - } - return `DTSTART;VALUE=DATE:${fmtDate(first.local_date)}\r\n`; - }; - - // Reservations as events - for (const r of reservations) { - const timeLines = buildReservationTimeLines(r); - if (!timeLines) continue; - const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; - - ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`; - ics += timeLines; - ics += `SUMMARY:${esc(r.title)}\r\n`; - - let desc = r.type ? `Type: ${r.type}` : ''; - if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; - if (meta.airline) desc += `\nAirline: ${meta.airline}`; - if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`; - if (Array.isArray(meta.legs) && meta.legs.length > 1) { - // Multi-leg flight: show the whole route (FRA → BER → HND) on one event. - const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean); - if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`; - } else if (meta.departure_airport || meta.arrival_airport) { - if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`; - if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`; - } else { - // Endpoint-based transport without route metadata: derive it from endpoints. - const eps = endpointsMap.get(r.id); - if (eps && eps.length > 1) { - const stops = [...eps].sort((a, b) => a.sequence - b.sequence).map(e => e.code || e.name).filter(Boolean); - if (stops.length > 1) desc += `\nRoute: ${stops.join(' → ')}`; - } - } - if (meta.train_number) desc += `\nTrain: ${meta.train_number}`; - if (r.notes) desc += `\n${r.notes}`; - if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; - if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; - ics += `END:VEVENT\r\n`; - } - - ics += 'END:VCALENDAR\r\n'; - - // Define every referenced zone with a VTIMEZONE, inserted before the first - // event so TZID references resolve. No-op when no timed event carried a zone. - if (usedZones.size > 0) { - let vtz = ''; - for (const [zone, yyyymmdd] of usedZones) vtz += buildVTimezone(zone, yyyymmdd); - ics = ics.replace('BEGIN:VEVENT', vtz + 'BEGIN:VEVENT'); - } - - const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); - return { ics: foldICS(ics), filename: `${safeFilename}.ics` }; -} - -// ── Copy / duplicate ───────────────────────────────────────────────────── - -/** - * Duplicates a trip (all days, places, assignments, accommodations, reservations, - * budget, packing bags/items, day notes) into a new trip owned by `newOwnerId`. - * Packing items are reset to unchecked. Budget paid status is cleared. - * Returns the new trip's ID. - */ -export function copyTripById(sourceTripId: string | number, newOwnerId: number, title?: string): number { - const src = db.prepare('SELECT * FROM trips WHERE id = ?').get(sourceTripId) as any; - if (!src) throw new NotFoundError('Trip not found'); - - const newTitle = title || src.title; - - const fn = db.transaction(() => { - const tripResult = db.prepare(` - INSERT INTO trips (user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?) - `).run(newOwnerId, newTitle, src.description, src.start_date, src.end_date, src.currency, src.cover_image, src.reminder_days ?? 3); - const newTripId = tripResult.lastInsertRowid; - - const oldDays = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(sourceTripId) as any[]; - const dayMap = new Map(); - const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)'); - for (const d of oldDays) { - const r = insertDay.run(newTripId, d.day_number, d.date, d.notes, d.title); - dayMap.set(d.id, r.lastInsertRowid); - } - - const oldPlaces = db.prepare('SELECT * FROM places WHERE trip_id = ?').all(sourceTripId) as any[]; - const placeMap = new Map(); - const insertPlace = db.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, - reservation_status, reservation_notes, reservation_datetime, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, google_ftid, website, phone, transport_mode, osm_id, - route_geometry, route_color) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const p of oldPlaces) { - const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id, - p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime, - p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id, - p.google_ftid, p.website, p.phone, p.transport_mode, p.osm_id, p.route_geometry, p.route_color); - placeMap.set(p.id, r.lastInsertRowid); - } - - const oldTags = db.prepare(` - SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ? - `).all(sourceTripId) as any[]; - const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); - for (const t of oldTags) { - const newPlaceId = placeMap.get(t.place_id); - if (newPlaceId) insertTag.run(newPlaceId, t.tag_id); - } - - const oldAssignments = db.prepare(` - SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ? - `).all(sourceTripId) as any[]; - const assignmentMap = new Map(); - const insertAssignment = db.prepare(` - INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const a of oldAssignments) { - const newDayId = dayMap.get(a.day_id); - const newPlaceId = placeMap.get(a.place_id); - if (newDayId && newPlaceId) { - const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes, - a.reservation_status, a.reservation_notes, a.reservation_datetime, - a.assignment_time, a.assignment_end_time); - assignmentMap.set(a.id, r.lastInsertRowid); - } - } - - const oldAccom = db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(sourceTripId) as any[]; - const accomMap = new Map(); - const insertAccom = db.prepare(` - INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const a of oldAccom) { - const newPlaceId = placeMap.get(a.place_id); - const newStartDay = dayMap.get(a.start_day_id); - const newEndDay = dayMap.get(a.end_day_id); - if (newPlaceId && newStartDay && newEndDay) { - const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_out, a.confirmation, a.notes); - accomMap.set(a.id, r.lastInsertRowid); - } - } - - const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(sourceTripId) as any[]; - const insertReservation = db.prepare(` - INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time, - location, confirmation_number, notes, status, type, metadata, day_plan_position, needs_review) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const r of oldReservations) { - insertReservation.run(newTripId, - r.day_id ? (dayMap.get(r.day_id) ?? null) : null, - // end_day_id is a day reference too (multi-day transport) — remap it like - // day_id, otherwise the duplicated trip loses the reservation's end-day link. - r.end_day_id ? (dayMap.get(r.end_day_id) ?? null) : null, - r.place_id ? (placeMap.get(r.place_id) ?? null) : null, - r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null, - r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null, - r.title, r.reservation_time, r.reservation_end_time, - r.location, r.confirmation_number, r.notes, r.status, r.type, - r.metadata, r.day_plan_position, r.needs_review ?? 0); - } - - const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(sourceTripId) as any[]; - const insertBudget = db.prepare(` - INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const b of oldBudget) { - insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order); - } - - const oldBags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(sourceTripId) as any[]; - const bagMap = new Map(); - const insertBag = db.prepare(` - INSERT INTO packing_bags (trip_id, name, color, weight_limit_grams, sort_order) - VALUES (?, ?, ?, ?, ?) - `); - for (const bag of oldBags) { - const r = insertBag.run(newTripId, bag.name, bag.color, bag.weight_limit_grams, bag.sort_order); - bagMap.set(bag.id, r.lastInsertRowid); - } - - const oldPacking = db.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(sourceTripId) as any[]; - const insertPacking = db.prepare(` - INSERT INTO packing_items (trip_id, name, checked, category, sort_order, weight_grams, bag_id, updated_at) - VALUES (?, ?, 0, ?, ?, ?, ?, CURRENT_TIMESTAMP) - `); - for (const p of oldPacking) { - insertPacking.run(newTripId, p.name, p.category, p.sort_order, p.weight_grams, - p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null); - } - - const oldNotes = db.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(sourceTripId) as any[]; - const insertNote = db.prepare(` - INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) - VALUES (?, ?, ?, ?, ?, ?) - `); - for (const n of oldNotes) { - const newDayId = dayMap.get(n.day_id); - if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order); - } - - const oldTodos = db.prepare('SELECT * FROM todo_items WHERE trip_id = ?').all(sourceTripId) as any[]; - const insertTodo = db.prepare(` - INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, assigned_user_id, priority) - VALUES (?, ?, 0, ?, ?, ?, ?, NULL, ?) - `); - for (const t of oldTodos) { - insertTodo.run(newTripId, t.name, t.category, t.sort_order, t.due_date, t.description, t.priority); - } - - const oldCategoryOrder = db.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ?').all(sourceTripId) as any[]; - const insertCategoryOrder = db.prepare(` - INSERT INTO budget_category_order (trip_id, category, sort_order) - VALUES (?, ?, ?) - `); - for (const o of oldCategoryOrder) { - insertCategoryOrder.run(newTripId, o.category, o.sort_order); - } - - return Number(newTripId); - }); - - return fn(); -} - -// ── Trip summary (used by MCP get_trip_summary tool) ────────────────────── - -export function getTripSummary(tripId: number, viewerUserId?: number) { - const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record | undefined; - if (!trip) return null; - - const ownerRow = getTripOwner(tripId); - if (!ownerRow) return null; - const { owner, members } = listMembers(tripId, ownerRow.user_id); - - const { days: rawDays } = listDays(tripId); - const days = rawDays.map(({ notes_items, ...day }) => ({ ...day, notes: notes_items })); - - const accommodations = listAccommodations(tripId); - - const budgetItems = listBudgetItems(tripId); - const budget = { - items: budgetItems, - item_count: budgetItems.length, - total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0), - currency: trip.currency, - }; - - // Thread the viewer so another member's private/personal packing items (#858) - // stay hidden — without it listItems returns the UNFILTERED list. - const packingItems = listPackingItems(tripId, viewerUserId); - const packing = { - items: packingItems, - total: packingItems.length, - checked: (packingItems as { checked: number }[]).filter(i => i.checked).length, - }; - - const reservations = listReservations(tripId); - const collab_notes = listCollabNotes(tripId); - - return { - trip, - members: { owner, collaborators: members }, - days, - accommodations, - budget, - packing, - reservations, - collab_notes, - }; -} - -// ── Custom error types ──────────────────────────────────────────────────── - -export class NotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = 'NotFoundError'; - } -} - -export class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'ValidationError'; - } -} diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 76dce8f5e..448ed9b86 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -18,6 +18,7 @@ import { SettingsService } from '../../src/nest/settings/settings.service'; 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 { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { AuditService } from '../../src/nest/audit/audit.service'; import { AddonsService } from '../../src/nest/addons/addons.service'; @@ -35,26 +36,34 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto const realtime = new RealtimeService(); const budget = new BudgetService(dbs, permissions, exchangeRates, realtime); const addons = new AddonsService(dbs); + const todos = new TodoService(dbs, permissions, realtime); + const packing = new PackingService(dbs, permissions, realtime); + const files = new FilesService(dbs, permissions, realtime); + const reservations = new ReservationsService(dbs, permissions, budget, realtime); + const collab = new CollabService(dbs, permissions, realtime); + const vacay = new VacayService(dbs, realtime); + const days = new DaysService(dbs, permissions, realtime); return new PluginHostDepsFactory( budget, - new ReservationsService(dbs, permissions, budget, realtime), + reservations, new TagsService(dbs), new CategoriesService(dbs), - new TodoService(dbs, permissions, realtime), - new PackingService(dbs, permissions, realtime), + todos, + packing, new PluginOAuthService(dbs), new DayNotesService(dbs, permissions, realtime), new AssignmentsService(dbs, permissions, realtime), new LlmConfigResolver(new SettingsService(dbs), dbs, addons), dbs, - new FilesService(dbs, permissions, realtime), - new CollabService(dbs, permissions, realtime), - new VacayService(dbs, realtime), - new DaysService(dbs, permissions, realtime), + files, + collab, + vacay, + days, permissions, exchangeRates, addons, realtime, + new TripsService(dbs, todos, packing, files, reservations, days, permissions, budget, collab, vacay, realtime), ); } diff --git a/server/tests/unit/nest/collab.service.test.ts b/server/tests/unit/nest/collab.service.test.ts index c32b5a899..e1e225994 100644 --- a/server/tests/unit/nest/collab.service.test.ts +++ b/server/tests/unit/nest/collab.service.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for the DI-native CollabService — COLLAB-SVC-001 to COLLAB-SVC-038 * (001–030 moved 1:1 from the legacy tests/unit/services/collabService.test.ts; - * 031–033 pin the collab.bridge delegation; 034–038 pin the post-migration + * 031–033 (collab.bridge delegation) died with the bridge; 034–038 pin the post-migration * hardening: transactional writes, trip-scoped getFormattedNoteById, the * integer vote guard and malformed-URL absorption). Covers votePoll edge * cases, listMessages pagination, deleteMessage ownership, updateNote partial @@ -61,7 +61,6 @@ import { avatarUrl } from '../../../src/services/avatarUrl'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { CollabService } from '../../../src/nest/collab/collab.service'; -import { listNotes as bridgeListNotes, listPolls as bridgeListPolls, countMessages as bridgeCountMessages } from '../../../src/nest/collab/collab.bridge'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); @@ -406,35 +405,9 @@ describe('linkPreview', () => { }); }); -// ── collab.bridge delegation (out-of-container consumers) ───────────────────── - -describe('collab.bridge', () => { - it('COLLAB-SVC-031: listNotes delegates to CollabService over the shared db', () => { - const { user1, trip } = setup(); - svc.createNote(trip.id, user1.id, { title: 'Bridged note' }); - - const notes = bridgeListNotes(trip.id); - expect(notes).toHaveLength(1); - expect(notes[0].title).toBe('Bridged note'); - }); - - it('COLLAB-SVC-032: listPolls delegates to CollabService over the shared db', () => { - const { user1, trip } = setup(); - svc.createPoll(trip.id, user1.id, { question: 'Bridged?', options: ['A', 'B'] }); - - const polls = bridgeListPolls(trip.id); - expect(polls).toHaveLength(1); - expect(polls[0]!.question).toBe('Bridged?'); - }); - - it('COLLAB-SVC-033: countMessages delegates to CollabService over the shared db', () => { - const { user1, trip } = setup(); - svc.createMessage(trip.id, user1.id, 'one'); - svc.createMessage(trip.id, user1.id, 'two'); - - expect(bridgeCountMessages(trip.id)).toBe(2); - }); -}); +// COLLAB-SVC-031..033 (collab.bridge delegation) were deleted with the bridge — +// its last consumers (the legacy tripService and the legacy get_trip_summary +// registrar) migrated into the DI-native TripsService/TripsMcp. // ── Post-migration hardening (transactions, scoping, guards) ────────────────── diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index 68b813378..35b74db16 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -52,13 +52,7 @@ import { createUser, createTrip, createDay, createPlace, createDayAssignment, cr import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { DaysService, addDays } from '../../../src/nest/days/days.service'; -import { - getDay as bridgeGetDay, - listDays as bridgeListDays, - listAccommodations as bridgeListAccommodations, - restampReservationDates as bridgeRestampReservationDates, - resyncAccommodationDays as bridgeResyncAccommodationDays, -} from '../../../src/nest/days/days.bridge'; +import { getDay as bridgeGetDay, listDays as bridgeListDays } from '../../../src/nest/days/days.bridge'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; const svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); @@ -406,6 +400,10 @@ describe('deleteAccommodation', () => { }); // ── days.bridge delegation (out-of-container consumers) ─────────────────────── +// The listAccommodations / restampReservationDates / resyncAccommodationDays / +// addDays bridge exports were pruned when their last outside-container +// consumer (legacy tripService) folded into the DI-native TripsService — +// 029/031/032 pin the same behavior on the service. describe('days.bridge', () => { it('DAY-SVC-027 — getDay delegates to DaysService.getDay', () => { @@ -425,13 +423,13 @@ describe('days.bridge', () => { expect(Array.isArray(result.days[0].assignments)).toBe(true); }); - it('DAY-SVC-029 — listAccommodations delegates to DaysService.listAccommodations', () => { + it('DAY-SVC-029 — listAccommodations returns the hydrated stays', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Ryokan' }); createDayAccommodation(testDb, trip.id, place.id, day.id, day.id); - const rows = bridgeListAccommodations(trip.id) as { place_name: string }[]; + const rows = svc.listAccommodations(trip.id) as { place_name: string }[]; expect(rows).toHaveLength(1); expect(rows[0].place_name).toBe('Ryokan'); }); @@ -450,7 +448,7 @@ describe('days.bridge', () => { 'INSERT INTO reservations (trip_id, day_id, title, reservation_time) VALUES (?, ?, ?, ?)' ).run(trip.id, day.id, 'Dinner', '2026-01-01T19:00'); - bridgeRestampReservationDates( + svc.restampReservationDates( trip.id, new Map([[day.id, '2026-01-01']]), new Map([[day.id, '2026-01-05']]), @@ -475,7 +473,7 @@ describe('days.bridge', () => { testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-01-01', d2.id); testDb.prepare('UPDATE days SET day_number = 0 WHERE id = ?').run(d2.id); - bridgeResyncAccommodationDays(trip.id, new Map([[d1.id, '2026-01-01'], [d2.id, '2026-01-02']])); + svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-01-01'], [d2.id, '2026-01-02']])); const row = testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(accom.id) as { start_day_id: number; end_day_id: number }; expect(row.start_day_id).toBe(d2.id); diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index 1e6b3a389..d12f4a26b 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -35,14 +35,14 @@ import { createUser } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { VacayService } from '../../../src/nest/vacay/vacay.service'; -import { shiftOwnerEntriesForTripWindow } from '../../../src/nest/vacay/vacay.bridge'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; // VACAY-SVC-001 through VACAY-SVC-066 moved 1:1 from the legacy // tests/unit/services/vacayService.test.ts (the named-function imports became // method calls on a directly constructed VacayService; the legacy -// updateUserYearSettings is the class's updateYearSettings). VACAY-SVC-067 -// pins the vacay.bridge delegation for the one outside-container consumer. +// updateUserYearSettings is the class's updateYearSettings). +// VACAY-SVC-067 (vacay.bridge delegation) died with the bridge — its only +// consumer, the legacy tripService, folded into the DI-native TripsService. const svc = new VacayService(new DatabaseService(testDb), new RealtimeService()); // ── Lifecycle ───────────────────────────────────────────────────────────────── @@ -1367,20 +1367,6 @@ describe('getSharedCalendars', () => { }); }); -// ── vacay.bridge delegation ─────────────────────────────────────────────────── - -describe('vacay.bridge', () => { - it('VACAY-SVC-067: shiftOwnerEntriesForTripWindow shifts the owner entries through the bridge', () => { - const { user, plan } = setupUserWithPlan(); - svc.toggleEntry(user.id, plan.id, '2025-05-10', 1); - - shiftOwnerEntriesForTripWindow(user.id, '2025-05-08', '2025-05-12', '2025-05-15'); - - const dates = testDb.prepare('SELECT date FROM vacay_entries WHERE plan_id = ? ORDER BY date').all(plan.id); - expect(dates).toEqual([{ date: '2025-05-17' }]); - }); -}); - // ── Quirk fixes (transactions, fetch hygiene, cache TTL, addYear errors) ────── describe('quirk fixes', () => { diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index bbfd7e07b..2059c1d49 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -66,27 +66,10 @@ const budgetStub = { const checkPermission = vi.fn((..._a: any[]) => true as boolean); const permissionsStub = { checkPermission } as unknown as PermissionsService; -// The core write services are delegated to; mock them so the host deps' -// wiring + error branches run without the full core schema. The error classes must -// be defined INSIDE the factory (vi.mock is hoisted above module-scope code). -vi.mock('../../../src/services/tripService', () => { - class NotFoundError extends Error {} - class ValidationError extends Error {} - return { - updateTrip: (tripId: number, _u: number, input: Record) => { - if (input.title === 'boom') throw new ValidationError('bad dates'); - if (input.title === 'gone') throw new NotFoundError('no trip'); - if (input.title === 'crash') throw new Error('unexpected'); - return { updatedTrip: { id: tripId, ...input } }; - }, - createTrip: (userId: number, input: Record) => { - if (input.title === 'boom') throw new ValidationError('bad dates'); - return { trip: { id: 99, user_id: userId, ...input }, tripId: 99, reminderDays: 3 }; - }, - listTrips: () => [{ id: 1 }], - NotFoundError, ValidationError, - }; -}); +// Trips are a constructor-injected stub since the trip fold (same behaviors as +// the old services/tripService path mock). The throws use the REAL error +// classes from trips.service so the factory's instanceof mapping keeps +// matching — see the imports below the mocks. // Exchange rates are a constructor-injected stub since the budget-domain fold // (was a path mock of the deleted services/exchangeRateService — same behavior). const exchangeRatesStub = { @@ -96,6 +79,9 @@ vi.mock('../../../src/services/placeService', () => ({ createPlace: vi.fn((tid: string, body: Record) => ({ id: 10, trip_id: Number(tid), ...body })), updatePlace: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })), deletePlace: vi.fn((_tid: string, pid: string) => pid !== '99'), + // 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(() => []), })); // Days are a constructor-injected stub (same behaviors as the old path mock, // mapped onto the DaysService method names). @@ -320,12 +306,27 @@ import type { VacayService } from '../../../src/nest/vacay/vacay.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.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'; // The factory under test, wired exactly like PluginsModule does — but with the // DI-native domain services replaced by the stubs above. The shim keeps the // ~45 historical call sites unchanged and supplies a default no-op router. const addonsStub = { isAddonEnabled } as unknown as import('../../../src/nest/addons/addons.service').AddonsService; -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()); +const tripsStub = { + updateTrip: (tripId: number, _u: number, input: Record) => { + if (input.title === 'boom') throw new ValidationError('bad dates'); + if (input.title === 'gone') throw new NotFoundError('no trip'); + if (input.title === 'crash') throw new Error('unexpected'); + return { updatedTrip: { id: tripId, ...input } }; + }, + create: (userId: number, input: Record) => { + if (input.title === 'boom') throw new ValidationError('bad dates'); + return { trip: { id: 99, user_id: userId, ...input }, tripId: 99, reminderDays: 3 }; + }, + 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 stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; const createRealRpcHost = (id: string, granted: ReadonlySet, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);