Fold the journey domain into Nest and finish the MCP migration

journeyService (1324 LOC, 36 exports) and journeyShareService (203) become
JourneyDomainService and JourneyShareService. Every SQL statement, error string
and broadcast is the one that shipped.

The two could never be split: the share service gates on isOwner, which lives in
the domain service, so it injects it.

JourneyDomainModule is a leaf carrying just the two services. Places,
assignments and the plugin host each need a handful of journey functions;
importing JourneyModule for that would drag MemoriesModule and both photo
providers into their graphs. Same shape as TrekPhotosModule.

The 23 tools in src/mcp/tools/journey.ts become JourneyMcp. src/mcp/tools/ is
down to _shared.ts and registerTools() no longer fans out to anything.

journey.bridge.ts keeps four exports, for the journey resources in
src/mcp/resources.ts that register at import time.

McpAccessMode is host-augmentable now. journey:share is neither read nor write,
so the three share tools would have had to be opaque predicates - and predicates
bypass the access policy entirely, invisible to the boot-time scope gate. A
scope typo in one would ship as a silently ungated tool. McpAccessModeRegistry
mirrors McpAccessGroupRegistry; unaugmented, mode stays 'read' | 'write'.

Fixes a latent bug in tests/helpers/mcp-test-controllers.ts: it constructed
PlacesService with five of eight arguments. tsconfig only includes src, so
nothing typechecked it, and the missing dependencies were harmless until the
journey skeleton hooks landed on the place write paths.

