feat(journey): update_journey takes every setting REST does and answers what it replaced

The MCP tool lacked show_trip_tracks and cover_gradient, which PATCH
/api/journeys/:id has always taken. Both now read the input through one
helper, and the tool goes through a small settings service that writes the
same patch in a transaction with the stored values it replaces, answered
as previous, so a change can be written back. The subtitle and the cover
gradient take null to clear them. The tool's inline zod moves into strict
schemas in @trek/shared; the REST route and its body are unchanged.
This commit is contained in:
Maurice
2026-10-24 21:36:12 +02:00
parent 4b3340546f
commit cf35dc7afd
11 changed files with 348 additions and 102 deletions
+21
View File
@@ -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);
});
});
+53
View File
@@ -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<typeof journeyUpdateRequestSchema>;
/** 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<typeof journeyCreateToolSchema>;
/**
* 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<typeof journeyUpdateToolSchema>;
export const journeyAddTripRequestSchema = z.looseObject({
// Handler answers 'trip_id required'.
trip_id: z.unknown().optional(),