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
+2
View File
@@ -20,6 +20,8 @@ export {
type McpAccess,
type McpAccessGroup,
type McpAccessGroupRegistry,
type McpAccessMode,
type McpAccessModeRegistry,
type McpAccessPolicy,
type McpAccessPredicate,
type McpAccessValidator,
+20 -1
View File
@@ -32,13 +32,32 @@ export type McpAccessGroup = keyof McpAccessGroupRegistry extends never
? string
: keyof McpAccessGroupRegistry & string;
/**
* Host augmentation point for the access *mode*, mirroring
* `McpAccessGroupRegistry`:
*
* declare module '@trek/nest-mcp' {
* interface McpAccessModeRegistry extends Record<MyModeUnion, true> {}
* }
*
* Unaugmented, `McpAccessMode` falls back to `'read' | 'write'` — the two
* modes every host had before this was augmentable, so leaving the registry
* empty keeps the previous contract exactly.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface McpAccessModeRegistry {}
export type McpAccessMode = keyof McpAccessModeRegistry extends never
? 'read' | 'write'
: keyof McpAccessModeRegistry & string;
/**
* Declarative access marker resolved by the host-supplied `accessPolicy`.
* The package attaches no semantics to `group`/`mode` — the policy does.
*/
export interface McpDeclarativeAccess {
group: McpAccessGroup;
mode: 'read' | 'write';
mode: McpAccessMode;
}
/** Predicate escape hatch — bypasses the policy entirely. */
+22 -6
View File
@@ -1,5 +1,8 @@
import type { McpAccessGroup, McpAccessPolicy, McpAccessValidator } from '@trek/nest-mcp';
import { ALL_SCOPES, canRead, canWrite, type ScopeGroup } from './scopes';
import type { McpAccessGroup, McpAccessMode, McpAccessPolicy, McpAccessValidator } from '@trek/nest-mcp';
import { ALL_SCOPES, canRead, canWrite, type Scope, type ScopeGroup } from './scopes';
/** The mode half of every scope: 'read' | 'write' | 'delete' | 'share'. */
export type ScopeMode = Scope extends `${string}:${infer M}` ? M : never;
/**
* TREK's context shape for @trek/nest-mcp handlers, predicates and the
@@ -24,6 +27,11 @@ declare module '@trek/nest-mcp' {
// same false positive (and same disable) as McpContext in @trek/nest-mcp.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface McpAccessGroupRegistry extends Record<ScopeGroup, true> {}
// Same augmentation, for the mode half. Without it `mode` is 'read' |
// 'write' and journey's share tools could only be expressed as opaque
// predicates, which the boot gate below cannot check.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface McpAccessModeRegistry extends Record<ScopeMode, true> {}
}
type AssertExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
@@ -36,15 +44,23 @@ type AssertExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : ne
*/
export const MCP_ACCESS_GROUPS_MATCH_SCOPE_GROUPS: AssertExact<McpAccessGroup, ScopeGroup> = true;
/** Same lockstep for the mode half. */
export const MCP_ACCESS_MODES_MATCH_SCOPE_MODES: AssertExact<McpAccessMode, ScopeMode> = true;
/**
* Resolves declarative `access: { group, mode }` markers with the exact
* scopes.ts semantics the legacy registrars used at registration time:
* null scopes ⇒ full access; read ⇒ `group:read` OR `group:write`;
* write ⇒ `group:write`. Single source for production (AppModule) and the
* MCP test harness.
* write ⇒ `group:write`. Any other mode (`share`, `delete`) is its own scope
* and is not implied by `:write` — matching canShareJourneys, which the
* journey registrar used for its three share-link tools. Single source for
* production (AppModule) and the MCP test harness.
*/
export const trekMcpAccessPolicy: McpAccessPolicy = ({ group, mode }, ctx) =>
mode === 'write' ? canWrite(ctx.scopes, group) : canRead(ctx.scopes, group);
export const trekMcpAccessPolicy: McpAccessPolicy = ({ group, mode }, ctx) => {
if (mode === 'read') return canRead(ctx.scopes, group);
if (mode === 'write') return canWrite(ctx.scopes, group);
return ctx.scopes === null || ctx.scopes.includes(`${group}:${mode}`);
};
const VALID_GROUP_MODES: ReadonlySet<string> = new Set(ALL_SCOPES);
+1 -1
View File
@@ -1,7 +1,7 @@
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import { isAddonEnabled } from '../nest/addons/addons.bridge';
import { ADDON_IDS } from '../addons';
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../nest/journey/journey.bridge';
import { canRead } from './scopes';
function parseId(value: string | string[]): number | null {
-2
View File
@@ -1,5 +1,4 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { registerJourneyTools } from './tools/journey';
import { getMcpRegistry } from './registry-handoff';
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
@@ -50,7 +49,6 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
// The transit tools moved to the DI-discovered src/nest/transit/transit.mcp.ts
// (@McpController, attached via the nest-mcp registry below).
registerJourneyTools(server, userId, scopes);
// The vacay tools moved to the DI-discovered src/nest/vacay/vacay.mcp.ts
// (@McpController, attached via the nest-mcp registry below).
-429
View File
@@ -1,429 +0,0 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser } from '../../nest/auth/auth.bridge';
import {
addContributor, addTripToJourney, canAccessJourney, createEntry, createJourney,
deleteEntry, deleteJourney, getJourneyFull, getSuggestions, listEntries,
listJourneys, listUserTrips, removeContributor, removeTripFromJourney,
reorderEntries, updateContributorRole, updateEntry, updateJourney,
updateJourneyPreferences,
} from '../../services/journeyService';
import {
createOrUpdateJourneyShareLink, deleteJourneyShareLink, getJourneyShareLink,
} from '../../services/journeyShareService';
import { isAddonEnabled } from '../../nest/addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
import {
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
demoDenied, ok,
} from './_shared';
import { canRead, canShareJourneys, canWrite } from '../scopes';
function notFound(msg: string) {
return { content: [{ type: 'text' as const, text: msg }], isError: true };
}
export function registerJourneyTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return;
const R = canRead(scopes, 'journey');
const W = canWrite(scopes, 'journey');
const S = canShareJourneys(scopes);
// --- READ TOOLS ---
if (R) server.registerTool(
'list_journeys',
{
description: 'List all journeys owned or contributed to by the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const journeys = listJourneys(userId);
return ok({ journeys });
}
);
if (R) server.registerTool(
'get_journey',
{
description: 'Get a full journey including entries, contributors, and linked trips.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
const journey = getJourneyFull(journeyId, userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
);
if (R) server.registerTool(
'list_journey_entries',
{
description: 'List all entries in a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const entries = listEntries(journeyId, userId);
return ok({ entries });
}
);
if (R) server.registerTool(
'list_journey_contributors',
{
description: 'List all contributors (owner and collaborators) of a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
const journey = getJourneyFull(journeyId, userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ contributors: (journey as any).contributors ?? [] });
}
);
if (R) server.registerTool(
'get_journey_suggestions',
{
description: 'Get trip suggestions for creating a new journey (recently completed trips not yet in any journey).',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const trips = getSuggestions(userId);
return ok({ trips });
}
);
if (R) server.registerTool(
'list_journey_available_trips',
{
description: 'List all trips available to link to a journey.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const trips = listUserTrips(userId);
return ok({ trips });
}
);
// --- WRITE TOOLS ---
if (W) server.registerTool(
'create_journey',
{
description: 'Create a new journey, optionally linking existing trips.',
inputSchema: {
title: z.string().min(1).max(200),
subtitle: z.string().max(300).optional(),
trip_ids: z.array(z.number().int().positive()).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ title, subtitle, trip_ids }) => {
if (isDemoUser(userId)) return demoDenied();
const journey = createJourney(userId, { title, subtitle, trip_ids });
// Return the fully-hydrated journey (entries/contributors/trips/stats/my_role),
// matching get_journey, rather than the bare row.
return ok({ journey: getJourneyFull(journey.id, userId) ?? journey });
}
);
if (W) server.registerTool(
'update_journey',
{
description: 'Update an existing journey\'s title, subtitle, cover, or status. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
subtitle: z.string().max(300).optional(),
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, title, subtitle, status }) => {
if (isDemoUser(userId)) return demoDenied();
const journey = updateJourney(journeyId, userId, { title, subtitle, status });
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
);
if (W) server.registerTool(
'delete_journey',
{
description: 'Delete a journey. Owner only — this cannot be undone.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteJourney(journeyId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'add_journey_trip',
{
description: 'Link a trip to a journey. Syncs skeleton entries for all places in the trip.',
inputSchema: {
journeyId: z.number().int().positive(),
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ journeyId, tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const success = addTripToJourney(journeyId, tripId, userId);
return ok({ success });
}
);
if (W) server.registerTool(
'remove_journey_trip',
{
description: 'Unlink a trip from a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId, tripId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = removeTripFromJourney(journeyId, tripId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success });
}
);
if (W) server.registerTool(
'create_journey_entry',
{
description: 'Create a new entry in a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Entry date (YYYY-MM-DD)'),
title: z.string().max(300).optional(),
story: z.string().optional(),
entry_time: z.string().optional().describe('Time of day (e.g. "14:30")'),
location_name: z.string().optional(),
mood: z.string().optional(),
sort_order: z.number().int().min(0).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ journeyId, entry_date, title, story, entry_time, location_name, mood, sort_order }) => {
if (isDemoUser(userId)) return demoDenied();
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
if (!entry) return notFound('Journey not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name).
const enriched = listEntries(journeyId, userId)?.find(e => e.id === entry.id) ?? entry;
return ok({ entry: enriched });
}
);
if (W) server.registerTool(
'update_journey_entry',
{
description: 'Update an existing journey entry.',
inputSchema: {
entryId: z.number().int().positive(),
title: z.string().max(300).optional(),
story: z.string().optional(),
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
entry_time: z.string().optional(),
mood: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ entryId, title, story, entry_date, entry_time, mood }) => {
if (isDemoUser(userId)) return demoDenied();
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
if (!entry) return notFound('Entry not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry.
const enriched = listEntries(entry.journey_id, userId)?.find(e => e.id === entry.id) ?? entry;
return ok({ entry: enriched });
}
);
if (W) server.registerTool(
'delete_journey_entry',
{
description: 'Delete a journey entry.',
inputSchema: {
entryId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ entryId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteEntry(entryId, userId, undefined);
if (!success) return notFound('Entry not found or access denied.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'reorder_journey_entries',
{
description: 'Reorder entries within a journey by providing the desired order of entry IDs.',
inputSchema: {
journeyId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
const success = reorderEntries(journeyId, userId, orderedIds, undefined);
if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'add_journey_contributor',
{
description: 'Add a contributor to a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
role: z.enum(['editor', 'viewer']),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ journeyId, targetUserId, role }) => {
if (isDemoUser(userId)) return demoDenied();
const success = addContributor(journeyId, userId, targetUserId, role);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'update_journey_contributor_role',
{
description: 'Update the role of a journey contributor. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
role: z.enum(['editor', 'viewer']),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, targetUserId, role }) => {
if (isDemoUser(userId)) return demoDenied();
const success = updateContributorRole(journeyId, userId, targetUserId, role);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'remove_journey_contributor',
{
description: 'Remove a contributor from a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId, targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = removeContributor(journeyId, userId, targetUserId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'update_journey_preferences',
{
description: 'Update per-user preferences for a journey (e.g. hide skeleton entries).',
inputSchema: {
journeyId: z.number().int().positive(),
hide_skeletons: z.boolean().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, hide_skeletons }) => {
if (isDemoUser(userId)) return demoDenied();
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
if (!result) return notFound('Journey not found or access denied.');
// Return the service result ({ hide_skeletons }), matching the REST route.
return ok(result);
}
);
// --- SHARE TOOLS ---
if (S) server.registerTool(
'get_journey_share_link',
{
description: 'Get the current public share link for a journey. Returns null if none exists.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const shareLink = getJourneyShareLink(journeyId);
return ok({ shareLink });
}
);
if (S) server.registerTool(
'create_journey_share_link',
{
description: 'Create or update the public share link for a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const shareLink = createOrUpdateJourneyShareLink(journeyId, userId, {});
if (!shareLink) return notFound('Journey not found or access denied.');
return ok({ shareLink });
}
);
if (S) server.registerTool(
'delete_journey_share_link',
{
description: 'Revoke the public share link for a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteJourneyShareLink(journeyId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
}
+15 -9
View File
@@ -30,8 +30,9 @@ remains as the platform underneath `@nestjs/platform-express`.
collab, vacay, reservations, day, permissions, audit, budget, trip, maps,
transit, place, transit-itinerary, collections, atlas, auth, oidc, passkey,
notifications, admin, webauthn-config, user-cleanup, oauth, wiki, mailer,
notification transports, notification preferences — see the migration recipe
below.
notification transports, notification preferences, memories (immich, synology,
unified, photo-resolver, thumbnails, trek-photo cache), journey, journeyShare
— see the migration recipe below.
- **Foundation (BE-Phase 1, complete):** the eight stateless helpers moved to
`common/`; every trip-access check routes through `DatabaseService`
(`services/tripAccess.ts` and the dead `middleware/tripAccess.ts` are gone);
@@ -42,11 +43,9 @@ remains as the platform underneath `@nestjs/platform-express`.
addon guards are one `AddonGuard` + `@RequireAddon`; the demo-mode block is one
condition. Still open: `TripAccessGuard`, default-deny, the MFA policy.
`src/services/` is down to three top-level files (journey, journeyShare, backup)
plus the `airtrail/` and `memories/` directories. What is left each waits on an
open decision: memories on its Zod contracts, journey on memories, backup on the
plugins module split and the db-lifecycle question, airtrail on the credential
handling. Note that the trip-access and
`src/services/` is down to one top-level file (backup) plus the `airtrail/`
directory. Both wait on an open decision: backup on the plugins module split and
the db-lifecycle question, airtrail on the credential handling. Note that the trip-access and
`canEdit` methods on the domain services are **not** dead weight waiting for a
guard: their callers are overwhelmingly the `*.mcp.ts` tools, which never pass
through an HTTP guard. In the five domains piloted for `TripAccessGuard`, 40 of
@@ -573,7 +572,7 @@ three `'enabled must be a boolean'` checks plus `'permissions object required'`
and `'Object body required'` for the pipe envelope; the schemas are
deliberately permissive wherever the service owns a bespoke 400 of its own.)
Repeat these steps per
service (next up: **journeyService** / **oauthService** —
service (next up: **backupService** / **airtrail** —
per the dependency-honest order in
`migration-graph.md`). This is a
**pure relocation** — byte-identical
@@ -602,7 +601,14 @@ bridges.
markers are typed against the scope-derived `ScopeGroup` union and
boot-validated by `trekMcpValidateAccess` (`src/mcp/nest-mcp-policy.ts`) —
an unknown group, or `mode: 'write'` on a read-only group (`geo`,
`weather`), fails app boot. *(Design decision, settled with the tags pilot:
`weather`), fails app boot. **`mode` is typed the same way**: it is the mode
half of `Scope` (`read | write | delete | share`), augmented into
`@trek/nest-mcp` through `McpAccessModeRegistry` exactly like the group
registry. A scope that is neither read nor write — `journey:share` is the
one today — gets a real marker, *not* a `(ctx) => canX(ctx.scopes)`
predicate: predicates bypass the policy entirely and are invisible to the
boot gate, so a scope typo in one would ship as a silent full-access tool.
Reach for a predicate only when the gate genuinely is not a scope. *(Design decision, settled with the tags pilot:
MCP tools stay outside the container and use the bridge. The alternative — handing the Nest app to the
MCP layer via `app.get(XService)` — was rejected: it would thread the container
through `mcpHandler` + every tool registrar and force a Nest bootstrap into the
@@ -4,6 +4,8 @@ import { RealtimeService } from '../realtime/realtime.service';
import { AssignmentsService } from './assignments.service';
import { PermissionsService } from '../permissions/permissions.service';
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
/**
* Non-Nest entry point for the assignments domain — for code running OUTSIDE
@@ -26,9 +28,14 @@ import { QueryHelpersService } from '../query-helpers/query-helpers.service';
* have finished evaluating. (`db` is the reinitialize-proof Proxy onto the
* shared better-sqlite3 singleton.)
*/
function journeyDomain(): JourneyDomainService {
const dbs = new DatabaseService(db);
return new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs));
}
let instance: AssignmentsService | undefined;
function assignments(): AssignmentsService {
return (instance ??= new AssignmentsService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService(), new QueryHelpersService(new DatabaseService(db))));
return (instance ??= new AssignmentsService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService(), new QueryHelpersService(new DatabaseService(db)), journeyDomain()));
}
export function createAssignment(dayId: string | number, placeId: string | number, notes: string | null) {
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { JourneyDomainModule } from '../journey/journey-domain.module';
import { DaysModule } from '../days/days.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { QueryHelpersModule } from '../query-helpers/query-helpers.module';
@@ -15,7 +16,7 @@ import { AuthModule } from '../auth/auth.module';
*/
@Module({
// DaysModule: AssignmentsMcp injects DaysService for the target-day checks.
imports: [DaysModule, PermissionsModule, QueryHelpersModule, AuthModule],
imports: [DaysModule, PermissionsModule, QueryHelpersModule, AuthModule, JourneyDomainModule],
controllers: [DayAssignmentsController, AssignmentOpsController],
providers: [AssignmentsService, AssignmentsMcp],
exports: [AssignmentsService],
@@ -6,7 +6,7 @@ import { PermissionsService } from '../permissions/permissions.service';
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
import { formatAssignmentWithPlace } from '../common/rowShape';
import type { AssignmentRow, DayAssignment, User } from '../../types';
import { reconcileTripSkeletons } from '../../services/journeyService';
import { JourneyDomainService } from '../journey/journey-domain.service';
type Trip = TripAccess;
@@ -30,6 +30,7 @@ export class AssignmentsService {
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly queryHelpers: QueryHelpersService,
private readonly journey: JourneyDomainService,
) {}
verifyTripAccess(tripId: string | number, userId: number) {
@@ -50,7 +51,7 @@ export class AssignmentsService {
* the journey stays in sync. Non-fatal, like the route's try/catch.
*/
reconcile(tripId: string | number, socketId?: string): void {
try { reconcileTripSkeletons(Number(tripId), socketId); } catch { /* non-fatal */ }
try { this.journey.reconcileTripSkeletons(Number(tripId), socketId); } catch { /* non-fatal */ }
}
private getAssignmentWithPlace(assignmentId: number | bigint) {
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { RealtimeModule } from '../realtime/realtime.module';
import { TrekPhotosModule } from '../photos/trek-photos.module';
import { JourneyDomainService } from './journey-domain.service';
import { JourneyShareService } from './journey-share.service';
/**
* Leaf module holding the journey domain itself, without the controllers.
*
* Places, assignments and the plugin host each need a handful of journey
* functions; importing the full JourneyModule for that would drag MemoriesModule
* (and with it both photo providers) into their graphs. This carries the two
* services and nothing else - same shape as TrekPhotosModule.
*
* RealtimeModule is imported explicitly even though it is @Global, so a
* single-domain e2e TestingModule can still resolve the broadcast.
*/
@Module({
imports: [RealtimeModule, TrekPhotosModule],
providers: [JourneyDomainService, JourneyShareService],
exports: [JourneyDomainService, JourneyShareService],
})
export class JourneyDomainModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,220 @@
import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { DatabaseService } from '../database/database.service';
import { JourneyDomainService } from './journey-domain.service';
interface JourneySharePermissions {
share_timeline?: boolean;
share_gallery?: boolean;
share_map?: boolean;
}
interface JourneyShareTokenInfo {
token: string;
created_at: string;
share_timeline: boolean;
share_gallery: boolean;
share_map: boolean;
}
/**
* Public share links for a journey: minting the token, validating it for a
* photo or a provider asset, and the read-only public view.
*
* Folded 1:1 from services/journeyShareService.ts. It injects
* JourneyDomainService for the owner check — the two files could never be split
* because that check lives there.
*/
@Injectable()
export class JourneyShareService {
constructor(
private readonly db: DatabaseService,
private readonly journey: JourneyDomainService,
) {}
createOrUpdateJourneyShareLink(
journeyId: number,
createdBy: number,
permissions: JourneySharePermissions
): { token: string; created: boolean } | null {
// Public sharing is an owner-only action — editors/viewers must not be
// able to publish the journey or change which screens are shared.
if (!this.journey.isOwner(journeyId, createdBy)) return null;
const {
share_timeline = true,
share_gallery = true,
share_map = true,
} = permissions;
const existing = this.db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
if (existing) {
this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
return { token, created: true };
}
getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | null {
const row = this.db.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as any;
if (!row) return null;
return {
token: row.token,
created_at: row.created_at,
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
};
}
deleteJourneyShareLink(journeyId: number, userId: number): boolean {
if (!this.journey.isOwner(journeyId, userId)) return false;
this.db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
return true;
}
validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
const row = this.db.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
// Photos only ever surface (inline or in the gallery) when share_gallery is on,
// so the byte proxy must honour the flag server-side too — the JSON payload
// already strips photos when it is off. Enumerable photo ids otherwise stay
// fetchable after the owner disables the gallery.
if (!row.share_gallery) return null;
const photo = this.db.prepare(`
SELECT gp.photo_id, tkp.owner_id, gp.journey_id
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.photo_id = ? AND gp.journey_id = ?
`).get(photoId, row.journey_id) as any;
if (!photo) return null;
const journey = this.db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { journeyId: row.journey_id, ownerId: photo.owner_id || journey.user_id } : null;
}
validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
const row = this.db.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
// Same as the unified photo proxy: no asset bytes leave the host unless the
// owner shared the gallery.
if (!row.share_gallery) return null;
const photo = this.db.prepare(`
SELECT tkp.owner_id FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE tkp.asset_id = ? AND gp.journey_id = ?
`).get(assetId, row.journey_id) as any;
// Only resolve assets that actually belong to this shared journey.
if (!photo) return null;
return { ownerId: photo.owner_id };
}
getPublicJourney(token: string) {
const row = this.db.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
const journey = this.db.prepare('SELECT * FROM journeys WHERE id = ?').get(row.journey_id) as any;
if (!journey) return null;
// Entries with photos
const entries = this.db.prepare(`
SELECT je.* FROM journey_entries je
WHERE je.journey_id = ? AND je.type != 'skeleton'
ORDER BY je.entry_date, je.sort_order
`).all(row.journey_id) as any[];
const photos = this.db.prepare(`
SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
tkp.media_type, tkp.duration_ms
FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
ORDER BY jep.sort_order
`).all(row.journey_id) as any[];
const photosByEntry: Record<number, any[]> = {};
for (const p of photos) {
(photosByEntry[p.entry_id] ||= []).push(p);
}
const gallery = this.db.prepare(`
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
tkp.media_type, tkp.duration_ms
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
ORDER BY gp.sort_order
`).all(row.journey_id) as any[];
const enrichedEntries = entries
.map(e => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
photos: photosByEntry[e.id] || [],
}));
// Stats are derived from the full data so the overview pills stay accurate
// even when a section is hidden.
const stats = {
entries: entries.length,
photos: gallery.length,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
const shareTimeline = !!row.share_timeline;
const shareGallery = !!row.share_gallery;
const shareMap = !!row.share_map;
// Honour the share flags server-side so the API only returns the sections the
// owner enabled (the client gates these too, but it must not rely on that).
let publicEntries: Record<string, unknown>[] = [];
if (shareTimeline) {
// Include the full entry, but drop GPS unless the map is shared and inline
// photos unless the gallery is shared.
publicEntries = enrichedEntries.map(e => {
const projected: Record<string, unknown> = { ...e };
if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
if (!shareGallery) projected.photos = [];
return projected;
});
} else if (shareMap) {
// Map-only share: just enough to plot markers, no story/photos/mood.
publicEntries = enrichedEntries.map(e => ({
id: e.id,
journey_id: e.journey_id,
type: e.type,
entry_date: e.entry_date,
title: e.title,
location_name: e.location_name,
location_lat: e.location_lat,
location_lng: e.location_lng,
sort_order: e.sort_order,
}));
}
return {
journey: {
title: journey.title,
subtitle: journey.subtitle,
cover_image: journey.cover_image,
status: journey.status,
},
entries: publicEntries,
gallery: shareGallery ? gallery : [],
stats,
permissions: {
share_timeline: shareTimeline,
share_gallery: shareGallery,
share_map: shareMap,
},
};
}
}
+32
View File
@@ -0,0 +1,32 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { JourneyDomainService } from './journey-domain.service';
/**
* Non-Nest entry point for the journey domain — for the four journey MCP
* resources in src/mcp/resources.ts, which are registered at import time and
* so cannot be injected. The journey *tools* no longer need it: they became
* src/nest/journey/journey.mcp.ts. Inside the container, inject
* JourneyDomainService or JourneyShareService instead. Delete this file when
* resources.ts moves to @ResourceTemplate.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton, and neither service holds state.
*/
const dbs = new DatabaseService(db);
const journey = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs));
export function canAccessJourney(...args: Parameters<JourneyDomainService['canAccessJourney']>) {
return journey.canAccessJourney(...args);
}
export function getJourneyFull(...args: Parameters<JourneyDomainService['getJourneyFull']>) {
return journey.getJourneyFull(...args);
}
export function listEntries(...args: Parameters<JourneyDomainService['listEntries']>) {
return journey.listEntries(...args);
}
export function listJourneys(...args: Parameters<JourneyDomainService['listJourneys']>) {
return journey.listJourneys(...args);
}
+425
View File
@@ -0,0 +1,425 @@
import {
McpController, Tool,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
demoDenied, ok, type McpContext,
} from '@trek/nest-mcp';
import { z } from 'zod';
// auth.bridge, not an injected AuthService: same documented in-container
// exception as atlas.mcp.ts and places.mcp.ts — AuthModule's graph is large and
// pulling it into JourneyModule for one isDemoUser call is not worth it.
import { isDemoUser } from '../auth/auth.bridge';
import { isAddonEnabled } from '../addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
import { JourneyDomainService } from './journey-domain.service';
import { JourneyShareService } from './journey-share.service';
import type { JourneyContributor } from '../../types';
/** Legacy registrar gate: the whole journey surface rode the journey addon. */
const journeyAddonOn = () => isAddonEnabled(ADDON_IDS.JOURNEY);
function notFound(msg: string) {
return { content: [{ type: 'text' as const, text: msg }], isError: true };
}
/**
* Journey MCP surface — ported 1:1 from the legacy registrar
* src/mcp/tools/journey.ts (23 tools): identical names, descriptions, zod input
* schemas, annotations, and error/payload shapes. The legacy `if (R)` / `if (W)`
* checks become declarative read/write markers; the three `if (S)` share tools
* become `{ group: 'journey', mode: 'share' }`, which is why McpAccessMode is
* host-augmentable now — canShareJourneys was the one scope the old two-mode
* marker could not express, and folding it into a predicate would have put it
* out of reach of the boot-time scope gate. The registration-time addon
* early-return becomes the `when:` gate.
*/
@McpController()
export class JourneyMcp {
constructor(
private readonly journey: JourneyDomainService,
private readonly share: JourneyShareService,
) {}
// ── Read ────────────────────────────────────────────────────────────────
@Tool({
name: 'list_journeys',
description: 'List all journeys owned or contributed to by the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
listJourneys(_args: unknown, ctx: McpContext) {
return ok({ journeys: this.journey.listJourneys(ctx.userId) });
}
@Tool({
name: 'get_journey',
description: 'Get a full journey including entries, contributors, and linked trips.',
inputSchema: { journeyId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY,
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
getJourney({ journeyId }: { journeyId: number }, ctx: McpContext) {
const journey = this.journey.getJourneyFull(journeyId, ctx.userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
@Tool({
name: 'list_journey_entries',
description: 'List all entries in a journey.',
inputSchema: { journeyId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY,
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
listJourneyEntries({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (!this.journey.canAccessJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
return ok({ entries: this.journey.listEntries(journeyId, ctx.userId) });
}
@Tool({
name: 'list_journey_contributors',
description: 'List all contributors (owner and collaborators) of a journey.',
inputSchema: { journeyId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY,
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
listJourneyContributors({ journeyId }: { journeyId: number }, ctx: McpContext) {
const journey = this.journey.getJourneyFull(journeyId, ctx.userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ contributors: (journey as { contributors?: JourneyContributor[] }).contributors ?? [] });
}
@Tool({
name: 'get_journey_suggestions',
description: 'Get trip suggestions for creating a new journey (recently completed trips not yet in any journey).',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
getJourneySuggestions(_args: unknown, ctx: McpContext) {
return ok({ trips: this.journey.getSuggestions(ctx.userId) });
}
@Tool({
name: 'list_journey_available_trips',
description: 'List all trips available to link to a journey.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
listJourneyAvailableTrips(_args: unknown, ctx: McpContext) {
return ok({ trips: this.journey.listUserTrips(ctx.userId) });
}
// ── Write ───────────────────────────────────────────────────────────────
@Tool({
name: 'create_journey',
description: 'Create a new journey, optionally linking existing trips.',
inputSchema: {
title: z.string().min(1).max(200),
subtitle: z.string().max(300).optional(),
trip_ids: z.array(z.number().int().positive()).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
createJourney(
{ title, subtitle, trip_ids }: { title: string; subtitle?: string; trip_ids?: number[] },
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
const journey = this.journey.createJourney(ctx.userId, { title, subtitle, trip_ids });
// Return the fully-hydrated journey (entries/contributors/trips/stats/my_role),
// matching get_journey, rather than the bare row.
return ok({ journey: this.journey.getJourneyFull(journey.id, ctx.userId) ?? journey });
}
@Tool({
name: 'update_journey',
description: "Update an existing journey's title, subtitle, cover, or status. Owner only.",
inputSchema: {
journeyId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
subtitle: z.string().max(300).optional(),
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
updateJourney(
{ journeyId, title, subtitle, status }: { journeyId: number; title?: string; subtitle?: string; status?: string },
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
const journey = this.journey.updateJourney(journeyId, ctx.userId, { title, subtitle, status });
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
@Tool({
name: 'delete_journey',
description: 'Delete a journey. Owner only — this cannot be undone.',
inputSchema: { journeyId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_DELETE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
deleteJourney({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.deleteJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@Tool({
name: 'add_journey_trip',
description: 'Link a trip to a journey. Syncs skeleton entries for all places in the trip.',
inputSchema: { journeyId: z.number().int().positive(), tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
addJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.canAccessJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
return ok({ success: this.journey.addTripToJourney(journeyId, tripId, ctx.userId) });
}
@Tool({
name: 'remove_journey_trip',
description: 'Unlink a trip from a journey. Owner only.',
inputSchema: { journeyId: z.number().int().positive(), tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_DELETE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
removeJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const success = this.journey.removeTripFromJourney(journeyId, tripId, ctx.userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success });
}
@Tool({
name: 'create_journey_entry',
description: 'Create a new entry in a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Entry date (YYYY-MM-DD)'),
title: z.string().max(300).optional(),
story: z.string().optional(),
entry_time: z.string().optional().describe('Time of day (e.g. "14:30")'),
location_name: z.string().optional(),
mood: z.string().optional(),
sort_order: z.number().int().min(0).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
createJourneyEntry(
{ journeyId, ...data }: { journeyId: number; entry_date: string; title?: string; story?: string; entry_time?: string; location_name?: string; mood?: string; sort_order?: number },
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
const entry = this.journey.createEntry(journeyId, ctx.userId, data);
if (!entry) return notFound('Journey not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name).
const enriched = this.journey.listEntries(journeyId, ctx.userId)?.find(e => e.id === entry.id) ?? entry;
return ok({ entry: enriched });
}
@Tool({
name: 'update_journey_entry',
description: 'Update an existing journey entry.',
inputSchema: {
entryId: z.number().int().positive(),
title: z.string().max(300).optional(),
story: z.string().optional(),
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
entry_time: z.string().optional(),
mood: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
updateJourneyEntry(
{ entryId, ...data }: { entryId: number; title?: string; story?: string; entry_date?: string; entry_time?: string; mood?: string },
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
const entry = this.journey.updateEntry(entryId, ctx.userId, data, undefined);
if (!entry) return notFound('Entry not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry.
const enriched = this.journey.listEntries(entry.journey_id, ctx.userId)?.find(e => e.id === entry.id) ?? entry;
return ok({ entry: enriched });
}
@Tool({
name: 'delete_journey_entry',
description: 'Delete a journey entry.',
inputSchema: { entryId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_DELETE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
deleteJourneyEntry({ entryId }: { entryId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.deleteEntry(entryId, ctx.userId, undefined)) return notFound('Entry not found or access denied.');
return ok({ success: true });
}
@Tool({
name: 'reorder_journey_entries',
description: 'Reorder entries within a journey by providing the desired order of entry IDs.',
inputSchema: {
journeyId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
reorderJourneyEntries({ journeyId, orderedIds }: { journeyId: number; orderedIds: number[] }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const success = this.journey.reorderEntries(journeyId, ctx.userId, orderedIds, undefined);
if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.');
return ok({ success: true });
}
@Tool({
name: 'add_journey_contributor',
description: 'Add a contributor to a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
role: z.enum(['editor', 'viewer']),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
addJourneyContributor(
{ journeyId, targetUserId, role }: { journeyId: number; targetUserId: number; role: 'editor' | 'viewer' },
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.addContributor(journeyId, ctx.userId, targetUserId, role)) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@Tool({
name: 'update_journey_contributor_role',
description: 'Update the role of a journey contributor. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
role: z.enum(['editor', 'viewer']),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
updateJourneyContributorRole(
{ journeyId, targetUserId, role }: { journeyId: number; targetUserId: number; role: 'editor' | 'viewer' },
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.updateContributorRole(journeyId, ctx.userId, targetUserId, role)) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@Tool({
name: 'remove_journey_contributor',
description: 'Remove a contributor from a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
removeJourneyContributor({ journeyId, targetUserId }: { journeyId: number; targetUserId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.removeContributor(journeyId, ctx.userId, targetUserId)) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@Tool({
name: 'update_journey_preferences',
description: 'Update per-user preferences for a journey (e.g. hide skeleton entries).',
inputSchema: {
journeyId: z.number().int().positive(),
hide_skeletons: z.boolean().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
updateJourneyPreferences(
{ journeyId, hide_skeletons }: { journeyId: number; hide_skeletons?: boolean },
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
const result = this.journey.updateJourneyPreferences(journeyId, ctx.userId, { hide_skeletons });
if (!result) return notFound('Journey not found or access denied.');
// Return the service result ({ hide_skeletons }), matching the REST route.
return ok(result);
}
// ── Share links (journey:share, not implied by journey:write) ────────────
@Tool({
name: 'get_journey_share_link',
description: 'Get the current public share link for a journey. Returns null if none exists.',
inputSchema: { journeyId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY,
when: journeyAddonOn,
access: { group: 'journey', mode: 'share' },
})
getJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (!this.journey.canAccessJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
return ok({ shareLink: this.share.getJourneyShareLink(journeyId) });
}
@Tool({
name: 'create_journey_share_link',
description: 'Create or update the public share link for a journey. Owner only.',
inputSchema: { journeyId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'share' },
})
createJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const shareLink = this.share.createOrUpdateJourneyShareLink(journeyId, ctx.userId, {});
if (!shareLink) return notFound('Journey not found or access denied.');
return ok({ shareLink });
}
@Tool({
name: 'delete_journey_share_link',
description: 'Revoke the public share link for a journey. Owner only.',
inputSchema: { journeyId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_DELETE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'share' },
})
deleteJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.deleteJourneyShareLink(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
}
+4 -2
View File
@@ -4,11 +4,13 @@ import { JourneyPublicController } from './journey-public.controller';
import { JourneyService } from './journey.service';
import { AddonsModule } from '../addons/addons.module';
import { MemoriesModule } from '../memories/memories.module';
import { JourneyDomainModule } from './journey-domain.module';
import { JourneyMcp } from './journey.mcp';
@Module({
// MemoriesModule: the journey gallery streams provider assets and uploads to Immich.
imports: [AddonsModule, MemoriesModule],
imports: [AddonsModule, MemoriesModule, JourneyDomainModule],
controllers: [JourneyController, JourneyPublicController],
providers: [JourneyService],
providers: [JourneyService, JourneyMcp],
})
export class JourneyModule {}
+39 -37
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import * as svc from '../../services/journeyService';
import * as share from '../../services/journeyShareService';
import { JourneyDomainService } from './journey-domain.service';
import { JourneyShareService } from './journey-share.service';
import { ImmichService } from '../memories/immich.service';
import { SynologyService } from '../memories/synology.service';
import { PhotoResolverService } from '../memories/photo-resolver.service';
@@ -23,6 +23,8 @@ export class JourneyService {
private readonly immich: ImmichService,
private readonly synology: SynologyService,
private readonly photoResolver: PhotoResolverService,
private readonly journey: JourneyDomainService,
private readonly share: JourneyShareService,
) {}
private get db() {
@@ -34,55 +36,55 @@ export class JourneyService {
}
// Journeys
listJourneys(userId: number) { return svc.listJourneys(userId); }
createJourney(userId: number, data: Parameters<typeof svc.createJourney>[1]) { return svc.createJourney(userId, data); }
getJourneyFull(id: number, userId: number) { return svc.getJourneyFull(id, userId); }
updateJourney(id: number, userId: number, data: Parameters<typeof svc.updateJourney>[2]) { return svc.updateJourney(id, userId, data); }
deleteJourney(id: number, userId: number) { return svc.deleteJourney(id, userId); }
getSuggestions(userId: number) { return svc.getSuggestions(userId); }
listUserTrips(userId: number) { return svc.listUserTrips(userId); }
updateJourneyPreferences(id: number, userId: number, data: Parameters<typeof svc.updateJourneyPreferences>[2]) { return svc.updateJourneyPreferences(id, userId, data); }
listJourneys(userId: number) { return this.journey.listJourneys(userId); }
createJourney(userId: number, data: Parameters<typeof this.journey.createJourney>[1]) { return this.journey.createJourney(userId, data); }
getJourneyFull(id: number, userId: number) { return this.journey.getJourneyFull(id, userId); }
updateJourney(id: number, userId: number, data: Parameters<typeof this.journey.updateJourney>[2]) { return this.journey.updateJourney(id, userId, data); }
deleteJourney(id: number, userId: number) { return this.journey.deleteJourney(id, userId); }
getSuggestions(userId: number) { return this.journey.getSuggestions(userId); }
listUserTrips(userId: number) { return this.journey.listUserTrips(userId); }
updateJourneyPreferences(id: number, userId: number, data: Parameters<typeof this.journey.updateJourneyPreferences>[2]) { return this.journey.updateJourneyPreferences(id, userId, data); }
// Trips
addTripToJourney(id: number, tripId: number, userId: number) { return svc.addTripToJourney(id, tripId, userId); }
removeTripFromJourney(id: number, tripId: number, userId: number) { return svc.removeTripFromJourney(id, tripId, userId); }
addTripToJourney(id: number, tripId: number, userId: number) { return this.journey.addTripToJourney(id, tripId, userId); }
removeTripFromJourney(id: number, tripId: number, userId: number) { return this.journey.removeTripFromJourney(id, tripId, userId); }
// Entries
listEntries(id: number, userId: number) { return svc.listEntries(id, userId); }
listEntries(id: number, userId: number) { return this.journey.listEntries(id, userId); }
// Entry create/update bodies are free-form in the legacy route (req.body: any);
// the cast keeps that boundary here so callers needn't pre-shape the payload.
createEntry(id: number, userId: number, data: Record<string, unknown>, sid?: string) { return svc.createEntry(id, userId, data as Parameters<typeof svc.createEntry>[2], sid); }
updateEntry(entryId: number, userId: number, data: Parameters<typeof svc.updateEntry>[2], sid?: string) { return svc.updateEntry(entryId, userId, data, sid); }
deleteEntry(entryId: number, userId: number, sid?: string) { return svc.deleteEntry(entryId, userId, sid); }
reorderEntries(id: number, userId: number, orderedIds: number[], sid?: string) { return svc.reorderEntries(id, userId, orderedIds, sid); }
createEntry(id: number, userId: number, data: Record<string, unknown>, sid?: string) { return this.journey.createEntry(id, userId, data as Parameters<typeof this.journey.createEntry>[2], sid); }
updateEntry(entryId: number, userId: number, data: Parameters<typeof this.journey.updateEntry>[2], sid?: string) { return this.journey.updateEntry(entryId, userId, data, sid); }
deleteEntry(entryId: number, userId: number, sid?: string) { return this.journey.deleteEntry(entryId, userId, sid); }
reorderEntries(id: number, userId: number, orderedIds: number[], sid?: string) { return this.journey.reorderEntries(id, userId, orderedIds, sid); }
// Photos
addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath: string | undefined, caption: string | undefined) { return svc.addPhoto(entryId, userId, filePath, thumbnailPath, caption); }
setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { return svc.setPhotoProvider(photoId, provider, assetId, ownerId); }
addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string, mediaType?: string) { return svc.addProviderPhoto(entryId, userId, provider, assetId, caption, passphrase, mediaType); }
linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number) { return svc.linkPhotoToEntry(entryId, journeyPhotoId, userId); }
unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number) { return svc.unlinkPhotoFromEntry(entryId, journeyPhotoId, userId); }
updatePhoto(photoId: number, userId: number, data: Parameters<typeof svc.updatePhoto>[2]) { return svc.updatePhoto(photoId, userId, data); }
deletePhoto(photoId: number, userId: number) { return svc.deletePhoto(photoId, userId); }
uploadGalleryPhotos(id: number, userId: number, filePaths: Parameters<typeof svc.uploadGalleryPhotos>[2]) { return svc.uploadGalleryPhotos(id, userId, filePaths); }
addProviderPhotoToGallery(id: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string, mediaType?: string) { return svc.addProviderPhotoToGallery(id, userId, provider, assetId, caption, passphrase, mediaType); }
deleteGalleryPhoto(journeyPhotoId: number, userId: number) { return svc.deleteGalleryPhoto(journeyPhotoId, userId); }
addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath: string | undefined, caption: string | undefined) { return this.journey.addPhoto(entryId, userId, filePath, thumbnailPath, caption); }
setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { return this.journey.setPhotoProvider(photoId, provider, assetId, ownerId); }
addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string, mediaType?: string) { return this.journey.addProviderPhoto(entryId, userId, provider, assetId, caption, passphrase, mediaType); }
linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number) { return this.journey.linkPhotoToEntry(entryId, journeyPhotoId, userId); }
unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number) { return this.journey.unlinkPhotoFromEntry(entryId, journeyPhotoId, userId); }
updatePhoto(photoId: number, userId: number, data: Parameters<typeof this.journey.updatePhoto>[2]) { return this.journey.updatePhoto(photoId, userId, data); }
deletePhoto(photoId: number, userId: number) { return this.journey.deletePhoto(photoId, userId); }
uploadGalleryPhotos(id: number, userId: number, filePaths: Parameters<typeof this.journey.uploadGalleryPhotos>[2]) { return this.journey.uploadGalleryPhotos(id, userId, filePaths); }
addProviderPhotoToGallery(id: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string, mediaType?: string) { return this.journey.addProviderPhotoToGallery(id, userId, provider, assetId, caption, passphrase, mediaType); }
deleteGalleryPhoto(journeyPhotoId: number, userId: number) { return this.journey.deleteGalleryPhoto(journeyPhotoId, userId); }
// Contributors
addContributor(id: number, userId: number, targetUserId: number, role: 'editor' | 'viewer') { return svc.addContributor(id, userId, targetUserId, role); }
updateContributorRole(id: number, userId: number, targetUserId: number, role: 'editor' | 'viewer') { return svc.updateContributorRole(id, userId, targetUserId, role); }
removeContributor(id: number, userId: number, targetUserId: number) { return svc.removeContributor(id, userId, targetUserId); }
addContributor(id: number, userId: number, targetUserId: number, role: 'editor' | 'viewer') { return this.journey.addContributor(id, userId, targetUserId, role); }
updateContributorRole(id: number, userId: number, targetUserId: number, role: 'editor' | 'viewer') { return this.journey.updateContributorRole(id, userId, targetUserId, role); }
removeContributor(id: number, userId: number, targetUserId: number) { return this.journey.removeContributor(id, userId, targetUserId); }
// Share links
// Authorization: only someone with access to the journey may read its public
// share token — same access model as create/delete here and the
// get_journey_share_link MCP tool.
getJourneyShareLink(id: number, userId: number) {
if (!svc.canAccessJourney(id, userId)) return null;
return share.getJourneyShareLink(id);
if (!this.journey.canAccessJourney(id, userId)) return null;
return this.share.getJourneyShareLink(id);
}
createOrUpdateJourneyShareLink(id: number, userId: number, data: Parameters<typeof share.createOrUpdateJourneyShareLink>[2]) { return share.createOrUpdateJourneyShareLink(id, userId, data); }
deleteJourneyShareLink(id: number, userId: number) { return share.deleteJourneyShareLink(id, userId); }
createOrUpdateJourneyShareLink(id: number, userId: number, data: Parameters<typeof this.share.createOrUpdateJourneyShareLink>[2]) { return this.share.createOrUpdateJourneyShareLink(id, userId, data); }
deleteJourneyShareLink(id: number, userId: number) { return this.share.deleteJourneyShareLink(id, userId); }
// Immich mirror (only when the user opted in via integration settings)
immichAutoUploadEnabled(userId: number): boolean {
@@ -92,9 +94,9 @@ export class JourneyService {
uploadToImmich(userId: number, relativePath: string, originalName: string) { return this.immich.uploadToImmich(userId, relativePath, originalName); }
// Public (share-token) access — no auth, validated by token.
getPublicJourney(token: string) { return share.getPublicJourney(token); }
validateShareTokenForPhoto(token: string, photoId: number) { return share.validateShareTokenForPhoto(token, photoId); }
validateShareTokenForAsset(token: string, assetId: string) { return share.validateShareTokenForAsset(token, assetId); }
getPublicJourney(token: string) { return this.share.getPublicJourney(token); }
validateShareTokenForPhoto(token: string, photoId: number) { return this.share.validateShareTokenForPhoto(token, photoId); }
validateShareTokenForAsset(token: string, assetId: string) { return this.share.validateShareTokenForAsset(token, assetId); }
streamPhoto(res: Response, ownerId: number, photoId: number, kind: 'thumbnail' | 'original') { return this.photoResolver.streamPhoto(res, ownerId, photoId, kind); }
streamImmichAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) { return this.immich.streamImmichAsset(res, userId, assetId, kind, ownerId); }
// Was a dynamic import(): the memories cluster had a real import cycle and
+5 -4
View File
@@ -7,7 +7,7 @@ import {
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { createAssignment, dayExists } from '../assignments/assignments.bridge';
import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
import { DatabaseService } from '../database/database.service';
import { MapsService } from '../maps/maps.service';
@@ -43,6 +43,7 @@ export class PlacesMcp {
private readonly maps: MapsService,
private readonly db: DatabaseService,
private readonly auth: AuthService,
private readonly journey: JourneyDomainService,
) {}
@Tool({
@@ -130,7 +131,7 @@ export class PlacesMcp {
});
safeBroadcast(tripId, 'place:created', { place: result.place });
safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment });
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
try { this.journey.reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
return ok(result);
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }], isError: true };
@@ -228,7 +229,7 @@ export class PlacesMcp {
if (!this.places.get(String(tripId), String(placeId))) {
return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
}
try { onPlaceDeleted(placeId); } catch { /* non-fatal */ } // sync journeys before the row is gone
try { this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } // sync journeys before the row is gone
const deleted = this.places.remove(String(tripId), String(placeId));
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:deleted', { placeId });
@@ -335,7 +336,7 @@ export class PlacesMcp {
// ON DELETE SET NULL, so a hook that ran afterwards found nothing left to
// detach and left the entries as orphans.
for (const id of this.places.scopedIds(String(tripId), placeIds)) {
try { onPlaceDeleted(id); } catch { /* non-fatal */ }
try { this.journey.onPlaceDeleted(id); } catch { /* non-fatal */ }
}
const deleted = this.places.removeMany(String(tripId), placeIds);
for (const id of deleted) safeBroadcast(tripId, 'place:deleted', { placeId: id });
+2 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { JourneyDomainModule } from '../journey/journey-domain.module';
import { PlacesController } from './places.controller';
import { PlacesService } from './places.service';
import { PlacesMcp } from './places.mcp';
@@ -19,7 +20,7 @@ import { AuthModule } from '../auth/auth.module';
* is no places.bridge.ts: nothing outside the container consumes this domain.
*/
@Module({
imports: [PermissionsModule, QueryHelpersModule, MapsModule, AuthModule, AppConfigModule, UnsplashModule, PlacePhotosModule],
imports: [PermissionsModule, QueryHelpersModule, MapsModule, AuthModule, AppConfigModule, UnsplashModule, PlacePhotosModule, JourneyDomainModule],
controllers: [PlacesController],
providers: [PlacesService, PlacesMcp],
exports: [PlacesService],
+5 -4
View File
@@ -23,7 +23,7 @@ import { UnsplashService } from '../unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { type UpdateConflict, isUpdateConflict } from '../common/conflictResult';
import { reclaimPlaceImage } from './place-image';
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../../services/journeyService';
import { JourneyDomainService } from '../journey/journey-domain.service';
import {
COORD_DEDUP_TOLERANCE,
ENRICH_CONCURRENCY,
@@ -108,6 +108,7 @@ export class PlacesService {
private readonly queryHelpers: QueryHelpersService,
private readonly unsplash: UnsplashService,
private readonly photoCache: PlacePhotoCacheService,
private readonly journey: JourneyDomainService,
) {}
verifyTripAccess(tripId: string, userId: number) {
@@ -1144,7 +1145,7 @@ export class PlacesService {
}
// Journey hooks — non-fatal, mirroring the route's try/catch wrappers.
onCreated(tripId: string, placeId: number): void { try { onPlaceCreated(Number(tripId), placeId); } catch { /* non-fatal */ } }
onUpdated(placeId: number): void { try { onPlaceUpdated(placeId); } catch { /* non-fatal */ } }
onDeleted(placeId: number): void { try { onPlaceDeleted(placeId); } catch { /* non-fatal */ } }
onCreated(tripId: string, placeId: number): void { try { this.journey.onPlaceCreated(Number(tripId), placeId); } catch { /* non-fatal */ } }
onUpdated(placeId: number): void { try { this.journey.onPlaceUpdated(placeId); } catch { /* non-fatal */ } }
onDeleted(placeId: number): void { try { this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } }
}
@@ -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
+4 -1
View File
@@ -1,4 +1,6 @@
import { db } from '../../db/database';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -39,6 +41,7 @@ const dbs = () => new DatabaseService(db);
// One instance, not one per consumer: the in-flight dedup only works if the
// stampede guard is shared (see PlacePhotoCacheService).
const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService());
const journeyDomain = new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()));
const budget = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const trips = new TripsService(
dbs(),
@@ -52,7 +55,7 @@ const trips = new TripsService(
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new VacayService(dbs(), new RealtimeService()),
new RealtimeService(),
new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService()), photoCache),
new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService()), photoCache, journeyDomain),
new UnsplashService(dbs(), new RuntimeEnvService()),
new UserCleanupService(dbs()),
);
File diff suppressed because it is too large Load Diff
-203
View File
@@ -1,203 +0,0 @@
import { db } from '../db/database';
import crypto from 'crypto';
import { isOwner } from './journeyService';
interface JourneySharePermissions {
share_timeline?: boolean;
share_gallery?: boolean;
share_map?: boolean;
}
interface JourneyShareTokenInfo {
token: string;
created_at: string;
share_timeline: boolean;
share_gallery: boolean;
share_map: boolean;
}
export function createOrUpdateJourneyShareLink(
journeyId: number,
createdBy: number,
permissions: JourneySharePermissions
): { token: string; created: boolean } | null {
// Public sharing is an owner-only action — editors/viewers must not be
// able to publish the journey or change which screens are shared.
if (!isOwner(journeyId, createdBy)) return null;
const {
share_timeline = true,
share_gallery = true,
share_map = true,
} = permissions;
const existing = db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
if (existing) {
db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
return { token, created: true };
}
export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | null {
const row = db.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as any;
if (!row) return null;
return {
token: row.token,
created_at: row.created_at,
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
};
}
export function deleteJourneyShareLink(journeyId: number, userId: number): boolean {
if (!isOwner(journeyId, userId)) return false;
db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
return true;
}
export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
const row = db.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
// Photos only ever surface (inline or in the gallery) when share_gallery is on,
// so the byte proxy must honour the flag server-side too — the JSON payload
// already strips photos when it is off. Enumerable photo ids otherwise stay
// fetchable after the owner disables the gallery.
if (!row.share_gallery) return null;
const photo = db.prepare(`
SELECT gp.photo_id, tkp.owner_id, gp.journey_id
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.photo_id = ? AND gp.journey_id = ?
`).get(photoId, row.journey_id) as any;
if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { journeyId: row.journey_id, ownerId: photo.owner_id || journey.user_id } : null;
}
export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
const row = db.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
// Same as the unified photo proxy: no asset bytes leave the host unless the
// owner shared the gallery.
if (!row.share_gallery) return null;
const photo = db.prepare(`
SELECT tkp.owner_id FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE tkp.asset_id = ? AND gp.journey_id = ?
`).get(assetId, row.journey_id) as any;
// Only resolve assets that actually belong to this shared journey.
if (!photo) return null;
return { ownerId: photo.owner_id };
}
export function getPublicJourney(token: string) {
const row = db.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
const journey = db.prepare('SELECT * FROM journeys WHERE id = ?').get(row.journey_id) as any;
if (!journey) return null;
// Entries with photos
const entries = db.prepare(`
SELECT je.* FROM journey_entries je
WHERE je.journey_id = ? AND je.type != 'skeleton'
ORDER BY je.entry_date, je.sort_order
`).all(row.journey_id) as any[];
const photos = db.prepare(`
SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
tkp.media_type, tkp.duration_ms
FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
ORDER BY jep.sort_order
`).all(row.journey_id) as any[];
const photosByEntry: Record<number, any[]> = {};
for (const p of photos) {
(photosByEntry[p.entry_id] ||= []).push(p);
}
const gallery = db.prepare(`
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
tkp.media_type, tkp.duration_ms
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
ORDER BY gp.sort_order
`).all(row.journey_id) as any[];
const enrichedEntries = entries
.map(e => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
photos: photosByEntry[e.id] || [],
}));
// Stats are derived from the full data so the overview pills stay accurate
// even when a section is hidden.
const stats = {
entries: entries.length,
photos: gallery.length,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
const shareTimeline = !!row.share_timeline;
const shareGallery = !!row.share_gallery;
const shareMap = !!row.share_map;
// Honour the share flags server-side so the API only returns the sections the
// owner enabled (the client gates these too, but it must not rely on that).
let publicEntries: Record<string, unknown>[] = [];
if (shareTimeline) {
// Include the full entry, but drop GPS unless the map is shared and inline
// photos unless the gallery is shared.
publicEntries = enrichedEntries.map(e => {
const projected: Record<string, unknown> = { ...e };
if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
if (!shareGallery) projected.photos = [];
return projected;
});
} else if (shareMap) {
// Map-only share: just enough to plot markers, no story/photos/mood.
publicEntries = enrichedEntries.map(e => ({
id: e.id,
journey_id: e.journey_id,
type: e.type,
entry_date: e.entry_date,
title: e.title,
location_name: e.location_name,
location_lat: e.location_lat,
location_lng: e.location_lng,
sort_order: e.sort_order,
}));
}
return {
journey: {
title: journey.title,
subtitle: journey.subtitle,
cover_image: journey.cover_image,
status: journey.status,
},
entries: publicEntries,
gallery: shareGallery ? gallery : [],
stats,
permissions: {
share_timeline: shareTimeline,
share_gallery: shareGallery,
share_map: shareMap,
},
};
}
+5 -2
View File
@@ -48,7 +48,7 @@ vi.mock('../../src/db/database', () => ({
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
const { reconcileTripSkeletons } = vi.hoisted(() => ({ reconcileTripSkeletons: vi.fn() }));
vi.mock('../../src/services/journeyService', () => ({ reconcileTripSkeletons }));
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
@@ -65,7 +65,10 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, AssignmentsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, AssignmentsModule] })
.overrideProvider(JourneyDomainService)
.useValue({ reconcileTripSkeletons })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
+6 -2
View File
@@ -47,10 +47,10 @@ vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal)
const { jsvc } = vi.hoisted(() => ({
jsvc: { listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn() },
}));
vi.mock('../../src/services/journeyService', () => jsvc);
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() } }));
vi.mock('../../src/services/journeyShareService', () => sharesvc);
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
import { JourneyModule } from '../../src/nest/journey/journey.module';
import { AddonsService } from '../../src/nest/addons/addons.service';
@@ -62,6 +62,10 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] })
.overrideProvider(JourneyDomainService)
.useValue(jsvc)
.overrideProvider(JourneyShareService)
.useValue(sharesvc)
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled })
.compile();
+5 -2
View File
@@ -59,7 +59,7 @@ vi.mock('../../src/db/database', () => ({
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags, closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/services/journeyService', () => ({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn() }));
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
@@ -77,7 +77,10 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, PlacesModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, PlacesModule] })
.overrideProvider(JourneyDomainService)
.useValue({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn() })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
+21 -3
View File
@@ -51,6 +51,13 @@ import { VacayMcp } from '../../src/nest/vacay/vacay.mcp';
import { VacayService } from '../../src/nest/vacay/vacay.service';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service';
import { JourneyMcp } from '../../src/nest/journey/journey.mcp';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service';
import { makeNotificationsService, makeNotificationPreferencesService } from './notifications';
/**
@@ -73,7 +80,17 @@ export function createMcpTestRegistry(): McpRegistry {
const packingService = new PackingService(dbService, permissionsService, realtimeService);
const collabService = new CollabService(dbService, permissionsService, realtimeService);
const mapsService = new MapsService(dbService);
const placesService = new PlacesService(dbService, permissionsService, realtimeService, mapsService, queryHelpersService);
const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService));
// The last three were previously omitted, which left them `undefined` at
// runtime — silently fine while nothing called them, a TypeError the moment
// the journey skeleton hooks landed on the place write paths. tsconfig only
// includes `src`, so nothing here is typechecked; pass them for real.
const placesService = new PlacesService(
dbService, permissionsService, realtimeService, mapsService, queryHelpersService,
new UnsplashService(dbService, new RuntimeEnvService()),
new PlacePhotoCacheService(dbService, new RuntimeEnvService()),
journeyDomain,
);
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService);
const tripsService = new TripsService(
dbService,
@@ -103,16 +120,17 @@ export function createMcpTestRegistry(): McpRegistry {
new ReservationsMcp(reservationsService, daysService, budgetService, authService),
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService),
new DaysMcp(daysService, dbService, placesService, authService),
new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService, queryHelpersService), daysService, authService),
new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService, queryHelpersService, journeyDomain), daysService, authService),
new CollabMcp(collabService, authService),
new VacayMcp(new VacayService(dbService, realtimeService), authService),
new TripsMcp(tripsService, todoService, collabService, authService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService), authService),
new MapsMcp(mapsService),
new PlacesMcp(placesService, mapsService, dbService, authService),
new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain),
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService), dbService, authService),
new TransitMcp(new TransitService(), daysService, reservationsService, dbService, authService),
new AtlasMcp(new AtlasService(dbService)),
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain)),
new NotificationsMcp(makeNotificationsService(dbService, realtimeService), authService),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
+348 -1
View File
@@ -42,7 +42,7 @@ vi.mock('../../../src/nest/addons/addons.bridge', async (importOriginal) => {
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createUser, createTrip } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
@@ -144,3 +144,350 @@ describe('Tool: update_journey_preferences', () => {
});
});
});
// ---------------------------------------------------------------------------
// The rest of the surface. The legacy registrar had no test file of its own —
// the two hydration cases above were the whole of it — so every tool below is
// covered here for the first time: the happy path, the "not found or access
// denied" branch a non-member hits, and the demo-mode denial on writes.
// ---------------------------------------------------------------------------
/** A journey owned by someone else — every access check must refuse it. */
function foreignJourney() {
const { user: other } = createUser(testDb);
const j = testDb.prepare(
'INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
).run(other.id, 'Not yours', 'draft', Date.now(), Date.now());
return { otherId: other.id, journeyId: Number(j.lastInsertRowid) };
}
async function seedJourney(h: McpHarness, title = 'J') {
return (parseToolResult(await h.client.callTool({
name: 'create_journey', arguments: { title },
})) as any).journey;
}
describe('journey read tools', () => {
it('list_journeys returns the caller-visible journeys', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
await seedJourney(h, 'Alpha');
const data = parseToolResult(await h.client.callTool({ name: 'list_journeys', arguments: {} })) as any;
expect(data.journeys.map((j: any) => j.title)).toContain('Alpha');
});
});
it('get_journey hydrates entries, contributors and trips', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
const data = parseToolResult(await h.client.callTool({
name: 'get_journey', arguments: { journeyId: journey.id },
})) as any;
expect(data.journey.id).toBe(journey.id);
expect(Array.isArray(data.journey.entries)).toBe(true);
});
});
it('get_journey refuses a journey the caller cannot see', async () => {
const { user } = createUser(testDb);
const { journeyId } = foreignJourney();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_journey', arguments: { journeyId } });
expect(result.isError).toBe(true);
});
});
it('list_journey_entries returns the entries', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
await h.client.callTool({
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'D1' },
});
const data = parseToolResult(await h.client.callTool({
name: 'list_journey_entries', arguments: { journeyId: journey.id },
})) as any;
expect(data.entries).toHaveLength(1);
});
});
it('list_journey_entries refuses a foreign journey', async () => {
const { user } = createUser(testDb);
const { journeyId } = foreignJourney();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_journey_entries', arguments: { journeyId } });
expect(result.isError).toBe(true);
});
});
it('list_journey_contributors returns the contributor rows', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
const data = parseToolResult(await h.client.callTool({
name: 'list_journey_contributors', arguments: { journeyId: journey.id },
})) as any;
expect(Array.isArray(data.contributors)).toBe(true);
});
});
it('list_journey_contributors refuses a foreign journey', async () => {
const { user } = createUser(testDb);
const { journeyId } = foreignJourney();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_journey_contributors', arguments: { journeyId } });
expect(result.isError).toBe(true);
});
});
it('get_journey_suggestions and list_journey_available_trips answer with arrays', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const s = parseToolResult(await h.client.callTool({ name: 'get_journey_suggestions', arguments: {} })) as any;
const t = parseToolResult(await h.client.callTool({ name: 'list_journey_available_trips', arguments: {} })) as any;
expect(Array.isArray(s.trips)).toBe(true);
expect(Array.isArray(t.trips)).toBe(true);
});
});
});
describe('journey write tools', () => {
it('update_journey applies the change and refuses a foreign journey', async () => {
const { user } = createUser(testDb);
const { journeyId } = foreignJourney();
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
const updated = parseToolResult(await h.client.callTool({
name: 'update_journey', arguments: { journeyId: journey.id, title: 'Renamed', status: 'active' },
})) as any;
expect(updated.journey.title).toBe('Renamed');
const denied = await h.client.callTool({ name: 'update_journey', arguments: { journeyId, title: 'X' } });
expect(denied.isError).toBe(true);
});
});
it('delete_journey removes it and refuses a foreign journey', async () => {
const { user } = createUser(testDb);
const { journeyId } = foreignJourney();
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
const gone = parseToolResult(await h.client.callTool({
name: 'delete_journey', arguments: { journeyId: journey.id },
})) as any;
expect(gone.success).toBe(true);
const denied = await h.client.callTool({ name: 'delete_journey', arguments: { journeyId } });
expect(denied.isError).toBe(true);
});
});
it('add_journey_trip links a trip, remove_journey_trip unlinks it', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
const added = parseToolResult(await h.client.callTool({
name: 'add_journey_trip', arguments: { journeyId: journey.id, tripId: trip.id },
})) as any;
expect(added.success).toBe(true);
const removed = parseToolResult(await h.client.callTool({
name: 'remove_journey_trip', arguments: { journeyId: journey.id, tripId: trip.id },
})) as any;
expect(removed.success).toBe(true);
});
});
it('add_journey_trip refuses a foreign journey; remove_journey_trip is idempotent but refuses one', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const { journeyId } = foreignJourney();
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
expect((await h.client.callTool({
name: 'add_journey_trip', arguments: { journeyId, tripId: trip.id },
})).isError).toBe(true);
// Unlinking a trip that was never linked is a no-op, not an error — the
// gate is journey ownership, not whether the row existed.
expect((parseToolResult(await h.client.callTool({
name: 'remove_journey_trip', arguments: { journeyId: journey.id, tripId: trip.id },
})) as any).success).toBe(true);
expect((await h.client.callTool({
name: 'remove_journey_trip', arguments: { journeyId, tripId: trip.id },
})).isError).toBe(true);
});
});
it('create_journey_entry refuses a foreign journey', async () => {
const { user } = createUser(testDb);
const { journeyId } = foreignJourney();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_journey_entry', arguments: { journeyId, entry_date: '2026-07-01' },
});
expect(result.isError).toBe(true);
});
});
it('update_journey_entry and delete_journey_entry refuse an unknown entry', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
expect((await h.client.callTool({
name: 'update_journey_entry', arguments: { entryId: 999999, title: 'X' },
})).isError).toBe(true);
expect((await h.client.callTool({
name: 'delete_journey_entry', arguments: { entryId: 999999 },
})).isError).toBe(true);
});
});
it('delete_journey_entry removes an owned entry', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
const entry = (parseToolResult(await h.client.callTool({
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-01' },
})) as any).entry;
const data = parseToolResult(await h.client.callTool({
name: 'delete_journey_entry', arguments: { entryId: entry.id },
})) as any;
expect(data.success).toBe(true);
});
});
it('reorder_journey_entries reorders, and rejects IDs from outside the journey', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
const a = (parseToolResult(await h.client.callTool({
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'A' },
})) as any).entry;
const b = (parseToolResult(await h.client.callTool({
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-02', title: 'B' },
})) as any).entry;
const reordered = parseToolResult(await h.client.callTool({
name: 'reorder_journey_entries', arguments: { journeyId: journey.id, orderedIds: [b.id, a.id] },
})) as any;
expect(reordered.success).toBe(true);
expect((await h.client.callTool({
name: 'reorder_journey_entries', arguments: { journeyId: journey.id, orderedIds: [999999] },
})).isError).toBe(true);
});
});
it('contributor tools add, re-role and remove; each refuses a foreign journey', async () => {
const { user } = createUser(testDb);
const { user: guest } = createUser(testDb);
const { journeyId } = foreignJourney();
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
expect((parseToolResult(await h.client.callTool({
name: 'add_journey_contributor', arguments: { journeyId: journey.id, targetUserId: guest.id, role: 'editor' },
})) as any).success).toBe(true);
expect((parseToolResult(await h.client.callTool({
name: 'update_journey_contributor_role', arguments: { journeyId: journey.id, targetUserId: guest.id, role: 'viewer' },
})) as any).success).toBe(true);
expect((parseToolResult(await h.client.callTool({
name: 'remove_journey_contributor', arguments: { journeyId: journey.id, targetUserId: guest.id },
})) as any).success).toBe(true);
for (const name of ['add_journey_contributor', 'update_journey_contributor_role']) {
expect((await h.client.callTool({
name, arguments: { journeyId, targetUserId: guest.id, role: 'editor' },
})).isError).toBe(true);
}
expect((await h.client.callTool({
name: 'remove_journey_contributor', arguments: { journeyId, targetUserId: guest.id },
})).isError).toBe(true);
});
});
it('update_journey_preferences refuses a foreign journey', async () => {
const { user } = createUser(testDb);
const { journeyId } = foreignJourney();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_journey_preferences', arguments: { journeyId, hide_skeletons: true },
});
expect(result.isError).toBe(true);
});
});
});
describe('journey share-link tools', () => {
it('creates, reads and revokes the link', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h);
// No link yet — the tool answers with a null shareLink, not an error.
const empty = parseToolResult(await h.client.callTool({
name: 'get_journey_share_link', arguments: { journeyId: journey.id },
})) as any;
expect(empty.shareLink ?? null).toBeNull();
const created = parseToolResult(await h.client.callTool({
name: 'create_journey_share_link', arguments: { journeyId: journey.id },
})) as any;
expect(created.shareLink).toBeTruthy();
const read = parseToolResult(await h.client.callTool({
name: 'get_journey_share_link', arguments: { journeyId: journey.id },
})) as any;
expect(read.shareLink).toBeTruthy();
const revoked = parseToolResult(await h.client.callTool({
name: 'delete_journey_share_link', arguments: { journeyId: journey.id },
})) as any;
expect(revoked.success).toBe(true);
});
});
it('every share tool refuses a foreign journey', async () => {
const { user } = createUser(testDb);
const { journeyId } = foreignJourney();
await withHarness(user.id, async (h) => {
for (const name of ['get_journey_share_link', 'create_journey_share_link', 'delete_journey_share_link']) {
expect((await h.client.callTool({ name, arguments: { journeyId } })).isError).toBe(true);
}
});
});
});
describe('demo mode', () => {
// The read tools are deliberately NOT demo-gated, matching the legacy
// registrar: only the writes and the share-link mutations refuse.
const WRITES: Array<[string, Record<string, unknown>]> = [
['create_journey', { title: 'X' }],
['update_journey', { journeyId: 1, title: 'X' }],
['delete_journey', { journeyId: 1 }],
['add_journey_trip', { journeyId: 1, tripId: 1 }],
['remove_journey_trip', { journeyId: 1, tripId: 1 }],
['create_journey_entry', { journeyId: 1, entry_date: '2026-07-01' }],
['update_journey_entry', { entryId: 1, title: 'X' }],
['delete_journey_entry', { entryId: 1 }],
['reorder_journey_entries', { journeyId: 1, orderedIds: [1] }],
['add_journey_contributor', { journeyId: 1, targetUserId: 2, role: 'editor' }],
['update_journey_contributor_role', { journeyId: 1, targetUserId: 2, role: 'viewer' }],
['remove_journey_contributor', { journeyId: 1, targetUserId: 2 }],
['update_journey_preferences', { journeyId: 1, hide_skeletons: true }],
['create_journey_share_link', { journeyId: 1 }],
['delete_journey_share_link', { journeyId: 1 }],
];
it.each(WRITES)('%s is blocked for a demo user', async (name, args) => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name, arguments: args });
expect(result.isError).toBe(true);
});
});
it('list_journeys still answers for a demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const data = parseToolResult(await h.client.callTool({ name: 'list_journeys', arguments: {} })) as any;
expect(Array.isArray(data.journeys)).toBe(true);
});
});
});
@@ -1,5 +1,5 @@
/**
* Unit tests for journeyShareService — JOURNEY-SHARE-001 through JOURNEY-SHARE-018.
* Unit tests for JourneyShareService — JOURNEY-SHARE-001 through JOURNEY-SHARE-018.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -34,14 +34,15 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createJourney, createJourneyEntry } from '../../helpers/factories';
import {
createOrUpdateJourneyShareLink,
getJourneyShareLink,
deleteJourneyShareLink,
validateShareTokenForPhoto,
validateShareTokenForAsset,
getPublicJourney,
} from '../../../src/services/journeyShareService';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { JourneyShareService } from '../../../src/nest/journey/journey-share.service';
import { db as dbConn } from '../../../src/db/database';
const dbs = new DatabaseService(dbConn);
const svc = new JourneyShareService(dbs, new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)));
beforeAll(() => {
createTables(testDb);
@@ -100,7 +101,7 @@ describe('createOrUpdateJourneyShareLink', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const result = createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect(result.created).toBe(true);
expect(result.token).toBeTruthy();
@@ -111,13 +112,13 @@ describe('createOrUpdateJourneyShareLink', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
createOrUpdateJourneyShareLink(journey.id, user.id, {
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
});
const link = getJourneyShareLink(journey.id);
const link = svc.getJourneyShareLink(journey.id);
expect(link).not.toBeNull();
expect(link!.share_timeline).toBe(true);
expect(link!.share_gallery).toBe(false);
@@ -128,12 +129,12 @@ describe('createOrUpdateJourneyShareLink', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const first = createOrUpdateJourneyShareLink(journey.id, user.id, {
const first = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: true,
share_map: true,
});
const second = createOrUpdateJourneyShareLink(journey.id, user.id, {
const second = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
@@ -142,7 +143,7 @@ describe('createOrUpdateJourneyShareLink', () => {
expect(second.created).toBe(false);
expect(second.token).toBe(first.token);
const link = getJourneyShareLink(journey.id);
const link = svc.getJourneyShareLink(journey.id);
expect(link!.share_gallery).toBe(false);
expect(link!.share_map).toBe(false);
});
@@ -152,8 +153,8 @@ describe('createOrUpdateJourneyShareLink', () => {
const j1 = createJourney(testDb, user.id);
const j2 = createJourney(testDb, user.id);
const r1 = createOrUpdateJourneyShareLink(j1.id, user.id, {});
const r2 = createOrUpdateJourneyShareLink(j2.id, user.id, {});
const r1 = svc.createOrUpdateJourneyShareLink(j1.id, user.id, {});
const r2 = svc.createOrUpdateJourneyShareLink(j2.id, user.id, {});
expect(r1.token).not.toBe(r2.token);
});
@@ -164,7 +165,7 @@ describe('getJourneyShareLink', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const result = getJourneyShareLink(journey.id);
const result = svc.getJourneyShareLink(journey.id);
expect(result).toBeNull();
});
@@ -172,13 +173,13 @@ describe('getJourneyShareLink', () => {
it('JOURNEY-SHARE-006: returns share link info when it exists', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
createOrUpdateJourneyShareLink(journey.id, user.id, {
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: true,
});
const result = getJourneyShareLink(journey.id);
const result = svc.getJourneyShareLink(journey.id);
expect(result).not.toBeNull();
expect(result!.token).toBeTruthy();
@@ -193,19 +194,19 @@ describe('deleteJourneyShareLink', () => {
it('JOURNEY-SHARE-007: owner can remove an existing share link', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
createOrUpdateJourneyShareLink(journey.id, user.id, {});
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const ok = deleteJourneyShareLink(journey.id, user.id);
const ok = svc.deleteJourneyShareLink(journey.id, user.id);
expect(ok).toBe(true);
expect(getJourneyShareLink(journey.id)).toBeNull();
expect(svc.getJourneyShareLink(journey.id)).toBeNull();
});
it('JOURNEY-SHARE-008: does not throw when deleting non-existent link', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
expect(() => deleteJourneyShareLink(journey.id, user.id)).not.toThrow();
expect(() => svc.deleteJourneyShareLink(journey.id, user.id)).not.toThrow();
});
});
@@ -215,9 +216,9 @@ describe('validateShareTokenForPhoto', () => {
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = validateShareTokenForPhoto(token, photoId);
const result = svc.validateShareTokenForPhoto(token, photoId);
expect(result).not.toBeNull();
expect(result!.journeyId).toBe(journey.id);
@@ -225,7 +226,7 @@ describe('validateShareTokenForPhoto', () => {
});
it('JOURNEY-SHARE-010: returns null for invalid token', () => {
const result = validateShareTokenForPhoto('nonexistent-token', 1);
const result = svc.validateShareTokenForPhoto('nonexistent-token', 1);
expect(result).toBeNull();
});
@@ -235,9 +236,9 @@ describe('validateShareTokenForPhoto', () => {
const journey2 = createJourney(testDb, user.id);
const entry2 = createJourneyEntry(testDb, journey2.id, user.id);
const photoId = insertJourneyPhoto(entry2.id);
const { token } = createOrUpdateJourneyShareLink(journey1.id, user.id, {});
const { token } = svc.createOrUpdateJourneyShareLink(journey1.id, user.id, {});
const result = validateShareTokenForPhoto(token, photoId);
const result = svc.validateShareTokenForPhoto(token, photoId);
expect(result).toBeNull();
});
@@ -247,9 +248,9 @@ describe('validateShareTokenForPhoto', () => {
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = insertJourneyPhoto(entry.id, { ownerId: undefined });
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = validateShareTokenForPhoto(token, photoId);
const result = svc.validateShareTokenForPhoto(token, photoId);
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
@@ -261,9 +262,9 @@ describe('validateShareTokenForPhoto', () => {
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
expect(validateShareTokenForPhoto(token, photoId)).toBeNull();
expect(svc.validateShareTokenForPhoto(token, photoId)).toBeNull();
});
it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', () => {
@@ -281,10 +282,10 @@ describe('validateShareTokenForPhoto', () => {
// This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1
const trekPhotoId = insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id });
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// photoId = trek_photos.id (6), not journey_photos.id (1)
const result = validateShareTokenForPhoto(token, trekPhotoId);
const result = svc.validateShareTokenForPhoto(token, trekPhotoId);
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
@@ -298,16 +299,16 @@ describe('validateShareTokenForAsset', () => {
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
insertJourneyPhoto(entry.id, { assetId: 'immich-asset-123', ownerId: user.id });
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = validateShareTokenForAsset(token, 'immich-asset-123');
const result = svc.validateShareTokenForAsset(token, 'immich-asset-123');
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-014: returns null for invalid token', () => {
const result = validateShareTokenForAsset('bad-token', 'some-asset');
const result = svc.validateShareTokenForAsset('bad-token', 'some-asset');
expect(result).toBeNull();
});
@@ -317,20 +318,20 @@ describe('validateShareTokenForAsset', () => {
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
insertJourneyPhoto(entry.id, { assetId: 'immich-asset-999', ownerId: user.id });
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
expect(validateShareTokenForAsset(token, 'immich-asset-999')).toBeNull();
expect(svc.validateShareTokenForAsset(token, 'immich-asset-999')).toBeNull();
});
it('JOURNEY-SHARE-015: denies (returns null) when the asset is not part of the shared journey', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// A valid share token must NOT resolve arbitrary asset IDs to the owner —
// otherwise it could proxy any asset out of the owner's Immich/Synology
// library (IDOR). Only assets actually in the journey may resolve.
const result = validateShareTokenForAsset(token, 'nonexistent-asset');
const result = svc.validateShareTokenForAsset(token, 'nonexistent-asset');
expect(result).toBeNull();
});
@@ -338,7 +339,7 @@ describe('validateShareTokenForAsset', () => {
describe('getPublicJourney', () => {
it('JOURNEY-SHARE-016: returns null for invalid token', () => {
const result = getPublicJourney('invalid-token');
const result = svc.getPublicJourney('invalid-token');
expect(result).toBeNull();
});
@@ -361,13 +362,13 @@ describe('getPublicJourney', () => {
location_name: 'Kyoto',
});
insertJourneyPhoto(entry1.id);
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: true,
share_map: false,
});
const result = getPublicJourney(token);
const result = svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.journey.title).toBe('Japan 2026');
@@ -394,9 +395,9 @@ describe('getPublicJourney', () => {
title: 'Skeleton Entry',
entry_date: '2026-01-11',
});
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = getPublicJourney(token);
const result = svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.entries).toHaveLength(1);
@@ -415,9 +416,9 @@ describe('getPublicJourney', () => {
.run(JSON.stringify(['food', 'culture']), entry.id);
insertJourneyPhoto(entry.id, { filePath: '/photos/a.jpg' });
insertJourneyPhoto(entry.id, { filePath: '/photos/b.jpg' });
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = getPublicJourney(token);
const result = svc.getPublicJourney(token);
expect(result).not.toBeNull();
const enriched = result!.entries[0];
@@ -428,9 +429,9 @@ describe('getPublicJourney', () => {
it('JOURNEY-SHARE-020: returns empty entries array for journey with no entries', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Empty Journey' });
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = getPublicJourney(token);
const result = svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.entries).toEqual([]);
@@ -447,11 +448,11 @@ describe('getPublicJourney', () => {
});
testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id);
insertJourneyPhoto(entry.id);
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: false, share_gallery: false, share_map: false,
});
const result = getPublicJourney(token)!;
const result = svc.getPublicJourney(token)!;
expect(result.entries).toEqual([]); // no timeline / story / GPS leaked
expect(result.gallery).toEqual([]); // no gallery leaked
expect(result.stats.entries).toBe(1); // counts stay accurate
@@ -464,11 +465,11 @@ describe('getPublicJourney', () => {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01', location_name: 'Paris',
});
testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id);
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: false,
});
const result = getPublicJourney(token)!;
const result = svc.getPublicJourney(token)!;
expect(result.entries).toHaveLength(1);
const e = result.entries[0] as Record<string, unknown>;
expect(e.story).toBe('notes'); // narrative present
@@ -483,11 +484,11 @@ describe('getPublicJourney', () => {
type: 'entry', title: 'Day 1', story: 'private notes', entry_date: '2026-05-01', location_name: 'Paris',
});
testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id);
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: false, share_gallery: false, share_map: true,
});
const result = getPublicJourney(token)!;
const result = svc.getPublicJourney(token)!;
expect(result.entries).toHaveLength(1);
const e = result.entries[0] as Record<string, unknown>;
expect(e.location_lat).toBe(48.8566); // coords for the map
@@ -501,11 +502,11 @@ describe('getPublicJourney', () => {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01',
});
insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: false, share_map: true,
});
const result = getPublicJourney(token)!;
const result = svc.getPublicJourney(token)!;
expect(result.gallery).toEqual([]); // gallery array withheld
expect(result.entries).toHaveLength(1);
expect((result.entries[0] as Record<string, unknown>).photos).toEqual([]); // inline photos withheld too
@@ -19,7 +19,7 @@ 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 }));
import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { JournalEntryRowsController } from '../../../src/nest/plugins/journal-entry-rows.controller';
@@ -35,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), addonsStub), runtime };
return { c: new JournalEntryRowsController(runtime, new DatabaseService(dbConn), addonsStub, { canAccessJourney } as unknown as JourneyDomainService), runtime };
}
const row = (over: Record<string, unknown> = {}) => ({ label: 'Distance', value: '12 km', ...over });
@@ -243,14 +243,14 @@ const reservationsStub = {
syncBudgetOnUpdate() {},
notifyBookingChange() {},
} as unknown as ReservationsService;
vi.mock('../../../src/services/journeyService', () => ({
// Consumed by the real assignments.service.ts module (loaded un-mocked as a
// factory constructor dep); the instance is stubbed, so this is never called.
reconcileTripSkeletons: vi.fn(),
// Journey is a constructor-injected stub since the journey fold (same behaviors
// as the old services/journeyService path mock, keyed by the service methods).
const { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } = vi.hoisted(() => ({
onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn(),
}));
const journeyStub = {
// The skeleton hooks the place writes fire (#1705).
onPlaceCreated: vi.fn(),
onPlaceUpdated: vi.fn(),
onPlaceDeleted: vi.fn(),
onPlaceCreated, onPlaceUpdated, onPlaceDeleted,
listJourneys: vi.fn((uid: number) => [{ id: 1, owner: uid }]),
// journeyId 88 = no access (listEntries self-gates to null); else returns entries
listEntries: vi.fn((journeyId: number, uid: number) => (journeyId === 88 ? null : [{ id: 10, journey_id: journeyId, author_id: uid }])),
@@ -258,7 +258,9 @@ vi.mock('../../../src/services/journeyService', () => ({
createEntry: vi.fn((journeyId: number, uid: number, data: unknown) => (journeyId === 99 ? null : { id: 120, journey_id: journeyId, created_by: uid, ...(data as object) })),
updateEntry: vi.fn((entryId: number, _uid: number, data: unknown) => (entryId === 99 ? null : { id: entryId, ...(data as object) })),
deleteEntry: vi.fn((entryId: number) => entryId !== 99),
}));
createJourney: vi.fn((uid: number, data: unknown) => ({ id: 130, owner: uid, ...(data as object) })),
deleteJourney: vi.fn((journeyId: number) => journeyId !== 99),
} as unknown as JourneyDomainService;
// Atlas is a constructor-injected stub since the atlas fold (same behaviors as
// the old services/atlasService path mock, keyed by the service method names).
const atlasStub = {
@@ -307,7 +309,6 @@ const vacayStub = {
import { PluginHostDepsFactory, type PluginCallRouter } from '../../../src/nest/plugins/host/plugin-host-deps.factory';
import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state';
import { db as mockDb } from '../../../src/db/database';
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../../../src/services/journeyService';
import type { BudgetService } from '../../../src/nest/budget/budget.service';
import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
@@ -318,6 +319,7 @@ import type { PackingService } from '../../../src/nest/packing/packing.service';
import type { DayNotesService } from '../../../src/nest/days/day-notes.service';
import type { DaysService } from '../../../src/nest/days/days.service';
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service';
import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver';
import type { FilesService } from '../../../src/nest/files/files.service';
@@ -351,7 +353,7 @@ const tripsStub = {
list: () => [{ id: 1 }],
removeMember: vi.fn(),
} as unknown as import('../../../src/nest/trips/trips.service').TripsService;
const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub, new RealtimeService(), tripsStub, placesStub, collectionsStub, atlasStub, notificationsStub, membershipStub);
const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub, new RealtimeService(), tripsStub, placesStub, collectionsStub, atlasStub, notificationsStub, membershipStub, journeyStub);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
const createRealRpcHost = (id: string, granted: ReadonlySet<string>, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);