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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
@@ -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 {}
@@ -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 | null> {
// 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;
@@ -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<JourneySettingsColumns> {
const flag = (value: boolean | number | undefined): number => (value ? 1 : 0);
return presenceSet<JourneySettingsColumns>({
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<Record<keyof JourneySettingsColumns, string | number | boolean | null>>;
/** The stored values of exactly the columns `patch` writes, read off the row before it, flags as booleans. */
export function previousSettings(
row: Readonly<Record<string, unknown>> | undefined,
patch: Partial<JourneySettingsColumns>,
): 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;
}
@@ -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<JourneySettingsUpdate | null> {
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<Record<string, unknown>> | 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;
}
}
+15 -51
View File
@@ -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({
@@ -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<string, unknown> };
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<string, unknown> };
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();
@@ -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 });
});
});
+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(),