mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(server): extract addon enablement into the addons domain
This commit is contained in:
+81
-47
@@ -1,28 +1,37 @@
|
||||
# Legacy `src/services/` dependency graph
|
||||
|
||||
Generated from the actual imports in `server/src` on **2026-07-27** (after the
|
||||
budgetService migration — completing step 1 of the dependency-honest order
|
||||
below and unblocking the tripService hub; the exchangeRateService fold was its
|
||||
prep half, following the Wave-2 permissions + auditLog pair).
|
||||
Regenerate any
|
||||
time — the extraction script only parses `from './x'` /
|
||||
`from '../services/x'` imports:
|
||||
|
||||
```bash
|
||||
cd server && python3 <scratch>/gen-graph.py # or re-run the grep-based analysis
|
||||
```
|
||||
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
|
||||
Phase 0 quick wins: `getAppUrl`/`getMcpSafeUrl` → `src/app-config` deleted the
|
||||
fake `→ notifications` edges for the identity/MCP stack and freed mapsService;
|
||||
the addon-enablement reads moved from adminService into `nest/addons/`
|
||||
(`addons.bridge.ts` for out-of-container consumers), collapsing the admin
|
||||
god-file's ~27-consumer fan-in to the admin routes + one `admin-2` residual
|
||||
and freeing oauthService).
|
||||
This regeneration parses **static `from '...'`, `require('...')` and dynamic
|
||||
`import('...')`** specifiers, so the lazy edges the earlier grep-based analysis
|
||||
missed (scheduler jobs, the db-boot airport backfill, the fire-and-forget
|
||||
notification sends) are now included. Regenerate any time by re-running that
|
||||
three-pattern import scan over `server/src` (a throwaway script suffices; the
|
||||
patterns are the whole trick).
|
||||
|
||||
How to read it:
|
||||
|
||||
- **imports (services/)** — what the legacy file pulls from other legacy files. A service is
|
||||
*migration-ready* when this column contains only helpers (see classification below) and/or
|
||||
`tripAccess` (Wave-2 "don't migrate, delete": absorb into `DatabaseService.canAccessTrip`).
|
||||
Edges tagged **(lazy)** are `import()`/`require()` at call time — they never block a
|
||||
migration, because a migrated Nest service can keep the same lazy import until the target
|
||||
domain migrates (collab/packing/trips/reservations/vacay all do exactly that for
|
||||
`notificationService`).
|
||||
- **imported by (services/)** — legacy files that would need a **bridge or repoint** when this
|
||||
one migrates (a legacy module can't inject).
|
||||
- **nest consumers** — in-container consumers: repoint to the injected service
|
||||
(`exports: [XService]` + module import), never a bridge.
|
||||
- **out-of-container consumers** — `mcp/`, `scheduler.ts`, `websocket.ts`, `db/`,
|
||||
`middleware/`, `index.ts`: these are the **bridge pressure** (todo.bridge.ts precedent).
|
||||
`middleware/`, `systemNotices/`, `index.ts`: these are the **bridge pressure**
|
||||
(todo.bridge.ts precedent).
|
||||
|
||||
## Node classification
|
||||
|
||||
@@ -55,7 +64,7 @@ How to read it:
|
||||
placeEnrichment → places — the exchangeRateService → budget fold landed 2026-07 on
|
||||
exactly this pattern).
|
||||
|
||||
## Domain-level graph (edges = "must migrate first, or bridge")
|
||||
## Domain-level graph (edges = "must migrate first, or bridge"; dotted = lazy, non-blocking)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -71,15 +80,16 @@ flowchart TD
|
||||
airport["airportService (boot special case)"]:::ready
|
||||
wiki[wikiService]:::ready
|
||||
collections[collectionsService]:::ready
|
||||
maps[mapsService]:::ready
|
||||
oauth[oauthService]:::ready
|
||||
end
|
||||
|
||||
place[placeService]:::blocked
|
||||
maps[mapsService]:::blocked
|
||||
notifSvc[notificationService]:::blocked
|
||||
transit[transitService]:::blocked
|
||||
transitItin[transitItineraryService]:::blocked
|
||||
admin[adminService]:::blocked
|
||||
auth[authService]:::blocked
|
||||
oauth[oauthService]:::blocked
|
||||
oidc[oidcService]:::blocked
|
||||
passkey[passkeyService]:::blocked
|
||||
journey[journeyService]:::blocked
|
||||
@@ -90,14 +100,13 @@ flowchart TD
|
||||
cleanup[userCleanupService]:::infra
|
||||
|
||||
trip --> cleanup
|
||||
collections -.-> notifSvc
|
||||
place --> maps
|
||||
maps --> notifCluster
|
||||
transit --> maps
|
||||
transitItin --> transit
|
||||
notifSvc --> notifCluster
|
||||
admin --> auth & notifSvc & cleanup
|
||||
auth --> atlas
|
||||
oauth --> admin
|
||||
oidc --> auth
|
||||
passkey --> auth
|
||||
journey --> memories
|
||||
@@ -105,8 +114,10 @@ flowchart TD
|
||||
memories --> notifSvc & admin
|
||||
```
|
||||
|
||||
(`placeService → mapsService` is via the `placeEnrichment` helper; `mapsService/notificationService
|
||||
→ notifications cluster` are hard imports. `memories/` ↔ admin/notificationService edges make the
|
||||
(`placeService → mapsService` is via the `placeEnrichment` helper; `notificationService
|
||||
→ notifications cluster` is a hard import — the former `mapsService/transitService/webauthnConfig/
|
||||
oauthService → notifications` edges were only `getAppUrl`/`getMcpSafeUrl` and died with the
|
||||
Phase 0 move to `src/app-config`, putting mapsService on the frontier. `memories/` ↔ admin/notificationService edges make the
|
||||
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`,
|
||||
@@ -114,20 +125,23 @@ the writeAudit consumers to `nest/audit/audit.bridge`, and the log*-only consume
|
||||
`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.)
|
||||
unmigrated domain dependency — the hub is frontier-ready. 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.)
|
||||
|
||||
## Full adjacency table
|
||||
|
||||
| service | imports (services/) | imported by (services/) | nest consumers | out-of-container consumers |
|
||||
|---|---|---|---|---|
|
||||
| `adminService` | apiKeyCrypto, authService, avatarUrl, llmConfig, memories/helpersService, notificationService, passwordPolicy, userCleanupService (+ `permissions.bridge`) | airtrail/airtrailSync, memories/thumbnailService, oauthService | nest/addons/addons.service.ts, nest/admin/admin.service.ts, nest/booking-import/booking-import.service.ts, nest/booking-import/features.controller.ts, nest/budget/budget.mcp.ts, nest/collab/collab.mcp.ts, nest/collections/collections-addon.guard.ts, nest/integrations/airtrail-addon.guard.ts, nest/journey/journey.service.ts, nest/llm-parse/llm-config.resolver.ts, nest/oauth/oauth.service.ts, nest/packing/packing.mcp.ts, nest/platform/platform.routes.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/plugins/journal-entry-rows.controller.ts, nest/plugins/plugin-runtime.service.ts, nest/plugins/plugins.service.ts, nest/todo/todo.mcp.ts, nest/vacay/vacay.mcp.ts | mcp/index.ts, mcp/resources.ts, mcp/tools/atlas.ts, mcp/tools/journey.ts, mcp/tools/prompts.ts, mcp/tools/trips.ts, scheduler.ts |
|
||||
| `adminService` | apiKeyCrypto, authService, avatarUrl, llmConfig, memories/helpersService, notificationService, passwordPolicy, userCleanupService (+ `permissions.bridge`) | (none) | nest/admin/admin.service.ts, nest/packing/packing.mcp.ts (`deletePackingTemplate`, the `admin-2` residual) | scheduler.ts |
|
||||
| `airportService` | (none) | (none) | nest/airports/airports.service.ts, nest/booking-import/kitinerary-mapper.ts | db/database.ts, mcp/tools/mapsWeather.ts, mcp/tools/transports.ts |
|
||||
| `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) |
|
||||
| `backupService` | (none — `permissions.bridge` only) | (none) | nest/backup/backup.controller.ts, nest/backup/backup.service.ts | (none) |
|
||||
| `collectionsService` | placeImage (+ `permissions.bridge`) | (none) | nest/collections/collections.service.ts, nest/plugins/host/plugin-host-deps.factory.ts | mcp/tools/collections.ts |
|
||||
| `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) |
|
||||
| `cookie` | (none) | (none) | nest/auth/auth-public.controller.ts, nest/auth/auth.service.ts, nest/auth/passkey.controller.ts, nest/oidc/oidc.controller.ts, nest/oidc/oidc.service.ts | (none) |
|
||||
| `demo` | (none) | authService | nest/auth/auth.controller.ts, nest/collections/collections.controller.ts, nest/files/files.controller.ts, nest/places/places.controller.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trips/trips.controller.ts | middleware/auth.ts, middleware/mfaPolicy.ts |
|
||||
@@ -139,12 +153,12 @@ unmigrated domain dependency — the hub is frontier-ready.)
|
||||
| `journeyShareService` | journeyService | (none) | nest/journey/journey.service.ts | mcp/tools/journey.ts |
|
||||
| `kmlImport` | (none) | placeService | (none) | (none) |
|
||||
| `llmConfig` | apiKeyCrypto | adminService | nest/llm-parse/llm-client.factory.ts, nest/llm-parse/llm-config.resolver.ts | (none) |
|
||||
| `mapsService` | apiKeyCrypto, notifications, placePhotoCache | placeEnrichment, transitService | nest/booking-import/booking-import.service.ts, nest/maps/maps.service.ts | mcp/tools/mapsWeather.ts, mcp/tools/places.ts |
|
||||
| `mapsService` | apiKeyCrypto, placePhotoCache | placeEnrichment, transitService | nest/booking-import/booking-import.service.ts, nest/maps/maps.service.ts | mcp/tools/mapsWeather.ts, mcp/tools/places.ts |
|
||||
| `mfaCrypto` | (none) | authService | (none) | (none) |
|
||||
| `notificationPreferencesService` | notifications, notifications/channelRegistry | inAppNotifications, notificationService, notifications, notifications/channelRegistry | nest/admin/admin.service.ts, nest/notifications/notifications.service.ts, nest/plugins/install/manifest.ts | (none) |
|
||||
| `notificationService` | inAppNotifications, notificationPreferencesService, notifications, notifications/channelRegistry (+ `audit-log.logger`) | adminService, memories/synologyService, memories/unifiedService | nest/admin/admin.controller.ts, nest/collab/collab.service.ts, nest/packing/packing.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trips/trips.service.ts | scheduler.ts |
|
||||
| `notifications` | apiKeyCrypto, notificationPreferencesService (+ `audit-log.logger`) | mapsService, notificationPreferencesService, notificationService, notifications/builtins, oauthService, transitService, webauthnConfig | nest/auth/auth.service.ts, nest/notifications/notifications.service.ts, nest/oauth/oauth.service.ts, nest/oidc/oidc.service.ts, nest/platform/platform.routes.ts, nest/plugins/plugin-oauth.service.ts | index.ts, mcp/index.ts, mcp/oauthProvider.ts |
|
||||
| `oauthService` | adminService, notifications (+ `audit.bridge`) | (none) | nest/oauth/oauth-api.controller.ts, nest/oauth/oauth.service.ts | mcp/index.ts, mcp/oauthProvider.ts |
|
||||
| `notificationPreferencesService` | notifications, notifications/builtins, notifications/channelRegistry | inAppNotifications, notificationService, notifications, notifications/channelRegistry | nest/admin/admin.service.ts, nest/notifications/notifications.service.ts, nest/plugins/install/manifest.ts | (none) |
|
||||
| `notifications` | apiKeyCrypto, notificationPreferencesService (+ `audit-log.logger`) | notificationPreferencesService, notificationService, notifications/builtins | nest/auth/auth.service.ts, nest/notifications/notifications.service.ts | (none) |
|
||||
| `notificationService` | inAppNotifications, notificationPreferencesService, notifications, notifications/channelRegistry (+ `audit-log.logger`) | adminService, collectionsService, memories/synologyService, memories/unifiedService | nest/admin/admin.controller.ts, nest/collab/collab.service.ts, nest/packing/packing.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/reservations/reservations.service.ts, nest/trips/trips.service.ts, nest/vacay/vacay.service.ts | scheduler.ts |
|
||||
| `oauthService` | (none — `addons.bridge` + `audit.bridge` only) | (none) | nest/oauth/oauth-api.controller.ts, nest/oauth/oauth.service.ts | mcp/index.ts, mcp/oauthProvider.ts |
|
||||
| `oidcService` | apiKeyCrypto, authService, tripMembership | (none) | nest/oidc/oidc.service.ts | (none) |
|
||||
| `passkeyService` | authService, webauthnConfig | (none) | nest/admin/admin.service.ts, nest/auth/passkey.controller.ts | (none) |
|
||||
| `passwordPolicy` | (none) | adminService, authService | (none) | (none) |
|
||||
@@ -155,40 +169,47 @@ unmigrated domain dependency — the hub is frontier-ready.)
|
||||
| `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) |
|
||||
| `transitItineraryService` | distanceService, timezoneService, transitService (+ type-only `reservations.bridge`) | (none) | (none) | mcp/tools/transit.ts |
|
||||
| `transitService` | mapsService, notifications | transitItineraryService | nest/transit/transit.controller.ts | 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) |
|
||||
| `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`, `days.bridge`, `reservations.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 |
|
||||
| `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` only) | adminService, authService, tripService | (none) | (none) |
|
||||
| `userCleanupService` | (none — `budget.bridge`, plugin paths infra only) | adminService, authService, tripService | (none) | (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` | notifications | authService, passkeyService | (none) | (none) |
|
||||
| `webauthnConfig` | (none) | authService, passkeyService | (none) | (none) |
|
||||
| `wikiService` | (none) | (none) | nest/help/help.controller.ts | (none) |
|
||||
|
||||
## Subdirectory clusters
|
||||
|
||||
- **`notifications/`**: `channelRegistry` ⇄ `notificationPreferencesService` (cycle),
|
||||
`builtins → notifications + channelRegistry`. Migrates as one unit with
|
||||
`notifications.ts`/`notificationService`.
|
||||
`builtins → notifications + channelRegistry`, and `notificationPreferencesService →
|
||||
builtins` (registering the built-in channels closes a second loop). Migrates as one unit
|
||||
with `notifications.ts`/`notificationService`.
|
||||
- **`memories/`**: `helpersService` (base) ← immich/synology/unified/photoResolver;
|
||||
`photoResolverService` is the seam `journeyService` consumes; `thumbnailService → adminService`
|
||||
and `synology/unified → notificationService` couple this cluster to the admin corner.
|
||||
`photoResolverService` is the seam `journeyService` consumes (it also pulls
|
||||
immich/synology/thumbnail/`trekPhotoCache` — the latter is swept by `scheduler.ts`);
|
||||
`synology/unified → notificationService` couples this
|
||||
cluster to the admin corner (`thumbnailService`'s adminService edge was only
|
||||
`isAddonEnabled` — now `addons.bridge`); `immichService` writes audits via `nest/audit/audit.bridge`.
|
||||
- **`airtrail/`**: `airtrailClient` (base) ← mapper ← service ← import/sync; `import`/`sync`
|
||||
consume `adminService` and (since the 2026-07 reservations fold) the
|
||||
`nest/reservations/reservations.bridge` instead of the deleted `reservationService`; since
|
||||
consume (since the 2026-07 reservations fold) the
|
||||
`nest/reservations/reservations.bridge` instead of the deleted `reservationService`
|
||||
(their adminService edge was only `isAddonEnabled` — now `addons.bridge`); since
|
||||
the auditLog fold, `airtrailService` writes audits via `nest/audit/audit.bridge` and
|
||||
`airtrailSync` logs via the plain `nest/audit/audit-log.logger` (same split in
|
||||
`memories/immichService` → bridge).
|
||||
|
||||
## Decoding "what's next"
|
||||
|
||||
**Ready frontier** (all legacy deps are helpers or `tripAccess`):
|
||||
**Ready frontier** (all legacy deps are helpers, `tripAccess`, or lazy sends):
|
||||
|
||||
| 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/days/reservations bridges); every other migration keeps paying it a repoint tax until it moves | no legacy dependents; mcp resources/prompts/trips registrars + the budget/todo/days bridges' summary seams |
|
||||
| **collectionsService** | Newly frontier-ready — its only legacy deps are the placeImage helper + `permissions.bridge` | `mcp/tools/collections.ts` |
|
||||
| **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 |
|
||||
| **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):**
|
||||
@@ -197,20 +218,25 @@ unmigrated domain dependency — the hub is frontier-ready.)
|
||||
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) ← notifications cluster.
|
||||
- `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
|
||||
`audit-log.logger` import — gone as a blocker since the 2026-07 Wave-2 pair).
|
||||
- `transitItineraryService` ← `transitService` (← mapsService) — its reservation edge is now
|
||||
the `reservations.bridge` repoint (type-only).
|
||||
- `adminService`/`oauthService`/`authService` corner: `authService` ← `atlasService`
|
||||
- `adminService`/`authService` corner: `authService` ← `atlasService`
|
||||
(its permissions edge is now the `permissions.bridge` repoint); `adminService` ←
|
||||
`authService` + `notificationService`; `oauthService` ← `adminService`. Order inside the
|
||||
corner: atlas → auth → admin → oauth (oidc/passkey ride auth; permissions done 2026-07).
|
||||
`authService` + `notificationService`. `oauthService` is dependency-free since the
|
||||
Phase 0 addons extraction (its adminService edge was only `isAddonEnabled`, now
|
||||
`addons.bridge`), but the coherence order stays atlas → auth → admin → oauth so the
|
||||
`mcp/oauthProvider.ts` merge (`mcp-2`) lands after admin (oidc/passkey ride auth;
|
||||
permissions done 2026-07).
|
||||
- `journeyService` ← `memories/` cluster (which itself touches admin + notificationService) →
|
||||
`journeyShareService` after.
|
||||
- `collectionsService`/`backupService`: unblocked since the permissions fold (their edges are
|
||||
`permissions.bridge` repoints now); backup still stays last by design (owns the
|
||||
closeDb/reinitialize lifecycle).
|
||||
`permissions.bridge` repoints now; collections' notificationService edge is lazy — see
|
||||
above); backup still stays last by design (owns the closeDb/reinitialize lifecycle and the
|
||||
plugin backup/paths infra).
|
||||
|
||||
**Dependency-honest order** (each step's deps are already done at that point;
|
||||
`vacayService`, `reservationService` (the residue fold), `dayService` and the
|
||||
@@ -238,3 +264,11 @@ Wave-2 `permissions` + `auditLog` pair were the first frontier picks — all don
|
||||
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.
|
||||
- (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
|
||||
airportService require, `index.ts` → ephemeralTokens, `systemNotices/conditions.ts` →
|
||||
adminService, and `collectionsService`'s call-time `import()` of notificationService. It
|
||||
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.
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
||||
import { User } from '../types';
|
||||
import { verifyMcpToken, verifyJwtToken } from '../services/authService';
|
||||
import { getUserByAccessToken } from '../services/oauthService';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { isAddonEnabled } from '../nest/addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { registerResources } from './resources';
|
||||
import { registerTools } from './tools';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripS
|
||||
import { listPlaces } from '../services/placeService';
|
||||
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
|
||||
import { getNotifications } from '../services/inAppNotifications';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { isAddonEnabled } from '../nest/addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
|
||||
import { canRead, canReadTrips } from './scopes';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getStats as getAtlasStats, listManuallyVisitedRegions,
|
||||
markRegionVisited, unmarkRegionVisited, getCountryPlaces, updateBucketItem,
|
||||
} from '../../services/atlasService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled } from '../../nest/addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
createOrUpdateJourneyShareLink, deleteJourneyShareLink, getJourneyShareLink,
|
||||
} from '../../services/journeyShareService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled } from '../../nest/addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { getTripSummary } from '../../services/tripService';
|
||||
import { listItems as listPackingItems } from '../../nest/packing/packing.bridge';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled } from '../../nest/addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
export function registerMcpPrompts(server: McpServer, _userId: number, isStaticToken = false): void {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import {
|
||||
createOrUpdateShareLink, getShareLink, deleteShareLink,
|
||||
} from '../../nest/share/share.bridge';
|
||||
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
|
||||
import { isAddonEnabled, getCollabFeatures } from '../../nest/addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { countMessages, listPolls } from '../../nest/collab/collab.bridge';
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AddonsService } from './addons.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the addons domain — for code running OUTSIDE the
|
||||
* Nest container (the legacy MCP registrars/resources, systemNotices, the
|
||||
* pre-init platform.routes Express router, the module-level `when:` gate
|
||||
* closures in the DI-discovered *.mcp.ts files, and the legacy
|
||||
* oauthService/airtrailSync/thumbnailService modules). Exports the legacy
|
||||
* services/adminService names 1:1 so repointing a consumer is an
|
||||
* import-path-only diff. Inside the container, inject AddonsService instead.
|
||||
* Delete this file when the last legacy consumer migrates.
|
||||
*
|
||||
* Only the enablement READS are bridged — the paired writers
|
||||
* (updateBagTracking/updateCollabFeatures) have in-container consumers only.
|
||||
* The reads are uncached per-call queries on purpose: admin toggles must stay
|
||||
* immediately visible without invalidation wiring.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton.
|
||||
*/
|
||||
const addons = new AddonsService(new DatabaseService(db));
|
||||
|
||||
export function isAddonEnabled(addonId: string): boolean {
|
||||
return addons.isAddonEnabled(addonId);
|
||||
}
|
||||
|
||||
export function getBagTracking(): { enabled: boolean } {
|
||||
return addons.getBagTracking();
|
||||
}
|
||||
|
||||
export function getCollabFeatures(): { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean } {
|
||||
return addons.getCollabFeatures();
|
||||
}
|
||||
@@ -6,9 +6,16 @@ import { AddonsService } from './addons.service';
|
||||
* GET /api/addons — enabled add-ons + photo providers (was an inline handler in
|
||||
* server/src/app.ts). The addon sub-features (atlas, vacay) keep their own
|
||||
* modules; this only serves the EXACT /api/addons listing.
|
||||
*
|
||||
* Exports AddonsService — the owner of addon/feature-flag enablement reads
|
||||
* (isAddonEnabled + bag-tracking/collab-features flags, extracted from
|
||||
* adminService per finding `admin-1`) — for the in-container consumers.
|
||||
* Deliberately NOT @Global (permissions precedent): e2e TestingModules resolve
|
||||
* it transitively through each consumer's explicit import.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AddonsController],
|
||||
providers: [AddonsService],
|
||||
exports: [AddonsService],
|
||||
})
|
||||
export class AddonsModule {}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import type { Addon } from '../../types';
|
||||
import { getBagTracking, getCollabFeatures } from '../../services/adminService';
|
||||
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
|
||||
|
||||
/**
|
||||
@@ -9,6 +8,14 @@ import { getPhotoProviderConfig } from '../../services/memories/helpersService';
|
||||
* inline `GET /api/addons` handler performed (server/src/app.ts). The SQL,
|
||||
* ordering, boolean coercions and the merged photo-provider entries are
|
||||
* reproduced 1:1 so the body is byte-identical for the client.
|
||||
*
|
||||
* Also the single owner of addon/feature-flag enablement state (moved verbatim
|
||||
* from services/adminService.ts, finding `admin-1`): isAddonEnabled plus the
|
||||
* bag-tracking and collab-features flags. The boolean polarities differ on
|
||||
* purpose — bag tracking is opt-in (`=== 'true'`, default OFF), the collab
|
||||
* sub-features are opt-out (`!== 'false'`, default ON). Reads are uncached
|
||||
* per-call queries so admin toggles stay immediately visible; out-of-container
|
||||
* consumers go through addons.bridge.ts.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AddonsService {
|
||||
@@ -18,6 +25,63 @@ export class AddonsService {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
isAddonEnabled(addonId: string): boolean {
|
||||
const addon = this.db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as
|
||||
| { enabled: number }
|
||||
| undefined;
|
||||
return !!addon?.enabled;
|
||||
}
|
||||
|
||||
getBagTracking() {
|
||||
const row = this.db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return { enabled: row?.value === 'true' };
|
||||
}
|
||||
|
||||
updateBagTracking(enabled: boolean) {
|
||||
this.db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(
|
||||
enabled ? 'true' : 'false',
|
||||
);
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
getCollabFeatures() {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
|
||||
)
|
||||
.all() as { key: string; value: string }[];
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
return {
|
||||
chat: map['collab_chat_enabled'] !== 'false',
|
||||
notes: map['collab_notes_enabled'] !== 'false',
|
||||
polls: map['collab_polls_enabled'] !== 'false',
|
||||
whatsnext: map['collab_whatsnext_enabled'] !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
|
||||
const mapping: Record<string, string> = {
|
||||
chat: 'collab_chat_enabled',
|
||||
notes: 'collab_notes_enabled',
|
||||
polls: 'collab_polls_enabled',
|
||||
whatsnext: 'collab_whatsnext_enabled',
|
||||
};
|
||||
const before = this.getCollabFeatures();
|
||||
const stmt = this.db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
|
||||
for (const [feat, key] of Object.entries(mapping)) {
|
||||
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
|
||||
}
|
||||
const after = this.getCollabFeatures();
|
||||
// Collab flags gate MCP tool/resource registration, so callers must know
|
||||
// whether anything actually flipped — a no-op save must not tear down every
|
||||
// live MCP session (#1414).
|
||||
const changed = (Object.keys(after) as Array<keyof typeof after>).some((k) => after[k] !== before[k]);
|
||||
return { features: after, changed };
|
||||
}
|
||||
|
||||
list() {
|
||||
const addons = this.db
|
||||
.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order')
|
||||
@@ -58,8 +122,8 @@ export class AddonsService {
|
||||
}
|
||||
|
||||
return {
|
||||
collabFeatures: getCollabFeatures(),
|
||||
bagTracking: getBagTracking().enabled,
|
||||
collabFeatures: this.getCollabFeatures(),
|
||||
bagTracking: this.getBagTracking().enabled,
|
||||
addons: [
|
||||
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
|
||||
...providers.map((p) => ({
|
||||
|
||||
@@ -4,9 +4,10 @@ import { AdminService } from './admin.service';
|
||||
import { PluginsModule } from '../plugins/plugins.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
|
||||
@Module({
|
||||
imports: [PluginsModule, SettingsModule, AuditModule],
|
||||
imports: [PluginsModule, SettingsModule, AuditModule, AddonsModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as svc from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { invalidateMcpSessions } from '../../mcp';
|
||||
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
|
||||
@@ -13,7 +14,10 @@ import { adminResetPasskeys } from '../../services/passkeyService';
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(private readonly settings: SettingsService) {}
|
||||
constructor(
|
||||
private readonly settings: SettingsService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
// Users
|
||||
listUsers() { return svc.listUsers(); }
|
||||
@@ -41,16 +45,16 @@ export class AdminService {
|
||||
deleteInvite(id: string) { return svc.deleteInvite(id); }
|
||||
|
||||
// Feature toggles
|
||||
getBagTracking() { return svc.getBagTracking(); }
|
||||
updateBagTracking(enabled: unknown) { return svc.updateBagTracking(enabled as boolean); }
|
||||
getBagTracking() { return this.addons.getBagTracking(); }
|
||||
updateBagTracking(enabled: unknown) { return this.addons.updateBagTracking(enabled as boolean); }
|
||||
getPlacesPhotos() { return svc.getPlacesPhotos(); }
|
||||
updatePlacesPhotos(enabled: boolean) { return svc.updatePlacesPhotos(enabled); }
|
||||
getPlacesAutocomplete() { return svc.getPlacesAutocomplete(); }
|
||||
updatePlacesAutocomplete(enabled: boolean) { return svc.updatePlacesAutocomplete(enabled); }
|
||||
getPlacesDetails() { return svc.getPlacesDetails(); }
|
||||
updatePlacesDetails(enabled: boolean) { return svc.updatePlacesDetails(enabled); }
|
||||
getCollabFeatures() { return svc.getCollabFeatures(); }
|
||||
updateCollabFeatures(body: unknown) { return svc.updateCollabFeatures(body as Parameters<typeof svc.updateCollabFeatures>[0]); }
|
||||
getCollabFeatures() { return this.addons.getCollabFeatures(); }
|
||||
updateCollabFeatures(body: unknown) { return this.addons.updateCollabFeatures(body as Parameters<AddonsService['updateCollabFeatures']>[0]); }
|
||||
|
||||
// Packing templates
|
||||
listPackingTemplates() { return svc.listPackingTemplates(); }
|
||||
|
||||
@@ -8,9 +8,10 @@ import { LlmParseModule } from '../llm-parse/llm-parse.module';
|
||||
import { ReservationsModule } from '../reservations/reservations.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { BudgetModule } from '../budget/budget.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
|
||||
@Module({
|
||||
imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule],
|
||||
imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule],
|
||||
controllers: [BookingImportController, FeaturesController],
|
||||
providers: [BookingImportService, KitineraryExtractorService, ImportJobsService],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { ReservationsService } from '../reservations/reservations.service';
|
||||
import { createPlace } from '../../services/placeService';
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { searchNominatim } from '../../services/mapsService';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
@@ -26,6 +26,7 @@ export class BookingImportService {
|
||||
private readonly reservations: ReservationsService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly budget: BudgetService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
@@ -235,7 +236,7 @@ export class BookingImportService {
|
||||
|
||||
// Turn an extracted price into a real linked cost (Costs addon), so the
|
||||
// booking shows up as an expense — not just a price in metadata.
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||
if (this.addons.isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||
const meta =
|
||||
reservationData.metadata && typeof reservationData.metadata === 'object'
|
||||
? (reservationData.metadata as Record<string, unknown>)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
/** Exposes server feature flags consumed by the frontend to show/hide optional UI. */
|
||||
@Controller('api/health')
|
||||
export class FeaturesController {
|
||||
constructor(private readonly extractor: KitineraryExtractorService) {}
|
||||
constructor(
|
||||
private readonly extractor: KitineraryExtractorService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Get('features')
|
||||
features() {
|
||||
@@ -14,7 +17,7 @@ export class FeaturesController {
|
||||
bookingImport: this.extractor.isAvailable(),
|
||||
// Addon-level flag (per-user config availability is reported per-file in
|
||||
// the preview response). Drives whether the client shows AI affordances.
|
||||
aiParsing: isAddonEnabled(ADDON_IDS.LLM_PARSING),
|
||||
aiParsing: this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled } from '../addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { getTripOwner, listMembers } from '../../services/tripService';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
|
||||
import { isAddonEnabled, getCollabFeatures } from '../addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { CollabService } from './collab.service';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CanActivate, HttpException, Injectable } from '@nestjs/common';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
/**
|
||||
@@ -9,8 +9,10 @@ import { ADDON_IDS } from '../../addons';
|
||||
*/
|
||||
@Injectable()
|
||||
export class CollectionsAddonGuard implements CanActivate {
|
||||
constructor(private readonly addons: AddonsService) {}
|
||||
|
||||
canActivate(): boolean {
|
||||
if (!isAddonEnabled(ADDON_IDS.COLLECTIONS)) {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.COLLECTIONS)) {
|
||||
throw new HttpException({ error: 'Collections addon is not enabled' }, 404);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { CollectionsController } from './collections.controller';
|
||||
import { CollectionsService } from './collections.service';
|
||||
import { CollectionsAddonGuard } from './collections-addon.guard';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
|
||||
@Module({
|
||||
imports: [AddonsModule],
|
||||
controllers: [CollectionsController],
|
||||
providers: [CollectionsService, CollectionsAddonGuard],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CanActivate, HttpException, Injectable } from '@nestjs/common';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
/**
|
||||
@@ -10,8 +10,10 @@ import { ADDON_IDS } from '../../addons';
|
||||
*/
|
||||
@Injectable()
|
||||
export class AirtrailAddonGuard implements CanActivate {
|
||||
constructor(private readonly addons: AddonsService) {}
|
||||
|
||||
canActivate(): boolean {
|
||||
if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.AIRTRAIL)) {
|
||||
throw new HttpException({ error: 'AirTrail addon is not enabled' }, 404);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
||||
import { AirtrailController } from './airtrail.controller';
|
||||
import { AirtrailImportController } from './airtrail-import.controller';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { AirtrailAddonGuard } from './airtrail-addon.guard';
|
||||
|
||||
/**
|
||||
* AirTrail integration domain. The connection lives under
|
||||
@@ -10,7 +12,8 @@ import { PermissionsModule } from '../permissions/permissions.module';
|
||||
* services/airtrail/* (plain functions over better-sqlite3).
|
||||
*/
|
||||
@Module({
|
||||
imports: [PermissionsModule],
|
||||
imports: [PermissionsModule, AddonsModule],
|
||||
controllers: [AirtrailController, AirtrailImportController],
|
||||
providers: [AirtrailAddonGuard],
|
||||
})
|
||||
export class AirtrailModule {}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { JourneyController } from './journey.controller';
|
||||
import { JourneyPublicController } from './journey-public.controller';
|
||||
import { JourneyService } from './journey.service';
|
||||
import { JourneyAddonGuard } from './journey-addon.guard';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
|
||||
@Module({
|
||||
imports: [AddonsModule],
|
||||
controllers: [JourneyController, JourneyPublicController],
|
||||
providers: [JourneyService, JourneyAddonGuard],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as svc from '../../services/journeyService';
|
||||
import * as share from '../../services/journeyShareService';
|
||||
import { uploadToImmich, streamImmichAsset } from '../../services/memories/immichService';
|
||||
import { streamPhoto } from '../../services/memories/photoResolverService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import type { Response } from 'express';
|
||||
|
||||
@@ -16,14 +16,17 @@ import type { Response } from 'express';
|
||||
*/
|
||||
@Injectable()
|
||||
export class JourneyService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
constructor(
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
journeyAddonEnabled(): boolean {
|
||||
return isAddonEnabled(ADDON_IDS.JOURNEY);
|
||||
return this.addons.isAddonEnabled(ADDON_IDS.JOURNEY);
|
||||
}
|
||||
|
||||
// Journeys
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { decryptLlmApiKey, LLM_PROVIDERS, type LlmProvider, type ResolvedLlmConfig } from '../../services/llmConfig';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
@@ -19,6 +19,7 @@ export class LlmConfigResolver {
|
||||
constructor(
|
||||
private readonly settings: SettingsService,
|
||||
private readonly dbService: DatabaseService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,7 @@ export class LlmConfigResolver {
|
||||
* else null. This is the single place the API key is decrypted.
|
||||
*/
|
||||
resolve(userId: number): ResolvedLlmConfig | null {
|
||||
if (!isAddonEnabled(ADDON_IDS.LLM_PARSING)) return null;
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING)) return null;
|
||||
return this.readInstanceConfig() ?? this.readUserConfig(userId);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ import { LlmLocalService } from './llm-local.service';
|
||||
import { LlmLocalController } from './llm-local.controller';
|
||||
import { LlmConfigResolver } from './llm-config.resolver';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
|
||||
/**
|
||||
* Provides the LLM booking-import fallback; imported by BookingImportModule.
|
||||
* Exports LlmConfigResolver for the plugin host wiring (PluginHostDepsFactory).
|
||||
*/
|
||||
@Module({
|
||||
imports: [SettingsModule],
|
||||
imports: [SettingsModule, AddonsModule],
|
||||
controllers: [LlmLocalController],
|
||||
providers: [LlmParseService, LlmLocalService, LlmConfigResolver],
|
||||
exports: [LlmParseService, LlmConfigResolver],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OauthApiController } from './oauth-api.controller';
|
||||
import { OauthService } from './oauth.service';
|
||||
import { RateLimitService } from '../auth/rate-limit.service';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
|
||||
/**
|
||||
* OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's
|
||||
@@ -12,7 +13,7 @@ import { AuditModule } from '../audit/audit.module';
|
||||
* strangler lists /oauth/token, /oauth/userinfo, /oauth/revoke explicitly.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
imports: [AuditModule, AddonsModule],
|
||||
controllers: [OauthPublicController, OauthApiController],
|
||||
providers: [OauthService, RateLimitService],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as oauth from '../../services/oauthService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { getMcpSafeUrl } from '../../app-config';
|
||||
|
||||
@@ -11,7 +11,9 @@ import { getMcpSafeUrl } from '../../app-config';
|
||||
*/
|
||||
@Injectable()
|
||||
export class OauthService {
|
||||
mcpEnabled(): boolean { return isAddonEnabled(ADDON_IDS.MCP); }
|
||||
constructor(private readonly addons: AddonsService) {}
|
||||
|
||||
mcpEnabled(): boolean { return this.addons.isAddonEnabled(ADDON_IDS.MCP); }
|
||||
mcpSafeUrl(): string { return getMcpSafeUrl(); }
|
||||
|
||||
consumeAuthCode(code: string) { return oauth.consumeAuthCode(code); }
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { isAddonEnabled, deletePackingTemplate } from '../../services/adminService';
|
||||
import { isAddonEnabled } from '../addons/addons.bridge';
|
||||
import { deletePackingTemplate } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied, isAdminUser, adminRequired } from '../../mcp/tools/_shared';
|
||||
import { PackingService } from './packing.service';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { verifyJwtAndLoadUser } from '../../middleware/auth';
|
||||
import { db } from '../../db/database';
|
||||
import { mcpHandler } from '../../mcp';
|
||||
import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled } from '../addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { ALL_SCOPES } from '../../mcp/scopes';
|
||||
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
|
||||
|
||||
@@ -15,7 +15,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { PermissionsService } from '../../permissions/permissions.service';
|
||||
import { listTrips, updateTrip, createTrip, removeMember as removeTripMemberSvc, NotFoundError, ValidationError } from '../../../services/tripService';
|
||||
import { createPlace, updatePlace, deletePlace } from '../../../services/placeService';
|
||||
import { isAddonEnabled } from '../../../services/adminService';
|
||||
import { AddonsService } from '../../addons/addons.service';
|
||||
import { isDemoEmail } from '../../../services/demo';
|
||||
import { ADDON_IDS } from '../../../addons';
|
||||
import { listJourneys, listEntries as listJournalEntriesSvc, createEntry as createJournalEntrySvc, updateEntry as updateJournalEntrySvc, deleteEntry as deleteJournalEntrySvc, createJourney as createJourneySvc, deleteJourney as deleteJourneySvc } from '../../../services/journeyService';
|
||||
@@ -40,12 +40,6 @@ import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host';
|
||||
import { appendAudit } from './plugin-audit';
|
||||
import { getPluginDataDb, budgetFor } from './plugin-host-state';
|
||||
|
||||
// A subsystem read is refused when its addon is off — parity with the app, where a
|
||||
// disabled addon means there is simply nothing to read (same shape as the Costs gate).
|
||||
function requireAddon(addonId: string, noun: string): void {
|
||||
if (!isAddonEnabled(addonId)) throw new ForbiddenResource(`the ${noun} addon is disabled`);
|
||||
}
|
||||
|
||||
// collectionsService self-gates per-collection role by THROWING status-tagged errors
|
||||
// (assertAccess 404, assertCanEdit 403, validation 400). Map them onto the RPC error
|
||||
// classes so a plugin sees RESOURCE_FORBIDDEN / BAD_PARAMS instead of HOST_ERROR.
|
||||
@@ -156,6 +150,7 @@ export class PluginHostDepsFactory {
|
||||
private readonly days: DaysService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly exchangeRates: ExchangeRatesService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -179,6 +174,11 @@ export class PluginHostDepsFactory {
|
||||
}
|
||||
|
||||
create(id: string, granted: ReadonlySet<string>, router: PluginCallRouter): PluginRpcHost {
|
||||
// A subsystem read is refused when its addon is off — parity with the app, where a
|
||||
// disabled addon means there is simply nothing to read (same shape as the Costs gate).
|
||||
const requireAddon = (addonId: string, noun: string): void => {
|
||||
if (!this.addons.isAddonEnabled(addonId)) throw new ForbiddenResource(`the ${noun} addon is disabled`);
|
||||
};
|
||||
return new PluginRpcHost(id, granted, {
|
||||
// Resolve the data handle lazily on every access rather than capturing it once.
|
||||
// disable()/uninstall() drop the entry from `running` BEFORE awaiting the kill grace
|
||||
@@ -210,7 +210,7 @@ export class PluginHostDepsFactory {
|
||||
broadcastToUser: (userId, payload) => broadcastToUser(userId, { type: `plugin:${id}`, ...payload }),
|
||||
audit: (entry) => appendAudit(this.db.connection, entry),
|
||||
// --- Costs (budget items) ---
|
||||
budgetAddonEnabled: () => isAddonEnabled(ADDON_IDS.BUDGET),
|
||||
budgetAddonEnabled: () => this.addons.isAddonEnabled(ADDON_IDS.BUDGET),
|
||||
// Same gate as a REST/MCP budget mutation: the acting user must have trip
|
||||
// access AND the 'budget_edit' permission for their global role.
|
||||
canEditCosts: (tripId, userId) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { canAccessJourney } from '../../services/journeyService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
@@ -69,6 +69,7 @@ export class JournalEntryRowsController {
|
||||
constructor(
|
||||
private readonly runtime: PluginRuntimeService,
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Get(':entryId')
|
||||
@@ -76,7 +77,7 @@ export class JournalEntryRowsController {
|
||||
@Param('entryId') entryIdRaw: string,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ providers: ProviderResult[] }> {
|
||||
if (!pluginsEnabled() || !isAddonEnabled(ADDON_IDS.JOURNEY)) return { providers: [] };
|
||||
if (!pluginsEnabled() || !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return { providers: [] };
|
||||
const entryId = Number(entryIdRaw);
|
||||
const userId = req.user?.id;
|
||||
if (!Number.isFinite(entryId) || userId == null) return { providers: [] };
|
||||
|
||||
@@ -27,7 +27,7 @@ import { assertHostCompatible, PluginRegistryService, RegistryError } from './re
|
||||
import { hostSatisfies, hostVersion } from './install/host-compat';
|
||||
import { keyFingerprint } from './signature-status';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import type { PluginDependency } from './install/manifest';
|
||||
import type { VersionMismatch, PluginDepRow } from './dependencies';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies';
|
||||
@@ -166,6 +166,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly addons: AddonsService,
|
||||
private readonly registry?: PluginRegistryService,
|
||||
private readonly hostDeps?: PluginHostDepsFactory,
|
||||
) {}
|
||||
@@ -514,7 +515,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
const deps = parseDependencies(row.dependencies);
|
||||
const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled);
|
||||
const disabledAddons = disabledRequiredAddons(deps, (id) => this.addons.isAddonEnabled(id));
|
||||
if (disabledAddons.length) {
|
||||
throw new PluginDependencyError(`plugin ${id} requires disabled addon(s): ${disabledAddons.join(', ')}`, 'ADDON_DISABLED', {
|
||||
addons: disabledAddons,
|
||||
|
||||
@@ -38,6 +38,7 @@ import { CollabModule } from '../collab/collab.module';
|
||||
import { VacayModule } from '../vacay/vacay.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
|
||||
/**
|
||||
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
|
||||
@@ -49,7 +50,7 @@ import { AuditModule } from '../audit/audit.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],
|
||||
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, 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
|
||||
|
||||
@@ -7,7 +7,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCry
|
||||
import { readAudit } from './host/plugin-audit';
|
||||
import { keyFingerprint } from './signature-status';
|
||||
import { pluginBudgetUsage } from './host/plugin-host-state';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
|
||||
import { hostSatisfies, hostVersion } from './install/host-compat';
|
||||
import type { PluginDependency } from './install/manifest';
|
||||
@@ -91,7 +91,10 @@ export interface PluginListItem {
|
||||
|
||||
@Injectable()
|
||||
export class PluginsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
constructor(
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
@@ -121,7 +124,7 @@ export class PluginsService {
|
||||
);
|
||||
const plugins: PluginListItem[] = rows.map((r) => {
|
||||
const deps = parseDependencies(r.dependencies);
|
||||
const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled);
|
||||
const disabledAddons = disabledRequiredAddons(deps, (id) => this.addons.isAddonEnabled(id));
|
||||
const state = resolveDependencyState(deps, installed);
|
||||
// Mirrors the order of assertActivatable's gate, so the card explains the same
|
||||
// blocker the activate call would hit rather than a second, lesser one.
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled } from '../addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { TodoService } from './todo.service';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser, getCurrentUser } from '../../services/authService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled } from '../addons/addons.bridge';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { VacayService } from './vacay.service';
|
||||
|
||||
|
||||
@@ -565,22 +565,6 @@ export function deleteInvite(id: string) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// ── Bag Tracking ───────────────────────────────────────────────────────────
|
||||
|
||||
export function getBagTracking() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return { enabled: row?.value === 'true' };
|
||||
}
|
||||
|
||||
export function updateBagTracking(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(
|
||||
enabled ? 'true' : 'false',
|
||||
);
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Photos ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesPhotos() {
|
||||
@@ -629,56 +613,6 @@ export function updatePlacesDetails(enabled: boolean) {
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
const COLLAB_FEATURE_KEYS = [
|
||||
'collab_chat_enabled',
|
||||
'collab_notes_enabled',
|
||||
'collab_polls_enabled',
|
||||
'collab_whatsnext_enabled',
|
||||
] as const;
|
||||
|
||||
export function getCollabFeatures() {
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
|
||||
)
|
||||
.all() as { key: string; value: string }[];
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
return {
|
||||
chat: map['collab_chat_enabled'] !== 'false',
|
||||
notes: map['collab_notes_enabled'] !== 'false',
|
||||
polls: map['collab_polls_enabled'] !== 'false',
|
||||
whatsnext: map['collab_whatsnext_enabled'] !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCollabFeatures(features: {
|
||||
chat?: boolean;
|
||||
notes?: boolean;
|
||||
polls?: boolean;
|
||||
whatsnext?: boolean;
|
||||
}) {
|
||||
const mapping: Record<string, string> = {
|
||||
chat: 'collab_chat_enabled',
|
||||
notes: 'collab_notes_enabled',
|
||||
polls: 'collab_polls_enabled',
|
||||
whatsnext: 'collab_whatsnext_enabled',
|
||||
};
|
||||
const before = getCollabFeatures();
|
||||
const stmt = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
|
||||
for (const [feat, key] of Object.entries(mapping)) {
|
||||
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
|
||||
}
|
||||
const after = getCollabFeatures();
|
||||
// Collab flags gate MCP tool/resource registration, so callers must know
|
||||
// whether anything actually flipped — a no-op save must not tear down every
|
||||
// live MCP session (#1414).
|
||||
const changed = (Object.keys(after) as Array<keyof typeof after>).some((k) => after[k] !== before[k]);
|
||||
return { features: after, changed };
|
||||
}
|
||||
|
||||
// ── Packing Templates ──────────────────────────────────────────────────────
|
||||
|
||||
export function listPackingTemplates() {
|
||||
@@ -804,11 +738,10 @@ export function deleteTemplateItem(itemId: string) {
|
||||
}
|
||||
|
||||
// ── Addons ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function isAddonEnabled(addonId: string): boolean {
|
||||
const addon = db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as { enabled: number } | undefined;
|
||||
return !!addon?.enabled;
|
||||
}
|
||||
// isAddonEnabled and the bag-tracking/collab-features flags moved to
|
||||
// nest/addons/addons.service.ts (bridge: nest/addons/addons.bridge.ts) —
|
||||
// finding `admin-1`. listAddons/updateAddon + photo-provider CRUD stay here
|
||||
// until the Wave-5 admin migration.
|
||||
|
||||
export function listAddons() {
|
||||
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { isAddonEnabled } from '../adminService';
|
||||
import { isAddonEnabled } from '../../nest/addons/addons.bridge';
|
||||
import { logError, logInfo } from '../../nest/audit/audit-log.logger';
|
||||
import { getReservation, getReservationWithJoins, updateReservation } from '../../nest/reservations/reservations.bridge';
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Jimp } from 'jimp'
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import crypto from 'crypto'
|
||||
import { isAddonEnabled } from '../adminService'
|
||||
import { isAddonEnabled } from '../../nest/addons/addons.bridge'
|
||||
import { ADDON_IDS } from '../../addons'
|
||||
|
||||
const THUMB_MAX = 800
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto, { randomBytes, createHash, randomUUID } from 'crypto';
|
||||
import { db } from '../db/database';
|
||||
import { isAddonEnabled } from './adminService';
|
||||
import { isAddonEnabled } from '../nest/addons/addons.bridge';
|
||||
import { validateScopes } from '../mcp/scopes';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { User } from '../types';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import semver from 'semver';
|
||||
import { isAddonEnabled } from '../services/adminService.js';
|
||||
import { isAddonEnabled } from '../nest/addons/addons.bridge.js';
|
||||
import type { NoticeCondition, SystemNotice } from './types.js';
|
||||
|
||||
interface ConditionContext {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* GET /api/addons e2e — exercises the AddonsController through the real
|
||||
* JwtAuthGuard against a temp SQLite db. getCollabFeatures + getPhotoProviderConfig
|
||||
* are mocked; the addons/photo_providers/photo_provider_fields reads run against
|
||||
* the temp db. Asserts the byte-identical body the legacy inline handler produced.
|
||||
* JwtAuthGuard against a temp SQLite db. getPhotoProviderConfig is
|
||||
* mocked; the addons/photo_providers/photo_provider_fields/app_settings reads
|
||||
* run against the temp db (the collab/bag-tracking flags are real AddonsService
|
||||
* reads since the admin-1 extraction). Asserts the byte-identical body the legacy inline handler produced.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -24,6 +25,7 @@ const { db } = vi.hoisted(() => {
|
||||
tmp.exec(`CREATE TABLE photo_provider_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT, field_key TEXT,
|
||||
label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER,
|
||||
settings_key TEXT, payload_key TEXT, sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
@@ -31,12 +33,9 @@ vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
|
||||
const { getCollabFeatures, getBagTracking, getPhotoProviderConfig } = vi.hoisted(() => ({
|
||||
getCollabFeatures: vi.fn(() => ({ chat: true, notes: true, polls: true, whatsnext: true })),
|
||||
getBagTracking: vi.fn(() => ({ enabled: true })),
|
||||
const { getPhotoProviderConfig } = vi.hoisted(() => ({
|
||||
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
|
||||
}));
|
||||
vi.mock('../../src/services/adminService', () => ({ getCollabFeatures, getBagTracking }));
|
||||
vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
|
||||
|
||||
import { AddonsModule } from '../../src/nest/addons/addons.module';
|
||||
@@ -57,6 +56,8 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
// bag tracking is opt-in (=== 'true'); collab flags default ON with no rows
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')").run();
|
||||
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('packing','Packing','trip','Backpack',1,1)").run();
|
||||
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('disabled','Disabled','trip','X',0,2)").run();
|
||||
db.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES ('immich','Immich','Image',1,1)").run();
|
||||
|
||||
@@ -32,7 +32,6 @@ vi.mock('../../src/db/database', () => ({
|
||||
}));
|
||||
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
@@ -40,6 +39,7 @@ import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createUser, createTrip, createCategory } from '../helpers/factories';
|
||||
import { CollectionsModule } from '../../src/nest/collections/collections.module';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
|
||||
@@ -51,7 +51,10 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
|
||||
let tripId: number;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [CollectionsModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [CollectionsModule] })
|
||||
.overrideProvider(AddonsService)
|
||||
.useValue({ isAddonEnabled })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -26,7 +26,6 @@ const { db } = vi.hoisted(() => {
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
// The controller's pure helpers (isVideoExtension/isVideoMime/MAX_VIDEO_SIZE)
|
||||
// now come from the real files.constants; only the request-time app_settings
|
||||
// read is mocked, preserving the old '*'-allowlist semantics for the fixtures.
|
||||
@@ -43,6 +42,7 @@ const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() }
|
||||
vi.mock('../../src/services/journeyShareService', () => sharesvc);
|
||||
|
||||
import { JourneyModule } from '../../src/nest/journey/journey.module';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
@@ -50,7 +50,10 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] })
|
||||
.overrideProvider(AddonsService)
|
||||
.useValue({ isAddonEnabled })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -37,7 +37,6 @@ vi.mock('../../src/app-config', async (importOriginal) => {
|
||||
});
|
||||
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
|
||||
const { oauthSvc } = vi.hoisted(() => ({
|
||||
oauthSvc: {
|
||||
@@ -51,6 +50,7 @@ const { oauthSvc } = vi.hoisted(() => ({
|
||||
vi.mock('../../src/services/oauthService', () => oauthSvc);
|
||||
|
||||
import { OauthModule } from '../../src/nest/oauth/oauth.module';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
@@ -59,7 +59,10 @@ describe('OAuth e2e (real guards + temp SQLite)', () => {
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, OauthModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, OauthModule] })
|
||||
.overrideProvider(AddonsService)
|
||||
.useValue({ isAddonEnabled })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CollabService } from '../../src/nest/collab/collab.service';
|
||||
import { VacayService } from '../../src/nest/vacay/vacay.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';
|
||||
|
||||
/**
|
||||
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
|
||||
@@ -31,6 +32,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto
|
||||
const permissions = new PermissionsService(dbs);
|
||||
const exchangeRates = new ExchangeRatesService();
|
||||
const budget = new BudgetService(dbs, permissions, exchangeRates);
|
||||
const addons = new AddonsService(dbs);
|
||||
return new PluginHostDepsFactory(
|
||||
budget,
|
||||
new ReservationsService(dbs, permissions, budget),
|
||||
@@ -41,7 +43,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto
|
||||
new PluginOAuthService(dbs),
|
||||
new DayNotesService(dbs, permissions),
|
||||
new AssignmentsService(dbs, permissions),
|
||||
new LlmConfigResolver(new SettingsService(dbs), dbs),
|
||||
new LlmConfigResolver(new SettingsService(dbs), dbs, addons),
|
||||
dbs,
|
||||
new FilesService(dbs, permissions),
|
||||
new CollabService(dbs, permissions),
|
||||
@@ -49,10 +51,11 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto
|
||||
new DaysService(dbs, permissions),
|
||||
permissions,
|
||||
exchangeRates,
|
||||
addons,
|
||||
);
|
||||
}
|
||||
|
||||
/** A PluginRuntimeService constructed the way Nest would: with a real host-deps factory. */
|
||||
export function createPluginRuntime(dbs: DatabaseService, registry?: PluginRegistryService): PluginRuntimeService {
|
||||
return new PluginRuntimeService(dbs, new AuditService(dbs), registry, createHostDepsFactory(dbs));
|
||||
return new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), registry, createHostDepsFactory(dbs));
|
||||
}
|
||||
|
||||
@@ -44,15 +44,6 @@ vi.mock('../../src/config', () => ({
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
|
||||
const { isAddonEnabledMock } = vi.hoisted(() => {
|
||||
const isAddonEnabledMock = vi.fn().mockReturnValue(true);
|
||||
return { isAddonEnabledMock };
|
||||
});
|
||||
vi.mock('../../src/services/adminService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/adminService')>();
|
||||
return { ...actual, isAddonEnabled: isAddonEnabledMock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/app-config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/app-config')>();
|
||||
return { ...actual, getMcpSafeUrl: () => 'https://trek.example.com' };
|
||||
@@ -79,13 +70,11 @@ function makePkce() {
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
// A7: under the unified Nest app the adminService mock only reaches the directly
|
||||
// imported isAddonEnabled (OauthService.mcpEnabled); oauthService.ts reads the
|
||||
// addon state through its own import that the Nest module graph loads unmocked,
|
||||
// so it falls back to the real DB row. Drive BOTH so the MCP-enabled state is
|
||||
// consistent across mcpEnabled() AND validateAuthorizeRequest()/token/revoke.
|
||||
// Since the admin-1 extraction, both OauthService (injected AddonsService) and
|
||||
// the legacy oauthService (addons.bridge) resolve the MCP addon from the real
|
||||
// addons row, so driving the DB row keeps mcpEnabled() AND
|
||||
// validateAuthorizeRequest()/token/revoke consistent from one source.
|
||||
function setMcpEnabled(enabled: boolean) {
|
||||
isAddonEnabledMock.mockReturnValue(enabled);
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)"
|
||||
).run(enabled ? 1 : 0);
|
||||
|
||||
@@ -36,7 +36,7 @@ const { isAddonEnabledMock } = vi.hoisted(() => {
|
||||
const isAddonEnabledMock = vi.fn().mockReturnValue(true);
|
||||
return { isAddonEnabledMock };
|
||||
});
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
vi.mock('../../../src/nest/addons/addons.bridge', () => ({
|
||||
isAddonEnabled: isAddonEnabledMock,
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock('../../../src/config', () => ({
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
vi.mock('../../../src/nest/addons/addons.bridge', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.mock('../../../src/config', () => ({
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
vi.mock('../../../src/nest/addons/addons.bridge', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock('../../../src/config', () => ({
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock, broadcastToUser: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', async (importOriginal) => {
|
||||
vi.mock('../../../src/nest/addons/addons.bridge', async (importOriginal) => {
|
||||
const original = await importOriginal() as Record<string, unknown>;
|
||||
return { ...original, isAddonEnabled: vi.fn().mockReturnValue(true) };
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ const { isAddonEnabledMock } = vi.hoisted(() => {
|
||||
const isAddonEnabledMock = vi.fn().mockReturnValue(true);
|
||||
return { isAddonEnabledMock };
|
||||
});
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
vi.mock('../../../src/nest/addons/addons.bridge', () => ({
|
||||
isAddonEnabled: isAddonEnabledMock,
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
@@ -42,7 +42,7 @@ vi.mock('../../../src/config', () => ({
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
vi.mock('../../../src/nest/addons/addons.bridge', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* addons.bridge delegation — one case per bridged export (the permissions
|
||||
* bridge-test precedent). The bridge constructs a module-level AddonsService
|
||||
* over the reinitialize-proof db Proxy, so a real in-memory SQLite exercises
|
||||
* the byte-identical SQL relocated from services/adminService (`admin-1`),
|
||||
* including the polarity quirks: isAddonEnabled reads the addons integer
|
||||
* column, bag tracking is opt-in (=== 'true'), collab flags opt-out
|
||||
* (!== 'false').
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||||
|
||||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||||
// The db/database mock builds a real in-memory SQLite; the test body reaches it
|
||||
// straight through the mocked `db` export (no hoisted require needed).
|
||||
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
return { db, closeDb: () => {}, reinitialize: () => {} };
|
||||
});
|
||||
|
||||
import { db as testDb } from '../../../src/db/database';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { isAddonEnabled, getBagTracking, getCollabFeatures } from '../../../src/nest/addons/addons.bridge';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key IN ('bag_tracking_enabled', 'collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").run();
|
||||
});
|
||||
|
||||
describe('addons.bridge', () => {
|
||||
it('isAddonEnabled reflects the addons row and is false for an unknown id', () => {
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('test-addon', 'Test', 'page', 'star', 1, 99)")
|
||||
.run();
|
||||
expect(isAddonEnabled('test-addon')).toBe(true);
|
||||
testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run('test-addon');
|
||||
expect(isAddonEnabled('test-addon')).toBe(false);
|
||||
expect(isAddonEnabled('no-such-addon')).toBe(false);
|
||||
});
|
||||
|
||||
it('getBagTracking is opt-in: absent row is OFF, only the string true enables', () => {
|
||||
expect(getBagTracking()).toEqual({ enabled: false });
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')").run();
|
||||
expect(getBagTracking()).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('getCollabFeatures is opt-out: absent rows are ON, only the string false disables', () => {
|
||||
expect(getCollabFeatures()).toEqual({ chat: true, notes: true, polls: true, whatsnext: true });
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('collab_polls_enabled', 'false')").run();
|
||||
expect(getCollabFeatures()).toEqual({ chat: true, notes: true, polls: false, whatsnext: true });
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,6 @@ vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, rein
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
const { getBagTracking, getCollabFeatures } = vi.hoisted(() => ({
|
||||
getBagTracking: vi.fn(() => ({ enabled: false })),
|
||||
getCollabFeatures: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock('../../../src/services/adminService', () => ({ getBagTracking, getCollabFeatures }));
|
||||
|
||||
const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) }));
|
||||
vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
|
||||
|
||||
@@ -25,30 +19,36 @@ function svc() {
|
||||
return new AddonsService(new DatabaseService(dbConn));
|
||||
}
|
||||
|
||||
// Feed the three reads in order: addons, providers, fields.
|
||||
function feedReads(addons: unknown[], providers: unknown[], fields: unknown[]) {
|
||||
// Feed list()'s reads in call order: addons, providers, fields (.all), then the
|
||||
// collab-features rows (.all) and the bag-tracking row (.get) from app_settings.
|
||||
function feedReads(
|
||||
addons: unknown[],
|
||||
providers: unknown[],
|
||||
fields: unknown[],
|
||||
collabRows: unknown[] = [],
|
||||
bagRow: { value: string } | undefined = undefined,
|
||||
) {
|
||||
dbMock._stmt.all
|
||||
.mockReturnValueOnce(addons)
|
||||
.mockReturnValueOnce(providers)
|
||||
.mockReturnValueOnce(fields);
|
||||
.mockReturnValueOnce(fields)
|
||||
.mockReturnValueOnce(collabRows);
|
||||
dbMock._stmt.get.mockReturnValueOnce(bagRow);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMock._stmt.all.mockReturnValue([]);
|
||||
getCollabFeatures.mockReturnValue({});
|
||||
getBagTracking.mockReturnValue({ enabled: false });
|
||||
dbMock._stmt.get.mockReturnValue(undefined);
|
||||
getPhotoProviderConfig.mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('AddonsService.list', () => {
|
||||
it('returns the collab features and the bag-tracking flag from the admin service', () => {
|
||||
getCollabFeatures.mockReturnValue({ comments: true });
|
||||
getBagTracking.mockReturnValue({ enabled: true });
|
||||
feedReads([], [], []);
|
||||
it('returns the collab features and the bag-tracking flag from app_settings', () => {
|
||||
feedReads([], [], [], [{ key: 'collab_chat_enabled', value: 'false' }], { value: 'true' });
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.collabFeatures).toEqual({ comments: true });
|
||||
expect(res.collabFeatures).toEqual({ chat: false, notes: true, polls: true, whatsnext: true });
|
||||
expect(res.bagTracking).toBe(true);
|
||||
expect(res.addons).toEqual([]);
|
||||
});
|
||||
@@ -232,3 +232,67 @@ describe('AddonsService.list', () => {
|
||||
expect((res.addons[1] as { type: string }).type).toBe('photo_provider');
|
||||
});
|
||||
});
|
||||
|
||||
// Direct coverage of the enablement reads/writers relocated from
|
||||
// services/adminService (finding `admin-1`). The polarity asymmetry is on
|
||||
// purpose: bag tracking is opt-in (=== 'true'), collab flags opt-out (!== 'false').
|
||||
describe('AddonsService addon/feature flags', () => {
|
||||
it('isAddonEnabled coerces the enabled column (1/0/missing row)', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ enabled: 1 });
|
||||
expect(svc().isAddonEnabled('budget')).toBe(true);
|
||||
dbMock._stmt.get.mockReturnValueOnce({ enabled: 0 });
|
||||
expect(svc().isAddonEnabled('budget')).toBe(false);
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined);
|
||||
expect(svc().isAddonEnabled('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('getBagTracking is opt-in: only the literal string true enables it', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'true' });
|
||||
expect(svc().getBagTracking()).toEqual({ enabled: true });
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'false' });
|
||||
expect(svc().getBagTracking()).toEqual({ enabled: false });
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined); // absent row → OFF
|
||||
expect(svc().getBagTracking()).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('updateBagTracking persists true/false strings and echoes the flag (ADMIN-SVC-030)', () => {
|
||||
expect(svc().updateBagTracking(true)).toEqual({ enabled: true });
|
||||
expect(dbMock._stmt.run).toHaveBeenCalledWith('true');
|
||||
expect(svc().updateBagTracking(false)).toEqual({ enabled: false });
|
||||
expect(dbMock._stmt.run).toHaveBeenCalledWith('false');
|
||||
});
|
||||
|
||||
it('getCollabFeatures is opt-out: absent rows default ON, only false disables', () => {
|
||||
dbMock._stmt.all.mockReturnValueOnce([
|
||||
{ key: 'collab_chat_enabled', value: 'false' },
|
||||
{ key: 'collab_polls_enabled', value: 'true' },
|
||||
]);
|
||||
expect(svc().getCollabFeatures()).toEqual({ chat: false, notes: true, polls: true, whatsnext: true });
|
||||
});
|
||||
|
||||
it('updateCollabFeatures writes only the provided flags and reports changed (#1414, ADMIN-SVC-070)', () => {
|
||||
// before-read: all default ON; after-read: chat flipped off → changed
|
||||
dbMock._stmt.all.mockReturnValueOnce([]).mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]);
|
||||
const first = svc().updateCollabFeatures({ chat: false });
|
||||
expect(first.changed).toBe(true);
|
||||
expect(first.features.chat).toBe(false);
|
||||
expect(dbMock._stmt.run).toHaveBeenCalledTimes(1);
|
||||
expect(dbMock._stmt.run).toHaveBeenCalledWith('collab_chat_enabled', 'false');
|
||||
|
||||
// identical save → before and after read the same → no change, MCP sessions must survive
|
||||
dbMock._stmt.run.mockClear();
|
||||
dbMock._stmt.all
|
||||
.mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }])
|
||||
.mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]);
|
||||
const second = svc().updateCollabFeatures({ chat: false });
|
||||
expect(second.changed).toBe(false);
|
||||
expect(dbMock._stmt.run).toHaveBeenCalledWith('collab_chat_enabled', 'false');
|
||||
|
||||
// undefined flags are not written
|
||||
dbMock._stmt.run.mockClear();
|
||||
dbMock._stmt.all.mockReturnValueOnce([]).mockReturnValueOnce([]);
|
||||
const third = svc().updateCollabFeatures({});
|
||||
expect(third.changed).toBe(false);
|
||||
expect(dbMock._stmt.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,12 +6,12 @@ const { dbMock } = vi.hoisted(() => {
|
||||
});
|
||||
vi.mock('../../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn() }));
|
||||
vi.mock('../../../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
const isAddonEnabled = vi.fn();
|
||||
|
||||
import { LlmConfigResolver } from '../../../../src/nest/llm-parse/llm-config.resolver';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
import type { SettingsService } from '../../../../src/nest/settings/settings.service';
|
||||
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
|
||||
|
||||
// The resolver injects SettingsService — a stub instance instead of the old
|
||||
// legacy-module path mock (same behaviors as before the DI move). The
|
||||
@@ -20,7 +20,8 @@ const getUserSettings = vi.fn(() => ({}) as Record<string, unknown>);
|
||||
const getDecryptedUserSetting = vi.fn(() => null as string | null);
|
||||
const settingsStub = { getUserSettings, getDecryptedUserSetting } as unknown as SettingsService;
|
||||
|
||||
const resolver = new LlmConfigResolver(settingsStub, new DatabaseService(dbMock as never));
|
||||
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
|
||||
const resolver = new LlmConfigResolver(settingsStub, new DatabaseService(dbMock as never), addonsStub);
|
||||
|
||||
function setInstanceConfig(config: unknown) {
|
||||
dbMock._stmt.get.mockReturnValue(config === undefined ? undefined : { config: JSON.stringify(config) });
|
||||
|
||||
@@ -26,7 +26,6 @@ const { oauth } = vi.hoisted(() => ({
|
||||
vi.mock('../../../src/services/oauthService', () => oauth);
|
||||
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn() }));
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
|
||||
const { getMcpSafeUrl } = vi.hoisted(() => ({ getMcpSafeUrl: vi.fn() }));
|
||||
vi.mock('../../../src/app-config', async (importOriginal) => {
|
||||
@@ -35,9 +34,11 @@ vi.mock('../../../src/app-config', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { OauthService } from '../../../src/nest/oauth/oauth.service';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
|
||||
function svc() { return new OauthService(); }
|
||||
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
|
||||
function svc() { return new OauthService(addonsStub); }
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('../../../src/middleware/auth', () => ({ verifyJwtAndLoadUser: h.verifyJ
|
||||
vi.mock('../../../src/db/database', () => ({ db: { prepare: h.dbPrepare } }));
|
||||
vi.mock('../../../src/mcp', () => ({ mcpHandler: h.mcpHandler }));
|
||||
vi.mock('../../../src/mcp/oauthProvider', () => ({ trekOAuthProvider: {}, trekClientsStore: {} }));
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: h.isAddonEnabled }));
|
||||
vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: h.isAddonEnabled }));
|
||||
vi.mock('../../../src/app-config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/app-config')>();
|
||||
return { ...actual, getMcpSafeUrl: h.getMcpSafeUrl };
|
||||
|
||||
@@ -20,11 +20,13 @@ vi.mock('../../../src/db/database', () => ({
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/services/journeyService', () => ({ canAccessJourney }));
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
||||
|
||||
import { JournalEntryRowsController } from '../../../src/nest/plugins/journal-entry-rows.controller';
|
||||
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
|
||||
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
|
||||
@@ -33,7 +35,7 @@ function controller(invoke: (id: string) => unknown, providers = ['p1']) {
|
||||
providersOf: vi.fn(() => providers),
|
||||
invokeHook: vi.fn(async (id: string) => invoke(id)),
|
||||
} as unknown as PluginRuntimeService;
|
||||
return { c: new JournalEntryRowsController(runtime, new DatabaseService(dbConn)), runtime };
|
||||
return { c: new JournalEntryRowsController(runtime, new DatabaseService(dbConn), addonsStub), runtime };
|
||||
}
|
||||
const row = (over: Record<string, unknown> = {}) => ({ label: 'Distance', value: '12 km', ...over });
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ vi.mock('../../../src/db/database', () => {
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser }));
|
||||
// Addon gate — flip per test to exercise the "addon disabled" branch of the reads.
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true as boolean) }));
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
// DI-native services are constructor-injected into PluginHostDepsFactory below —
|
||||
// stub instances instead of path mocks (same behaviors as before the DI move).
|
||||
const budgetStub = {
|
||||
@@ -324,7 +323,8 @@ import { DatabaseService } from '../../../src/nest/database/database.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 factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub);
|
||||
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);
|
||||
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
|
||||
const createRealRpcHost = (id: string, granted: ReadonlySet<string>, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('../../../src/db/database', () => dbMock);
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
@@ -123,7 +124,7 @@ describe('a plugin channel label is bounded by the host', () => {
|
||||
VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`,
|
||||
).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } }));
|
||||
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn), new AuditService(new DatabaseService(dbConn)));
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn), new AuditService(new DatabaseService(dbConn)), new AddonsService(new DatabaseService(dbConn)));
|
||||
// Stand the plugin up as a granted, active notificationChannel provider.
|
||||
(rt as unknown as { supervisor: { running: Map<string, unknown> } }).supervisor.running.set('loud', {
|
||||
id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']),
|
||||
|
||||
@@ -58,8 +58,6 @@ import {
|
||||
listInvites,
|
||||
createInvite,
|
||||
deleteInvite,
|
||||
getBagTracking,
|
||||
updateBagTracking,
|
||||
listPackingTemplates,
|
||||
createPackingTemplate,
|
||||
updatePackingTemplate,
|
||||
@@ -78,7 +76,6 @@ import {
|
||||
checkVersion,
|
||||
listAddons,
|
||||
updateAddon,
|
||||
updateCollabFeatures,
|
||||
listMcpTokens,
|
||||
deleteMcpToken,
|
||||
} from '../../../src/services/adminService';
|
||||
@@ -309,22 +306,6 @@ describe('Invites', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bag tracking ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Bag tracking', () => {
|
||||
it('ADMIN-SVC-029 — getBagTracking returns enabled state', () => {
|
||||
const result = getBagTracking() as any;
|
||||
expect(typeof result.enabled).toBe('boolean');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-030 — updateBagTracking persists the value', () => {
|
||||
updateBagTracking(true);
|
||||
expect((getBagTracking() as any).enabled).toBe(true);
|
||||
updateBagTracking(false);
|
||||
expect((getBagTracking() as any).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Packing templates ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Packing templates', () => {
|
||||
@@ -695,19 +676,6 @@ describe('updateAddon', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCollabFeatures', () => {
|
||||
it('ADMIN-SVC-070 — reports whether a flag actually flipped (#1414)', () => {
|
||||
const first = updateCollabFeatures({ chat: false });
|
||||
expect(first.changed).toBe(true);
|
||||
expect(first.features.chat).toBe(false);
|
||||
// identical save → no change, MCP sessions must survive
|
||||
const second = updateCollabFeatures({ chat: false });
|
||||
expect(second.changed).toBe(false);
|
||||
const third = updateCollabFeatures({ chat: true });
|
||||
expect(third.changed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── MCP Tokens ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MCP Tokens', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
*/
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({ db: { prepare: vi.fn() } }));
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ logError: vi.fn(), logInfo: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/nest/reservations/reservations.bridge', () => ({
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));
|
||||
import { revokeUserSessionsForClient } from '../../../src/mcp/sessionManager';
|
||||
vi.mock('../../../src/demo/demo-reset', () => ({ saveBaseline: vi.fn() }));
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
vi.mock('../../../src/nest/addons/addons.bridge', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
getConsent,
|
||||
isConsentSufficient,
|
||||
} from '../../../src/services/oauthService';
|
||||
import { isAddonEnabled } from '../../../src/services/adminService';
|
||||
import { isAddonEnabled } from '../../../src/nest/addons/addons.bridge';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
|
||||
Reference in New Issue
Block a user