journey.mcp.ts arrived from src/mcp/ at 14.5% branch coverage - the legacy
registrar had no test file of its own. 40 tool tests take it to 93.4%, and 25
cases for the skeleton lifecycle, the photo gallery and the preference row take
the domain service from 73.9% to 82.8%.
This commit is contained in:
Maurice
2026-08-08 23:24:23 +02:00
committed by Maurice
parent 86e2a96eb6
commit f0f69df67e
35 changed files with 3090 additions and 2299 deletions
@@ -19,7 +19,7 @@ import { PlacesService, type PlaceCreateInput, type PlaceUpdateInput } from '../
import { AddonsService } from '../../addons/addons.service';
import { isDemoEmail } from '../../common/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, onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../../../services/journeyService';
import { JourneyDomainService } from '../../journey/journey-domain.service';
import { AtlasService } from '../../atlas/atlas.service';
import { CollectionsService } from '../../collections/collections.service';
import { BudgetService } from '../../budget/budget.service';
@@ -166,6 +166,7 @@ export class PluginHostDepsFactory {
private readonly atlas: AtlasService,
private readonly notifications: NotificationsService,
private readonly membership: TripMembershipService,
private readonly journey: JourneyDomainService,
) {}
/**
@@ -482,14 +483,14 @@ export class PluginHostDepsFactory {
createPlace: (tripId, input) => {
const place = this.places.create(String(tripId), input as unknown as PlaceCreateInput);
this.realtime.broadcast(tripId, 'place:created', { place });
mirrorJourneys(() => onPlaceCreated(Number(tripId), place.id));
mirrorJourneys(() => this.journey.onPlaceCreated(Number(tripId), place.id));
return place;
},
updatePlace: (tripId, placeId, input) => {
const place = this.places.update(String(tripId), String(placeId), input as PlaceUpdateInput);
if (place === null) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
this.realtime.broadcast(tripId, 'place:updated', { place });
mirrorJourneys(() => onPlaceUpdated(Number(placeId)));
mirrorJourneys(() => this.journey.onPlaceUpdated(Number(placeId)));
return place;
},
deletePlace: (tripId, placeId) => {
@@ -500,7 +501,7 @@ export class PluginHostDepsFactory {
// Ahead of the DELETE, like the REST route and the MCP tool: journey_entries
// .source_place_id is ON DELETE SET NULL, so afterwards the hook finds nothing
// left to detach and the entries linger as orphans.
mirrorJourneys(() => onPlaceDeleted(Number(placeId)));
mirrorJourneys(() => this.journey.onPlaceDeleted(Number(placeId)));
const deleted = this.places.remove(String(tripId), String(placeId));
if (!deleted) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
this.realtime.broadcast(tripId, 'place:deleted', { placeId });
@@ -652,12 +653,12 @@ export class PluginHostDepsFactory {
// --- User-scoped addon reads (the acting user's own data across all trips). Each
// reuses the same service the addon's REST/MCP path uses; the addon-enabled gate
// mirrors the app (a disabled addon has nothing to read). ---
listJournalsForUser: (userId) => { requireAddon(ADDON_IDS.JOURNEY, 'journey'); return listJourneys(userId); },
listJournalsForUser: (userId) => { requireAddon(ADDON_IDS.JOURNEY, 'journey'); return this.journey.listJourneys(userId); },
journalEntriesForUser: (userId, journeyId) => {
requireAddon(ADDON_IDS.JOURNEY, 'journey');
// listEntries self-gates via canAccessJourney(journeyId, userId) → null if the
// user can't see it (owner/contributor only).
const entries = listJournalEntriesSvc(journeyId, userId);
const entries = this.journey.listEntries(journeyId, userId);
if (entries === null) throw new ForbiddenResource(`no access to journey ${journeyId}`);
return entries;
},
@@ -717,30 +718,30 @@ export class PluginHostDepsFactory {
// --- Journal write: journeyService.canEdit self-gates each call (owner/contributor). ---
createJournalEntry: (userId, journeyId, input) => {
requireAddon(ADDON_IDS.JOURNEY, 'journey');
const entry = createJournalEntrySvc(journeyId, userId, input as never);
const entry = this.journey.createEntry(journeyId, userId, input as never);
if (!entry) throw new ForbiddenResource(`no editable journey ${journeyId} for this user`);
return entry;
},
updateJournalEntry: (userId, entryId, input) => {
requireAddon(ADDON_IDS.JOURNEY, 'journey');
const entry = updateJournalEntrySvc(entryId, userId, input as never);
const entry = this.journey.updateEntry(entryId, userId, input as never);
if (!entry) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
return entry;
},
deleteJournalEntry: (userId, entryId) => {
requireAddon(ADDON_IDS.JOURNEY, 'journey');
if (!deleteJournalEntrySvc(entryId, userId)) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
if (!this.journey.deleteEntry(entryId, userId)) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
return { deleted: true };
},
createJournal: (userId, input) => {
requireAddon(ADDON_IDS.JOURNEY, 'journey');
const title = typeof (input as { title?: unknown }).title === 'string' ? String((input as { title: string }).title).trim() : '';
if (!title) throw new BadParams('journal title is required');
return createJourneySvc(userId, { title, subtitle: (input as { subtitle?: string }).subtitle, trip_ids: (input as { trip_ids?: number[] }).trip_ids });
return this.journey.createJourney(userId, { title, subtitle: (input as { subtitle?: string }).subtitle, trip_ids: (input as { trip_ids?: number[] }).trip_ids });
},
deleteJournal: (userId, journeyId) => {
requireAddon(ADDON_IDS.JOURNEY, 'journey');
if (!deleteJourneySvc(journeyId, userId)) throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`);
if (!this.journey.deleteJourney(journeyId, userId)) throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`);
return { deleted: true };
},
// Day notes are core (no addon) and trip-scoped; membership is enforced by the host.
@@ -1,7 +1,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 { JourneyDomainService } from '../journey/journey-domain.service';
import { AddonsService } from '../addons/addons.service';
import { ADDON_IDS } from '../../addons';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -70,6 +70,7 @@ export class JournalEntryRowsController {
private readonly runtime: PluginRuntimeService,
private readonly dbs: DatabaseService,
private readonly addons: AddonsService,
private readonly journey: JourneyDomainService,
) {}
@Get(':entryId')
@@ -84,7 +85,7 @@ export class JournalEntryRowsController {
// The entry's journey must be one the caller can access — same gate as a read.
const row = this.dbs.connection.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number } | undefined;
if (!row || !canAccessJourney(row.journey_id, userId)) return { providers: [] };
if (!row || !this.journey.canAccessJourney(row.journey_id, userId)) return { providers: [] };
const ids = this.runtime.providersOf('journalEntryProvider');
const results = await Promise.all(
+2 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { JourneyDomainModule } from '../journey/journey-domain.module';
import { PluginsController } from './plugins.controller';
import { PluginsFeedController } from './plugins-feed.controller';
import { PluginsProxyController } from './plugins-proxy.controller';
@@ -57,7 +58,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module'
@Module({
// The DI-native domain services the plugin host wiring injects
// (PluginHostDepsFactory); DatabaseModule is @Global, so not listed.
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule, PlacesModule, CollectionsModule, AtlasModule, NotificationsModule, TripMembershipModule],
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule, PlacesModule, CollectionsModule, AtlasModule, NotificationsModule, TripMembershipModule, JourneyDomainModule],
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, DayTintsController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory],
// Exported so the admin addon-toggle handler can cascade-disable plugins whose