mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user