diff --git a/nest-mcp/src/index.ts b/nest-mcp/src/index.ts index c7d2f9cc0..678d609c3 100644 --- a/nest-mcp/src/index.ts +++ b/nest-mcp/src/index.ts @@ -20,6 +20,8 @@ export { type McpAccess, type McpAccessGroup, type McpAccessGroupRegistry, + type McpAccessMode, + type McpAccessModeRegistry, type McpAccessPolicy, type McpAccessPredicate, type McpAccessValidator, diff --git a/nest-mcp/src/types.ts b/nest-mcp/src/types.ts index 6af473953..30efb7ff7 100644 --- a/nest-mcp/src/types.ts +++ b/nest-mcp/src/types.ts @@ -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 {} + * } + * + * 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. */ diff --git a/server/src/mcp/nest-mcp-policy.ts b/server/src/mcp/nest-mcp-policy.ts index ccc6814d4..c52094251 100644 --- a/server/src/mcp/nest-mcp-policy.ts +++ b/server/src/mcp/nest-mcp-policy.ts @@ -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 {} + // 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 {} } type AssertExact = [A] extends [B] ? ([B] extends [A] ? true : never) : never; @@ -36,15 +44,23 @@ type AssertExact = [A] extends [B] ? ([B] extends [A] ? true : never) : ne */ export const MCP_ACCESS_GROUPS_MATCH_SCOPE_GROUPS: AssertExact = true; +/** Same lockstep for the mode half. */ +export const MCP_ACCESS_MODES_MATCH_SCOPE_MODES: AssertExact = 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 = new Set(ALL_SCOPES); diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index dd5cedf00..da8454523 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -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 { diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 0fbd7f0ba..f63cb8ff4 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -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). diff --git a/server/src/mcp/tools/journey.ts b/server/src/mcp/tools/journey.ts deleted file mode 100644 index e81397aec..000000000 --- a/server/src/mcp/tools/journey.ts +++ /dev/null @@ -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 }); - } - ); -} diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 64434f9c0..4758d0a20 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -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 diff --git a/server/src/nest/assignments/assignments.bridge.ts b/server/src/nest/assignments/assignments.bridge.ts index d08074abb..150e624fb 100644 --- a/server/src/nest/assignments/assignments.bridge.ts +++ b/server/src/nest/assignments/assignments.bridge.ts @@ -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) { diff --git a/server/src/nest/assignments/assignments.module.ts b/server/src/nest/assignments/assignments.module.ts index d645310b6..ff590ae9f 100644 --- a/server/src/nest/assignments/assignments.module.ts +++ b/server/src/nest/assignments/assignments.module.ts @@ -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], diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index ab41ef5ea..3479e1bfe 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -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) { diff --git a/server/src/nest/journey/journey-domain.module.ts b/server/src/nest/journey/journey-domain.module.ts new file mode 100644 index 000000000..2806f4080 --- /dev/null +++ b/server/src/nest/journey/journey-domain.module.ts @@ -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 {} diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts new file mode 100644 index 000000000..b9fbe2150 --- /dev/null +++ b/server/src/nest/journey/journey-domain.service.ts @@ -0,0 +1,1346 @@ +import { Injectable } from '@nestjs/common'; +import { avatarUrl } from '../common/avatarUrl'; +import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../../types'; +import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import type { TrekWsUserEventName } from '@trek/shared'; +import { TrekPhotosRepository } from '../photos/trek-photos.repository'; + + + +// Per-journey gallery view: journey_photos → trek_photos (no entry context). +// Per-entry photo view: join journey_entry_photos → journey_photos (gallery) → trek_photos. +// id = gp.id (gallery photo id) — used by clients for linkPhoto/updatePhoto/unlink/delete. +const JP_SELECT = ` + gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at, + tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height, + tp.media_type, tp.duration_ms +`; + +const JP_JOIN = `journey_entry_photos jep + JOIN journey_photos gp ON gp.id = jep.journey_photo_id + JOIN trek_photos tp ON tp.id = gp.photo_id`; + +const GALLERY_SELECT = ` + gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at, + tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height, + tp.media_type, tp.duration_ms +`; + +const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id'; + +/** + * The journey (travel journal) domain: journeys, their trips, entries, the + * photo gallery, contributors and the trip-skeleton reconciliation. + * + * Folded 1:1 from services/journeyService.ts - every SQL statement, error and + * broadcast is the one that shipped. + */ +@Injectable() +export class JourneyDomainService { + constructor( + private readonly db: DatabaseService, + private readonly realtime: RealtimeService, + private readonly photos: TrekPhotosRepository, + ) {} + + private ts(): number { + return Date.now(); + } + + + + private broadcastJourneyEvent( + journeyId: number, + // Typed against the shared WS registry rather than left as `string`: the + // legacy broadcastToUser took anything, so a typo'd event name shipped as a + // silent no-op in the client's remoteEventHandler. + event: TrekWsUserEventName, + data: Record, + excludeSocketId?: string | number, + ) { + const contributors = this.db.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ?').all(journeyId) as { + user_id: number; + }[]; + const owner = this.db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as + | { user_id: number } + | undefined; + + const userIds = new Set(contributors.map((c) => c.user_id)); + if (owner) userIds.add(owner.user_id); + + for (const uid of userIds) { + this.realtime.broadcastToUser(uid, { type: event, journeyId, ...data }, excludeSocketId); + } + } + + // ── Access control ─────────────────────────────────────────────────────── + + canAccessJourney(journeyId: number, userId: number): Journey | null { + const own = this.db.prepare('SELECT * FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId) as + | Journey + | undefined; + if (own) return own; + const contrib = this.db + .prepare('SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?') + .get(journeyId, userId); + if (contrib) return (this.db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey) || null; + return null; + } + + isOwner(journeyId: number, userId: number): boolean { + return !!this.db.prepare('SELECT 1 FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId); + } + + canEdit(journeyId: number, userId: number): boolean { + if (this.isOwner(journeyId, userId)) return true; + const c = this.db + .prepare('SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?') + .get(journeyId, userId) as { role: string } | undefined; + return c?.role === 'editor' || c?.role === 'owner'; + } + + // ── Journey CRUD ───────────────────────────────────────────────────────── + + listJourneys(userId: number) { + return this.db + .prepare( + ` + SELECT DISTINCT j.*, + (SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count, + (SELECT COUNT(*) FROM journey_photos jp WHERE jp.journey_id = j.id) as photo_count, + (SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count, + (SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min, + (SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max + FROM journeys j + LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ? + WHERE j.user_id = ? OR jc.user_id = ? + ORDER BY j.updated_at DESC + `, + ) + .all(userId, userId, userId) as (Journey & { + entry_count: number; + photo_count: number; + place_count: number; + trip_date_min: string | null; + trip_date_max: string | null; + })[]; + } + + createJourney( + userId: number, + data: { + title: string; + subtitle?: string; + trip_ids?: number[]; + }, + ): Journey { + const now = this.ts(); + const res = this.db + .prepare( + ` + INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, ?) + `, + ) + .run(userId, data.title, data.subtitle || null, now, now); + + const journeyId = Number(res.lastInsertRowid); + + // add owner as contributor + this.db.prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)').run( + journeyId, + userId, + 'owner', + now, + ); + + // link trips and sync skeleton entries + if (data.trip_ids?.length) { + // Track the first trip that was ACTUALLY linked (addTripToJourney access-checks and + // returns false for a foreign/inaccessible trip). Inheriting the cover from a raw + // trip_ids[0] would otherwise leak an arbitrary trip's cover image cross-tenant. + let coverTripId: number | undefined; + for (const tripId of data.trip_ids) { + if (this.addTripToJourney(journeyId, tripId, userId) && coverTripId === undefined) coverTripId = tripId; + } + + if (coverTripId !== undefined) { + const firstTrip = this.db.prepare('SELECT cover_image FROM trips WHERE id = ?').get(coverTripId) as + | { cover_image: string | null } + | undefined; + if (firstTrip?.cover_image) { + // trip stores full path (/uploads/covers/x.jpg), journey stores relative (covers/x.jpg) + const relativePath = firstTrip.cover_image.replace(/^\/uploads\//, ''); + this.db.prepare('UPDATE journeys SET cover_image = ? WHERE id = ?').run(relativePath, journeyId); + } + } + } + + return this.db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; + } + + getJourneyFull(journeyId: number, userId: number) { + const journey = this.canAccessJourney(journeyId, userId); + if (!journey) return null; + + const entries = this.db + .prepare('SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC') + .all(journeyId) as JourneyEntry[]; + + const photos = this.db + .prepare( + `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`, + ) + .all(journeyId) as JourneyPhoto[]; + + // group photos by entry + const photosByEntry: Record = {}; + for (const p of photos) { + (photosByEntry[p.entry_id] ||= []).push(p); + } + + const gallery = this.db + .prepare( + `SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? ORDER BY gp.sort_order ASC, gp.id ASC`, + ) + .all(journeyId); + + 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] || [], + source_trip_name: e.source_trip_id + ? (this.db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined) + ?.title || null + : null, + })); + + // linked trips + const trips = this.db + .prepare( + ` + SELECT jt.trip_id, jt.added_at, t.title, t.start_date, t.end_date, t.cover_image, t.currency, + (SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count + FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id + WHERE jt.journey_id = ? ORDER BY t.start_date ASC + `, + ) + .all(journeyId); + + // contributors + const contributorsRaw = this.db + .prepare( + ` + SELECT jc.journey_id, jc.user_id, jc.role, jc.added_at, u.username, u.avatar + FROM journey_contributors jc JOIN users u ON jc.user_id = u.id + WHERE jc.journey_id = ? ORDER BY jc.added_at + `, + ) + .all(journeyId) as any[]; + const contributors = contributorsRaw.map((c) => ({ + ...c, + avatar_url: avatarUrl(c), + })); + + // stats + const entryCount = entries.filter((e) => e.type === 'entry').length; + const photoCount = (gallery as any[]).length; + const places = [...new Set(entries.map((e) => e.location_name).filter(Boolean))]; + + const userPrefs = this.db + .prepare('SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?') + .get(journeyId, userId) as { hide_skeletons: number } | undefined; + + // Determine the viewer's role on this journey so the UI can gate edit/settings + // actions. 'owner' = creator, 'editor' | 'viewer' = from journey_contributors. + const journeyRow = journey as unknown as { user_id?: number }; + let myRole: 'owner' | 'editor' | 'viewer' | null; + if (journeyRow.user_id === userId) { + myRole = 'owner'; + } else { + const contribRow = this.db + .prepare('SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?') + .get(journeyId, userId) as { role: 'editor' | 'viewer' } | undefined; + myRole = contribRow?.role ?? null; + } + + return { + ...journey, + entries: enrichedEntries, + gallery, + trips, + contributors, + stats: { entries: entryCount, photos: photoCount, places: places.length }, + hide_skeletons: !!userPrefs?.hide_skeletons, + my_role: myRole, + }; + } + + updateJourney( + journeyId: number, + userId: number, + data: Partial<{ + title: string; + subtitle: string; + cover_gradient: string; + cover_image: string; + status: string; + }>, + ): Journey | null { + // Journey-level settings (title, cover, status) are owner-only — editors + // may only edit entries and photos, not reshape the journey itself. + if (!this.isOwner(journeyId, userId)) return null; + + const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived']; + const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status']; + const fields: string[] = []; + const values: unknown[] = []; + for (const [key, val] of Object.entries(data)) { + if (val !== undefined && allowed.includes(key)) { + if (key === 'status' && !ALLOWED_STATUSES.includes(val as string)) continue; + fields.push(`${key} = ?`); + values.push(val); + } + } + if (fields.length === 0) return this.db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; + + fields.push('updated_at = ?'); + values.push(this.ts()); + values.push(journeyId); + this.db.prepare(`UPDATE journeys SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return this.db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; + } + + updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) { + if (!this.canAccessJourney(journeyId, userId)) return null; + if (data.hide_skeletons !== undefined) { + this.db.prepare('UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?').run( + data.hide_skeletons ? 1 : 0, + journeyId, + userId, + ); + } + const row = this.db + .prepare('SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?') + .get(journeyId, userId) as { hide_skeletons: number }; + return { hide_skeletons: !!row.hide_skeletons }; + } + + deleteJourney(journeyId: number, userId: number): boolean { + if (!this.isOwner(journeyId, userId)) return false; + this.db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId); + return true; + } + + // ── Trip management ────────────────────────────────────────────────────── + + addTripToJourney(journeyId: number, tripId: number, userId: number): boolean { + // Only attach a trip the caller can actually access — otherwise a journey + // owner could pull an arbitrary trip's places + photos into their journey + // (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped + // path enforces. + if (!this.db.canAccessTrip(tripId, userId)) return false; + const now = this.ts(); + try { + this.db.prepare('INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run( + journeyId, + tripId, + now, + ); + } catch { + return false; + } + + // sync skeleton entries for all places in this trip + this.syncTripPlaces(journeyId, tripId, userId); + // import existing trip photos (Immich/Synology) with sharing settings + this.syncTripPhotos(journeyId, tripId); + this.broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }); + return true; + } + + removeTripFromJourney(journeyId: number, tripId: number, userId: number): boolean { + if (!this.isOwner(journeyId, userId)) return false; + + // remove skeleton entries that haven't been filled in + this.db.prepare( + ` + DELETE FROM journey_entries + WHERE journey_id = ? AND source_trip_id = ? AND type = 'skeleton' + `, + ).run(journeyId, tripId); + + // detach filled entries from this trip + this.db.prepare( + ` + UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL + WHERE journey_id = ? AND source_trip_id = ? AND type != 'skeleton' + `, + ).run(journeyId, tripId); + + this.db.prepare('DELETE FROM journey_trips WHERE journey_id = ? AND trip_id = ?').run(journeyId, tripId); + return true; + } + + // ── Sync engine ────────────────────────────────────────────────────────── + + syncTripPlaces(journeyId: number, tripId: number, authorId: number) { + const places = this.db + .prepare( + ` + SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number + FROM places p + INNER JOIN day_assignments da ON da.place_id = p.id + INNER JOIN days d ON da.day_id = d.id + WHERE p.trip_id = ? + ORDER BY d.day_number ASC, da.order_index ASC + `, + ) + .all(tripId) as any[]; + + const now = this.ts(); + const existing = this.db + .prepare('SELECT source_place_id FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?') + .all(journeyId, tripId) as { source_place_id: number }[]; + const existingPlaceIds = new Set(existing.map((e) => e.source_place_id)); + + // Track next sort_order per date so synced skeletons get unique, sequential positions. + const dateMaxOrder = new Map(); + const maxRows = this.db + .prepare( + 'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date', + ) + .all(journeyId) as { entry_date: string; m: number }[]; + for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m); + + for (const place of places) { + if (existingPlaceIds.has(place.id)) continue; + existingPlaceIds.add(place.id); + + const entryDate = place.day_date || new Date().toISOString().split('T')[0]; + const entryTime = place.assignment_time || place.place_time || null; + const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1; + dateMaxOrder.set(entryDate, nextOrder); + + this.insertSkeletonEntry({ + journeyId, + tripId, + placeId: place.id, + authorId, + title: place.name, + entryDate, + entryTime, + locationName: place.address || place.name, + lat: place.lat || null, + lng: place.lng || null, + sortOrder: nextOrder, + now, + }); + } + } + + // import trip_photos into journey gallery when a trip is linked + private syncTripPhotos(journeyId: number, tripId: number) { + const tripPhotos = this.db + .prepare('SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?') + .all(tripId) as { photo_id: number; shared: number }[]; + if (!tripPhotos.length) return; + + const now = this.ts(); + const maxOrderRow = this.db + .prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?') + .get(journeyId) as { m: number | null }; + let nextOrder = (maxOrderRow?.m ?? -1) + 1; + + for (const tp of tripPhotos) { + this.db.prepare( + ` + INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at) + VALUES (?, ?, ?, ?, ?) + `, + ).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now); + } + } + + // called when a trip place is created + onPlaceCreated(tripId: number, placeId: number) { + const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { + journey_id: number; + }[]; + if (!links.length) return; + + const place = this.db + .prepare( + ` + SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number + FROM places p + INNER JOIN day_assignments da ON da.place_id = p.id + INNER JOIN days d ON da.day_id = d.id + WHERE p.id = ? + `, + ) + .get(placeId) as any; + if (!place) return; // not assigned to a day yet — skip + + const now = this.ts(); + for (const link of links) { + const already = this.db + .prepare('SELECT 1 FROM journey_entries WHERE journey_id = ? AND source_place_id = ?') + .get(link.journey_id, placeId); + if (already) continue; + + const journey = this.db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number }; + const entryDate = place.day_date; + const maxOrder = this.db + .prepare('SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?') + .get(link.journey_id, entryDate) as { m: number | null }; + const nextOrder = (maxOrder?.m ?? -1) + 1; + + this.insertSkeletonEntry({ + journeyId: link.journey_id, + tripId, + placeId, + authorId: journey.user_id, + title: place.name, + entryDate, + entryTime: place.assignment_time || place.place_time || null, + locationName: place.address || place.name, + lat: place.lat || null, + lng: place.lng || null, + sortOrder: nextOrder, + now, + }); + } + } + + // called when a trip place is updated + onPlaceUpdated(placeId: number) { + const entries = this.db.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(placeId) as JourneyEntry[]; + if (!entries.length) return; + + const place = this.db + .prepare( + ` + SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number + FROM places p + LEFT JOIN day_assignments da ON da.place_id = p.id + LEFT JOIN days d ON da.day_id = d.id + WHERE p.id = ? + `, + ) + .get(placeId) as any; + if (!place) return; + + const now = this.ts(); + for (const entry of entries) { + if (entry.type === 'skeleton') { + // update everything on skeletons + this.db.prepare( + ` + UPDATE journey_entries SET title = ?, entry_date = ?, entry_time = ?, location_name = ?, location_lat = ?, location_lng = ?, updated_at = ? + WHERE id = ? + `, + ).run( + place.name, + place.day_date || entry.entry_date, + place.assignment_time || place.place_time || entry.entry_time, + place.address || place.name, + place.lat || null, + place.lng || null, + now, + entry.id, + ); + } else { + // for filled entries, only update location silently + this.db.prepare( + ` + UPDATE journey_entries SET location_name = ?, location_lat = ?, location_lng = ?, updated_at = ? + WHERE id = ? + `, + ).run(place.address || place.name, place.lat || null, place.lng || null, now, entry.id); + } + } + } + + // called when a trip place is deleted + onPlaceDeleted(placeId: number) { + const entries = this.db.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(placeId) as JourneyEntry[]; + + for (const entry of entries) { + if (entry.type === 'skeleton') { + // no content: just delete + const hasPhotos = this.db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(entry.id); + if (!hasPhotos && !entry.story) { + this.db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id); + continue; + } + } + // entry has content: keep it, detach, add note + const note = '\n\n> _Note: the original trip place was removed from the trip plan_'; + const newStory = (entry.story || '') + note; + this.db.prepare( + 'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?', + ).run(entry.type === 'skeleton' ? 'entry' : entry.type, newStory, this.ts(), entry.id); + } + } + + // Shared skeleton INSERT, reused by syncTripPlaces / onPlaceCreated / reconcileTripSkeletons. + private insertSkeletonEntry(p: { + journeyId: number; + tripId: number; + placeId: number; + authorId: number; + title: string; + entryDate: string; + entryTime: string | null; + locationName: string; + lat: number | null; + lng: number | null; + sortOrder: number; + now: number; + }) { + this.db.prepare( + ` + INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + p.journeyId, + p.tripId, + p.placeId, + p.authorId, + p.title, + p.entryDate, + p.entryTime, + p.locationName, + p.lat, + p.lng, + p.sortOrder, + p.now, + p.now, + ); + } + + // Make every journey linked to `tripId` mirror the trip's currently day-assigned + // places: add skeletons for newly-assigned places, refresh skeleton snapshots when a + // place is moved to another day / its time changes, and drop skeletons for places no + // longer assigned. Filled entries are never destroyed — only detached + annotated, + // mirroring onPlaceDeleted. Idempotent: a second call with no underlying change is a + // no-op (no writes, no broadcast). Called from every assignment mutation path. + reconcileTripSkeletons(tripId: number, sid?: string | number) { + const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { + journey_id: number; + }[]; + if (!links.length) return; + + const places = this.db + .prepare( + ` + SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number, da.order_index + FROM places p + INNER JOIN day_assignments da ON da.place_id = p.id + INNER JOIN days d ON da.day_id = d.id + WHERE p.trip_id = ? + ORDER BY d.day_number ASC, da.order_index ASC + `, + ) + .all(tripId) as any[]; + + // One skeleton per place (a place on multiple days keeps its first-by-day/order row), + // matching the one-skeleton-per-place model used by onPlaceCreated. + const placeById = new Map(); + for (const place of places) if (!placeById.has(place.id)) placeById.set(place.id, place); + const assignedPlaceIds = new Set(placeById.keys()); + + const now = this.ts(); + for (const { journey_id } of links) { + const journey = this.db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journey_id) as + | { user_id: number } + | undefined; + if (!journey) continue; + + let changed = false; + const existing = this.db + .prepare( + `SELECT id, source_place_id, type, story, title, entry_date, entry_time, location_name, location_lat, location_lng + FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?`, + ) + .all(journey_id, tripId) as { + id: number; + source_place_id: number | null; + type: string; + story: string | null; + title: string | null; + entry_date: string | null; + entry_time: string | null; + location_name: string | null; + location_lat: number | null; + location_lng: number | null; + }[]; + const existingByPlace = new Map(); + for (const e of existing) if (e.source_place_id != null) existingByPlace.set(e.source_place_id, e); + + // Next sort_order per date for freshly inserted skeletons. + const dateMaxOrder = new Map(); + const maxRows = this.db + .prepare( + 'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date', + ) + .all(journey_id) as { entry_date: string; m: number }[]; + for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m); + + // 1) Upsert a skeleton for every currently-assigned place. + for (const place of placeById.values()) { + const entryDate = place.day_date || new Date().toISOString().split('T')[0]; + const entryTime = place.assignment_time || place.place_time || null; + const locationName = place.address || place.name; + const lat = place.lat || null; + const lng = place.lng || null; + const found = existingByPlace.get(place.id); + + if (!found) { + const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1; + dateMaxOrder.set(entryDate, nextOrder); + this.insertSkeletonEntry({ + journeyId: journey_id, + tripId, + placeId: place.id, + authorId: journey.user_id, + title: place.name, + entryDate, + entryTime, + locationName, + lat, + lng, + sortOrder: nextOrder, + now, + }); + changed = true; + } else if (found.type === 'skeleton') { + // Skeletons follow the place's day/time/location snapshot. + const stale = + found.title !== place.name || + found.entry_date !== entryDate || + found.entry_time !== entryTime || + found.location_name !== locationName || + found.location_lat !== lat || + found.location_lng !== lng; + if (stale) { + this.db.prepare( + `UPDATE journey_entries SET title = ?, entry_date = ?, entry_time = ?, location_name = ?, location_lat = ?, location_lng = ?, updated_at = ? WHERE id = ?`, + ).run(place.name, entryDate, entryTime, locationName, lat, lng, now, found.id); + changed = true; + } + } else { + // Filled entries keep the user's date/story; only location follows the place. + const stale = + found.location_name !== locationName || found.location_lat !== lat || found.location_lng !== lng; + if (stale) { + this.db.prepare( + `UPDATE journey_entries SET location_name = ?, location_lat = ?, location_lng = ?, updated_at = ? WHERE id = ?`, + ).run(locationName, lat, lng, now, found.id); + changed = true; + } + } + } + + // 2) Drop skeletons whose place is no longer assigned to a day in this trip. + for (const e of existing) { + if (e.source_place_id == null || assignedPlaceIds.has(e.source_place_id)) continue; + if (e.type === 'skeleton') { + const hasPhotos = this.db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(e.id); + if (!hasPhotos && !e.story) { + this.db.prepare('DELETE FROM journey_entries WHERE id = ?').run(e.id); + changed = true; + continue; + } + } + const note = '\n\n> _Note: the original trip place was removed from the trip plan_'; + const newStory = (e.story || '') + note; + this.db.prepare( + 'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?', + ).run(e.type === 'skeleton' ? 'entry' : e.type, newStory, now, e.id); + changed = true; + } + + if (changed) this.broadcastJourneyEvent(journey_id, 'journey:trip:synced', { tripId }, sid); + } + } + + // ── Entries ────────────────────────────────────────────────────────────── + + listEntries(journeyId: number, userId: number) { + if (!this.canAccessJourney(journeyId, userId)) return null; + + const entries = this.db + .prepare('SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC') + .all(journeyId) as JourneyEntry[]; + + const photos = this.db + .prepare( + `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`, + ) + .all(journeyId) as JourneyPhoto[]; + + const photosByEntry: Record = {}; + for (const p of photos) { + (photosByEntry[p.entry_id] ||= []).push(p); + } + + return 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] || [], + source_trip_name: e.source_trip_id + ? (this.db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined) + ?.title || null + : null, + })); + } + + createEntry( + journeyId: number, + userId: number, + data: { + type?: string; + title?: string; + story?: string; + entry_date: string; + entry_time?: string; + location_name?: string; + location_lat?: number; + location_lng?: number; + mood?: string; + weather?: string; + tags?: string[]; + pros_cons?: { pros: string[]; cons: string[] }; + visibility?: string; + sort_order?: number; + }, + sid?: string, + ): JourneyEntry | null { + if (!this.canEdit(journeyId, userId)) return null; + + const now = this.ts(); + const maxOrder = this.db + .prepare('SELECT MAX(sort_order) as m FROM journey_entries WHERE journey_id = ? AND entry_date = ?') + .get(journeyId, data.entry_date) as { m: number | null }; + + const prosConsJson = + data.pros_cons && (data.pros_cons.pros.length || data.pros_cons.cons.length) + ? JSON.stringify(data.pros_cons) + : null; + + const res = this.db + .prepare( + ` + INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + journeyId, + userId, + data.type || 'entry', + data.title || null, + data.story || null, + data.entry_date, + data.entry_time || null, + data.location_name || null, + data.location_lat ?? null, + data.location_lng ?? null, + data.mood || null, + data.weather || null, + data.tags?.length ? JSON.stringify(data.tags) : null, + prosConsJson, + data.visibility || 'private', + (maxOrder?.m ?? -1) + 1, + now, + now, + ); + + const created = this.db + .prepare('SELECT * FROM journey_entries WHERE id = ?') + .get(Number(res.lastInsertRowid)) as JourneyEntry; + this.broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid); + return created; + } + + updateEntry( + entryId: number, + userId: number, + data: Partial<{ + type: string; + title: string; + story: string; + entry_date: string; + entry_time: string; + location_name: string; + location_lat: number; + location_lng: number; + mood: string; + weather: string; + tags: string[]; + pros_cons: { pros: string[]; cons: string[] }; + visibility: string; + sort_order: number; + }>, + sid?: string, + ): JourneyEntry | null { + const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + if (!entry) return null; + if (!this.canEdit(entry.journey_id, userId)) return null; + + const fields: string[] = []; + const values: unknown[] = []; + + // Allow-list the columns a client may set: keys come from the request body + // and are interpolated as SQL column names, so restrict them to the known + // entry fields. Keep this in sync with the data type above. + const allowed = new Set([ + 'type', + 'title', + 'story', + 'entry_date', + 'entry_time', + 'location_name', + 'location_lat', + 'location_lng', + 'mood', + 'weather', + 'tags', + 'pros_cons', + 'visibility', + 'sort_order', + ]); + + for (const [key, val] of Object.entries(data)) { + if (val === undefined) continue; + if (!allowed.has(key)) continue; + if (key === 'tags') { + fields.push('tags = ?'); + values.push(Array.isArray(val) ? JSON.stringify(val) : val); + } else if (key === 'pros_cons') { + fields.push('pros_cons = ?'); + values.push(val && typeof val === 'object' ? JSON.stringify(val) : val); + } else { + fields.push(`${key} = ?`); + values.push(val); + } + } + + // if adding story to a skeleton, promote to entry + if (entry.type === 'skeleton' && data.story && data.story.trim()) { + fields.push('type = ?'); + values.push('entry'); + } + + if (fields.length === 0) return entry; + + fields.push('updated_at = ?'); + values.push(this.ts()); + values.push(entryId); + this.db.prepare(`UPDATE journey_entries SET ${fields.join(', ')} WHERE id = ?`).run(...values); + + // touch the journey + this.db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(this.ts(), entry.journey_id); + + const updated = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry; + this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid); + return updated; + } + + // Reorder entries (typically within a single day). Caller passes the new + // desired order of ids; each entry's sort_order is set to its index in the + // array. Only entries owned by this journey are accepted. + reorderEntries(journeyId: number, userId: number, orderedIds: number[], sid?: string): boolean { + if (!this.canEdit(journeyId, userId)) return false; + if (!orderedIds.length) return true; + + const placeholders = orderedIds.map(() => '?').join(','); + const rows = this.db + .prepare(`SELECT id FROM journey_entries WHERE id IN (${placeholders}) AND journey_id = ?`) + .all(...orderedIds, journeyId) as { id: number }[]; + if (rows.length !== orderedIds.length) return false; + + const now = this.ts(); + const update = this.db.prepare('UPDATE journey_entries SET sort_order = ?, updated_at = ? WHERE id = ?'); + const tx = this.db.connection.transaction(() => { + orderedIds.forEach((id, index) => update.run(index, now, id)); + this.db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(now, journeyId); + }); + tx(); + + this.broadcastJourneyEvent(journeyId, 'journey:entries:reordered', { orderedIds }, sid); + return true; + } + + deleteEntry(entryId: number, userId: number, sid?: string): boolean { + const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + if (!entry) return false; + if (!this.canEdit(entry.journey_id, userId)) return false; + + if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') { + // Revert filled entry back to skeleton instead of deleting + this.db.prepare( + ` + UPDATE journey_entries + SET type = 'skeleton', story = NULL, mood = NULL, weather = NULL, pros_cons = NULL, + visibility = 'private', updated_at = ? + WHERE id = ? + `, + ).run(this.ts(), entryId); + this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid); + } else { + this.db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); + this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); + } + + return true; + } + + // ── Photos ─────────────────────────────────────────────────────────────── + + // Promote a skeleton suggestion to a concrete entry. Called whenever the user + // adds content (photo upload, provider photo, gallery link) — a suggestion + // with photos is no longer just a suggestion. + private promoteSkeletonIfNeeded(entry: JourneyEntry): void { + if (entry.type !== 'skeleton') return; + this.db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', this.ts(), entry.id); + } + + // Ensure a trek_photo_id is in the journey gallery; return its gallery row id. + private ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): number { + const now = this.ts(); + const maxOrderRow = this.db + .prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?') + .get(journeyId) as { m: number | null }; + this.db.prepare( + ` + INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, shared, sort_order, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `, + ).run(journeyId, trekPhotoId, caption || null, shared ?? 0, (maxOrderRow?.m ?? -1) + 1, now); + const row = this.db + .prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?') + .get(journeyId, trekPhotoId) as { id: number }; + return row.id; + } + + // Link a gallery photo to an entry (idempotent). Returns the junction JP_SELECT row. + private linkGalleryPhotoToEntry(galleryId: number, entryId: number): JourneyPhoto | null { + const now = this.ts(); + const maxOrderRow = this.db + .prepare('SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?') + .get(entryId) as { m: number | null }; + this.db.prepare( + ` + INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at) + VALUES (?, ?, ?, ?) + `, + ).run(entryId, galleryId, (maxOrderRow?.m ?? -1) + 1, now); + return this.db + .prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id = ? AND jep.journey_photo_id = ?`) + .get(entryId, galleryId) as JourneyPhoto | null; + } + + addPhoto( + entryId: number, + userId: number, + filePath: string, + thumbnailPath?: string, + caption?: string, + ): JourneyPhoto | null { + const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + if (!entry) return null; + if (!this.canEdit(entry.journey_id, userId)) return null; + + const trekPhotoId = this.photos.getOrCreateLocal(filePath, thumbnailPath); + const galleryId = this.db.connection.transaction(() => this.ensureInGallery(entry.journey_id, trekPhotoId, caption))(); + const result = this.linkGalleryPhotoToEntry(galleryId, entryId); + this.promoteSkeletonIfNeeded(entry); + return result; + } + + addProviderPhoto( + entryId: number, + userId: number, + provider: string, + assetId: string, + caption?: string, + passphrase?: string, + mediaType: string = 'image', + ): JourneyPhoto | null { + const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + if (!entry) return null; + if (!this.canEdit(entry.journey_id, userId)) return null; + + const trekPhotoId = this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType); + + // skip if this photo is already linked to this entry + const alreadyLinked = this.db + .prepare( + ` + SELECT 1 FROM journey_entry_photos jep + JOIN journey_photos gp ON gp.id = jep.journey_photo_id + WHERE jep.entry_id = ? AND gp.photo_id = ? + `, + ) + .get(entryId, trekPhotoId); + if (alreadyLinked) return null; + + const galleryId = this.db.connection.transaction(() => this.ensureInGallery(entry.journey_id, trekPhotoId, caption))(); + const result = this.linkGalleryPhotoToEntry(galleryId, entryId); + this.promoteSkeletonIfNeeded(entry); + return result; + } + + // Link a gallery photo (by its journey_photos.id) to an entry — idempotent. + linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): JourneyPhoto | null { + const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + if (!entry) return null; + if (!this.canEdit(entry.journey_id, userId)) return null; + + // Verify the gallery photo belongs to this journey + const galleryRow = this.db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(journeyPhotoId) as + | { id: number; journey_id: number } + | undefined; + if (!galleryRow || galleryRow.journey_id !== entry.journey_id) return null; + + const result = this.linkGalleryPhotoToEntry(galleryRow.id, entryId); + this.promoteSkeletonIfNeeded(entry); + return result; + } + + // Upload photos to the journey gallery only (no entry association). + uploadGalleryPhotos( + journeyId: number, + userId: number, + filePaths: { path: string; thumbnail?: string; mediaType?: string; durationMs?: number | null }[], + ): JourneyPhoto[] { + if (!this.canEdit(journeyId, userId)) return []; + const results: any[] = []; + const now = this.ts(); + const maxOrderRow = this.db + .prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?') + .get(journeyId) as { m: number | null }; + let nextOrder = (maxOrderRow?.m ?? -1) + 1; + + for (const f of filePaths) { + const trekPhotoId = this.photos.getOrCreateLocal(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null); + this.db.prepare( + ` + INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at) + VALUES (?, ?, 0, ?, ?) + `, + ).run(journeyId, trekPhotoId, nextOrder++, now); + const row = this.db + .prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? AND gp.photo_id = ?`) + .get(journeyId, trekPhotoId); + if (row) results.push(row); + } + return results; + } + + // Add a provider photo to the gallery only (no entry link). + addProviderPhotoToGallery( + journeyId: number, + userId: number, + provider: string, + assetId: string, + caption?: string, + passphrase?: string, + mediaType: string = 'image', + ): any | null { + if (!this.canEdit(journeyId, userId)) return null; + const trekPhotoId = this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType); + const galleryId = this.db.connection.transaction(() => this.ensureInGallery(journeyId, trekPhotoId, caption))(); + return this.db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null; + } + + // Unlink a photo from a specific entry; gallery row is preserved. + unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): boolean { + const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + if (!entry) return false; + if (!this.canEdit(entry.journey_id, userId)) return false; + + const result = this.db + .prepare('DELETE FROM journey_entry_photos WHERE entry_id = ? AND journey_photo_id = ?') + .run(entryId, journeyPhotoId); + return result.changes > 0; + } + + // Hard-delete a gallery photo (removes from all entries and the gallery). + deleteGalleryPhoto( + journeyPhotoId: number, + userId: number, + ): { photo_id: number; file_path?: string | null } | null { + const row = this.db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as + | { id: number; journey_id: number; photo_id: number } + | undefined; + if (!row) return null; + if (!this.canEdit(row.journey_id, userId)) return null; + + const trekRow = this.db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as + | { file_path?: string; provider?: string } + | undefined; + + // cascade on journey_entry_photos.journey_photo_id handles junction cleanup + this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId); + this.photos.deleteIfOrphan(row.photo_id); + + return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null }; + } + + setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { + // photoId = journey_photos.id (gallery row); look up the trek_photo_id + const jp = this.db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as + | { photo_id: number } + | undefined; + if (!jp) return; + this.photos.setProvider(jp.photo_id, provider, assetId, ownerId); + // also denorm on gallery row for fast reads + this.db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run( + provider, + assetId, + ownerId, + photoId, + ); + } + + updatePhoto( + photoId: number, + userId: number, + data: { caption?: string; sort_order?: number }, + ): JourneyPhoto | null { + // photoId = journey_photos.id (gallery row) + const row = this.db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(photoId) as + | { id: number; journey_id: number } + | undefined; + if (!row) return null; + if (!this.canEdit(row.journey_id, userId)) return null; + + // caption lives on the gallery row; sort_order lives on the junction table + // (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order + // would not be reflected in the returned row). + if (data.caption !== undefined) { + this.db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId); + } + if (data.sort_order !== undefined) { + this.db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run( + data.sort_order, + photoId, + ); + } + return this.db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null; + } + + // deletePhoto: hard-delete (backwards compat name used by old route). + deletePhoto( + photoId: number, + userId: number, + ): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null { + const row = this.db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as + | { id: number; journey_id: number; photo_id: number } + | undefined; + if (!row) return null; + if (!this.canEdit(row.journey_id, userId)) return null; + + const trekRow = this.db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as + | { file_path?: string; provider?: string } + | undefined; + + this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId); + this.photos.deleteIfOrphan(row.photo_id); + + return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id }; + } + + // ── Contributors ───────────────────────────────────────────────────────── + + addContributor( + journeyId: number, + userId: number, + targetUserId: number, + role: 'editor' | 'viewer', + ): boolean { + if (!this.isOwner(journeyId, userId)) return false; + if (targetUserId === userId) return false; + try { + this.db.prepare( + 'INSERT OR REPLACE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)', + ).run(journeyId, targetUserId, role, this.ts()); + this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role }); + return true; + } catch { + return false; + } + } + + updateContributorRole( + journeyId: number, + userId: number, + targetUserId: number, + role: 'editor' | 'viewer', + ): boolean { + if (!this.isOwner(journeyId, userId)) return false; + this.db.prepare('UPDATE journey_contributors SET role = ? WHERE journey_id = ? AND user_id = ?').run( + role, + journeyId, + targetUserId, + ); + this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role }); + return true; + } + + removeContributor(journeyId: number, userId: number, targetUserId: number): boolean { + if (!this.isOwner(journeyId, userId)) return false; + this.db.prepare("DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ? AND role != 'owner'").run( + journeyId, + targetUserId, + ); + return true; + } + + // ── Suggestions ────────────────────────────────────────────────────────── + + getSuggestions(userId: number) { + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + return this.db + .prepare( + ` + SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image, + (SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count + FROM trips t + LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ? + WHERE (t.user_id = ? OR tm.user_id = ?) + AND t.end_date IS NOT NULL + AND t.end_date >= ? + AND t.end_date <= date('now') + AND t.id NOT IN (SELECT trip_id FROM journey_trips) + ORDER BY t.end_date DESC + `, + ) + .all(userId, userId, userId, thirtyDaysAgo); + } + + // ── User trips (for trip picker) ───────────────────────────────────────── + + listUserTrips(userId: number) { + return this.db + .prepare( + ` + SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image, + (SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count + FROM trips t + LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ? + WHERE t.user_id = ? OR tm.user_id = ? + ORDER BY t.start_date DESC + `, + ) + .all(userId, userId, userId); + } +} diff --git a/server/src/nest/journey/journey-share.service.ts b/server/src/nest/journey/journey-share.service.ts new file mode 100644 index 000000000..2aefb766d --- /dev/null +++ b/server/src/nest/journey/journey-share.service.ts @@ -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 = {}; + 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[] = []; + 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 = { ...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, + }, + }; + } +} diff --git a/server/src/nest/journey/journey.bridge.ts b/server/src/nest/journey/journey.bridge.ts new file mode 100644 index 000000000..8e84396fb --- /dev/null +++ b/server/src/nest/journey/journey.bridge.ts @@ -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) { + return journey.canAccessJourney(...args); +} +export function getJourneyFull(...args: Parameters) { + return journey.getJourneyFull(...args); +} +export function listEntries(...args: Parameters) { + return journey.listEntries(...args); +} +export function listJourneys(...args: Parameters) { + return journey.listJourneys(...args); +} diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts new file mode 100644 index 000000000..e8df81845 --- /dev/null +++ b/server/src/nest/journey/journey.mcp.ts @@ -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 }); + } +} diff --git a/server/src/nest/journey/journey.module.ts b/server/src/nest/journey/journey.module.ts index 763f06ab8..f4c5c358e 100644 --- a/server/src/nest/journey/journey.module.ts +++ b/server/src/nest/journey/journey.module.ts @@ -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 {} diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index 3c415cbe4..8ad32c6e7 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -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[1]) { return svc.createJourney(userId, data); } - getJourneyFull(id: number, userId: number) { return svc.getJourneyFull(id, userId); } - updateJourney(id: number, userId: number, data: Parameters[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[2]) { return svc.updateJourneyPreferences(id, userId, data); } + listJourneys(userId: number) { return this.journey.listJourneys(userId); } + createJourney(userId: number, data: Parameters[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[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[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, sid?: string) { return svc.createEntry(id, userId, data as Parameters[2], sid); } - updateEntry(entryId: number, userId: number, data: Parameters[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, sid?: string) { return this.journey.createEntry(id, userId, data as Parameters[2], sid); } + updateEntry(entryId: number, userId: number, data: Parameters[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[2]) { return svc.updatePhoto(photoId, userId, data); } - deletePhoto(photoId: number, userId: number) { return svc.deletePhoto(photoId, userId); } - uploadGalleryPhotos(id: number, userId: number, filePaths: Parameters[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[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[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[2]) { return share.createOrUpdateJourneyShareLink(id, userId, data); } - deleteJourneyShareLink(id: number, userId: number) { return share.deleteJourneyShareLink(id, userId); } + createOrUpdateJourneyShareLink(id: number, userId: number, data: Parameters[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 diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 684c17f6b..2525f619d 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -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 }); diff --git a/server/src/nest/places/places.module.ts b/server/src/nest/places/places.module.ts index 62b87aab1..c3278cc3d 100644 --- a/server/src/nest/places/places.module.ts +++ b/server/src/nest/places/places.module.ts @@ -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], diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 7edd5f270..48aff2aba 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -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 */ } } } diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index 724cd8d8a..18c0da3ab 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -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. diff --git a/server/src/nest/plugins/journal-entry-rows.controller.ts b/server/src/nest/plugins/journal-entry-rows.controller.ts index bea66cd36..b63e6ad4c 100644 --- a/server/src/nest/plugins/journal-entry-rows.controller.ts +++ b/server/src/nest/plugins/journal-entry-rows.controller.ts @@ -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( diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 1402d1641..a9318669a 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -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 diff --git a/server/src/nest/trips/trips.bridge.ts b/server/src/nest/trips/trips.bridge.ts index 9dad573d6..fdbb32bd4 100644 --- a/server/src/nest/trips/trips.bridge.ts +++ b/server/src/nest/trips/trips.bridge.ts @@ -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()), ); diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts deleted file mode 100644 index 7b619c3ec..000000000 --- a/server/src/services/journeyService.ts +++ /dev/null @@ -1,1324 +0,0 @@ -import { db, canAccessTrip } from '../db/database'; -import { avatarUrl } from '../nest/common/avatarUrl'; -import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types'; -import { broadcastToUser } from '../websocket'; -import { - getOrCreateTrekPhoto, - getOrCreateLocalTrekPhoto, - setTrekPhotoProvider, - deleteTrekPhotoIfOrphan, -} from '../nest/photos/photos.bridge'; - -function ts(): number { - return Date.now(); -} - -// Per-entry photo view: join journey_entry_photos → journey_photos (gallery) → trek_photos. -// id = gp.id (gallery photo id) — used by clients for linkPhoto/updatePhoto/unlink/delete. -const JP_SELECT = ` - gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at, - tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height, - tp.media_type, tp.duration_ms -`; -const JP_JOIN = `journey_entry_photos jep - JOIN journey_photos gp ON gp.id = jep.journey_photo_id - JOIN trek_photos tp ON tp.id = gp.photo_id`; - -// Per-journey gallery view: journey_photos → trek_photos (no entry context). -const GALLERY_SELECT = ` - gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at, - tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height, - tp.media_type, tp.duration_ms -`; -const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id'; - -function broadcastJourneyEvent( - journeyId: number, - event: string, - data: Record, - excludeSocketId?: string | number, -) { - const contributors = db.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ?').all(journeyId) as { - user_id: number; - }[]; - const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as - | { user_id: number } - | undefined; - - const userIds = new Set(contributors.map((c) => c.user_id)); - if (owner) userIds.add(owner.user_id); - - for (const uid of userIds) { - broadcastToUser(uid, { type: event, journeyId, ...data }, excludeSocketId); - } -} - -// ── Access control ─────────────────────────────────────────────────────── - -export function canAccessJourney(journeyId: number, userId: number): Journey | null { - const own = db.prepare('SELECT * FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId) as - | Journey - | undefined; - if (own) return own; - const contrib = db - .prepare('SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?') - .get(journeyId, userId); - if (contrib) return (db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey) || null; - return null; -} - -export function isOwner(journeyId: number, userId: number): boolean { - return !!db.prepare('SELECT 1 FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId); -} - -export function canEdit(journeyId: number, userId: number): boolean { - if (isOwner(journeyId, userId)) return true; - const c = db - .prepare('SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?') - .get(journeyId, userId) as { role: string } | undefined; - return c?.role === 'editor' || c?.role === 'owner'; -} - -// ── Journey CRUD ───────────────────────────────────────────────────────── - -export function listJourneys(userId: number) { - return db - .prepare( - ` - SELECT DISTINCT j.*, - (SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count, - (SELECT COUNT(*) FROM journey_photos jp WHERE jp.journey_id = j.id) as photo_count, - (SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count, - (SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min, - (SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max - FROM journeys j - LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ? - WHERE j.user_id = ? OR jc.user_id = ? - ORDER BY j.updated_at DESC - `, - ) - .all(userId, userId, userId) as (Journey & { - entry_count: number; - photo_count: number; - place_count: number; - trip_date_min: string | null; - trip_date_max: string | null; - })[]; -} - -export function createJourney( - userId: number, - data: { - title: string; - subtitle?: string; - trip_ids?: number[]; - }, -): Journey { - const now = ts(); - const res = db - .prepare( - ` - INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at) - VALUES (?, ?, ?, 'active', ?, ?) - `, - ) - .run(userId, data.title, data.subtitle || null, now, now); - - const journeyId = Number(res.lastInsertRowid); - - // add owner as contributor - db.prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)').run( - journeyId, - userId, - 'owner', - now, - ); - - // link trips and sync skeleton entries - if (data.trip_ids?.length) { - // Track the first trip that was ACTUALLY linked (addTripToJourney access-checks and - // returns false for a foreign/inaccessible trip). Inheriting the cover from a raw - // trip_ids[0] would otherwise leak an arbitrary trip's cover image cross-tenant. - let coverTripId: number | undefined; - for (const tripId of data.trip_ids) { - if (addTripToJourney(journeyId, tripId, userId) && coverTripId === undefined) coverTripId = tripId; - } - - if (coverTripId !== undefined) { - const firstTrip = db.prepare('SELECT cover_image FROM trips WHERE id = ?').get(coverTripId) as - | { cover_image: string | null } - | undefined; - if (firstTrip?.cover_image) { - // trip stores full path (/uploads/covers/x.jpg), journey stores relative (covers/x.jpg) - const relativePath = firstTrip.cover_image.replace(/^\/uploads\//, ''); - db.prepare('UPDATE journeys SET cover_image = ? WHERE id = ?').run(relativePath, journeyId); - } - } - } - - return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; -} - -export function getJourneyFull(journeyId: number, userId: number) { - const journey = canAccessJourney(journeyId, userId); - if (!journey) return null; - - const entries = db - .prepare('SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC') - .all(journeyId) as JourneyEntry[]; - - const photos = db - .prepare( - `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`, - ) - .all(journeyId) as JourneyPhoto[]; - - // group photos by entry - const photosByEntry: Record = {}; - for (const p of photos) { - (photosByEntry[p.entry_id] ||= []).push(p); - } - - const gallery = db - .prepare( - `SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? ORDER BY gp.sort_order ASC, gp.id ASC`, - ) - .all(journeyId); - - 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] || [], - source_trip_name: e.source_trip_id - ? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined) - ?.title || null - : null, - })); - - // linked trips - const trips = db - .prepare( - ` - SELECT jt.trip_id, jt.added_at, t.title, t.start_date, t.end_date, t.cover_image, t.currency, - (SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count - FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id - WHERE jt.journey_id = ? ORDER BY t.start_date ASC - `, - ) - .all(journeyId); - - // contributors - const contributorsRaw = db - .prepare( - ` - SELECT jc.journey_id, jc.user_id, jc.role, jc.added_at, u.username, u.avatar - FROM journey_contributors jc JOIN users u ON jc.user_id = u.id - WHERE jc.journey_id = ? ORDER BY jc.added_at - `, - ) - .all(journeyId) as any[]; - const contributors = contributorsRaw.map((c) => ({ - ...c, - avatar_url: avatarUrl(c), - })); - - // stats - const entryCount = entries.filter((e) => e.type === 'entry').length; - const photoCount = (gallery as any[]).length; - const places = [...new Set(entries.map((e) => e.location_name).filter(Boolean))]; - - const userPrefs = db - .prepare('SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?') - .get(journeyId, userId) as { hide_skeletons: number } | undefined; - - // Determine the viewer's role on this journey so the UI can gate edit/settings - // actions. 'owner' = creator, 'editor' | 'viewer' = from journey_contributors. - const journeyRow = journey as unknown as { user_id?: number }; - let myRole: 'owner' | 'editor' | 'viewer' | null; - if (journeyRow.user_id === userId) { - myRole = 'owner'; - } else { - const contribRow = db - .prepare('SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?') - .get(journeyId, userId) as { role: 'editor' | 'viewer' } | undefined; - myRole = contribRow?.role ?? null; - } - - return { - ...journey, - entries: enrichedEntries, - gallery, - trips, - contributors, - stats: { entries: entryCount, photos: photoCount, places: places.length }, - hide_skeletons: !!userPrefs?.hide_skeletons, - my_role: myRole, - }; -} - -export function updateJourney( - journeyId: number, - userId: number, - data: Partial<{ - title: string; - subtitle: string; - cover_gradient: string; - cover_image: string; - status: string; - }>, -): Journey | null { - // Journey-level settings (title, cover, status) are owner-only — editors - // may only edit entries and photos, not reshape the journey itself. - if (!isOwner(journeyId, userId)) return null; - - const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived']; - const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status']; - const fields: string[] = []; - const values: unknown[] = []; - for (const [key, val] of Object.entries(data)) { - if (val !== undefined && allowed.includes(key)) { - if (key === 'status' && !ALLOWED_STATUSES.includes(val as string)) continue; - fields.push(`${key} = ?`); - values.push(val); - } - } - if (fields.length === 0) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; - - fields.push('updated_at = ?'); - values.push(ts()); - values.push(journeyId); - db.prepare(`UPDATE journeys SET ${fields.join(', ')} WHERE id = ?`).run(...values); - return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; -} - -export function updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) { - if (!canAccessJourney(journeyId, userId)) return null; - if (data.hide_skeletons !== undefined) { - db.prepare('UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?').run( - data.hide_skeletons ? 1 : 0, - journeyId, - userId, - ); - } - const row = db - .prepare('SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?') - .get(journeyId, userId) as { hide_skeletons: number }; - return { hide_skeletons: !!row.hide_skeletons }; -} - -export function deleteJourney(journeyId: number, userId: number): boolean { - if (!isOwner(journeyId, userId)) return false; - db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId); - return true; -} - -// ── Trip management ────────────────────────────────────────────────────── - -export function addTripToJourney(journeyId: number, tripId: number, userId: number): boolean { - // Only attach a trip the caller can actually access — otherwise a journey - // owner could pull an arbitrary trip's places + photos into their journey - // (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped - // path enforces. - if (!canAccessTrip(tripId, userId)) return false; - const now = ts(); - try { - db.prepare('INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run( - journeyId, - tripId, - now, - ); - } catch { - return false; - } - - // sync skeleton entries for all places in this trip - syncTripPlaces(journeyId, tripId, userId); - // import existing trip photos (Immich/Synology) with sharing settings - syncTripPhotos(journeyId, tripId); - broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }); - return true; -} - -export function removeTripFromJourney(journeyId: number, tripId: number, userId: number): boolean { - if (!isOwner(journeyId, userId)) return false; - - // remove skeleton entries that haven't been filled in - db.prepare( - ` - DELETE FROM journey_entries - WHERE journey_id = ? AND source_trip_id = ? AND type = 'skeleton' - `, - ).run(journeyId, tripId); - - // detach filled entries from this trip - db.prepare( - ` - UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL - WHERE journey_id = ? AND source_trip_id = ? AND type != 'skeleton' - `, - ).run(journeyId, tripId); - - db.prepare('DELETE FROM journey_trips WHERE journey_id = ? AND trip_id = ?').run(journeyId, tripId); - return true; -} - -// ── Sync engine ────────────────────────────────────────────────────────── - -export function syncTripPlaces(journeyId: number, tripId: number, authorId: number) { - const places = db - .prepare( - ` - SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number - FROM places p - INNER JOIN day_assignments da ON da.place_id = p.id - INNER JOIN days d ON da.day_id = d.id - WHERE p.trip_id = ? - ORDER BY d.day_number ASC, da.order_index ASC - `, - ) - .all(tripId) as any[]; - - const now = ts(); - const existing = db - .prepare('SELECT source_place_id FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?') - .all(journeyId, tripId) as { source_place_id: number }[]; - const existingPlaceIds = new Set(existing.map((e) => e.source_place_id)); - - // Track next sort_order per date so synced skeletons get unique, sequential positions. - const dateMaxOrder = new Map(); - const maxRows = db - .prepare( - 'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date', - ) - .all(journeyId) as { entry_date: string; m: number }[]; - for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m); - - for (const place of places) { - if (existingPlaceIds.has(place.id)) continue; - existingPlaceIds.add(place.id); - - const entryDate = place.day_date || new Date().toISOString().split('T')[0]; - const entryTime = place.assignment_time || place.place_time || null; - const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1; - dateMaxOrder.set(entryDate, nextOrder); - - insertSkeletonEntry({ - journeyId, - tripId, - placeId: place.id, - authorId, - title: place.name, - entryDate, - entryTime, - locationName: place.address || place.name, - lat: place.lat || null, - lng: place.lng || null, - sortOrder: nextOrder, - now, - }); - } -} - -// import trip_photos into journey gallery when a trip is linked -function syncTripPhotos(journeyId: number, tripId: number) { - const tripPhotos = db - .prepare('SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?') - .all(tripId) as { photo_id: number; shared: number }[]; - if (!tripPhotos.length) return; - - const now = ts(); - const maxOrderRow = db - .prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?') - .get(journeyId) as { m: number | null }; - let nextOrder = (maxOrderRow?.m ?? -1) + 1; - - for (const tp of tripPhotos) { - db.prepare( - ` - INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at) - VALUES (?, ?, ?, ?, ?) - `, - ).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now); - } -} - -// called when a trip place is created -export function onPlaceCreated(tripId: number, placeId: number) { - const links = db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { - journey_id: number; - }[]; - if (!links.length) return; - - const place = db - .prepare( - ` - SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number - FROM places p - INNER JOIN day_assignments da ON da.place_id = p.id - INNER JOIN days d ON da.day_id = d.id - WHERE p.id = ? - `, - ) - .get(placeId) as any; - if (!place) return; // not assigned to a day yet — skip - - const now = ts(); - for (const link of links) { - const already = db - .prepare('SELECT 1 FROM journey_entries WHERE journey_id = ? AND source_place_id = ?') - .get(link.journey_id, placeId); - if (already) continue; - - const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number }; - const entryDate = place.day_date; - const maxOrder = db - .prepare('SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?') - .get(link.journey_id, entryDate) as { m: number | null }; - const nextOrder = (maxOrder?.m ?? -1) + 1; - - insertSkeletonEntry({ - journeyId: link.journey_id, - tripId, - placeId, - authorId: journey.user_id, - title: place.name, - entryDate, - entryTime: place.assignment_time || place.place_time || null, - locationName: place.address || place.name, - lat: place.lat || null, - lng: place.lng || null, - sortOrder: nextOrder, - now, - }); - } -} - -// called when a trip place is updated -export function onPlaceUpdated(placeId: number) { - const entries = db.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(placeId) as JourneyEntry[]; - if (!entries.length) return; - - const place = db - .prepare( - ` - SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number - FROM places p - LEFT JOIN day_assignments da ON da.place_id = p.id - LEFT JOIN days d ON da.day_id = d.id - WHERE p.id = ? - `, - ) - .get(placeId) as any; - if (!place) return; - - const now = ts(); - for (const entry of entries) { - if (entry.type === 'skeleton') { - // update everything on skeletons - db.prepare( - ` - UPDATE journey_entries SET title = ?, entry_date = ?, entry_time = ?, location_name = ?, location_lat = ?, location_lng = ?, updated_at = ? - WHERE id = ? - `, - ).run( - place.name, - place.day_date || entry.entry_date, - place.assignment_time || place.place_time || entry.entry_time, - place.address || place.name, - place.lat || null, - place.lng || null, - now, - entry.id, - ); - } else { - // for filled entries, only update location silently - db.prepare( - ` - UPDATE journey_entries SET location_name = ?, location_lat = ?, location_lng = ?, updated_at = ? - WHERE id = ? - `, - ).run(place.address || place.name, place.lat || null, place.lng || null, now, entry.id); - } - } -} - -// called when a trip place is deleted -export function onPlaceDeleted(placeId: number) { - const entries = db.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(placeId) as JourneyEntry[]; - - for (const entry of entries) { - if (entry.type === 'skeleton') { - // no content: just delete - const hasPhotos = db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(entry.id); - if (!hasPhotos && !entry.story) { - db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id); - continue; - } - } - // entry has content: keep it, detach, add note - const note = '\n\n> _Note: the original trip place was removed from the trip plan_'; - const newStory = (entry.story || '') + note; - db.prepare( - 'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?', - ).run(entry.type === 'skeleton' ? 'entry' : entry.type, newStory, ts(), entry.id); - } -} - -// Shared skeleton INSERT, reused by syncTripPlaces / onPlaceCreated / reconcileTripSkeletons. -function insertSkeletonEntry(p: { - journeyId: number; - tripId: number; - placeId: number; - authorId: number; - title: string; - entryDate: string; - entryTime: string | null; - locationName: string; - lat: number | null; - lng: number | null; - sortOrder: number; - now: number; -}) { - db.prepare( - ` - INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - p.journeyId, - p.tripId, - p.placeId, - p.authorId, - p.title, - p.entryDate, - p.entryTime, - p.locationName, - p.lat, - p.lng, - p.sortOrder, - p.now, - p.now, - ); -} - -// Make every journey linked to `tripId` mirror the trip's currently day-assigned -// places: add skeletons for newly-assigned places, refresh skeleton snapshots when a -// place is moved to another day / its time changes, and drop skeletons for places no -// longer assigned. Filled entries are never destroyed — only detached + annotated, -// mirroring onPlaceDeleted. Idempotent: a second call with no underlying change is a -// no-op (no writes, no broadcast). Called from every assignment mutation path. -export function reconcileTripSkeletons(tripId: number, sid?: string | number) { - const links = db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { - journey_id: number; - }[]; - if (!links.length) return; - - const places = db - .prepare( - ` - SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number, da.order_index - FROM places p - INNER JOIN day_assignments da ON da.place_id = p.id - INNER JOIN days d ON da.day_id = d.id - WHERE p.trip_id = ? - ORDER BY d.day_number ASC, da.order_index ASC - `, - ) - .all(tripId) as any[]; - - // One skeleton per place (a place on multiple days keeps its first-by-day/order row), - // matching the one-skeleton-per-place model used by onPlaceCreated. - const placeById = new Map(); - for (const place of places) if (!placeById.has(place.id)) placeById.set(place.id, place); - const assignedPlaceIds = new Set(placeById.keys()); - - const now = ts(); - for (const { journey_id } of links) { - const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journey_id) as - | { user_id: number } - | undefined; - if (!journey) continue; - - let changed = false; - const existing = db - .prepare( - `SELECT id, source_place_id, type, story, title, entry_date, entry_time, location_name, location_lat, location_lng - FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?`, - ) - .all(journey_id, tripId) as { - id: number; - source_place_id: number | null; - type: string; - story: string | null; - title: string | null; - entry_date: string | null; - entry_time: string | null; - location_name: string | null; - location_lat: number | null; - location_lng: number | null; - }[]; - const existingByPlace = new Map(); - for (const e of existing) if (e.source_place_id != null) existingByPlace.set(e.source_place_id, e); - - // Next sort_order per date for freshly inserted skeletons. - const dateMaxOrder = new Map(); - const maxRows = db - .prepare( - 'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date', - ) - .all(journey_id) as { entry_date: string; m: number }[]; - for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m); - - // 1) Upsert a skeleton for every currently-assigned place. - for (const place of placeById.values()) { - const entryDate = place.day_date || new Date().toISOString().split('T')[0]; - const entryTime = place.assignment_time || place.place_time || null; - const locationName = place.address || place.name; - const lat = place.lat || null; - const lng = place.lng || null; - const found = existingByPlace.get(place.id); - - if (!found) { - const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1; - dateMaxOrder.set(entryDate, nextOrder); - insertSkeletonEntry({ - journeyId: journey_id, - tripId, - placeId: place.id, - authorId: journey.user_id, - title: place.name, - entryDate, - entryTime, - locationName, - lat, - lng, - sortOrder: nextOrder, - now, - }); - changed = true; - } else if (found.type === 'skeleton') { - // Skeletons follow the place's day/time/location snapshot. - const stale = - found.title !== place.name || - found.entry_date !== entryDate || - found.entry_time !== entryTime || - found.location_name !== locationName || - found.location_lat !== lat || - found.location_lng !== lng; - if (stale) { - db.prepare( - `UPDATE journey_entries SET title = ?, entry_date = ?, entry_time = ?, location_name = ?, location_lat = ?, location_lng = ?, updated_at = ? WHERE id = ?`, - ).run(place.name, entryDate, entryTime, locationName, lat, lng, now, found.id); - changed = true; - } - } else { - // Filled entries keep the user's date/story; only location follows the place. - const stale = - found.location_name !== locationName || found.location_lat !== lat || found.location_lng !== lng; - if (stale) { - db.prepare( - `UPDATE journey_entries SET location_name = ?, location_lat = ?, location_lng = ?, updated_at = ? WHERE id = ?`, - ).run(locationName, lat, lng, now, found.id); - changed = true; - } - } - } - - // 2) Drop skeletons whose place is no longer assigned to a day in this trip. - for (const e of existing) { - if (e.source_place_id == null || assignedPlaceIds.has(e.source_place_id)) continue; - if (e.type === 'skeleton') { - const hasPhotos = db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(e.id); - if (!hasPhotos && !e.story) { - db.prepare('DELETE FROM journey_entries WHERE id = ?').run(e.id); - changed = true; - continue; - } - } - const note = '\n\n> _Note: the original trip place was removed from the trip plan_'; - const newStory = (e.story || '') + note; - db.prepare( - 'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?', - ).run(e.type === 'skeleton' ? 'entry' : e.type, newStory, now, e.id); - changed = true; - } - - if (changed) broadcastJourneyEvent(journey_id, 'journey:trip:synced', { tripId }, sid); - } -} - -// ── Entries ────────────────────────────────────────────────────────────── - -export function listEntries(journeyId: number, userId: number) { - if (!canAccessJourney(journeyId, userId)) return null; - - const entries = db - .prepare('SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC') - .all(journeyId) as JourneyEntry[]; - - const photos = db - .prepare( - `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`, - ) - .all(journeyId) as JourneyPhoto[]; - - const photosByEntry: Record = {}; - for (const p of photos) { - (photosByEntry[p.entry_id] ||= []).push(p); - } - - return 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] || [], - source_trip_name: e.source_trip_id - ? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined) - ?.title || null - : null, - })); -} - -export function createEntry( - journeyId: number, - userId: number, - data: { - type?: string; - title?: string; - story?: string; - entry_date: string; - entry_time?: string; - location_name?: string; - location_lat?: number; - location_lng?: number; - mood?: string; - weather?: string; - tags?: string[]; - pros_cons?: { pros: string[]; cons: string[] }; - visibility?: string; - sort_order?: number; - }, - sid?: string, -): JourneyEntry | null { - if (!canEdit(journeyId, userId)) return null; - - const now = ts(); - const maxOrder = db - .prepare('SELECT MAX(sort_order) as m FROM journey_entries WHERE journey_id = ? AND entry_date = ?') - .get(journeyId, data.entry_date) as { m: number | null }; - - const prosConsJson = - data.pros_cons && (data.pros_cons.pros.length || data.pros_cons.cons.length) - ? JSON.stringify(data.pros_cons) - : null; - - const res = db - .prepare( - ` - INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ) - .run( - journeyId, - userId, - data.type || 'entry', - data.title || null, - data.story || null, - data.entry_date, - data.entry_time || null, - data.location_name || null, - data.location_lat ?? null, - data.location_lng ?? null, - data.mood || null, - data.weather || null, - data.tags?.length ? JSON.stringify(data.tags) : null, - prosConsJson, - data.visibility || 'private', - (maxOrder?.m ?? -1) + 1, - now, - now, - ); - - const created = db - .prepare('SELECT * FROM journey_entries WHERE id = ?') - .get(Number(res.lastInsertRowid)) as JourneyEntry; - broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid); - return created; -} - -export function updateEntry( - entryId: number, - userId: number, - data: Partial<{ - type: string; - title: string; - story: string; - entry_date: string; - entry_time: string; - location_name: string; - location_lat: number; - location_lng: number; - mood: string; - weather: string; - tags: string[]; - pros_cons: { pros: string[]; cons: string[] }; - visibility: string; - sort_order: number; - }>, - sid?: string, -): JourneyEntry | null { - const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; - if (!entry) return null; - if (!canEdit(entry.journey_id, userId)) return null; - - const fields: string[] = []; - const values: unknown[] = []; - - // Allow-list the columns a client may set: keys come from the request body - // and are interpolated as SQL column names, so restrict them to the known - // entry fields. Keep this in sync with the data type above. - const allowed = new Set([ - 'type', - 'title', - 'story', - 'entry_date', - 'entry_time', - 'location_name', - 'location_lat', - 'location_lng', - 'mood', - 'weather', - 'tags', - 'pros_cons', - 'visibility', - 'sort_order', - ]); - - for (const [key, val] of Object.entries(data)) { - if (val === undefined) continue; - if (!allowed.has(key)) continue; - if (key === 'tags') { - fields.push('tags = ?'); - values.push(Array.isArray(val) ? JSON.stringify(val) : val); - } else if (key === 'pros_cons') { - fields.push('pros_cons = ?'); - values.push(val && typeof val === 'object' ? JSON.stringify(val) : val); - } else { - fields.push(`${key} = ?`); - values.push(val); - } - } - - // if adding story to a skeleton, promote to entry - if (entry.type === 'skeleton' && data.story && data.story.trim()) { - fields.push('type = ?'); - values.push('entry'); - } - - if (fields.length === 0) return entry; - - fields.push('updated_at = ?'); - values.push(ts()); - values.push(entryId); - db.prepare(`UPDATE journey_entries SET ${fields.join(', ')} WHERE id = ?`).run(...values); - - // touch the journey - db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(ts(), entry.journey_id); - - const updated = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry; - broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid); - return updated; -} - -// Reorder entries (typically within a single day). Caller passes the new -// desired order of ids; each entry's sort_order is set to its index in the -// array. Only entries owned by this journey are accepted. -export function reorderEntries(journeyId: number, userId: number, orderedIds: number[], sid?: string): boolean { - if (!canEdit(journeyId, userId)) return false; - if (!orderedIds.length) return true; - - const placeholders = orderedIds.map(() => '?').join(','); - const rows = db - .prepare(`SELECT id FROM journey_entries WHERE id IN (${placeholders}) AND journey_id = ?`) - .all(...orderedIds, journeyId) as { id: number }[]; - if (rows.length !== orderedIds.length) return false; - - const now = ts(); - const update = db.prepare('UPDATE journey_entries SET sort_order = ?, updated_at = ? WHERE id = ?'); - const tx = db.transaction(() => { - orderedIds.forEach((id, index) => update.run(index, now, id)); - db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(now, journeyId); - }); - tx(); - - broadcastJourneyEvent(journeyId, 'journey:entries:reordered', { orderedIds }, sid); - return true; -} - -export function deleteEntry(entryId: number, userId: number, sid?: string): boolean { - const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; - if (!entry) return false; - if (!canEdit(entry.journey_id, userId)) return false; - - if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') { - // Revert filled entry back to skeleton instead of deleting - db.prepare( - ` - UPDATE journey_entries - SET type = 'skeleton', story = NULL, mood = NULL, weather = NULL, pros_cons = NULL, - visibility = 'private', updated_at = ? - WHERE id = ? - `, - ).run(ts(), entryId); - broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid); - } else { - db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); - broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); - } - - return true; -} - -// ── Photos ─────────────────────────────────────────────────────────────── - -// Promote a skeleton suggestion to a concrete entry. Called whenever the user -// adds content (photo upload, provider photo, gallery link) — a suggestion -// with photos is no longer just a suggestion. -function promoteSkeletonIfNeeded(entry: JourneyEntry): void { - if (entry.type !== 'skeleton') return; - db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id); -} - -// Ensure a trek_photo_id is in the journey gallery; return its gallery row id. -function ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): number { - const now = ts(); - const maxOrderRow = db - .prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?') - .get(journeyId) as { m: number | null }; - db.prepare( - ` - INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, shared, sort_order, created_at) - VALUES (?, ?, ?, ?, ?, ?) - `, - ).run(journeyId, trekPhotoId, caption || null, shared ?? 0, (maxOrderRow?.m ?? -1) + 1, now); - const row = db - .prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?') - .get(journeyId, trekPhotoId) as { id: number }; - return row.id; -} - -// Link a gallery photo to an entry (idempotent). Returns the junction JP_SELECT row. -function linkGalleryPhotoToEntry(galleryId: number, entryId: number): JourneyPhoto | null { - const now = ts(); - const maxOrderRow = db - .prepare('SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?') - .get(entryId) as { m: number | null }; - db.prepare( - ` - INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at) - VALUES (?, ?, ?, ?) - `, - ).run(entryId, galleryId, (maxOrderRow?.m ?? -1) + 1, now); - return db - .prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id = ? AND jep.journey_photo_id = ?`) - .get(entryId, galleryId) as JourneyPhoto | null; -} - -export function addPhoto( - entryId: number, - userId: number, - filePath: string, - thumbnailPath?: string, - caption?: string, -): JourneyPhoto | null { - const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; - if (!entry) return null; - if (!canEdit(entry.journey_id, userId)) return null; - - const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath); - const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))(); - const result = linkGalleryPhotoToEntry(galleryId, entryId); - promoteSkeletonIfNeeded(entry); - return result; -} - -export function addProviderPhoto( - entryId: number, - userId: number, - provider: string, - assetId: string, - caption?: string, - passphrase?: string, - mediaType: string = 'image', -): JourneyPhoto | null { - const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; - if (!entry) return null; - if (!canEdit(entry.journey_id, userId)) return null; - - const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase, mediaType); - - // skip if this photo is already linked to this entry - const alreadyLinked = db - .prepare( - ` - SELECT 1 FROM journey_entry_photos jep - JOIN journey_photos gp ON gp.id = jep.journey_photo_id - WHERE jep.entry_id = ? AND gp.photo_id = ? - `, - ) - .get(entryId, trekPhotoId); - if (alreadyLinked) return null; - - const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))(); - const result = linkGalleryPhotoToEntry(galleryId, entryId); - promoteSkeletonIfNeeded(entry); - return result; -} - -// Link a gallery photo (by its journey_photos.id) to an entry — idempotent. -export function linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): JourneyPhoto | null { - const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; - if (!entry) return null; - if (!canEdit(entry.journey_id, userId)) return null; - - // Verify the gallery photo belongs to this journey - const galleryRow = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(journeyPhotoId) as - | { id: number; journey_id: number } - | undefined; - if (!galleryRow || galleryRow.journey_id !== entry.journey_id) return null; - - const result = linkGalleryPhotoToEntry(galleryRow.id, entryId); - promoteSkeletonIfNeeded(entry); - return result; -} - -// Upload photos to the journey gallery only (no entry association). -export function uploadGalleryPhotos( - journeyId: number, - userId: number, - filePaths: { path: string; thumbnail?: string; mediaType?: string; durationMs?: number | null }[], -): JourneyPhoto[] { - if (!canEdit(journeyId, userId)) return []; - const results: any[] = []; - const now = ts(); - const maxOrderRow = db - .prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?') - .get(journeyId) as { m: number | null }; - let nextOrder = (maxOrderRow?.m ?? -1) + 1; - - for (const f of filePaths) { - const trekPhotoId = getOrCreateLocalTrekPhoto(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null); - db.prepare( - ` - INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at) - VALUES (?, ?, 0, ?, ?) - `, - ).run(journeyId, trekPhotoId, nextOrder++, now); - const row = db - .prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? AND gp.photo_id = ?`) - .get(journeyId, trekPhotoId); - if (row) results.push(row); - } - return results; -} - -// Add a provider photo to the gallery only (no entry link). -export function addProviderPhotoToGallery( - journeyId: number, - userId: number, - provider: string, - assetId: string, - caption?: string, - passphrase?: string, - mediaType: string = 'image', -): any | null { - if (!canEdit(journeyId, userId)) return null; - const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase, mediaType); - const galleryId = db.transaction(() => ensureInGallery(journeyId, trekPhotoId, caption))(); - return db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null; -} - -// Unlink a photo from a specific entry; gallery row is preserved. -export function unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): boolean { - const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; - if (!entry) return false; - if (!canEdit(entry.journey_id, userId)) return false; - - const result = db - .prepare('DELETE FROM journey_entry_photos WHERE entry_id = ? AND journey_photo_id = ?') - .run(entryId, journeyPhotoId); - return result.changes > 0; -} - -// Hard-delete a gallery photo (removes from all entries and the gallery). -export function deleteGalleryPhoto( - journeyPhotoId: number, - userId: number, -): { photo_id: number; file_path?: string | null } | null { - const row = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as - | { id: number; journey_id: number; photo_id: number } - | undefined; - if (!row) return null; - if (!canEdit(row.journey_id, userId)) return null; - - const trekRow = db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as - | { file_path?: string; provider?: string } - | undefined; - - // cascade on journey_entry_photos.journey_photo_id handles junction cleanup - db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId); - deleteTrekPhotoIfOrphan(row.photo_id); - - return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null }; -} - -export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { - // photoId = journey_photos.id (gallery row); look up the trek_photo_id - const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as - | { photo_id: number } - | undefined; - if (!jp) return; - setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId); - // also denorm on gallery row for fast reads - db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run( - provider, - assetId, - ownerId, - photoId, - ); -} - -export function updatePhoto( - photoId: number, - userId: number, - data: { caption?: string; sort_order?: number }, -): JourneyPhoto | null { - // photoId = journey_photos.id (gallery row) - const row = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(photoId) as - | { id: number; journey_id: number } - | undefined; - if (!row) return null; - if (!canEdit(row.journey_id, userId)) return null; - - // caption lives on the gallery row; sort_order lives on the junction table - // (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order - // would not be reflected in the returned row). - if (data.caption !== undefined) { - db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId); - } - if (data.sort_order !== undefined) { - db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run( - data.sort_order, - photoId, - ); - } - return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null; -} - -// deletePhoto: hard-delete (backwards compat name used by old route). -export function deletePhoto( - photoId: number, - userId: number, -): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null { - const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as - | { id: number; journey_id: number; photo_id: number } - | undefined; - if (!row) return null; - if (!canEdit(row.journey_id, userId)) return null; - - const trekRow = db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as - | { file_path?: string; provider?: string } - | undefined; - - db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId); - deleteTrekPhotoIfOrphan(row.photo_id); - - return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id }; -} - -// ── Contributors ───────────────────────────────────────────────────────── - -export function addContributor( - journeyId: number, - userId: number, - targetUserId: number, - role: 'editor' | 'viewer', -): boolean { - if (!isOwner(journeyId, userId)) return false; - if (targetUserId === userId) return false; - try { - db.prepare( - 'INSERT OR REPLACE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)', - ).run(journeyId, targetUserId, role, ts()); - broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role }); - return true; - } catch { - return false; - } -} - -export function updateContributorRole( - journeyId: number, - userId: number, - targetUserId: number, - role: 'editor' | 'viewer', -): boolean { - if (!isOwner(journeyId, userId)) return false; - db.prepare('UPDATE journey_contributors SET role = ? WHERE journey_id = ? AND user_id = ?').run( - role, - journeyId, - targetUserId, - ); - broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role }); - return true; -} - -export function removeContributor(journeyId: number, userId: number, targetUserId: number): boolean { - if (!isOwner(journeyId, userId)) return false; - db.prepare("DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ? AND role != 'owner'").run( - journeyId, - targetUserId, - ); - return true; -} - -// ── Suggestions ────────────────────────────────────────────────────────── - -export function getSuggestions(userId: number) { - const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - return db - .prepare( - ` - SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image, - (SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count - FROM trips t - LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ? - WHERE (t.user_id = ? OR tm.user_id = ?) - AND t.end_date IS NOT NULL - AND t.end_date >= ? - AND t.end_date <= date('now') - AND t.id NOT IN (SELECT trip_id FROM journey_trips) - ORDER BY t.end_date DESC - `, - ) - .all(userId, userId, userId, thirtyDaysAgo); -} - -// ── User trips (for trip picker) ───────────────────────────────────────── - -export function listUserTrips(userId: number) { - return db - .prepare( - ` - SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image, - (SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count - FROM trips t - LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ? - WHERE t.user_id = ? OR tm.user_id = ? - ORDER BY t.start_date DESC - `, - ) - .all(userId, userId, userId); -} diff --git a/server/src/services/journeyShareService.ts b/server/src/services/journeyShareService.ts deleted file mode 100644 index de7e97d55..000000000 --- a/server/src/services/journeyShareService.ts +++ /dev/null @@ -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 = {}; - 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[] = []; - 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 = { ...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, - }, - }; -} diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index f92f65a2c..ccde14883 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 2ad32828b..01758fde0 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -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(); diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index 6fad99a05..978cd926f 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -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>; 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()); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 0c3a6418e..d0c2227f0 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -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 }, diff --git a/server/tests/unit/mcp/tools-journey.test.ts b/server/tests/unit/mcp/tools-journey.test.ts index 6606f1342..bba0788fd 100644 --- a/server/tests/unit/mcp/tools-journey.test.ts +++ b/server/tests/unit/mcp/tools-journey.test.ts @@ -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]> = [ + ['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); + }); + }); +}); diff --git a/server/tests/unit/services/journeyService.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts similarity index 71% rename from server/tests/unit/services/journeyService.test.ts rename to server/tests/unit/nest/journey-domain.service.test.ts index bf22cd809..0d6932e26 100644 --- a/server/tests/unit/services/journeyService.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for journeyService (JOURNEY-SVC-001 through JOURNEY-SVC-038). + * Unit tests for JourneyDomainService (JOURNEY-SVC-001 through JOURNEY-SVC-038). * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -53,39 +53,14 @@ import { createDayAssignment, addTripPhoto, } from '../../helpers/factories'; -import { - canAccessJourney, - isOwner, - canEdit, - listJourneys, - createJourney as svcCreateJourney, - getJourneyFull, - updateJourney, - deleteJourney, - addTripToJourney, - removeTripFromJourney, - listEntries, - createEntry, - updateEntry, - deleteEntry, - addPhoto, - addProviderPhoto, - deletePhoto, - addContributor, - updateContributorRole, - removeContributor, - getSuggestions, - syncTripPlaces, - reconcileTripSkeletons, - reorderEntries, - onPlaceCreated, - onPlaceUpdated, - onPlaceDeleted, - linkPhotoToEntry, - setPhotoProvider, - updatePhoto, - listUserTrips, -} from '../../../src/services/journeyService'; +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 { db as dbConn } from '../../../src/db/database'; + +const dbs = new DatabaseService(dbConn); +const svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)); beforeAll(() => { createTables(testDb); @@ -107,7 +82,7 @@ describe('canAccessJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'My Journey' }); - const result = canAccessJourney(journey.id, user.id); + const result = svc.canAccessJourney(journey.id, user.id); expect(result).not.toBeNull(); expect(result!.id).toBe(journey.id); @@ -120,7 +95,7 @@ describe('canAccessJourney', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, contrib.id, 'editor'); - const result = canAccessJourney(journey.id, contrib.id); + const result = svc.canAccessJourney(journey.id, contrib.id); expect(result).not.toBeNull(); expect(result!.id).toBe(journey.id); @@ -131,7 +106,7 @@ describe('canAccessJourney', () => { const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - const result = canAccessJourney(journey.id, stranger.id); + const result = svc.canAccessJourney(journey.id, stranger.id); expect(result).toBeNull(); }); @@ -142,7 +117,7 @@ describe('isOwner', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - expect(isOwner(journey.id, user.id)).toBe(true); + expect(svc.isOwner(journey.id, user.id)).toBe(true); }); it('JOURNEY-SVC-005: returns false for contributor', () => { @@ -151,7 +126,7 @@ describe('isOwner', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, contrib.id, 'editor'); - expect(isOwner(journey.id, contrib.id)).toBe(false); + expect(svc.isOwner(journey.id, contrib.id)).toBe(false); }); it('JOURNEY-SVC-006: returns false for stranger', () => { @@ -159,7 +134,7 @@ describe('isOwner', () => { const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - expect(isOwner(journey.id, stranger.id)).toBe(false); + expect(svc.isOwner(journey.id, stranger.id)).toBe(false); }); }); @@ -168,7 +143,7 @@ describe('canEdit', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - expect(canEdit(journey.id, user.id)).toBe(true); + expect(svc.canEdit(journey.id, user.id)).toBe(true); }); it('JOURNEY-SVC-008: editor contributor can edit', () => { @@ -177,7 +152,7 @@ describe('canEdit', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - expect(canEdit(journey.id, editor.id)).toBe(true); + expect(svc.canEdit(journey.id, editor.id)).toBe(true); }); it('JOURNEY-SVC-009: viewer contributor cannot edit', () => { @@ -186,7 +161,7 @@ describe('canEdit', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); - expect(canEdit(journey.id, viewer.id)).toBe(false); + expect(svc.canEdit(journey.id, viewer.id)).toBe(false); }); it('JOURNEY-SVC-010: stranger cannot edit', () => { @@ -194,7 +169,7 @@ describe('canEdit', () => { const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - expect(canEdit(journey.id, stranger.id)).toBe(false); + expect(svc.canEdit(journey.id, stranger.id)).toBe(false); }); }); @@ -207,7 +182,7 @@ describe('listJourneys', () => { createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', location_name: 'Paris' }); createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02', location_name: 'Lyon' }); - const result = listJourneys(user.id); + const result = svc.listJourneys(user.id); expect(result).toHaveLength(1); expect(result[0].title).toBe('Road Trip'); @@ -221,7 +196,7 @@ describe('listJourneys', () => { const journey = createJourney(testDb, owner.id, { title: 'Shared Trip' }); addJourneyContributor(testDb, journey.id, contrib.id, 'editor'); - const result = listJourneys(contrib.id); + const result = svc.listJourneys(contrib.id); expect(result).toHaveLength(1); expect(result[0].title).toBe('Shared Trip'); @@ -232,7 +207,7 @@ describe('listJourneys', () => { const { user: other } = createUser(testDb); createJourney(testDb, owner.id, { title: 'Private' }); - const result = listJourneys(other.id); + const result = svc.listJourneys(other.id); expect(result).toHaveLength(0); }); @@ -242,10 +217,10 @@ describe('listJourneys', () => { const journey = createJourney(testDb, user.id, { title: 'Multi Trip' }); const trip1 = createTrip(testDb, user.id, { title: 'Trip A', start_date: '2025-06-01', end_date: '2025-06-10' }); const trip2 = createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-03-15', end_date: '2026-03-20' }); - addTripToJourney(journey.id, trip1.id, user.id); - addTripToJourney(journey.id, trip2.id, user.id); + svc.addTripToJourney(journey.id, trip1.id, user.id); + svc.addTripToJourney(journey.id, trip2.id, user.id); - const result = listJourneys(user.id); + const result = svc.listJourneys(user.id); expect(result).toHaveLength(1); expect(result[0].trip_date_min).toBe('2025-06-01'); @@ -257,7 +232,7 @@ describe('createJourney (service)', () => { it('JOURNEY-SVC-014: creates journey with contributor record', () => { const { user } = createUser(testDb); - const journey = svcCreateJourney(user.id, { title: 'New Journey', subtitle: 'Subtitle' }); + const journey = svc.createJourney(user.id, { title: 'New Journey', subtitle: 'Subtitle' }); expect(journey.title).toBe('New Journey'); expect(journey.subtitle).toBe('Subtitle'); @@ -276,7 +251,7 @@ describe('createJourney (service)', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris 2026' }); - const journey = svcCreateJourney(user.id, { title: 'Euro Trip', trip_ids: [trip.id] }); + const journey = svc.createJourney(user.id, { title: 'Euro Trip', trip_ids: [trip.id] }); const link = testDb.prepare( 'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?' @@ -295,7 +270,7 @@ describe('getJourneyFull', () => { story: 'Arrived!', }); - const result = getJourneyFull(journey.id, user.id); + const result = svc.getJourneyFull(journey.id, user.id); expect(result).not.toBeNull(); expect(result!.title).toBe('Full Journey'); @@ -310,7 +285,7 @@ describe('getJourneyFull', () => { const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - const result = getJourneyFull(journey.id, stranger.id); + const result = svc.getJourneyFull(journey.id, stranger.id); expect(result).toBeNull(); }); @@ -321,7 +296,7 @@ describe('updateJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Old Title' }); - const updated = updateJourney(journey.id, user.id, { title: 'New Title', subtitle: 'New Sub' }); + const updated = svc.updateJourney(journey.id, user.id, { title: 'New Title', subtitle: 'New Sub' }); expect(updated).not.toBeNull(); expect(updated!.title).toBe('New Title'); @@ -336,7 +311,7 @@ describe('updateJourney', () => { const journey = createJourney(testDb, owner.id, { title: 'Original' }); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - const updated = updateJourney(journey.id, editor.id, { title: 'Edited' }); + const updated = svc.updateJourney(journey.id, editor.id, { title: 'Edited' }); expect(updated).toBeNull(); }); @@ -347,7 +322,7 @@ describe('updateJourney', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); - const result = updateJourney(journey.id, viewer.id, { title: 'Hacked' }); + const result = svc.updateJourney(journey.id, viewer.id, { title: 'Hacked' }); expect(result).toBeNull(); }); @@ -356,7 +331,7 @@ describe('updateJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Same' }); - const result = updateJourney(journey.id, user.id, {}); + const result = svc.updateJourney(journey.id, user.id, {}); expect(result).not.toBeNull(); expect(result!.title).toBe('Same'); @@ -366,7 +341,7 @@ describe('updateJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'To Archive' }); - const result = updateJourney(journey.id, user.id, { status: 'archived' }); + const result = svc.updateJourney(journey.id, user.id, { status: 'archived' }); expect(result).not.toBeNull(); expect(result!.status).toBe('archived'); @@ -376,7 +351,7 @@ describe('updateJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Stay Active' }); - const result = updateJourney(journey.id, user.id, { status: 'bogus' }); + const result = svc.updateJourney(journey.id, user.id, { status: 'bogus' }); expect(result).not.toBeNull(); expect(result!.status).toBe('active'); @@ -388,7 +363,7 @@ describe('deleteJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const result = deleteJourney(journey.id, user.id); + const result = svc.deleteJourney(journey.id, user.id); expect(result).toBe(true); const row = testDb.prepare('SELECT * FROM journeys WHERE id = ?').get(journey.id); @@ -401,7 +376,7 @@ describe('deleteJourney', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - const result = deleteJourney(journey.id, editor.id); + const result = svc.deleteJourney(journey.id, editor.id); expect(result).toBe(false); const row = testDb.prepare('SELECT * FROM journeys WHERE id = ?').get(journey.id); @@ -417,7 +392,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Linked Trip' }); - const result = addTripToJourney(journey.id, trip.id, user.id); + const result = svc.addTripToJourney(journey.id, trip.id, user.id); expect(result).toBe(true); const link = testDb.prepare( @@ -433,7 +408,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { // A trip owned by someone else, that `user` is not a member of. const foreignTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip" }); - const result = addTripToJourney(journey.id, foreignTrip.id, user.id); + const result = svc.addTripToJourney(journey.id, foreignTrip.id, user.id); expect(result).toBe(false); const link = testDb.prepare( @@ -454,7 +429,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { const day025 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day025.id, place.id); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); const skeletons = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" @@ -466,9 +441,9 @@ describe('addTripToJourney / removeTripFromJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Remove Me' }); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); - const result = removeTripFromJourney(journey.id, trip.id, user.id); + const result = svc.removeTripFromJourney(journey.id, trip.id, user.id); expect(result).toBe(true); const link = testDb.prepare( @@ -482,10 +457,10 @@ describe('addTripToJourney / removeTripFromJourney', () => { const { user: editor } = createUser(testDb); const journey = createJourney(testDb, owner.id); const trip = createTrip(testDb, owner.id, { title: 'Stay Linked' }); - addTripToJourney(journey.id, trip.id, owner.id); + svc.addTripToJourney(journey.id, trip.id, owner.id); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - const result = removeTripFromJourney(journey.id, trip.id, editor.id); + const result = svc.removeTripFromJourney(journey.id, trip.id, editor.id); expect(result).toBe(false); }); @@ -502,7 +477,7 @@ describe('listEntries', () => { entry_date: '2026-03-01', }); - const result = listEntries(journey.id, user.id); + const result = svc.listEntries(journey.id, user.id); expect(result).not.toBeNull(); expect(result).toHaveLength(1); @@ -515,7 +490,7 @@ describe('listEntries', () => { const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - const result = listEntries(journey.id, stranger.id); + const result = svc.listEntries(journey.id, stranger.id); expect(result).toBeNull(); }); @@ -526,7 +501,7 @@ describe('createEntry', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = createEntry(journey.id, user.id, { + const entry = svc.createEntry(journey.id, user.id, { title: 'Beach Day', entry_date: '2026-03-10', story: 'Beautiful sunset', @@ -549,7 +524,7 @@ describe('createEntry', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); - const entry = createEntry(journey.id, viewer.id, { + const entry = svc.createEntry(journey.id, viewer.id, { title: 'Should Fail', entry_date: '2026-03-10', }); @@ -567,7 +542,7 @@ describe('updateEntry', () => { entry_date: '2026-03-01', }); - const updated = updateEntry(entry.id, user.id, { title: 'Updated', mood: 'excited' }); + const updated = svc.updateEntry(entry.id, user.id, { title: 'Updated', mood: 'excited' }); expect(updated).not.toBeNull(); expect(updated!.title).toBe('Updated'); @@ -583,7 +558,7 @@ describe('updateEntry', () => { entry_date: '2026-03-01', }); - const updated = updateEntry(entry.id, user.id, { story: 'Now I have a story!' }); + const updated = svc.updateEntry(entry.id, user.id, { story: 'Now I have a story!' }); expect(updated).not.toBeNull(); expect(updated!.type).toBe('entry'); @@ -593,7 +568,7 @@ describe('updateEntry', () => { it('JOURNEY-SVC-034: returns null for non-existent entry', () => { const { user } = createUser(testDb); - const result = updateEntry(99999, user.id, { title: 'No Such Entry' }); + const result = svc.updateEntry(99999, user.id, { title: 'No Such Entry' }); expect(result).toBeNull(); }); @@ -616,7 +591,7 @@ describe('updateEntry', () => { author_id: 999999, }; - const updated = updateEntry(entry.id, user.id, malicious as Parameters[2]); + const updated = svc.updateEntry(entry.id, user.id, malicious as Parameters[2]); expect(updated).not.toBeNull(); expect(updated!.title).toBe('Updated'); // legit field still applied @@ -631,7 +606,7 @@ describe('deleteEntry', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const result = deleteEntry(entry.id, user.id); + const result = svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id); @@ -641,7 +616,7 @@ describe('deleteEntry', () => { it('JOURNEY-SVC-036: returns false for non-existent entry', () => { const { user } = createUser(testDb); - expect(deleteEntry(99999, user.id)).toBe(false); + expect(svc.deleteEntry(99999, user.id)).toBe(false); }); it('JOURNEY-SVC-037: viewer cannot delete entry', () => { @@ -651,7 +626,7 @@ describe('deleteEntry', () => { addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); - expect(deleteEntry(entry.id, viewer.id)).toBe(false); + expect(svc.deleteEntry(entry.id, viewer.id)).toBe(false); }); it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', () => { @@ -668,7 +643,7 @@ describe('deleteEntry', () => { `).run(journey.id, trip.id, place.id, user.id, now, now); const entry = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as any; - const result = deleteEntry(entry.id, user.id); + const result = svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); // Entry should still exist but reverted to skeleton @@ -687,7 +662,7 @@ describe('deleteEntry', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', story: 'Manual entry' }); - const result = deleteEntry(entry.id, user.id); + const result = svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id); @@ -703,7 +678,7 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = addPhoto(entry.id, user.id, '/uploads/photo.jpg', '/uploads/thumb.jpg', 'Sunset'); + const photo = svc.addPhoto(entry.id, user.id, '/uploads/photo.jpg', '/uploads/thumb.jpg', 'Sunset'); expect(photo).not.toBeNull(); expect(photo!.file_path).toBe('/uploads/photo.jpg'); @@ -715,7 +690,7 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { it('JOURNEY-SVC-039: addPhoto returns null for non-existent entry', () => { const { user } = createUser(testDb); - const result = addPhoto(99999, user.id, '/uploads/photo.jpg'); + const result = svc.addPhoto(99999, user.id, '/uploads/photo.jpg'); expect(result).toBeNull(); }); @@ -725,7 +700,7 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = addProviderPhoto(entry.id, user.id, 'immich', 'asset-123', 'My caption'); + const photo = svc.addProviderPhoto(entry.id, user.id, 'immich', 'asset-123', 'My caption'); expect(photo).not.toBeNull(); expect(photo!.provider).toBe('immich'); @@ -738,8 +713,8 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset'); - const duplicate = addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset'); + svc.addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset'); + const duplicate = svc.addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset'); expect(duplicate).toBeNull(); }); @@ -748,9 +723,9 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = addPhoto(entry.id, user.id, '/uploads/delete-me.jpg'); + const photo = svc.addPhoto(entry.id, user.id, '/uploads/delete-me.jpg'); - const deleted = deletePhoto(photo!.id, user.id); + const deleted = svc.deletePhoto(photo!.id, user.id); expect(deleted).not.toBeNull(); expect(deleted!.id).toBe(photo!.id); @@ -761,7 +736,7 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { it('JOURNEY-SVC-043: deletePhoto returns null for non-existent photo', () => { const { user } = createUser(testDb); - expect(deletePhoto(99999, user.id)).toBeNull(); + expect(svc.deletePhoto(99999, user.id)).toBeNull(); }); it('JOURNEY-SVC-044: viewer cannot add photo', () => { @@ -771,7 +746,7 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); - const result = addPhoto(entry.id, viewer.id, '/uploads/no.jpg'); + const result = svc.addPhoto(entry.id, viewer.id, '/uploads/no.jpg'); expect(result).toBeNull(); }); @@ -785,7 +760,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const { user: newContrib } = createUser(testDb); const journey = createJourney(testDb, owner.id); - const result = addContributor(journey.id, owner.id, newContrib.id, 'editor'); + const result = svc.addContributor(journey.id, owner.id, newContrib.id, 'editor'); expect(result).toBe(true); const row = testDb.prepare( @@ -802,7 +777,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - const result = addContributor(journey.id, editor.id, newUser.id, 'viewer'); + const result = svc.addContributor(journey.id, editor.id, newUser.id, 'viewer'); expect(result).toBe(false); }); @@ -811,7 +786,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const { user: owner } = createUser(testDb); const journey = createJourney(testDb, owner.id); - const result = addContributor(journey.id, owner.id, owner.id, 'editor'); + const result = svc.addContributor(journey.id, owner.id, owner.id, 'editor'); expect(result).toBe(false); }); @@ -822,7 +797,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, contrib.id, 'viewer'); - const result = updateContributorRole(journey.id, owner.id, contrib.id, 'editor'); + const result = svc.updateContributorRole(journey.id, owner.id, contrib.id, 'editor'); expect(result).toBe(true); const row = testDb.prepare( @@ -839,7 +814,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { addJourneyContributor(testDb, journey.id, editor.id, 'editor'); addJourneyContributor(testDb, journey.id, target.id, 'viewer'); - const result = updateContributorRole(journey.id, editor.id, target.id, 'editor'); + const result = svc.updateContributorRole(journey.id, editor.id, target.id, 'editor'); expect(result).toBe(false); }); @@ -850,7 +825,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, contrib.id, 'editor'); - const result = removeContributor(journey.id, owner.id, contrib.id); + const result = svc.removeContributor(journey.id, owner.id, contrib.id); expect(result).toBe(true); const row = testDb.prepare( @@ -865,7 +840,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { // attempting to remove the owner's own contributor record should not work // (the SQL filters role != 'owner') - removeContributor(journey.id, owner.id, owner.id); + svc.removeContributor(journey.id, owner.id, owner.id); const row = testDb.prepare( 'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?' @@ -888,7 +863,7 @@ describe('getSuggestions', () => { end_date: fiveDaysAgo, }); - const suggestions = getSuggestions(user.id); + const suggestions = svc.getSuggestions(user.id); expect(suggestions.length).toBe(1); expect((suggestions[0] as any).title).toBe('Recent Trip'); @@ -904,9 +879,9 @@ describe('getSuggestions', () => { end_date: fiveDaysAgo, }); const journey = createJourney(testDb, user.id); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); - const suggestions = getSuggestions(user.id); + const suggestions = svc.getSuggestions(user.id); expect(suggestions.length).toBe(0); }); @@ -920,7 +895,7 @@ describe('getSuggestions', () => { end_date: tomorrow, }); - const suggestions = getSuggestions(user.id); + const suggestions = svc.getSuggestions(user.id); expect(suggestions.length).toBe(0); }); @@ -943,7 +918,7 @@ describe('syncTripPlaces', () => { createDayAssignment(testDb, days055[0].id, place1.id); createDayAssignment(testDb, days055[1].id, place2.id); - syncTripPlaces(journey.id, trip.id, user.id); + svc.syncTripPlaces(journey.id, trip.id, user.id); const skeletons = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'" @@ -965,8 +940,8 @@ describe('syncTripPlaces', () => { const day056 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day056.id, place056.id); - syncTripPlaces(journey.id, trip.id, user.id); - syncTripPlaces(journey.id, trip.id, user.id); // second call + svc.syncTripPlaces(journey.id, trip.id, user.id); + svc.syncTripPlaces(journey.id, trip.id, user.id); // second call const skeletons = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'" @@ -989,7 +964,7 @@ describe('syncTripPlaces', () => { const place = createPlace(testDb, trip.id, { name: 'Colosseum' }); createDayAssignment(testDb, day.id, place.id); - syncTripPlaces(journey.id, trip.id, user.id); + svc.syncTripPlaces(journey.id, trip.id, user.id); const skeleton = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" @@ -1010,13 +985,13 @@ describe('onPlaceCreated', () => { start_date: '2026-07-01', end_date: '2026-07-03', }); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); // Create a new place after trip is linked const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia' }); const day058 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day058.id, place.id); - onPlaceCreated(trip.id, place.id); + svc.onPlaceCreated(trip.id, place.id); const skeleton = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" @@ -1029,7 +1004,7 @@ describe('onPlaceCreated', () => { const trip = createTrip(testDb, user.id, { title: 'Unlinked Trip' }); const place = createPlace(testDb, trip.id, { name: 'Remote Place' }); - onPlaceCreated(trip.id, place.id); + svc.onPlaceCreated(trip.id, place.id); const entries = testDb.prepare( "SELECT * FROM journey_entries WHERE source_place_id = ?" @@ -1045,13 +1020,13 @@ describe('onPlaceCreated', () => { start_date: '2026-07-01', end_date: '2026-07-02', }); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); const place = createPlace(testDb, trip.id, { name: 'Arc de Triomphe' }); const day060 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day060.id, place.id); - onPlaceCreated(trip.id, place.id); - onPlaceCreated(trip.id, place.id); // second call + svc.onPlaceCreated(trip.id, place.id); + svc.onPlaceCreated(trip.id, place.id); // second call const entries = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" @@ -1072,11 +1047,11 @@ describe('onPlaceUpdated', () => { const place = createPlace(testDb, trip.id, { name: 'Old Name' }); const day061 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day061.id, place.id); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); // Update the place name directly in DB testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('New Name', 'New Address', place.id); - onPlaceUpdated(place.id); + svc.onPlaceUpdated(place.id); const entry = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" @@ -1097,17 +1072,17 @@ describe('onPlaceUpdated', () => { const place = createPlace(testDb, trip.id, { name: 'Original Place' }); const day062 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day062.id, place.id); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); // Promote the skeleton to a full entry const skeleton = testDb.prepare( "SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" ).get(journey.id, place.id) as { id: number }; - updateEntry(skeleton.id, user.id, { story: 'My story', title: 'Custom Title' }); + svc.updateEntry(skeleton.id, user.id, { story: 'My story', title: 'Custom Title' }); // Now update the place testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('Changed Place', 'Changed Addr', place.id); - onPlaceUpdated(place.id); + svc.onPlaceUpdated(place.id); const entry = testDb.prepare( "SELECT * FROM journey_entries WHERE id = ?" @@ -1122,7 +1097,7 @@ describe('onPlaceUpdated', () => { const place = createPlace(testDb, trip.id, { name: 'Orphan Place' }); // Should not throw - onPlaceUpdated(place.id); + svc.onPlaceUpdated(place.id); const entries = testDb.prepare( "SELECT * FROM journey_entries WHERE source_place_id = ?" @@ -1141,9 +1116,9 @@ describe('onPlaceDeleted', () => { end_date: '2026-09-02', }); const place = createPlace(testDb, trip.id, { name: 'To Be Deleted' }); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); - onPlaceDeleted(place.id); + svc.onPlaceDeleted(place.id); const entry = testDb.prepare( "SELECT * FROM journey_entries WHERE source_place_id = ?" @@ -1162,15 +1137,15 @@ describe('onPlaceDeleted', () => { const place = createPlace(testDb, trip.id, { name: 'Detach Place' }); const day065 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day065.id, place.id); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); // Promote the skeleton to a filled entry const skeleton = testDb.prepare( "SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" ).get(journey.id, place.id) as { id: number }; - updateEntry(skeleton.id, user.id, { story: 'I really enjoyed this place' }); + svc.updateEntry(skeleton.id, user.id, { story: 'I really enjoyed this place' }); - onPlaceDeleted(place.id); + svc.onPlaceDeleted(place.id); const entry = testDb.prepare( "SELECT * FROM journey_entries WHERE id = ?" @@ -1187,7 +1162,7 @@ describe('onPlaceDeleted', () => { const place = createPlace(testDb, trip.id, { name: 'Nowhere' }); // Should not throw - onPlaceDeleted(place.id); + svc.onPlaceDeleted(place.id); }); }); @@ -1200,10 +1175,10 @@ describe('linkPhotoToEntry', () => { const entry1 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); const entry2 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02' }); - const photo = addPhoto(entry1.id, user.id, '/uploads/link-test.jpg'); + const photo = svc.addPhoto(entry1.id, user.id, '/uploads/link-test.jpg'); expect(photo).not.toBeNull(); - const result = linkPhotoToEntry(entry2.id, photo!.id, user.id); + const result = svc.linkPhotoToEntry(entry2.id, photo!.id, user.id); expect(result).not.toBeNull(); expect(result!.entry_id).toBe(entry2.id); }); @@ -1212,9 +1187,9 @@ describe('linkPhotoToEntry', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = addPhoto(entry.id, user.id, '/uploads/same-entry.jpg'); + const photo = svc.addPhoto(entry.id, user.id, '/uploads/same-entry.jpg'); - const result = linkPhotoToEntry(entry.id, photo!.id, user.id); + const result = svc.linkPhotoToEntry(entry.id, photo!.id, user.id); expect(result).not.toBeNull(); expect(result!.id).toBe(photo!.id); expect(result!.entry_id).toBe(entry.id); @@ -1223,7 +1198,7 @@ describe('linkPhotoToEntry', () => { it('JOURNEY-SVC-069: returns null for non-existent entry', () => { const { user } = createUser(testDb); - const result = linkPhotoToEntry(99999, 1, user.id); + const result = svc.linkPhotoToEntry(99999, 1, user.id); expect(result).toBeNull(); }); @@ -1232,7 +1207,7 @@ describe('linkPhotoToEntry', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const result = linkPhotoToEntry(entry.id, 99999, user.id); + const result = svc.linkPhotoToEntry(entry.id, 99999, user.id); expect(result).toBeNull(); }); @@ -1242,9 +1217,9 @@ describe('linkPhotoToEntry', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); - const photo = addPhoto(entry.id, owner.id, '/uploads/owner-photo.jpg'); + const photo = svc.addPhoto(entry.id, owner.id, '/uploads/owner-photo.jpg'); - const result = linkPhotoToEntry(entry.id, photo!.id, viewer.id); + const result = svc.linkPhotoToEntry(entry.id, photo!.id, viewer.id); expect(result).toBeNull(); }); }); @@ -1256,9 +1231,9 @@ describe('setPhotoProvider', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = addPhoto(entry.id, user.id, '/uploads/provider-test.jpg'); + const photo = svc.addPhoto(entry.id, user.id, '/uploads/provider-test.jpg'); - setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id); + svc.setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id); const updated = testDb.prepare(` SELECT jp.*, tkp.provider, tkp.asset_id, tkp.owner_id @@ -1278,9 +1253,9 @@ describe('updatePhoto', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = addPhoto(entry.id, user.id, '/uploads/caption-test.jpg', undefined, 'Old caption'); + const photo = svc.addPhoto(entry.id, user.id, '/uploads/caption-test.jpg', undefined, 'Old caption'); - const result = updatePhoto(photo!.id, user.id, { caption: 'New caption' }); + const result = svc.updatePhoto(photo!.id, user.id, { caption: 'New caption' }); expect(result).not.toBeNull(); expect(result!.caption).toBe('New caption'); @@ -1290,9 +1265,9 @@ describe('updatePhoto', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = addPhoto(entry.id, user.id, '/uploads/sort-test.jpg'); + const photo = svc.addPhoto(entry.id, user.id, '/uploads/sort-test.jpg'); - const result = updatePhoto(photo!.id, user.id, { sort_order: 10 }); + const result = svc.updatePhoto(photo!.id, user.id, { sort_order: 10 }); expect(result).not.toBeNull(); expect(result!.sort_order).toBe(10); @@ -1301,7 +1276,7 @@ describe('updatePhoto', () => { it('JOURNEY-SVC-075: returns null for non-existent photo', () => { const { user } = createUser(testDb); - const result = updatePhoto(99999, user.id, { caption: 'Nope' }); + const result = svc.updatePhoto(99999, user.id, { caption: 'Nope' }); expect(result).toBeNull(); }); @@ -1309,9 +1284,9 @@ describe('updatePhoto', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = addPhoto(entry.id, user.id, '/uploads/noop-test.jpg', undefined, 'Stay'); + const photo = svc.addPhoto(entry.id, user.id, '/uploads/noop-test.jpg', undefined, 'Stay'); - const result = updatePhoto(photo!.id, user.id, {}); + const result = svc.updatePhoto(photo!.id, user.id, {}); expect(result).not.toBeNull(); expect(result!.caption).toBe('Stay'); @@ -1323,9 +1298,9 @@ describe('updatePhoto', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); - const photo = addPhoto(entry.id, owner.id, '/uploads/viewer-update.jpg'); + const photo = svc.addPhoto(entry.id, owner.id, '/uploads/viewer-update.jpg'); - const result = updatePhoto(photo!.id, viewer.id, { caption: 'Hacked' }); + const result = svc.updatePhoto(photo!.id, viewer.id, { caption: 'Hacked' }); expect(result).toBeNull(); }); }); @@ -1338,7 +1313,7 @@ describe('listUserTrips', () => { createTrip(testDb, user.id, { title: 'Trip A', start_date: '2026-01-01', end_date: '2026-01-03' }); createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-02-01', end_date: '2026-02-03' }); - const trips = listUserTrips(user.id); + const trips = svc.listUserTrips(user.id); expect(trips.length).toBe(2); // ordered by start_date DESC @@ -1349,7 +1324,7 @@ describe('listUserTrips', () => { it('JOURNEY-SVC-079: returns empty for user with no trips', () => { const { user } = createUser(testDb); - const trips = listUserTrips(user.id); + const trips = svc.listUserTrips(user.id); expect(trips.length).toBe(0); }); @@ -1359,7 +1334,7 @@ describe('listUserTrips', () => { const { user: user2 } = createUser(testDb); createTrip(testDb, user1.id, { title: 'User1 Trip' }); - const trips = listUserTrips(user2.id); + const trips = svc.listUserTrips(user2.id); expect(trips.length).toBe(0); }); @@ -1372,9 +1347,9 @@ describe('Edge cases', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = addPhoto(entry.id, user.id, '/uploads/gallery-move.jpg'); + const photo = svc.addPhoto(entry.id, user.id, '/uploads/gallery-move.jpg'); - const result = deleteEntry(entry.id, user.id); + const result = svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); // Junction row must be gone (ON DELETE CASCADE from journey_entries). @@ -1387,7 +1362,7 @@ describe('Edge cases', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const result = updateJourney(journey.id, user.id, { cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)' }); + const result = svc.updateJourney(journey.id, user.id, { cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)' }); expect(result).not.toBeNull(); expect((result as any).cover_gradient).toBe('linear-gradient(to right, #ff0000, #0000ff)'); @@ -1397,7 +1372,7 @@ describe('Edge cases', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Original' }); - const result = updateJourney(journey.id, user.id, { bogus: 'field' } as any); + const result = svc.updateJourney(journey.id, user.id, { bogus: 'field' } as any); expect(result).not.toBeNull(); expect(result!.title).toBe('Original'); @@ -1407,7 +1382,7 @@ describe('Edge cases', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = createEntry(journey.id, user.id, { + const entry = svc.createEntry(journey.id, user.id, { entry_date: '2026-03-10', tags: ['food', 'culture'], pros_cons: { pros: ['Great view'], cons: ['Expensive'] }, @@ -1425,7 +1400,7 @@ describe('Edge cases', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const result = updateEntry(entry.id, user.id, { + const result = svc.updateEntry(entry.id, user.id, { tags: ['beach', 'adventure'], pros_cons: { pros: ['Fun'], cons: [] }, }); @@ -1446,7 +1421,7 @@ describe('Edge cases', () => { }); addTripPhoto(testDb, trip.id, user.id, 'immich-photo-1', 'immich', { shared: true }); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); // Trip photos now go straight into the journey gallery (no wrapper entry). const photos = testDb.prepare(` @@ -1471,15 +1446,15 @@ describe('Edge cases', () => { const days087 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[]; createDayAssignment(testDb, days087[0].id, place1.id); createDayAssignment(testDb, days087[1].id, place2.id); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); // Promote one skeleton to a filled entry const filled = testDb.prepare( "SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" ).get(journey.id, place2.id) as { id: number }; - updateEntry(filled.id, user.id, { story: 'Now filled!' }); + svc.updateEntry(filled.id, user.id, { story: 'Now filled!' }); - removeTripFromJourney(journey.id, trip.id, user.id); + svc.removeTripFromJourney(journey.id, trip.id, user.id); // skeleton for place1 should be deleted const skeletonRow = testDb.prepare( @@ -1505,7 +1480,7 @@ describe('addProviderPhoto — passphrase', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-15' }); - const photo = addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp'); + const photo = svc.addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp'); expect(photo).not.toBeNull(); @@ -1536,10 +1511,10 @@ describe('reorderEntries', () => { const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 }); const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 }); - const ok = reorderEntries(journey.id, user.id, [e2.id, e1.id]); + const ok = svc.reorderEntries(journey.id, user.id, [e2.id, e1.id]); expect(ok).toBe(true); - const entries = listEntries(journey.id, user.id)!; + const entries = svc.listEntries(journey.id, user.id)!; const dayEntries = entries.filter(e => e.entry_date === '2026-08-01'); expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]); }); @@ -1550,7 +1525,7 @@ describe('reorderEntries', () => { const j2 = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' }); - const ok = reorderEntries(j1.id, user.id, [entry.id]); + const ok = svc.reorderEntries(j1.id, user.id, [entry.id]); expect(ok).toBe(false); }); @@ -1561,9 +1536,9 @@ describe('reorderEntries', () => { const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 }); const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 }); - reorderEntries(journey.id, user.id, [day1b.id, day1a.id]); + svc.reorderEntries(journey.id, user.id, [day1b.id, day1a.id]); - const entries = listEntries(journey.id, user.id)!; + const entries = svc.listEntries(journey.id, user.id)!; const day2Entry = entries.find(e => e.id === day2.id)!; expect(day2Entry.sort_order).toBe(0); }); @@ -1586,7 +1561,7 @@ describe('syncTripPlaces sort_order', () => { createDayAssignment(testDb, day.id, p2.id); createDayAssignment(testDb, day.id, p3.id); - syncTripPlaces(journey.id, trip.id, user.id); + svc.syncTripPlaces(journey.id, trip.id, user.id); const rows = testDb.prepare( 'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC' @@ -1606,14 +1581,14 @@ describe('onPlaceCreated sort_order', () => { start_date: '2026-10-01', end_date: '2026-10-02', }); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string }; insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 }); const place = createPlace(testDb, trip.id, { name: 'Late Addition' }); createDayAssignment(testDb, day.id, place.id); - onPlaceCreated(trip.id, place.id); + svc.onPlaceCreated(trip.id, place.id); const newEntry = testDb.prepare( 'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?' @@ -1635,7 +1610,7 @@ describe('reconcileTripSkeletons', () => { start_date: '2026-05-01', end_date: '2026-05-03', }); - addTripToJourney(journey.id, trip.id, user.id); + svc.addTripToJourney(journey.id, trip.id, user.id); return { user, journey, trip }; } @@ -1658,7 +1633,7 @@ describe('reconcileTripSkeletons', () => { const place = createPlace(testDb, trip.id, { name: 'New Museum' }); createDayAssignment(testDb, days[0].id, place.id); - reconcileTripSkeletons(trip.id); + svc.reconcileTripSkeletons(trip.id); const skeleton = skeletonFor(journey.id, place.id); expect(skeleton).toBeDefined(); @@ -1672,11 +1647,11 @@ describe('reconcileTripSkeletons', () => { const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'To Remove' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); - reconcileTripSkeletons(trip.id); + svc.reconcileTripSkeletons(trip.id); expect(skeletonFor(journey.id, place.id)).toBeDefined(); testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignment.id); - reconcileTripSkeletons(trip.id); + svc.reconcileTripSkeletons(trip.id); expect(skeletonFor(journey.id, place.id)).toBeUndefined(); }); @@ -1686,7 +1661,7 @@ describe('reconcileTripSkeletons', () => { const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Filled Place' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); - reconcileTripSkeletons(trip.id); + svc.reconcileTripSkeletons(trip.id); const skeleton = skeletonFor(journey.id, place.id); // Promote to a filled entry with content. testDb @@ -1694,7 +1669,7 @@ describe('reconcileTripSkeletons', () => { .run(skeleton.id); testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignment.id); - reconcileTripSkeletons(trip.id); + svc.reconcileTripSkeletons(trip.id); const kept = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(skeleton.id) as any; expect(kept).toBeDefined(); @@ -1710,11 +1685,11 @@ describe('reconcileTripSkeletons', () => { const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Moving Place' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); - reconcileTripSkeletons(trip.id); + svc.reconcileTripSkeletons(trip.id); expect(skeletonFor(journey.id, place.id).entry_date).toBe(days[0].date); testDb.prepare('UPDATE day_assignments SET day_id = ? WHERE id = ?').run(days[1].id, assignment.id); - reconcileTripSkeletons(trip.id); + svc.reconcileTripSkeletons(trip.id); expect(skeletonFor(journey.id, place.id).entry_date).toBe(days[1].date); }); @@ -1724,12 +1699,12 @@ describe('reconcileTripSkeletons', () => { const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Stable Place' }); createDayAssignment(testDb, days[0].id, place.id); - reconcileTripSkeletons(trip.id); + svc.reconcileTripSkeletons(trip.id); const before = testDb .prepare('SELECT id, updated_at FROM journey_entries WHERE journey_id = ? ORDER BY id') .all(journey.id) as { id: number; updated_at: number }[]; - reconcileTripSkeletons(trip.id); + svc.reconcileTripSkeletons(trip.id); const after = testDb .prepare('SELECT id, updated_at FROM journey_entries WHERE journey_id = ? ORDER BY id') .all(journey.id) as { id: number; updated_at: number }[]; @@ -1744,8 +1719,293 @@ describe('reconcileTripSkeletons', () => { const place = createPlace(testDb, trip.id, { name: 'Orphan' }); createDayAssignment(testDb, days[0].id, place.id); - expect(() => reconcileTripSkeletons(trip.id)).not.toThrow(); + expect(() => svc.reconcileTripSkeletons(trip.id)).not.toThrow(); const anyEntry = testDb.prepare('SELECT COUNT(*) AS n FROM journey_entries').get() as { n: number }; expect(anyEntry.n).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// Skeleton lifecycle, the photo gallery and the per-user preference row. These +// paths were reachable only through the REST controller before the fold, so the +// service-level branches below had no direct case. +// --------------------------------------------------------------------------- + +/** A trip with one day and one place assigned to it — the shape skeleton sync reads. */ +function tripWithPlace(userId: number, opts: { name?: string; date?: string } = {}) { + const trip = createTrip(testDb, userId); + const day = createDay(testDb, trip.id, { date: opts.date ?? '2026-05-01' }); + const place = createPlace(testDb, trip.id, { name: opts.name ?? 'Fushimi Inari' }); + createDayAssignment(testDb, day.id, place.id); + return { trip, day, place }; +} + +function skeletonsOf(journeyId: number) { + return testDb + .prepare("SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton' ORDER BY id") + .all(journeyId) as any[]; +} + +describe('skeleton sync', () => { + it('JOURNEY-SVC-SKEL-001: linking a trip materialises one skeleton per assigned place', () => { + const { user } = createUser(testDb); + const { trip, place } = tripWithPlace(user.id); + const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + + const skeletons = skeletonsOf(journey.id); + expect(skeletons).toHaveLength(1); + expect(skeletons[0].title).toBe('Fushimi Inari'); + expect(skeletons[0].source_place_id).toBe(place.id); + expect(skeletons[0].source_trip_id).toBe(trip.id); + }); + + it('JOURNEY-SVC-SKEL-002: syncing the same trip twice does not duplicate skeletons', () => { + const { user } = createUser(testDb); + const { trip } = tripWithPlace(user.id); + const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + svc.syncTripPlaces(journey.id, trip.id, user.id); + expect(skeletonsOf(journey.id)).toHaveLength(1); + }); + + it('JOURNEY-SVC-SKEL-003: onPlaceCreated adds a skeleton to every journey the trip is linked to', () => { + const { user } = createUser(testDb); + const { trip, day } = tripWithPlace(user.id); + const a = svc.createJourney(user.id, { title: 'A', trip_ids: [trip.id] }); + const b = svc.createJourney(user.id, { title: 'B', trip_ids: [trip.id] }); + + const extra = createPlace(testDb, trip.id, { name: 'Nishiki Market' }); + createDayAssignment(testDb, day.id, extra.id); + svc.onPlaceCreated(trip.id, extra.id); + + for (const j of [a, b]) { + expect(skeletonsOf(j.id).map((s) => s.title)).toContain('Nishiki Market'); + } + }); + + it('JOURNEY-SVC-SKEL-004: onPlaceCreated is a no-op for a trip in no journey', () => { + const { user } = createUser(testDb); + const { trip, place } = tripWithPlace(user.id); + expect(() => svc.onPlaceCreated(trip.id, place.id)).not.toThrow(); + }); + + it('JOURNEY-SVC-SKEL-005: onPlaceUpdated carries the rename and the new address onto the skeleton', () => { + const { user } = createUser(testDb); + const { trip, place } = tripWithPlace(user.id); + const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + + testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('Kinkaku-ji', '1 Kinkakujicho', place.id); + svc.onPlaceUpdated(place.id); + + const [skeleton] = skeletonsOf(journey.id); + expect(skeleton.title).toBe('Kinkaku-ji'); + expect(skeleton.location_name).toBe('1 Kinkakujicho'); + }); + + it('JOURNEY-SVC-SKEL-006: onPlaceUpdated is a no-op when no skeleton points at the place', () => { + const { user } = createUser(testDb); + const { place } = tripWithPlace(user.id); + expect(() => svc.onPlaceUpdated(place.id)).not.toThrow(); + }); + + it('JOURNEY-SVC-SKEL-007: onPlaceDeleted drops an empty skeleton outright', () => { + const { user } = createUser(testDb); + const { trip, place } = tripWithPlace(user.id); + const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + expect(skeletonsOf(journey.id)).toHaveLength(1); + + svc.onPlaceDeleted(place.id); + expect(skeletonsOf(journey.id)).toHaveLength(0); + }); + + it('JOURNEY-SVC-SKEL-008: onPlaceDeleted keeps a skeleton that has a story, detaches it and appends the note', () => { + const { user } = createUser(testDb); + const { trip, place } = tripWithPlace(user.id); + const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const [skeleton] = skeletonsOf(journey.id); + testDb.prepare('UPDATE journey_entries SET story = ? WHERE id = ?').run('We queued for an hour.', skeleton.id); + + svc.onPlaceDeleted(place.id); + + const kept = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(skeleton.id) as any; + expect(kept).toBeDefined(); + expect(kept.source_place_id).toBeNull(); + expect(kept.source_trip_id).toBeNull(); + // A skeleton that survives is promoted to a real entry. + expect(kept.type).toBe('entry'); + expect(kept.story).toContain('removed from the trip plan'); + }); + + it('JOURNEY-SVC-SKEL-009: reconcileTripSkeletons adds what is missing and removes what is gone', () => { + const { user } = createUser(testDb); + const { trip, day, place } = tripWithPlace(user.id); + const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + + // A second place lands without firing the hook, and the first is unassigned. + const second = createPlace(testDb, trip.id, { name: 'Gion' }); + createDayAssignment(testDb, day.id, second.id); + testDb.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(place.id); + + svc.reconcileTripSkeletons(trip.id); + + const titles = skeletonsOf(journey.id).map((s) => s.title); + expect(titles).toContain('Gion'); + expect(titles).not.toContain('Fushimi Inari'); + }); + + it('JOURNEY-SVC-SKEL-010: reconcileTripSkeletons is a no-op for a trip in no journey', () => { + const { user } = createUser(testDb); + const { trip } = tripWithPlace(user.id); + expect(() => svc.reconcileTripSkeletons(trip.id)).not.toThrow(); + }); + + it('JOURNEY-SVC-SKEL-011: createJourney takes its cover from the first linked trip and strips the /uploads prefix', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + testDb.prepare('UPDATE trips SET cover_image = ? WHERE id = ?').run('/uploads/covers/kyoto.jpg', trip.id); + + const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const row = testDb.prepare('SELECT cover_image FROM journeys WHERE id = ?').get(journey.id) as any; + expect(row.cover_image).toBe('covers/kyoto.jpg'); + }); +}); + +describe('journey gallery', () => { + function ownedEntry() { + const { user } = createUser(testDb); + const journey = svc.createJourney(user.id, { title: 'J' }); + const entry = svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Day 1' })!; + return { user, journey, entry }; + } + + it('JOURNEY-SVC-PHOTO-001: addPhoto puts the file in the gallery and links it to the entry', () => { + const { user, journey, entry } = ownedEntry(); + const photo = svc.addPhoto(entry.id, user.id, 'journey/a.jpg', 'journey/a-thumb.jpg', 'Torii'); + expect(photo).toBeTruthy(); + + const entries = svc.listEntries(journey.id, user.id)!; + expect(entries.find((e) => e.id === entry.id)!.photos).toHaveLength(1); + }); + + it('JOURNEY-SVC-PHOTO-002: addPhoto refuses an unknown entry and a non-editor', () => { + const { entry } = ownedEntry(); + const { user: stranger } = createUser(testDb); + expect(svc.addPhoto(999999, 1, 'journey/a.jpg')).toBeNull(); + expect(svc.addPhoto(entry.id, stranger.id, 'journey/a.jpg')).toBeNull(); + }); + + it('JOURNEY-SVC-PHOTO-003: uploadGalleryPhotos appends in order and refuses a non-editor', () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const journey = svc.createJourney(user.id, { title: 'J' }); + + const first = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/1.jpg' }]); + const second = svc.uploadGalleryPhotos(journey.id, user.id, [ + { path: 'journey/2.jpg', thumbnail: 'journey/2-t.jpg' }, + { path: 'journey/3.mp4', mediaType: 'video', durationMs: 4200 }, + ]); + expect(first).toHaveLength(1); + expect(second).toHaveLength(2); + + const orders = testDb + .prepare('SELECT sort_order FROM journey_photos WHERE journey_id = ? ORDER BY sort_order') + .all(journey.id) as any[]; + expect(orders.map((o) => o.sort_order)).toEqual([0, 1, 2]); + + expect(svc.uploadGalleryPhotos(journey.id, stranger.id, [{ path: 'journey/x.jpg' }])).toEqual([]); + }); + + it('JOURNEY-SVC-PHOTO-004: linkPhotoToEntry attaches a gallery row, unlinkPhotoFromEntry detaches it', () => { + const { user, journey, entry } = ownedEntry(); + const [gallery] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); + + expect(svc.linkPhotoToEntry(entry.id, gallery.id, user.id)).toBeTruthy(); + expect(svc.listEntries(journey.id, user.id)!.find((e) => e.id === entry.id)!.photos).toHaveLength(1); + + expect(svc.unlinkPhotoFromEntry(entry.id, gallery.id, user.id)).toBe(true); + expect(svc.listEntries(journey.id, user.id)!.find((e) => e.id === entry.id)!.photos).toHaveLength(0); + // The gallery row survives the unlink — that is the whole point of the split. + expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE id = ?').get(gallery.id)).toBeDefined(); + }); + + it('JOURNEY-SVC-PHOTO-005: link/unlink refuse an unknown entry and a non-editor', () => { + const { user, journey, entry } = ownedEntry(); + const { user: stranger } = createUser(testDb); + const [gallery] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); + + expect(svc.linkPhotoToEntry(999999, gallery.id, user.id)).toBeNull(); + expect(svc.linkPhotoToEntry(entry.id, gallery.id, stranger.id)).toBeNull(); + expect(svc.unlinkPhotoFromEntry(999999, gallery.id, user.id)).toBe(false); + expect(svc.unlinkPhotoFromEntry(entry.id, gallery.id, stranger.id)).toBe(false); + }); + + it('JOURNEY-SVC-PHOTO-006: deleteGalleryPhoto removes the row and refuses an unknown id', () => { + const { user } = createUser(testDb); + const journey = svc.createJourney(user.id, { title: 'J' }); + const [gallery] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); + + expect(svc.deleteGalleryPhoto(gallery.id, user.id)).toBeTruthy(); + expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE id = ?').get(gallery.id)).toBeUndefined(); + expect(svc.deleteGalleryPhoto(999999, user.id)).toBeNull(); + }); + + it('JOURNEY-SVC-PHOTO-007: deleteGalleryPhoto refuses a non-editor', () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const journey = svc.createJourney(user.id, { title: 'J' }); + const [gallery] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); + expect(svc.deleteGalleryPhoto(gallery.id, stranger.id)).toBeNull(); + }); +}); + +describe('per-user journey preferences', () => { + it('JOURNEY-SVC-PREF-001: hide_skeletons round-trips for the owner', () => { + const { user } = createUser(testDb); + const journey = svc.createJourney(user.id, { title: 'J' }); + expect(svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: true })).toEqual({ hide_skeletons: true }); + expect(svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: false })).toEqual({ hide_skeletons: false }); + }); + + it('JOURNEY-SVC-PREF-002: an empty patch is accepted and changes nothing', () => { + const { user } = createUser(testDb); + const journey = svc.createJourney(user.id, { title: 'J' }); + svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: true }); + expect(svc.updateJourneyPreferences(journey.id, user.id, {})).toEqual({ hide_skeletons: true }); + }); + + it('JOURNEY-SVC-PREF-003: a stranger gets null, not a row', () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const journey = svc.createJourney(user.id, { title: 'J' }); + expect(svc.updateJourneyPreferences(journey.id, stranger.id, { hide_skeletons: true })).toBeNull(); + }); +}); + +describe('entry enrichment', () => { + it('JOURNEY-SVC-ENRICH-001: tags and pros_cons come back parsed, and source_trip_name is resolved', () => { + const { user } = createUser(testDb); + const { trip } = tripWithPlace(user.id); + testDb.prepare('UPDATE trips SET title = ? WHERE id = ?').run('Japan 2026', trip.id); + const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + + const [skeleton] = skeletonsOf(journey.id); + testDb + .prepare('UPDATE journey_entries SET tags = ?, pros_cons = ? WHERE id = ?') + .run(JSON.stringify(['shrine']), JSON.stringify({ pros: ['quiet'], cons: [] }), skeleton.id); + + const entry = svc.listEntries(journey.id, user.id)!.find((e) => e.id === skeleton.id)!; + expect(entry.tags).toEqual(['shrine']); + expect((entry as any).pros_cons).toEqual({ pros: ['quiet'], cons: [] }); + expect((entry as any).source_trip_name).toBe('Japan 2026'); + }); + + it('JOURNEY-SVC-ENRICH-002: an entry with no trip and no tags gets [] and null, not undefined', () => { + const { user } = createUser(testDb); + const journey = svc.createJourney(user.id, { title: 'J' }); + const created = svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Solo' })!; + + const entry = svc.listEntries(journey.id, user.id)!.find((e) => e.id === created.id)!; + expect(entry.tags).toEqual([]); + expect((entry as any).pros_cons).toBeNull(); + expect((entry as any).source_trip_name).toBeNull(); + }); +}); diff --git a/server/tests/unit/services/journeyShareService.test.ts b/server/tests/unit/nest/journey-share.service.test.ts similarity index 79% rename from server/tests/unit/services/journeyShareService.test.ts rename to server/tests/unit/nest/journey-share.service.test.ts index 505926cd6..0f48104a0 100644 --- a/server/tests/unit/services/journeyShareService.test.ts +++ b/server/tests/unit/nest/journey-share.service.test.ts @@ -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; 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; 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).photos).toEqual([]); // inline photos withheld too diff --git a/server/tests/unit/plugins/journal-entry-rows.controller.test.ts b/server/tests/unit/plugins/journal-entry-rows.controller.test.ts index ea69ec8e8..dac19fec5 100644 --- a/server/tests/unit/plugins/journal-entry-rows.controller.test.ts +++ b/server/tests/unit/plugins/journal-entry-rows.controller.test.ts @@ -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 = {}) => ({ label: 'Distance', value: '12 km', ...over }); diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index 5c678732f..e2161d2f2 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -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, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);