diff --git a/server/scripts/mcp-inline-zod-baseline.json b/server/scripts/mcp-inline-zod-baseline.json index 624147061..76081dbdb 100644 --- a/server/scripts/mcp-inline-zod-baseline.json +++ b/server/scripts/mcp-inline-zod-baseline.json @@ -14,7 +14,7 @@ "src/nest/help/help.mcp.ts": 4, "src/nest/integrations/airtrail.mcp.ts": 2, "src/nest/integrations/dawarich.mcp.ts": 13, - "src/nest/journey/journey.mcp.ts": 57, + "src/nest/journey/journey.mcp.ts": 46, "src/nest/maps/maps.mcp.ts": 18, "src/nest/memories/memories.mcp.ts": 8, "src/nest/notifications/notifications.mcp.ts": 2, diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json index 3175d9811..648efa905 100644 --- a/server/scripts/size-baseline.json +++ b/server/scripts/size-baseline.json @@ -11,7 +11,7 @@ "src/nest/collections/collections.service.ts": 1478, "src/nest/doc-sync/doc-sync.service.ts": 1060, "src/nest/doc-sync/providers/synology-drive.client.ts": 1160, - "src/nest/journey/journey-domain.service.ts": 1770, + "src/nest/journey/journey-domain.service.ts": 1725, "src/nest/places/places.service.ts": 1592, "src/nest/plugins/plugin-runtime.service.ts": 1463, "src/nest/plugins/runtime/plugin-sdk.ts": 1093, diff --git a/server/src/nest/journey/journey-domain.module.ts b/server/src/nest/journey/journey-domain.module.ts index 9a3feb266..681b90ef3 100644 --- a/server/src/nest/journey/journey-domain.module.ts +++ b/server/src/nest/journey/journey-domain.module.ts @@ -19,6 +19,7 @@ import { JourneyEntryStaysService } from './journey-entry-stays.service'; import { JourneyMapService } from './journey-map.service'; import { JourneyRecordedDaysService } from './journey-recorded-days.service'; import { JourneyRecordingPrepService } from './journey-recording-prep.service'; +import { JourneySettingsService } from './journey-settings.service'; import { JourneyShareService } from './journey-share.service'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; @@ -108,6 +109,7 @@ import { Module } from '@nestjs/common'; JourneyEntryStaysService, JourneyRecordingPrepService, JourneyMapService, + JourneySettingsService, ], exports: [ JourneyDomainService, @@ -116,6 +118,7 @@ import { Module } from '@nestjs/common'; JourneyEntryStaysService, JourneyRecordingPrepService, JourneyMapService, + JourneySettingsService, ], }) export class JourneyDomainModule {} diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index a362cb71b..75ff77657 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -29,6 +29,7 @@ import { Places } from '../../db/entities/Places.entity'; import type { PlacesRepository } from '../../db/repositories/Places.repository'; import { presenceSet } from '../../db/repositories/_shared/presence-set'; import { parseRouteGeometry } from './journey-geometry'; +import { journeySettingsPatch, type JourneySettingsInput } from './journey-settings.helpers'; /** * What makes a journal skeleton unique: the place *and* the day assignment it @@ -287,61 +288,15 @@ export class JourneyDomainService { async updateJourney( journeyId: number, userId: number, - data: Partial<{ - title: string; - subtitle: string; - cover_gradient: string; - cover_image: string; - status: string; - status_override: string | null; - show_trip_tracks: boolean | number; - photo_location: boolean | number; - show_verdict: boolean | number; - show_mood: boolean | number; - show_weather: boolean | number; - auto_prepare_recordings: boolean | number; - }>, + data: JourneySettingsInput, sid?: string, ): Promise { // Journey-level settings (title, cover, status) are owner-only — editors // may only edit entries and photos, not reshape the journey itself. if (!(await this.isOwner(journeyId, userId))) return null; - const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived']; - // null hands the state back to the trip dates (#762). - const ALLOWED_OVERRIDES: (string | null)[] = [null, 'draft', 'live', 'completed']; - // JG24: each field is resolved to its bound value here (the status allow-list, every flag to 0/1); - // a field absent from `data` never reaches the SET clause, as the legacy dynamic SET list behaved. - const patch = presenceSet<{ - title: string; - subtitle: string | null; - cover_gradient: string | null; - cover_image: string | null; - status: string; - status_override: string | null; - show_trip_tracks: number; - photo_location: number; - show_verdict: number; - show_mood: number; - show_weather: number; - auto_prepare_recordings: number; - }>({ - title: [data.title !== undefined, data.title as string], - subtitle: [data.subtitle !== undefined, data.subtitle as string], - cover_gradient: [data.cover_gradient !== undefined, data.cover_gradient as string], - cover_image: [data.cover_image !== undefined, data.cover_image as string], - status: [data.status !== undefined && ALLOWED_STATUSES.includes(data.status as string), data.status as string], - status_override: [ - data.status_override !== undefined && ALLOWED_OVERRIDES.includes(data.status_override as string | null), - data.status_override as string | null, - ], - show_trip_tracks: [data.show_trip_tracks !== undefined, data.show_trip_tracks ? 1 : 0], - photo_location: [data.photo_location !== undefined, data.photo_location ? 1 : 0], - show_verdict: [data.show_verdict !== undefined, data.show_verdict ? 1 : 0], - show_mood: [data.show_mood !== undefined, data.show_mood ? 1 : 0], - show_weather: [data.show_weather !== undefined, data.show_weather ? 1 : 0], - auto_prepare_recordings: [data.auto_prepare_recordings !== undefined, data.auto_prepare_recordings ? 1 : 0], - }); + // JG24: one reading of the settings for every surface (journey-settings.helpers.ts). + const patch = journeySettingsPatch(data); if (Object.keys(patch).length === 0) return ((await this.journeysRepo.findById(journeyId)) as Journey) ?? null; diff --git a/server/src/nest/journey/journey-settings.helpers.ts b/server/src/nest/journey/journey-settings.helpers.ts new file mode 100644 index 000000000..c1d64acd0 --- /dev/null +++ b/server/src/nest/journey/journey-settings.helpers.ts @@ -0,0 +1,94 @@ +import { presenceSet } from '../../db/repositories/_shared/presence-set'; + +/** + * A journey's own settings as an update sets them: what the REST route and + * the MCP tool hand in, and the columns that answer. One reading of the + * input for both surfaces: a status outside the allow-list and a status + * override outside its own are left alone, every flag is stored as 0 or 1, + * and a field absent from the input never reaches the update. + */ + +/** What an update of a journey's settings may carry. Flags come as booleans (or the stored 0/1). */ +export type JourneySettingsInput = Partial<{ + title: string; + subtitle: string | null; + cover_gradient: string | null; + cover_image: string; + status: string; + status_override: string | null; + show_trip_tracks: boolean | number; + photo_location: boolean | number; + show_verdict: boolean | number; + show_mood: boolean | number; + show_weather: boolean | number; + auto_prepare_recordings: boolean | number; +}>; + +/** The columns an update writes, as they are stored. */ +export interface JourneySettingsColumns { + title: string; + subtitle: string | null; + cover_gradient: string | null; + cover_image: string | null; + status: string; + status_override: string | null; + show_trip_tracks: number; + photo_location: number; + show_verdict: number; + show_mood: number; + show_weather: number; + auto_prepare_recordings: number; +} + +const ALLOWED_STATUSES: readonly string[] = ['draft', 'active', 'completed', 'archived']; +// null hands the state back to the trip dates (#762). +const ALLOWED_OVERRIDES: readonly (string | null)[] = [null, 'draft', 'live', 'completed']; + +/** The flag columns, stored 0/1 and spoken as booleans. */ +export const JOURNEY_SETTING_FLAGS = [ + 'show_trip_tracks', + 'photo_location', + 'show_verdict', + 'show_mood', + 'show_weather', + 'auto_prepare_recordings', +] as const; + +/** The columns an update of `data` writes, each resolved to its stored value (JG24). */ +export function journeySettingsPatch(data: JourneySettingsInput): Partial { + const flag = (value: boolean | number | undefined): number => (value ? 1 : 0); + return presenceSet({ + title: [data.title !== undefined, data.title as string], + subtitle: [data.subtitle !== undefined, data.subtitle as string | null], + cover_gradient: [data.cover_gradient !== undefined, data.cover_gradient as string | null], + cover_image: [data.cover_image !== undefined, data.cover_image as string], + status: [data.status !== undefined && ALLOWED_STATUSES.includes(data.status as string), data.status as string], + status_override: [ + data.status_override !== undefined && ALLOWED_OVERRIDES.includes(data.status_override as string | null), + data.status_override as string | null, + ], + show_trip_tracks: [data.show_trip_tracks !== undefined, flag(data.show_trip_tracks)], + photo_location: [data.photo_location !== undefined, flag(data.photo_location)], + show_verdict: [data.show_verdict !== undefined, flag(data.show_verdict)], + show_mood: [data.show_mood !== undefined, flag(data.show_mood)], + show_weather: [data.show_weather !== undefined, flag(data.show_weather)], + auto_prepare_recordings: [data.auto_prepare_recordings !== undefined, flag(data.auto_prepare_recordings)], + }); +} + +/** What the settings an update wrote held before, by column, as the MCP tool takes them back (flags as booleans). */ +export type JourneySettingsPrevious = Partial>; + +/** The stored values of exactly the columns `patch` writes, read off the row before it, flags as booleans. */ +export function previousSettings( + row: Readonly> | undefined, + patch: Partial, +): JourneySettingsPrevious { + const previous: JourneySettingsPrevious = {}; + for (const key of Object.keys(patch) as (keyof JourneySettingsColumns)[]) { + const value = row?.[key]; + const isFlag = (JOURNEY_SETTING_FLAGS as readonly string[]).includes(key); + previous[key] = isFlag ? Number(value) === 1 : ((value ?? null) as string | number | null); + } + return previous; +} diff --git a/server/src/nest/journey/journey-settings.service.ts b/server/src/nest/journey/journey-settings.service.ts new file mode 100644 index 000000000..e16b8024d --- /dev/null +++ b/server/src/nest/journey/journey-settings.service.ts @@ -0,0 +1,56 @@ +import { Journeys } from '../../db/entities/Journeys.entity'; +import type { JourneysRepository } from '../../db/repositories/Journeys.repository'; +import type { Journey } from '../../types'; +import { UnitOfWork } from '../database/unit-of-work'; +import { JourneyDomainService } from './journey-domain.service'; +import { + journeySettingsPatch, + previousSettings, + type JourneySettingsInput, + type JourneySettingsPrevious, +} from './journey-settings.helpers'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; + +/** A settings update as the MCP tool answers it: the journey as stored now, and what the update replaced. */ +export interface JourneySettingsUpdate { + journey: Journey; + previous: JourneySettingsPrevious; +} + +/** + * A journey's own settings changed with what they held before: the update + * the REST route makes (JourneyDomainService.updateJourney, the same reading + * of the input and the same owner check), with the stored values of exactly + * the columns it wrote, read in the same transaction. That is what takes the + * change back: the assistant's undo writes those values again through the + * same tool. The broadcast goes out after the commit. + */ +@Injectable() +export class JourneySettingsService { + constructor( + private readonly journeys: JourneyDomainService, + private readonly uow: UnitOfWork, + @InjectRepository(Journeys) private readonly journeysRepo: JourneysRepository, + ) {} + + /** Null for anyone but the owner, who alone reshapes the journey itself, as on the route. */ + async updateWithPrevious( + journeyId: number, + userId: number, + data: JourneySettingsInput, + sid?: string, + ): Promise { + if (!(await this.journeys.isOwner(journeyId, userId))) return null; + const patch = journeySettingsPatch(data); + const written = Object.keys(patch).length > 0; + const previous = await this.uow.transactional(async () => { + const before = await this.journeysRepo.findById(journeyId); + if (written) await this.journeysRepo.updateFields(journeyId, { ...patch, updated_at: Date.now() }); + return previousSettings(before as Readonly> | undefined, patch); + }); + if (written) await this.journeys.broadcastJourneyEvent(journeyId, 'journey:updated', {}, sid); + const journey = (await this.journeysRepo.findById(journeyId)) as Journey | undefined; + return journey ? { journey, previous } : null; + } +} diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 3d75cf1fb..f61921b60 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -16,17 +16,21 @@ import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; import { JourneyDomainService } from './journey-domain.service'; import { JourneyPhotoCaptureService } from './journey-photo-capture.service'; +import { JourneySettingsService } from './journey-settings.service'; import { JourneyShareService } from './journey-share.service'; import { idSchema, + journeyCreateToolSchema, journeyEntriesRangeSchema, journeyEntryProsConsSchema, journeyEntryTypeSchema, journeyEntryVisibilitySchema, journeyShareLinkRequestSchema, - journeyUpdateRequestSchema, + journeyUpdateToolSchema, + type JourneyCreateTool, type JourneyEntryType, type JourneyEntryVisibility, + type JourneyUpdateTool, } from '@trek/shared'; import { z } from 'zod'; @@ -118,6 +122,7 @@ export class JourneyMcp { private readonly share: JourneyShareService, readonly addons: AddonsService, private readonly photoCapture: JourneyPhotoCaptureService, + private readonly settings: JourneySettingsService, ) {} // ── Read ──────────────────────────────────────────────────────────────── @@ -257,19 +262,12 @@ export class JourneyMcp { @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(idSchema).optional(), - }, + inputSchema: journeyCreateToolSchema.shape, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - async createJourney( - { title, subtitle, trip_ids }: { title: string; subtitle?: string; trip_ids?: number[] }, - ctx: McpContext, - ) { + async createJourney({ title, subtitle, trip_ids }: JourneyCreateTool, ctx: McpContext) { const journey = await 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. @@ -279,51 +277,17 @@ export class JourneyMcp { @Tool({ name: 'update_journey', description: - "Update an existing journey's title, subtitle, cover, or status. Owner only. status_override sets the state shown for the journey (draft, live, completed) instead of deriving it from the linked trips' dates; null goes back to the dates. auto_prepare_recordings switches the background preparation of its recorded days from Dawarich on or off (see get_journey_recording_prep).", - inputSchema: { - journeyId: idSchema, - title: z.string().min(1).max(200).optional(), - subtitle: z.string().max(300).optional(), - status: z.enum(['draft', 'active', 'completed', 'archived']).optional(), - status_override: z - .enum(['draft', 'live', 'completed']) - .nullable() - .optional() - .describe('Shown state set by hand, or null to follow the trip dates'), - show_verdict: z.boolean().optional().describe('Whether entries in this journey offer a pros/cons list'), - show_mood: z.boolean().optional().describe('Whether entries in this journey offer a mood'), - show_weather: z.boolean().optional().describe('Whether entries in this journey offer a weather note'), - photo_location: z - .boolean() - .optional() - .describe('Whether an entry without a place takes the position of the first geotagged photo added to it'), - auto_prepare_recordings: journeyUpdateRequestSchema.shape.auto_prepare_recordings, - }, + "Update an existing journey's title, subtitle, cover, or status. Owner only. status_override sets the state shown for the journey (draft, live, completed) instead of deriving it from the linked trips' dates; null goes back to the dates. auto_prepare_recordings switches the background preparation of its recorded days from Dawarich on or off (see get_journey_recording_prep). Answers with the journey and, under previous, what the changed settings held before.", + inputSchema: { journeyId: idSchema, ...journeyUpdateToolSchema.shape }, annotations: TOOL_ANNOTATIONS_WRITE, when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - async updateJourney( - { - journeyId, - ...data - }: { - journeyId: number; - title?: string; - subtitle?: string; - status?: string; - status_override?: string | null; - show_verdict?: boolean; - show_mood?: boolean; - show_weather?: boolean; - photo_location?: boolean; - auto_prepare_recordings?: boolean; - }, - ctx: McpContext, - ) { - const journey = await this.journey.updateJourney(journeyId, ctx.userId, data); - if (!journey) return notFound('Journey not found or access denied.'); - return ok({ journey }); + async updateJourney({ journeyId, ...data }: JourneyUpdateTool & { journeyId: number }, ctx: McpContext) { + // The same owner check and reading of the settings as PATCH /api/journeys/:id, with what they held before. + const updated = await this.settings.updateWithPrevious(journeyId, ctx.userId, data); + if (!updated) return notFound('Journey not found or access denied.'); + return ok(updated); } @Tool({ diff --git a/server/tests/unit/mcp/tools-journey.test.ts b/server/tests/unit/mcp/tools-journey.test.ts index d2a0c2dc6..42ebb09ad 100644 --- a/server/tests/unit/mcp/tools-journey.test.ts +++ b/server/tests/unit/mcp/tools-journey.test.ts @@ -454,6 +454,54 @@ describe('journey write tools', () => { }); }); + it('update_journey answers what the changed settings held before, flags as booleans, and only those', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const journey = await seedJourney(h, 'Before'); + const updated = parseToolResult( + await h.client.callTool({ + name: 'update_journey', + arguments: { journeyId: journey.id, title: 'After', show_mood: false, status_override: 'live' }, + }), + ) as { journey: { title: string; show_mood: number }; previous: Record }; + expect(updated.journey).toMatchObject({ title: 'After', show_mood: 0 }); + expect(updated.previous).toEqual({ title: 'Before', show_mood: true, status_override: null }); + // Nothing to change answers the journey as it is, with nothing replaced. + const same = parseToolResult( + await h.client.callTool({ name: 'update_journey', arguments: { journeyId: journey.id } }), + ) as { previous: Record }; + expect(same.previous).toEqual({}); + }); + }); + + it('update_journey takes the trip tracks and the cover gradient the REST route takes, and clears a subtitle', async () => { + const { user } = createUser(testDb); + 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, + show_trip_tracks: true, + cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)', + subtitle: null, + }, + }), + ) as { journey: { show_trip_tracks: number; cover_gradient: string; subtitle: string | null } }; + expect(updated.journey).toMatchObject({ + show_trip_tracks: 1, + cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)', + subtitle: null, + }); + const refused = await h.client.callTool({ + name: 'update_journey', + arguments: { journeyId: journey.id, show_trip_tracks: 'yes' }, + }); + expect(refused.isError).toBe(true); + }); + }); + it('delete_journey removes it and refuses a foreign journey', async () => { const { user } = createUser(testDb); const { journeyId } = await foreignJourney(); diff --git a/server/tests/unit/nest/journey-settings.helpers.test.ts b/server/tests/unit/nest/journey-settings.helpers.test.ts new file mode 100644 index 000000000..687c1d92d --- /dev/null +++ b/server/tests/unit/nest/journey-settings.helpers.test.ts @@ -0,0 +1,52 @@ +/** + * The one reading of a journey's settings both the REST route and the MCP + * tool update through, and what an update replaced, as the undo writes it + * back: only the columns the update named, flags as booleans. + */ +import { journeySettingsPatch, previousSettings } from '../../../src/nest/journey/journey-settings.helpers'; + +import { describe, expect, it } from 'vitest'; + +describe('journeySettingsPatch', () => { + it('JSET-001: writes only what the input names, every flag as 0 or 1, null where it clears', () => { + expect(journeySettingsPatch({ title: 'Japan', show_mood: false, show_trip_tracks: true, subtitle: null })).toEqual({ + title: 'Japan', + subtitle: null, + show_mood: 0, + show_trip_tracks: 1, + }); + expect(journeySettingsPatch({})).toEqual({}); + expect(journeySettingsPatch({ auto_prepare_recordings: 1, cover_gradient: null })).toEqual({ + auto_prepare_recordings: 1, + cover_gradient: null, + }); + }); + + it('JSET-002: leaves a status or an override outside its allow-list alone, and takes null back to the dates', () => { + expect(journeySettingsPatch({ status: 'bogus', status_override: 'upcoming' })).toEqual({}); + expect(journeySettingsPatch({ status: 'archived', status_override: null })).toEqual({ + status: 'archived', + status_override: null, + }); + }); +}); + +describe('previousSettings', () => { + const row = { title: 'Japan', subtitle: null, show_mood: 1, show_verdict: 0, status: 'active', cover_gradient: null }; + + it('JSET-010: reads the stored values of exactly the written columns, flags as booleans', () => { + const patch = journeySettingsPatch({ title: 'Nippon', show_mood: false, show_verdict: true, subtitle: 'Autumn' }); + expect(previousSettings(row, patch)).toEqual({ + title: 'Japan', + subtitle: null, + show_mood: true, + show_verdict: false, + }); + }); + + it('JSET-011: a column the row does not hold reads as null, and a journey that is gone gives nulls', () => { + const patch = journeySettingsPatch({ status_override: 'live', show_weather: true }); + expect(previousSettings(row, patch)).toEqual({ status_override: null, show_weather: false }); + expect(previousSettings(undefined, journeySettingsPatch({ title: 'X' }))).toEqual({ title: null }); + }); +}); diff --git a/shared/src/journey/journey.schema.spec.ts b/shared/src/journey/journey.schema.spec.ts index d953d5885..cb88c6a03 100644 --- a/shared/src/journey/journey.schema.spec.ts +++ b/shared/src/journey/journey.schema.spec.ts @@ -12,6 +12,8 @@ import { journeyEntryProsConsSchema, journeyEntryTypeSchema, journeyEntryVisibilitySchema, + journeyCreateToolSchema, + journeyUpdateToolSchema, } from './journey.schema'; import { describe, it, expect } from 'vitest'; @@ -140,3 +142,22 @@ describe('the entry enums and the range of a read', () => { expect(journeyEntriesRangeSchema.safeParse({ to: 'yesterday' }).success).toBe(false); }); }); + +describe('the journey tool arguments', () => { + it('JSCHEMA-TOOL-001: a new journey needs a title and refuses what the tool does not take', () => { + expect(journeyCreateToolSchema.parse({ title: 'Japan', trip_ids: [5] })).toEqual({ title: 'Japan', trip_ids: [5] }); + expect(journeyCreateToolSchema.safeParse({ title: '' }).success).toBe(false); + expect(journeyCreateToolSchema.safeParse({ title: 'x'.repeat(201) }).success).toBe(false); + expect(journeyCreateToolSchema.safeParse({ title: 'Japan', cover: 'x' }).success).toBe(false); + }); + + it('JSCHEMA-TOOL-002: an update takes the trip tracks and the cover gradient, and null for the subtitle', () => { + const update = { show_trip_tracks: true, cover_gradient: null, subtitle: null, status_override: null }; + expect(journeyUpdateToolSchema.parse(update)).toEqual(update); + expect(journeyUpdateToolSchema.safeParse({ status: 'upcoming' }).success).toBe(false); + expect(journeyUpdateToolSchema.safeParse({ status_override: 'active' }).success).toBe(false); + expect(journeyUpdateToolSchema.safeParse({ title: null }).success).toBe(false); + expect(journeyUpdateToolSchema.safeParse({ show_mood: 'yes' }).success).toBe(false); + expect(journeyUpdateToolSchema.safeParse({ cover_gradient: 'x'.repeat(201) }).success).toBe(false); + }); +}); diff --git a/shared/src/journey/journey.schema.ts b/shared/src/journey/journey.schema.ts index c6d8fac30..c4c8e448a 100644 --- a/shared/src/journey/journey.schema.ts +++ b/shared/src/journey/journey.schema.ts @@ -1,3 +1,4 @@ +import { idSchema } from '../common/primitives.schema'; import { journeyRecordingDateSchema } from './journey-recordings.schema'; import { z } from 'zod'; @@ -49,6 +50,58 @@ export const journeyUpdateRequestSchema = z.looseObject({ }); export type JourneyUpdateRequest = z.infer; +/** A journey's stored states, and the state shown in place of the one its trips' dates give (#762). */ +export const JOURNEY_STATUSES = ['draft', 'active', 'completed', 'archived'] as const; +export const journeyStatusSchema = z.enum(JOURNEY_STATUSES); +export const JOURNEY_STATUS_OVERRIDES = ['draft', 'live', 'completed'] as const; +export const journeyStatusOverrideSchema = z.enum(JOURNEY_STATUS_OVERRIDES); + +/** + * The new journey the MCP tool `create_journey` takes. Strict, unlike the + * REST body above, whose bespoke 'Title is required' is pinned: a tool's + * arguments are checked here and nowhere else. + */ +export const journeyCreateToolSchema = z.strictObject({ + title: z.string().min(1).max(200), + subtitle: z.string().max(300).optional(), + trip_ids: z.array(idSchema).optional(), +}); +export type JourneyCreateTool = z.infer; + +/** + * The settings the MCP tool `update_journey` changes, every one the REST + * PATCH takes but the cover image (an upload). The subtitle and the cover + * gradient take null to clear them, so a change can always be written back. + */ +export const journeyUpdateToolSchema = z.strictObject({ + title: z.string().min(1).max(200).optional(), + subtitle: z.string().max(300).nullable().optional(), + status: journeyStatusSchema.optional(), + status_override: journeyStatusOverrideSchema + .nullable() + .optional() + .describe('Shown state set by hand, or null to follow the trip dates'), + show_verdict: z.boolean().optional().describe('Whether entries in this journey offer a pros/cons list'), + show_mood: z.boolean().optional().describe('Whether entries in this journey offer a mood'), + show_weather: z.boolean().optional().describe('Whether entries in this journey offer a weather note'), + photo_location: z + .boolean() + .optional() + .describe('Whether an entry without a place takes the position of the first geotagged photo added to it'), + auto_prepare_recordings: journeyUpdateRequestSchema.shape.auto_prepare_recordings, + show_trip_tracks: z + .boolean() + .optional() + .describe("Whether the journey's map draws the GPX tracks of its trips' places"), + cover_gradient: z + .string() + .max(200) + .nullable() + .optional() + .describe('A CSS gradient the cover shows while it has no image; null clears it'), +}); +export type JourneyUpdateTool = z.infer; + export const journeyAddTripRequestSchema = z.looseObject({ // Handler answers 'trip_id required'. trip_id: z.unknown().optional(),