mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(studio): change a book page by page over MCP, and pick its photos from the gallery
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": 66,
|
||||
"src/nest/journey/journey.mcp.ts": 57,
|
||||
"src/nest/maps/maps.mcp.ts": 18,
|
||||
"src/nest/memories/memories.mcp.ts": 8,
|
||||
"src/nest/notifications/notifications.mcp.ts": 2,
|
||||
|
||||
@@ -15,6 +15,28 @@ import { PACKAGE_HELD_READS, PACKAGE_TOOL_CONTROLLERS } from './cards/registry';
|
||||
* and the model loads an addon's tools when it needs them (USE_TOOLS_TOOL).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Studio book tools wait for the Studio's own chat: a trip chat has no
|
||||
* page to show a change on, no way yet to take one back and no forced
|
||||
* `returnPrevious`, and an applied write would reload an open Studio from its
|
||||
* cover. External MCP clients keep them; only the assistant leaves them out
|
||||
* until the journey scope offers them.
|
||||
*/
|
||||
const STUDIO_ONLY_TOOLS: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
[
|
||||
'get_book',
|
||||
'describe_book_page',
|
||||
'list_book_layouts',
|
||||
'create_book',
|
||||
'add_book_pages',
|
||||
'move_book_page',
|
||||
'delete_book_pages',
|
||||
'edit_book_page',
|
||||
'set_book_cover',
|
||||
'suggest_book_photos',
|
||||
].map((name) => [name, 'a Studio book tool: offered in the Studio chat only, never in a trip chat']),
|
||||
);
|
||||
|
||||
/** Never offered, whatever the user approves, each for the reason beside it. */
|
||||
export const ASSISTANT_DENIED_TOOLS: Readonly<Record<string, string>> = {
|
||||
delete_trip: 'irreversible, and it takes the conversation with it',
|
||||
@@ -60,6 +82,8 @@ export const ASSISTANT_DENIED_TOOLS: Readonly<Record<string, string>> = {
|
||||
update_manual_school_holiday_region: 'changes the holidays of every user',
|
||||
delete_manual_school_holiday_region: 'changes the holidays of every user',
|
||||
delete_manual_school_holiday_country: 'changes the holidays of every user',
|
||||
...STUDIO_ONLY_TOOLS,
|
||||
restore_book_page: 'an undo target only: it puts a book page back as an earlier change left it',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -121,7 +145,7 @@ const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
about:
|
||||
'Tours: hikes, bike tours, city walks, paddling and ski routes; plan, create, change and export them ' +
|
||||
'(hikes with their heights and SAC grades), their packing, the tour library and imports from GPX files ' +
|
||||
'and Google Maps.',
|
||||
'and Google Maps walks.',
|
||||
controllers: [
|
||||
'ToursMcp',
|
||||
'CityWalkMcp',
|
||||
@@ -136,11 +160,12 @@ const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
'CollectionToursMcp',
|
||||
'PaddleRoutingMcp',
|
||||
'SkiRoutingMcp',
|
||||
'GoogleRouteMcp',
|
||||
],
|
||||
},
|
||||
roadtrip: {
|
||||
about: 'Road trips and transit: the driving route and its stops, charging, hazards, public transport journeys.',
|
||||
about:
|
||||
'Road trips and transit: the driving route and its stops, where each day ends, charging, hazards, ' +
|
||||
'public transport journeys and driving routes imported from Google Maps.',
|
||||
controllers: [
|
||||
'RoadtripMcp',
|
||||
'RoadtripPlanningMcp',
|
||||
@@ -158,6 +183,8 @@ const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
'JourneyMcp',
|
||||
'JourneyMapMcp',
|
||||
'JourneyTracksMcp',
|
||||
'JourneyBookMcp',
|
||||
'JourneyBookPhotosMcp',
|
||||
'JourneyRecordingsMcp',
|
||||
'JourneyDayImportMcp',
|
||||
'TripJournalLinkMcp',
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { JourneyEntries } from '../../db/entities/JourneyEntries.entity';
|
||||
import { JourneyPhotos } from '../../db/entities/JourneyPhotos.entity';
|
||||
import type { JourneyEntriesRepository } from '../../db/repositories/JourneyEntries.repository';
|
||||
import type { JourneyPhotosRepository } from '../../db/repositories/JourneyPhotos.repository';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { JourneyBookService } from './journey-book.service';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
addBookPages,
|
||||
applyBookOps,
|
||||
blankBook,
|
||||
BOOK_OP_LIMITS,
|
||||
bookPageSetupSchema,
|
||||
deleteBookPages,
|
||||
describeSpread,
|
||||
listBookLayouts,
|
||||
moveBookPage,
|
||||
restoreBookPage,
|
||||
setBookCover,
|
||||
spreadFindings,
|
||||
summarizeBook,
|
||||
summarizeSpread,
|
||||
type AddBookPagesRequest,
|
||||
type BookDocument,
|
||||
type BookEditResult,
|
||||
type BookOpError,
|
||||
type BookOpsContext,
|
||||
type BookOverview,
|
||||
type BookPageDescription,
|
||||
type BookPreviousSpread,
|
||||
type BookSpreadSummary,
|
||||
type BookWriteResult,
|
||||
type CreateBookRequest,
|
||||
type DeleteBookPagesRequest,
|
||||
type EditBookPageRequest,
|
||||
type MoveBookPageRequest,
|
||||
type RestoreBookPageRequest,
|
||||
type SetBookCoverRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A book write's answer, in the shared result's shape. */
|
||||
export type BookWriteAnswer = BookWriteResult;
|
||||
|
||||
/** One write as the engine runs it: the stored document (null without a book) and the journey in, a result out. */
|
||||
type BookChange = (doc: BookDocument | null, ctx: BookOpsContext) => BookEditResult;
|
||||
|
||||
/** A successful engine result. */
|
||||
type BookMade = Extract<BookEditResult, { ok: true }>;
|
||||
|
||||
/** What of the journey a binding reads. */
|
||||
interface JourneyWords {
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
}
|
||||
|
||||
/** How one write answers beyond the pages it touched. */
|
||||
interface WriteOptions {
|
||||
returnPrevious?: boolean;
|
||||
expectedVersion?: number;
|
||||
/** More of the answer, from the book as it was before the write. */
|
||||
extra?: (before: BookDocument | null) => Partial<BookWriteResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The assistant's and every MCP client's way to read and change a TREK Studio
|
||||
* book (journey-book.mcp.ts), one page at a time.
|
||||
*
|
||||
* The edits themselves are the shared engine (shared/src/book/), pure and the
|
||||
* same one the Studio previews and a chat card draws with. What needs I/O
|
||||
* stays here: the journey's access, its gallery and entries for the engine to
|
||||
* check ids against, and the answer a model reads. Every write goes through
|
||||
* `JourneyBookService.editBook`, so it is one transaction against the stored
|
||||
* version, refused whole when an operation is refused, and announced after the
|
||||
* commit like a save from the Studio.
|
||||
*
|
||||
* The rights are the save route's: any member reads, the owner and editors
|
||||
* write, a viewer is refused (403), an outsider is told the journey is not
|
||||
* there (404). Unlike the save route a tool may only bring in photos of the
|
||||
* journey's own gallery: the REST save stays as it is.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JourneyBookEditService {
|
||||
constructor(
|
||||
private readonly books: JourneyBookService,
|
||||
private readonly journey: JourneyDomainService,
|
||||
@InjectRepository(JourneyPhotos) private readonly photosRepo: JourneyPhotosRepository,
|
||||
@InjectRepository(JourneyEntries) private readonly entriesRepo: JourneyEntriesRepository,
|
||||
) {}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** `get_book`: the book without its document; a journey without one answers `exists: false`. */
|
||||
async overview(journeyId: number, userId: number): Promise<BookOverview> {
|
||||
const { record } = await this.readable(journeyId, userId);
|
||||
return summarizeBook(record?.document ?? null, record?.version ?? null);
|
||||
}
|
||||
|
||||
/** `describe_book_page`: one page in words, with its findings and its elements. */
|
||||
async describePage(journeyId: number, userId: number, spreadId: string): Promise<BookPageDescription> {
|
||||
const { journey, record } = await this.readable(journeyId, userId);
|
||||
if (!record) throw noBook();
|
||||
const ctx = await this.context(journeyId, journey);
|
||||
const described = describeSpread(record.document, spreadId, ctx.photos);
|
||||
if (!described) throw pageNotFound(spreadId);
|
||||
return described;
|
||||
}
|
||||
|
||||
/** `list_book_layouts`: the layouts a page can take, with what each does to what is on it. */
|
||||
async layouts(journeyId: number, userId: number, role?: 'inner' | 'single') {
|
||||
const { record } = await this.readable(journeyId, userId);
|
||||
const doc = record?.document ?? null;
|
||||
return listBookLayouts(doc, doc?.page ?? bookPageSetupSchema.parse({}), role);
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** A blank book (cover, first page, one spread, last page, back) for a journey that has none. */
|
||||
async createBook(journeyId: number, userId: number, args: CreateBookRequest): Promise<BookWriteAnswer> {
|
||||
return this.write(journeyId, userId, (doc, ctx) => {
|
||||
if (doc) {
|
||||
throw new DomainError(409, 'This journey has a book already', {
|
||||
code: 'book_exists',
|
||||
mcpMessage: 'This journey has a book already: read it with get_book and change it instead.',
|
||||
});
|
||||
}
|
||||
const title = args.title?.trim() || ctx.journey?.title || '';
|
||||
const book = blankBook({ title, preset: args.preset });
|
||||
const ids = book.spreads.map((s) => s.id);
|
||||
return { ok: true, doc: book, spreadIds: ids, created: ids, dropped: [] };
|
||||
});
|
||||
}
|
||||
|
||||
async addPages(journeyId: number, userId: number, args: AddBookPagesRequest): Promise<BookWriteAnswer> {
|
||||
return this.write(journeyId, userId, (doc, ctx) => addBookPages(stored(doc), args, ctx));
|
||||
}
|
||||
|
||||
/** The answer's `from_index` is where the page stood, so the assistant's undo can move it back. */
|
||||
async movePage(journeyId: number, userId: number, args: MoveBookPageRequest): Promise<BookWriteAnswer> {
|
||||
return this.write(journeyId, userId, (doc) => moveBookPage(stored(doc), args), {
|
||||
extra: (before) => {
|
||||
const from = before?.spreads.findIndex((s) => s.id === args.spreadId) ?? -1;
|
||||
return from >= 0 ? { from_index: from } : {};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deletePages(journeyId: number, userId: number, args: DeleteBookPagesRequest): Promise<BookWriteAnswer> {
|
||||
return this.write(journeyId, userId, (doc) => deleteBookPages(stored(doc), args), {
|
||||
returnPrevious: args.returnPrevious,
|
||||
});
|
||||
}
|
||||
|
||||
async editPage(journeyId: number, userId: number, args: EditBookPageRequest): Promise<BookWriteAnswer> {
|
||||
const edit = { spreadId: args.spreadId, ops: args.ops };
|
||||
return this.write(journeyId, userId, (doc, ctx) => applyBookOps(stored(doc), edit, ctx), {
|
||||
returnPrevious: args.returnPrevious,
|
||||
});
|
||||
}
|
||||
|
||||
async setCover(journeyId: number, userId: number, args: SetBookCoverRequest): Promise<BookWriteAnswer> {
|
||||
return this.write(journeyId, userId, (doc, ctx) => setBookCover(stored(doc), args, ctx), {
|
||||
returnPrevious: args.returnPrevious,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages put back as they were, for the assistant's undo: all the pages one
|
||||
* change answered under `previous_spreads` in one call, and only while the
|
||||
* book is still at `expectedVersion`, so an undo never overwrites what anyone
|
||||
* saved after the change it takes back (409 instead). The engine holds a
|
||||
* restore to an edit's rules: photos from the gallery or the book, locked
|
||||
* elements as they are.
|
||||
*/
|
||||
async restorePage(journeyId: number, userId: number, args: RestoreBookPageRequest): Promise<BookWriteAnswer> {
|
||||
return this.write(journeyId, userId, (doc, ctx) => restoreBookPage(stored(doc), args, ctx), {
|
||||
expectedVersion: args.expectedVersion,
|
||||
});
|
||||
}
|
||||
|
||||
// ── The pieces ───────────────────────────────────────────────────────────
|
||||
|
||||
/** The journey as a member sees it and its book. 404 for anyone else. */
|
||||
private async readable(journeyId: number, userId: number) {
|
||||
const journey = await this.journey.canAccessJourney(journeyId, userId);
|
||||
if (!journey) throw journeyNotFound();
|
||||
const record = await this.books.getBook(journeyId, userId);
|
||||
return { journey, record };
|
||||
}
|
||||
|
||||
/**
|
||||
* The gallery and the entries, for the engine: a photo an operation brings in
|
||||
* must be in the gallery, an entry a page or a binding names must be the
|
||||
* journey's. Sizes are left out on purpose: `trek_photos.width/height` hold
|
||||
* a thumbnail's size for an uploaded photo and nothing for a provider's, and
|
||||
* the print check must read the original's or say nothing.
|
||||
*/
|
||||
private async context(journeyId: number, journey: JourneyWords): Promise<BookOpsContext> {
|
||||
const gallery = await this.photosRepo.galleryRead(journeyId);
|
||||
const entries = await this.entriesRepo.listForJourney(journeyId);
|
||||
return {
|
||||
photos: gallery.map((p) => ({ photoId: p.photo_id, caption: p.caption ?? null })),
|
||||
journey: {
|
||||
title: journey.title,
|
||||
subtitle: journey.subtitle ?? null,
|
||||
entries: entries.map((e) => ({
|
||||
id: e.id,
|
||||
title: e.title ?? null,
|
||||
story: e.story ?? null,
|
||||
location: e.location_name ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One write: the change run on the stored document inside `editBook`'s
|
||||
* transaction, with the gallery and the entries read in that same
|
||||
* transaction, so a photo taken out of the gallery a moment before cannot be
|
||||
* placed. A refusal from the engine throws there, so the transaction rolls
|
||||
* back with nothing written or sent. An engine answer that is the stored
|
||||
* document itself changed nothing, and nothing is written.
|
||||
*/
|
||||
private async write(
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
change: BookChange,
|
||||
options: WriteOptions = {},
|
||||
): Promise<BookWriteAnswer> {
|
||||
const journey = await this.journey.canAccessJourney(journeyId, userId);
|
||||
if (!journey) throw journeyNotFound();
|
||||
const { record, result } = await this.books.editBook(
|
||||
journeyId,
|
||||
userId,
|
||||
async (current) => {
|
||||
const ctx = await this.context(journeyId, journey);
|
||||
const before = current?.document ?? null;
|
||||
const made = change(before, ctx);
|
||||
if (made.ok === false) throw refused(made.error);
|
||||
const unchanged = before !== null && made.doc === before;
|
||||
const title = current?.title ?? made.doc.title;
|
||||
return { title, document: made.doc, unchanged, result: { before, made, ctx } };
|
||||
},
|
||||
{ expectedVersion: options.expectedVersion },
|
||||
);
|
||||
const answer = pagesOf(record.document, result.made, result.ctx);
|
||||
return {
|
||||
version: record.version,
|
||||
spread_ids: result.made.spreadIds,
|
||||
pages: answer.pages,
|
||||
findings: answer.findings,
|
||||
dropped: result.made.dropped,
|
||||
...(options.returnPrevious ? { previous_spreads: previousOf(result.before, result.made.spreadIds) } : {}),
|
||||
...options.extra?.(result.before),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The pages a write touched as they are stored now, with their findings, severe first and capped. */
|
||||
function pagesOf(doc: BookDocument, made: BookMade, ctx: BookOpsContext) {
|
||||
const pages: BookSpreadSummary[] = [];
|
||||
const findings: BookWriteResult['findings'] = [];
|
||||
for (const id of made.spreadIds) {
|
||||
const index = doc.spreads.findIndex((s) => s.id === id);
|
||||
const spread = doc.spreads[index];
|
||||
if (!spread) continue;
|
||||
pages.push(summarizeSpread(spread, index));
|
||||
findings.push(...spreadFindings(spread, doc.page, ctx.photos));
|
||||
}
|
||||
const order = { severe: 0, warn: 1, info: 2 } as const;
|
||||
findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
||||
return { pages, findings: findings.slice(0, BOOK_OP_LIMITS.findings) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages as they were before the write, lowest index first, for the
|
||||
* assistant's undo (`restore_book_page` puts them back in that order, so each
|
||||
* index means what it meant before). Only pages that existed before.
|
||||
*/
|
||||
function previousOf(before: BookDocument | null, spreadIds: readonly string[]): BookPreviousSpread[] {
|
||||
if (!before) return [];
|
||||
return before.spreads.flatMap((spread, index) => (spreadIds.includes(spread.id) ? [{ index, spread }] : []));
|
||||
}
|
||||
|
||||
/** Every write but `create_book` needs a book to change. */
|
||||
function stored(doc: BookDocument | null): BookDocument {
|
||||
if (!doc) throw noBook();
|
||||
return doc;
|
||||
}
|
||||
|
||||
function journeyNotFound(): DomainError {
|
||||
return new DomainError(404, 'Journey not found', { mcpMessage: 'Journey not found or access denied.' });
|
||||
}
|
||||
|
||||
function noBook(): DomainError {
|
||||
return new DomainError(404, 'This journey has no book yet', {
|
||||
code: 'book_missing',
|
||||
mcpMessage: 'This journey has no book yet: create_book makes one.',
|
||||
});
|
||||
}
|
||||
|
||||
function pageNotFound(spreadId: string): DomainError {
|
||||
return new DomainError(404, `No page ${spreadId} in this book`, {
|
||||
code: 'book_page_missing',
|
||||
mcpMessage: `The book has no page ${spreadId}: get_book lists the pages with their ids.`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An operation the engine refused, as a 422 that names it: which of the
|
||||
* call's operations (from 0) and the engine's sentence, which says what to do.
|
||||
*/
|
||||
function refused(error: BookOpError): DomainError {
|
||||
const message = error.opIndex === null ? error.message : `ops[${error.opIndex}]: ${error.message}`;
|
||||
return new DomainError(422, message, {
|
||||
code: `book_${error.code}`,
|
||||
details: {
|
||||
code: error.code,
|
||||
opIndex: error.opIndex,
|
||||
...(error.elementId ? { elementId: error.elementId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { McpController, TOOL_ANNOTATIONS_READONLY, Tool, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { JourneyBookPhotosService } from './journey-book-photos.service';
|
||||
import { idSchema, PHOTO_PICK, suggestBookPhotosArgs, type SuggestBookPhotosRequest } from '@trek/shared';
|
||||
|
||||
const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY);
|
||||
|
||||
/**
|
||||
* Photos for a TREK Studio book, picked over MCP from the journey's gallery
|
||||
* (JourneyBookPhotosService). Read only: any member may ask, as anyone who
|
||||
* sees the gallery may. Every field comes from @trek/shared.
|
||||
*/
|
||||
@McpController()
|
||||
export class JourneyBookPhotosMcp {
|
||||
constructor(
|
||||
private readonly photos: JourneyBookPhotosService,
|
||||
// Read by the addon gate.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'suggest_book_photos',
|
||||
description:
|
||||
`Photos of a journey's gallery for a page of its book or for the cover, without looking at them: grouped by day ` +
|
||||
`(the linked entry's date, else when the photo was taken) and by place (${PHOTO_PICK.placeRadiusM} m around, ` +
|
||||
`named after the nearest entry), best first. Each pick says why (sharpest of several similar, landscape, not in ` +
|
||||
`the book yet, has a caption); dropped names what was left out and why: near_duplicate_of another photo (a burst ` +
|
||||
`or a near-identical picture), possibly_blurry, or low_resolution for print, and dropped_more counts those it ` +
|
||||
`has no room to name. Say what you left out. entryId or ` +
|
||||
`date narrows it to one entry or day, purpose cover prefers wide photos with more pixels, limit caps the picks ` +
|
||||
`(${PHOTO_PICK.defaultLimit} unless told, at most ${PHOTO_PICK.maxLimit}), shared over the groups. Sharpness and ` +
|
||||
`likeness are measured once per photo; signals_pending counts photos judged on their date, place and size alone ` +
|
||||
`because they are still being measured, and asking again later knows more. It cannot judge beauty, faces, closed ` +
|
||||
`eyes, who or what is in a photo, or the same scene from another angle.`,
|
||||
inputSchema: { journeyId: idSchema, ...suggestBookPhotosArgs },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async suggestBookPhotos({ journeyId, ...args }: { journeyId: number } & SuggestBookPhotosRequest, ctx: McpContext) {
|
||||
return ok(await this.photos.suggest(journeyId, ctx.userId, args));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { JourneyEntries } from '../../db/entities/JourneyEntries.entity';
|
||||
import { JourneyEntryPhotos } from '../../db/entities/JourneyEntryPhotos.entity';
|
||||
import type { JourneyEntriesRepository } from '../../db/repositories/JourneyEntries.repository';
|
||||
import type { JourneyEntryPhotosRepository } from '../../db/repositories/JourneyEntryPhotos.repository';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { PhotoSignalsService } from '../photos/photo-signals.service';
|
||||
import { JourneyBookService } from './journey-book.service';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
photosInScope,
|
||||
pickBookPhotos,
|
||||
type BookDocument,
|
||||
type BookPhotoSuggestions,
|
||||
type PhotoPickCandidate,
|
||||
type SuggestBookPhotosRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* `suggest_book_photos`: photos of a journey's gallery for a book page or
|
||||
* the cover, grouped by day and place, near duplicates folded onto the best
|
||||
* of them and the doubtful ones named, so the assistant picks from a short
|
||||
* list instead of the whole gallery.
|
||||
*
|
||||
* The picking is the shared `pickBookPhotos`; what needs I/O is here: the
|
||||
* journey's access (any member may ask, as anyone who sees the gallery may),
|
||||
* its entries and which photos they show, the photos the book has already,
|
||||
* and the pixel signals, which PhotoSignalsService measures off the event loop
|
||||
* and this waits for only so long. The photos an entry or a day narrows to go
|
||||
* first in that line; the rest of the gallery follows behind, unwaited, since
|
||||
* the journey's typical sharpness is taken over every photo measured so far.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JourneyBookPhotosService {
|
||||
constructor(
|
||||
private readonly journey: JourneyDomainService,
|
||||
private readonly books: JourneyBookService,
|
||||
private readonly signals: PhotoSignalsService,
|
||||
@InjectRepository(JourneyEntries) private readonly entriesRepo: JourneyEntriesRepository,
|
||||
@InjectRepository(JourneyEntryPhotos) private readonly entryPhotosRepo: JourneyEntryPhotosRepository,
|
||||
) {}
|
||||
|
||||
async suggest(journeyId: number, userId: number, args: SuggestBookPhotosRequest): Promise<BookPhotoSuggestions> {
|
||||
if (!(await this.journey.canAccessJourney(journeyId, userId))) {
|
||||
throw new DomainError(404, 'Journey not found', { mcpMessage: 'Journey not found or access denied.' });
|
||||
}
|
||||
const entries = await this.entriesRepo.listForJourney(journeyId);
|
||||
if (args.entryId !== undefined && !entries.some((e) => e.id === args.entryId)) {
|
||||
throw new DomainError(404, `No entry ${args.entryId} in this journey`, {
|
||||
code: 'entry_not_found',
|
||||
mcpMessage: `This journey has no entry ${args.entryId}: get_journey lists its entries.`,
|
||||
});
|
||||
}
|
||||
const links = await this.entryPhotosRepo.listForJourney(journeyId);
|
||||
const record = await this.books.getBook(journeyId, userId);
|
||||
const gallery = await this.signals.galleryFacts(journeyId);
|
||||
|
||||
// Links to a dismissed entry are left out: get_journey does not list that entry either.
|
||||
const listed = new Set(entries.map((e) => e.id));
|
||||
const entriesOf = new Map<number, number[]>();
|
||||
for (const link of links) {
|
||||
if (listed.has(link.entry_id)) {
|
||||
entriesOf.set(link.photo_id, [...(entriesOf.get(link.photo_id) ?? []), link.entry_id]);
|
||||
}
|
||||
}
|
||||
const inBook = photosInBook(record?.document ?? null);
|
||||
const candidates: PhotoPickCandidate[] = gallery.map((p) => ({
|
||||
photoId: p.photo_id,
|
||||
takenAt: p.taken_at,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
caption: p.caption,
|
||||
entryIds: entriesOf.get(p.photo_id) ?? [],
|
||||
inBook: inBook.has(p.photo_id),
|
||||
signals: null,
|
||||
}));
|
||||
const pickEntries = entries.map((e) => ({
|
||||
id: e.id,
|
||||
date: e.entry_date,
|
||||
title: e.title ?? null,
|
||||
location: e.location_name ?? null,
|
||||
lat: e.location_lat ?? null,
|
||||
lng: e.location_lng ?? null,
|
||||
}));
|
||||
const scope = photosInScope(candidates, pickEntries, { entryId: args.entryId, date: args.date });
|
||||
const ids = candidates.map((c) => c.photoId);
|
||||
const asked = ids.filter((id) => scope.has(id));
|
||||
const rest = ids.filter((id) => !scope.has(id));
|
||||
const { byPhoto, pending } = await this.signals.signalsFor(asked, { later: rest });
|
||||
const measured = candidates.map((c) => ({ ...c, signals: byPhoto.get(c.photoId) ?? null }));
|
||||
return pickBookPhotos(measured, pickEntries, {
|
||||
entryId: args.entryId,
|
||||
date: args.date,
|
||||
purpose: args.purpose,
|
||||
limit: args.limit,
|
||||
signalsPending: pending,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The photos the book shows in a frame, placed or parked. A map marker's
|
||||
* photo is not counted: a pin's thumbnail is not the photo printed on a page.
|
||||
*/
|
||||
function photosInBook(doc: BookDocument | null): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
for (const spread of doc?.spreads ?? []) {
|
||||
for (const element of [...spread.elements, ...spread.parked]) {
|
||||
if (element.kind === 'photo' && element.photoId != null) ids.add(element.photoId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
McpController,
|
||||
TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
TOOL_ANNOTATIONS_WRITE,
|
||||
Tool,
|
||||
ok,
|
||||
type McpContext,
|
||||
} from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { JourneyBookEditService } from './journey-book-edit.service';
|
||||
import {
|
||||
addBookPagesArgs,
|
||||
BOOK_OP_LIMITS,
|
||||
bookPageSetupForPreset,
|
||||
createBookArgs,
|
||||
deleteBookPagesArgs,
|
||||
describeBookPageArgs,
|
||||
editBookPageArgs,
|
||||
idSchema,
|
||||
listBookLayouts,
|
||||
listBookLayoutsArgs,
|
||||
moveBookPageArgs,
|
||||
restoreBookPageArgs,
|
||||
setBookCoverArgs,
|
||||
type AddBookPagesRequest,
|
||||
type CreateBookRequest,
|
||||
type DeleteBookPagesRequest,
|
||||
type EditBookPageRequest,
|
||||
type MoveBookPageRequest,
|
||||
type RestoreBookPageRequest,
|
||||
type SetBookCoverRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY);
|
||||
|
||||
/** What a layout does to what is on the page, said where a model picks one (list_book_layouts answers it too). */
|
||||
const LAYOUT_NOTES = listBookLayouts(null, bookPageSetupForPreset('square-210')).notes;
|
||||
|
||||
const UNITS =
|
||||
'A book is a list of pages (spreads): the cover, the first page, inner double spreads, the last page and the back. ' +
|
||||
'Positions and sizes are millimetres from the top left of the page, a double spread being twice the page width ' +
|
||||
'with the fold at one page width.';
|
||||
|
||||
const WRITE_ANSWER =
|
||||
'It answers the new version, the ids of the pages it changed, each of those pages as get_book lists it, their ' +
|
||||
'findings (severe ones are faults to fix, such as overlapping photos, words past their frame or in the fold, ' +
|
||||
'empty frames) and dropped, the elements whose content left the page. Nothing is written when any part is refused.';
|
||||
|
||||
interface OnJourney {
|
||||
journeyId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TREK Studio books over MCP: read a book page by page, and change it with the
|
||||
* same edit engine the Studio and the assistant's cards use. The rights are
|
||||
* those of PUT /api/journeys/:id/book: any member reads, the owner and editors
|
||||
* write, a viewer is refused, an outsider is told the journey is not there.
|
||||
* Every write is one transaction against the stored version and sends
|
||||
* journey:book:saved to every member's tabs after the commit, the asking one
|
||||
* included, like a save from the Studio.
|
||||
*
|
||||
* Every field comes from the book contracts in @trek/shared, so a bound that
|
||||
* changes there changes here.
|
||||
*/
|
||||
@McpController()
|
||||
export class JourneyBookMcp {
|
||||
constructor(
|
||||
private readonly books: JourneyBookEditService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
// ── Read ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Tool({
|
||||
name: 'get_book',
|
||||
description: `The TREK Studio book of a journey, without its document: whether it exists, its version, title and page setup, every page with its index, id, role, entry, photo ids, the first words of each text and how many elements, empty frames and parked elements it holds, and the book's own saved layouts. ${UNITS} Read it before changing the book: page and element ids are never invented, they come from here and from describe_book_page.`,
|
||||
inputSchema: { journeyId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async getBook({ journeyId }: OnJourney, ctx: McpContext) {
|
||||
return ok(await this.books.overview(journeyId, ctx.userId));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'describe_book_page',
|
||||
description: `One page of a journey's book in words: its size and role, every element back to front with its id, kind, frame in mm, rotation, lock, photo id or first words and binding, then the findings (overlaps, content in the fold or past the trim, empty frames, low print resolution, words past their frame, parked elements). ${UNITS} Read it before editing a page and again after, to check the result.`,
|
||||
inputSchema: { journeyId: idSchema, ...describeBookPageArgs },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async describeBookPage({ journeyId, spreadId }: OnJourney & { spreadId: string }, ctx: McpContext) {
|
||||
return ok(await this.books.describePage(journeyId, ctx.userId, spreadId));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_book_layouts',
|
||||
description: `The layouts a page of a journey's book can take: the templates for inner double spreads and for single pages (the cover, first, last and back) with their photo and text slots, the photo grids with their cell count, and the book's own saved layouts. role narrows the answer to inner or single. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids}`,
|
||||
inputSchema: { journeyId: idSchema, ...listBookLayoutsArgs },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async listLayouts({ journeyId, role }: OnJourney & { role?: 'inner' | 'single' }, ctx: McpContext) {
|
||||
return ok(await this.books.layouts(journeyId, ctx.userId, role));
|
||||
}
|
||||
|
||||
// ── Write ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Tool({
|
||||
name: 'create_book',
|
||||
description: `Lay down a blank book for a journey that has none yet: a cover, the first page, one empty double spread, the last page and the back, in the page format preset names (a 21 cm square unless told otherwise), titled title or else the journey's title. A journey that has a book already is refused. ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...createBookArgs },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async createBook({ journeyId, ...args }: OnJourney & CreateBookRequest, ctx: McpContext) {
|
||||
return ok(await this.books.createBook(journeyId, ctx.userId, args));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'add_book_pages',
|
||||
description: `Add 1 to ${BOOK_OP_LIMITS.pagesPerCall} empty double spreads after afterSpreadId, always kept between the first and the last page, laid out on template when one is named and tied to the journey entry entryId when given. ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...addBookPagesArgs },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async addBookPages({ journeyId, ...args }: OnJourney & AddBookPagesRequest, ctx: McpContext) {
|
||||
return ok(await this.books.addPages(journeyId, ctx.userId, args));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'move_book_page',
|
||||
description: `Move one inner double spread to toIndex, which must lie between the first and the last page (get_book gives the indexes). The cover, first, last and back pages keep their place. The answer also says where the page stood, under from_index; a move to where the page already is writes nothing. ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...moveBookPageArgs },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async moveBookPage({ journeyId, ...args }: OnJourney & MoveBookPageRequest, ctx: McpContext) {
|
||||
return ok(await this.books.movePage(journeyId, ctx.userId, args));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_book_pages',
|
||||
description: `Delete 1 to ${BOOK_OP_LIMITS.pagesPerCall} inner double spreads with everything on them. The cover, first, last and back pages cannot be deleted. Only when the person asked for it. With returnPrevious the answer also holds the deleted pages as they were, under previous_spreads. ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...deleteBookPagesArgs },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async deleteBookPages({ journeyId, ...args }: OnJourney & DeleteBookPagesRequest, ctx: McpContext) {
|
||||
return ok(await this.books.deletePages(journeyId, ctx.userId, args));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'edit_book_page',
|
||||
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid or saved layout), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes) and background (null is the paper). A locked element refuses any operation that would change it. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads. ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...editBookPageArgs },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async editBookPage({ journeyId, ...args }: OnJourney & EditBookPageRequest, ctx: McpContext) {
|
||||
return ok(await this.books.editPage(journeyId, ctx.userId, args));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_book_cover',
|
||||
description: `The cover of a journey's book: its layout (a single page template), its photo (from the journey's gallery, into a free frame, else a full bleed one behind everything), its title and its subtitle. A title or subtitle bound to the journey's own stays bound unless other words are given. With returnPrevious the answer also holds the cover as it was, under previous_spreads. ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...setBookCoverArgs },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async setBookCover({ journeyId, ...args }: OnJourney & SetBookCoverRequest, ctx: McpContext) {
|
||||
return ok(await this.books.setCover(journeyId, ctx.userId, args));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'restore_book_page',
|
||||
description: `Put pages of a journey's book back exactly as pages gives them, all in one call: each in its place when the book still has a page with its id, else at its index, lowest index first. Refused unless the book is still at expectedVersion, so nothing saved since is overwritten. A photo must be in the journey's gallery or already in the book, and a locked element stays as it is. This takes back an earlier change (pages is that change's previous_spreads, whole); it is not a way to edit a page. ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...restoreBookPageArgs },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async restoreBookPage({ journeyId, ...args }: OnJourney & RestoreBookPageRequest, ctx: McpContext) {
|
||||
return ok(await this.books.restorePage(journeyId, ctx.userId, args));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { JourneyBooks } from '../../db/entities/JourneyBooks.entity';
|
||||
import { JourneyEntries } from '../../db/entities/JourneyEntries.entity';
|
||||
import { JourneyEntryPhotos } from '../../db/entities/JourneyEntryPhotos.entity';
|
||||
import { JourneyPhotos } from '../../db/entities/JourneyPhotos.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { PhotoSignalsModule } from '../photos/photo-signals.module';
|
||||
import { JourneyBookEditService } from './journey-book-edit.service';
|
||||
import { JourneyBookPhotosMcp } from './journey-book-photos.mcp';
|
||||
import { JourneyBookPhotosService } from './journey-book-photos.service';
|
||||
import { JourneyBookMcp } from './journey-book.mcp';
|
||||
import { JourneyBookService } from './journey-book.service';
|
||||
import { JourneyDomainModule } from './journey-domain.module';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* TREK Studio books: storing them (`JourneyBookService`, the REST routes in
|
||||
* journey.controller.ts) and changing them page by page through the shared
|
||||
* edit engine (`JourneyBookEditService`, the MCP tools in journey-book.mcp.ts),
|
||||
* and picking photos for it from the gallery (`JourneyBookPhotosService`,
|
||||
* `suggest_book_photos`, on the pixel signals of PhotoSignalsModule).
|
||||
*
|
||||
* The storing and the editing service are exported: the journey controller
|
||||
* saves through the first, and the assistant's Studio chat reads and writes
|
||||
* through both.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
AddonsModule,
|
||||
JourneyDomainModule,
|
||||
PhotoSignalsModule,
|
||||
// JB1-JB7, the gallery and entries the edit engine checks ids against, and which photos each entry shows.
|
||||
MikroOrmModule.forFeature([JourneyBooks, JourneyPhotos, JourneyEntries, JourneyEntryPhotos]),
|
||||
],
|
||||
providers: [
|
||||
JourneyBookService,
|
||||
JourneyBookEditService,
|
||||
JourneyBookMcp,
|
||||
JourneyBookPhotosService,
|
||||
JourneyBookPhotosMcp,
|
||||
],
|
||||
exports: [JourneyBookService, JourneyBookEditService],
|
||||
})
|
||||
export class JourneyBookModule {}
|
||||
@@ -1,12 +1,31 @@
|
||||
import { JourneyBooks } from '../../db/entities/JourneyBooks.entity';
|
||||
import type { JourneyBookRow, JourneyBooksRepository } from '../../db/repositories/JourneyBooks.repository';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { BookRecord, BookSummary } from '@trek/shared';
|
||||
import type { BookDocument, BookRecord, BookSummary } from '@trek/shared';
|
||||
import { normalizeBookDocument } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* What an edit of the book hands back to `editBook`: the record's title and
|
||||
* the whole new document, plus whatever the caller wants returned beside the
|
||||
* saved record. `unchanged` says the edit left the stored book as it is (a
|
||||
* page moved to where it already was): nothing is written or announced then.
|
||||
*/
|
||||
interface BookEdit<T> {
|
||||
title: string;
|
||||
document: BookDocument;
|
||||
result: T;
|
||||
unchanged?: boolean;
|
||||
}
|
||||
|
||||
/** Where a write came from: REST names the requesting tab, an MCP or assistant write none. */
|
||||
interface BookWriteOrigin {
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storing TREK Studio books.
|
||||
*
|
||||
@@ -112,14 +131,27 @@ export class JourneyBookService {
|
||||
* Returns the saved record, or `{ conflict }` when the base version has
|
||||
* moved. Throwing would be the obvious shape and the wrong one: a conflict is
|
||||
* an ordinary outcome of two people working, not an exception.
|
||||
*
|
||||
* A save that lands is announced once the transaction has committed, never
|
||||
* from inside it and never for a conflict: nothing was written then.
|
||||
*/
|
||||
async saveBook(
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
input: { title: string; document: unknown; baseVersion?: number },
|
||||
origin: BookWriteOrigin = {},
|
||||
): Promise<{ record: BookRecord } | { conflict: BookRecord } | null> {
|
||||
if (!(await this.canWrite(journeyId, userId))) return null;
|
||||
const outcome = await this.storeSave(journeyId, userId, input);
|
||||
if ('record' in outcome) await this.broadcastSaved(journeyId, userId, outcome.record, origin.socketId);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async storeSave(
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
input: { title: string; document: unknown; baseVersion?: number },
|
||||
): Promise<{ record: BookRecord } | { conflict: BookRecord }> {
|
||||
const document = JSON.stringify(normalizeBookDocument(input.document));
|
||||
|
||||
// M1 (rule 11/24) — JB3's existing-link read and JB4's insert are
|
||||
@@ -167,6 +199,69 @@ export class JourneyBookService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the book by a pure edit of what is stored now: the book tools' one
|
||||
* way to write (journey-book-edit.service.ts).
|
||||
*
|
||||
* The read, the edit and the conditional update run in one transaction, so
|
||||
* the connection mutex serializes them against a REST save: the edit always
|
||||
* sees the version it replaces, and no retry loop is needed. `edit` gets the
|
||||
* current record (null for a journey without a book, which the update then
|
||||
* creates), may read what else it checks against inside the same
|
||||
* transaction, and may throw a refusal; a throw rolls back before anything
|
||||
* is written, and nobody is told anything. `expectedVersion` is the undo's
|
||||
* guard: the book must still be at that version, else 409 and nothing moves.
|
||||
* An edit that answers `unchanged` for a stored book writes nothing, bumps
|
||||
* no version and sends nothing.
|
||||
*
|
||||
* The event goes out after the commit. A write that names no socket (MCP,
|
||||
* the assistant) reaches every tab of every member, the asking one too, so
|
||||
* the Studio a person has open shows what the assistant did.
|
||||
*/
|
||||
async editBook<T>(
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
edit: (current: BookRecord | null) => BookEdit<T> | Promise<BookEdit<T>>,
|
||||
options: BookWriteOrigin & { expectedVersion?: number } = {},
|
||||
): Promise<{ record: BookRecord; result: T }> {
|
||||
await this.requireEditor(journeyId, userId);
|
||||
const done = await this.uow.transactional(async () => {
|
||||
const row = await this.booksRepo.findFirstForJourney(journeyId);
|
||||
const current = row ? this.toRecord(row) : null;
|
||||
if (options.expectedVersion !== undefined && current?.version !== options.expectedVersion) {
|
||||
throw bookMoved(current);
|
||||
}
|
||||
const next = await edit(current);
|
||||
if (current && next.unchanged) return { record: current, result: next.result, wrote: false };
|
||||
const document = JSON.stringify(normalizeBookDocument(next.document));
|
||||
if (!row) {
|
||||
const id = await this.booksRepo.insertBook(journeyId, next.title, document, userId);
|
||||
return { record: await this.written(id), result: next.result, wrote: true };
|
||||
}
|
||||
const changes = await this.booksRepo.casUpdate(row.id, row.version, {
|
||||
title: next.title,
|
||||
document,
|
||||
updatedBy: userId,
|
||||
});
|
||||
if (changes === 0) throw bookMoved(await this.byId(row.id));
|
||||
return { record: await this.written(row.id), result: next.result, wrote: true };
|
||||
});
|
||||
if (done.wrote) await this.broadcastSaved(journeyId, userId, done.record, options.socketId);
|
||||
return { record: done.record, result: done.result };
|
||||
}
|
||||
|
||||
/** 404 for someone who cannot see the journey (no existence oracle), 403 for a viewer. */
|
||||
private async requireEditor(journeyId: number, userId: number): Promise<void> {
|
||||
if (await this.canWrite(journeyId, userId)) return;
|
||||
if (await this.canAccess(journeyId, userId)) {
|
||||
throw new DomainError(403, 'Not allowed', {
|
||||
code: 'book_read_only',
|
||||
mcpMessage: "You can read this journey's book but not change it: only its owner and editors can.",
|
||||
});
|
||||
}
|
||||
throw new DomainError(404, 'Journey not found', { mcpMessage: 'Journey not found or access denied.' });
|
||||
}
|
||||
|
||||
async deleteBook(journeyId: number, userId: number): Promise<boolean | null> {
|
||||
if (!(await this.canWrite(journeyId, userId))) return null;
|
||||
// JB6.
|
||||
@@ -182,10 +277,11 @@ export class JourneyBookService {
|
||||
* Broadcasting a few hundred kilobytes of JSON on every autosave would make
|
||||
* the notification the size of the thing being edited.
|
||||
*
|
||||
* The saver is excluded by socket id, the same way every other TREK mutation
|
||||
* does it, so the client that just saved does not process its own change.
|
||||
* A REST save excludes the saving tab by socket id, the same way every other
|
||||
* TREK mutation does it, so the client that just saved does not process its
|
||||
* own change. An MCP or assistant write has no socket, so every tab hears it.
|
||||
*/
|
||||
async broadcastSaved(journeyId: number, userId: number, record: BookRecord, socketId?: string) {
|
||||
private async broadcastSaved(journeyId: number, userId: number, record: BookRecord, socketId?: string) {
|
||||
await this.journey.broadcastJourneyEvent(
|
||||
journeyId,
|
||||
'journey:book:saved',
|
||||
@@ -199,6 +295,25 @@ export class JourneyBookService {
|
||||
const row = await this.booksRepo.findById(id);
|
||||
return row ? this.toRecord(row) : null;
|
||||
}
|
||||
|
||||
/** The row a write just made or changed, read back inside the same transaction, so it is there. */
|
||||
private async written(id: number): Promise<BookRecord> {
|
||||
const record = await this.byId(id);
|
||||
if (!record) throw new Error(`Book ${id} is gone inside the transaction that wrote it`);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The book is not at the version a write was made against. The record rides
|
||||
* along for REST like the save route's conflict; a tool answers the sentence.
|
||||
*/
|
||||
function bookMoved(current: BookRecord | null): DomainError {
|
||||
return new DomainError(409, 'Book was changed by someone else', {
|
||||
code: 'book_conflict',
|
||||
details: { current },
|
||||
mcpMessage: 'The book was changed since that version. Read it again with get_book before changing it.',
|
||||
});
|
||||
}
|
||||
|
||||
/** JSON that will not parse is an empty document, never an exception. */
|
||||
|
||||
@@ -683,11 +683,13 @@ export class JourneyController {
|
||||
@Body() body: BookSaveDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const result = await this.books.saveBook(Number(id), user.id, {
|
||||
title: body.title ?? '',
|
||||
document: body.document,
|
||||
baseVersion: body.baseVersion,
|
||||
});
|
||||
// The service announces the save after its commit, leaving out this tab.
|
||||
const result = await this.books.saveBook(
|
||||
Number(id),
|
||||
user.id,
|
||||
{ title: body.title ?? '', document: body.document, baseVersion: body.baseVersion },
|
||||
{ socketId },
|
||||
);
|
||||
if (result === null) {
|
||||
throw await this.bookRefusal(Number(id), user.id);
|
||||
}
|
||||
@@ -705,7 +707,6 @@ export class JourneyController {
|
||||
409,
|
||||
);
|
||||
}
|
||||
await this.books.broadcastSaved(Number(id), user.id, result.record, socketId);
|
||||
return result.record;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,17 @@ import { AddonsService } from '../addons/addons.service';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
import { JourneyPhotoCaptureService } from './journey-photo-capture.service';
|
||||
import { JourneyShareService } from './journey-share.service';
|
||||
import { idSchema, journeyShareLinkRequestSchema, journeyUpdateRequestSchema } from '@trek/shared';
|
||||
import {
|
||||
idSchema,
|
||||
journeyEntriesRangeSchema,
|
||||
journeyEntryProsConsSchema,
|
||||
journeyEntryTypeSchema,
|
||||
journeyEntryVisibilitySchema,
|
||||
journeyShareLinkRequestSchema,
|
||||
journeyUpdateRequestSchema,
|
||||
type JourneyEntryType,
|
||||
type JourneyEntryVisibility,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -58,26 +68,13 @@ function jsonContent(uri: string, data: unknown) {
|
||||
}
|
||||
|
||||
/*
|
||||
* The three entry fields with a fixed set of values. `journey_entries.type` and
|
||||
* `.visibility` are plain TEXT columns and the REST route forwards whatever the
|
||||
* client sent, but the row type (JourneyEntry in src/types) only ever models
|
||||
* these, so the tools name them rather than take a free string: a model that
|
||||
* The entry types, visibilities and the verdict come from @trek/shared
|
||||
* (journey.schema.ts), where the assistant's cards read them too: a model that
|
||||
* invents "friends-only" would write a value nothing reads.
|
||||
*/
|
||||
const ENTRY_TYPE = z.enum(['entry', 'checkin', 'skeleton']);
|
||||
const ENTRY_VISIBILITY = z.enum(['private', 'shared', 'public']);
|
||||
type EntryType = z.infer<typeof ENTRY_TYPE>;
|
||||
type EntryVisibility = z.infer<typeof ENTRY_VISIBILITY>;
|
||||
|
||||
/**
|
||||
* The verdict on a place. Either side defaults to empty so a caller can send
|
||||
* only the half it has, which is what the entry editor does when a place was
|
||||
* all good or all bad. The service stores nothing when both come back empty.
|
||||
*/
|
||||
const PROS_CONS = z.strictObject({
|
||||
pros: z.array(z.string()).default([]),
|
||||
cons: z.array(z.string()).default([]),
|
||||
});
|
||||
const ENTRY_TYPE = journeyEntryTypeSchema;
|
||||
const ENTRY_VISIBILITY = journeyEntryVisibilitySchema;
|
||||
const PROS_CONS = journeyEntryProsConsSchema;
|
||||
|
||||
/**
|
||||
* The photo backends a journey can reference. The REST route forwards whatever
|
||||
@@ -181,16 +178,40 @@ export class JourneyMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'list_journey_entries',
|
||||
description: 'List all entries in a journey.',
|
||||
inputSchema: { journeyId: idSchema },
|
||||
description:
|
||||
'List the entries of a journey in reading order, with the id and title of the journey. from/to keep only the entries of those days (YYYY-MM-DD, both included), so asking for one day reads that day and no more.',
|
||||
inputSchema: { journeyId: idSchema, ...journeyEntriesRangeSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async listJourneyEntries({ journeyId }: { journeyId: number }, ctx: McpContext) {
|
||||
if (!(await this.journey.canAccessJourney(journeyId, ctx.userId)))
|
||||
return notFound('Journey not found or access denied.');
|
||||
return ok({ entries: await this.journey.listEntries(journeyId, ctx.userId) });
|
||||
async listJourneyEntries(
|
||||
{ journeyId, from, to }: { journeyId: number; from?: string; to?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const journey = await this.journey.canAccessJourney(journeyId, ctx.userId);
|
||||
const entries = journey ? await this.journey.listEntries(journeyId, ctx.userId) : null;
|
||||
if (!journey || !entries) return notFound('Journey not found or access denied.');
|
||||
const inRange = entries.filter((e) => (!from || e.entry_date >= from) && (!to || e.entry_date <= to));
|
||||
return ok({ entries: inRange, journey: { id: journey.id, title: journey.title } });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_journey_entry',
|
||||
description:
|
||||
'One journal entry by its id, as list_journey_entries answers it (photos included), with the id and title of its journey.',
|
||||
inputSchema: { entryId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async getJourneyEntry({ entryId }: { entryId: number }, ctx: McpContext) {
|
||||
const journeyId = await this.journey.journeyIdOfEntry(entryId);
|
||||
const journey = journeyId === null ? null : await this.journey.canAccessJourney(journeyId, ctx.userId);
|
||||
const entries = journey ? await this.journey.listEntries(journey.id, ctx.userId) : null;
|
||||
const entry = entries?.find((e) => e.id === entryId);
|
||||
if (!journey || !entry) return notFound('Entry not found or access denied.');
|
||||
return ok({ entry, journey: { id: journey.id, title: journey.title } });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -426,8 +447,8 @@ export class JourneyMcp {
|
||||
weather?: string;
|
||||
tags?: string[];
|
||||
pros_cons?: { pros: string[]; cons: string[] };
|
||||
visibility?: EntryVisibility;
|
||||
type?: EntryType;
|
||||
visibility?: JourneyEntryVisibility;
|
||||
type?: JourneyEntryType;
|
||||
sort_order?: number;
|
||||
is_draft?: boolean;
|
||||
},
|
||||
@@ -509,8 +530,8 @@ export class JourneyMcp {
|
||||
weather?: string | null;
|
||||
tags?: string[] | null;
|
||||
pros_cons?: { pros: string[]; cons: string[] } | null;
|
||||
visibility?: EntryVisibility;
|
||||
type?: EntryType;
|
||||
visibility?: JourneyEntryVisibility;
|
||||
type?: JourneyEntryType;
|
||||
sort_order?: number;
|
||||
stats_excluded?: boolean;
|
||||
dismissed?: boolean;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { JourneyBooks } from '../../db/entities/JourneyBooks.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { UserConnectionRepositoriesModule } from '../database/user-connection-repositories.module';
|
||||
@@ -8,7 +7,7 @@ import { MemoriesModule } from '../memories/memories.module';
|
||||
import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { JourneyBookService } from './journey-book.service';
|
||||
import { JourneyBookModule } from './journey-book.module';
|
||||
import { JourneyDomainModule } from './journey-domain.module';
|
||||
import { JourneyMapController } from './journey-map.controller';
|
||||
import { JourneyMapMcp } from './journey-map.mcp';
|
||||
@@ -44,16 +43,18 @@ import { MulterModule } from '@nestjs/platform-express';
|
||||
MemoriesModule,
|
||||
JourneyDomainModule,
|
||||
JourneyPhotoCaptureModule,
|
||||
// The Studio book: its service (the book routes below) and its MCP tools.
|
||||
JourneyBookModule,
|
||||
// JourneyService's JV1 read (`UserImmichRepository.getImmichAutoUpload`).
|
||||
UserConnectionRepositoriesModule,
|
||||
// Plan 3g Task 3: `JourneyService` (JV1, now through the module above)
|
||||
// and `JourneyBookService` (JB1-JB7, `JourneyBooksRepository`) both
|
||||
// constructed HERE — `@InjectRepository` resolves from this module's own
|
||||
// `forFeature` graph, not from `AuthModule`'s (which registers `Users`
|
||||
// for its own providers only, per that module's own docstring).
|
||||
MikroOrmModule.forFeature([Users, JourneyBooks]),
|
||||
// for its own providers only, per that module's own docstring). The
|
||||
// book's repository moved with `JourneyBookService` to JourneyBookModule.
|
||||
MikroOrmModule.forFeature([Users]),
|
||||
],
|
||||
controllers: [JourneyController, JourneyMapController, JourneyPublicController],
|
||||
providers: [JourneyService, JourneyBookService, JourneyMcp, JourneyMapMcp, JourneyTracksMcp],
|
||||
providers: [JourneyService, JourneyMcp, JourneyMapMcp, JourneyTracksMcp],
|
||||
})
|
||||
export class JourneyModule {}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Studio book e2e: the book tools called through a real MCP session on the
|
||||
* whole app (buildApp, the real registry with its addon gate, scopes and error
|
||||
* mapper, the real services and a migrated temp SQLite), checked against the
|
||||
* REST route the Studio saves through. The broadcasts are spied on the
|
||||
* container's RealtimeService.
|
||||
*
|
||||
* What it pins: an MCP write is what GET /api/journeys/:id/book reads next; a
|
||||
* viewer's write is refused with the tool's own words, and an operation the
|
||||
* engine refuses with the operation it names; a REST save and an MCP write
|
||||
* send the same event to the same users, REST leaving out the tab that saved
|
||||
* and MCP none; and the tools are gone while the Journey addon is off.
|
||||
*/
|
||||
import { ADDON_IDS } from '../../src/addons';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { addJourneyContributor, makeJourney } from '../helpers/factories/journeys';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { bookWriteResultSchema } from '@trek/shared';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const BOOK_TOOLS = [
|
||||
'get_book',
|
||||
'describe_book_page',
|
||||
'list_book_layouts',
|
||||
'create_book',
|
||||
'add_book_pages',
|
||||
'move_book_page',
|
||||
'delete_book_pages',
|
||||
'edit_book_page',
|
||||
'set_book_cover',
|
||||
'restore_book_page',
|
||||
];
|
||||
|
||||
let app: INestApplication;
|
||||
let orm: MikroORM;
|
||||
let broadcasts: RealtimeSpies;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
await app.init();
|
||||
orm = app.get(MikroORM);
|
||||
broadcasts = spyOnRealtime(app);
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await setAddonEnabled(orm, ADDON_IDS.JOURNEY, true);
|
||||
broadcasts.broadcastToUser.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
/** An MCP session of its own as `userId`, with every scope, holding only the tools `include` lets through. */
|
||||
async function session<T>(userId: number, include: (tool: string) => boolean, use: (client: Client) => Promise<T>) {
|
||||
const server = createLocalMcpServer('TREK book e2e');
|
||||
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
|
||||
await withRequestContext(orm, () =>
|
||||
app
|
||||
.get(McpRegistryService)
|
||||
.attach(
|
||||
server,
|
||||
{ userId, scopes: null, isStaticToken: false },
|
||||
{ include: (kind, tool) => kind === 'tool' && include(tool), around },
|
||||
),
|
||||
);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: 'book-e2e', version: '1.0.0' });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
return await use(client);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** One tool call: the JSON it answered, or its error text. */
|
||||
async function callTool(userId: number, name: string, args: Record<string, unknown>) {
|
||||
const only = (tool: string) => tool === name;
|
||||
return session(userId, only, async (client) => {
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
|
||||
const text = content[0]?.text ?? '';
|
||||
return result.isError === true ? { error: text } : (JSON.parse(text) as unknown);
|
||||
});
|
||||
}
|
||||
|
||||
/** The user sends as [user, event type, left-out socket], in user order, emptied after each read. */
|
||||
function sent() {
|
||||
const calls = broadcasts.broadcastToUser.mock.calls
|
||||
.map(([to, msg, sid]) => [to, String(msg.type), sid] as const)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
broadcasts.broadcastToUser.mockClear();
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** A journey with its owner, an editor and a viewer, and a blank book the owner laid down over MCP. */
|
||||
async function journeyWithBook() {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { user: editor } = await makeUser(orm);
|
||||
const { user: viewer } = await makeUser(orm);
|
||||
const journey = await makeJourney(orm, owner.id);
|
||||
await addJourneyContributor(orm, journey.id, editor.id, 'editor');
|
||||
await addJourneyContributor(orm, journey.id, viewer.id, 'viewer');
|
||||
const created = bookWriteResultSchema.parse(await callTool(owner.id, 'create_book', { journeyId: journey.id }));
|
||||
const inner = created.pages.find((p) => p.role === 'inner')?.id ?? 'no inner page';
|
||||
sent();
|
||||
return { owner, editor, viewer, journey, inner, everyone: [owner.id, editor.id, viewer.id].sort((a, b) => a - b) };
|
||||
}
|
||||
|
||||
describe('Studio book over MCP and REST', () => {
|
||||
it('JBE2E-001: an MCP write is what GET reads next, and a viewer is refused in the words a tool answers', async () => {
|
||||
const { editor, viewer, journey, inner } = await journeyWithBook();
|
||||
|
||||
const edited = bookWriteResultSchema.parse(
|
||||
await callTool(editor.id, 'edit_book_page', {
|
||||
journeyId: journey.id,
|
||||
spreadId: inner,
|
||||
ops: [{ op: 'background', color: '#123456' }],
|
||||
}),
|
||||
);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(viewer.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.book.version).toBe(edited.version);
|
||||
expect(res.body.book.document.spreads[2].background).toBe('#123456');
|
||||
expect(
|
||||
await callTool(viewer.id, 'edit_book_page', {
|
||||
journeyId: journey.id,
|
||||
spreadId: inner,
|
||||
ops: [{ op: 'background', color: null }],
|
||||
}),
|
||||
).toEqual({ error: "You can read this journey's book but not change it: only its owner and editors can." });
|
||||
});
|
||||
|
||||
it('JBE2E-002: a REST save and an MCP write send the same event to the same users, REST leaving out its tab', async () => {
|
||||
const { owner, journey, inner, everyone } = await journeyWithBook();
|
||||
const before = await request(app.getHttpServer())
|
||||
.get(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
|
||||
const saved = await request(app.getHttpServer())
|
||||
.put(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.set('X-Socket-Id', 'sock-rest')
|
||||
.send({ title: before.body.book.title, document: before.body.book.document, baseVersion: 1 });
|
||||
expect(saved.status).toBe(200);
|
||||
expect(sent()).toEqual(everyone.map((id) => [id, 'journey:book:saved', 'sock-rest']));
|
||||
|
||||
const paper = [{ op: 'background', color: '#fafafa' }];
|
||||
await callTool(owner.id, 'edit_book_page', { journeyId: journey.id, spreadId: inner, ops: paper });
|
||||
expect(sent()).toEqual(everyone.map((id) => [id, 'journey:book:saved', undefined]));
|
||||
});
|
||||
|
||||
it('JBE2E-004: an operation the engine refuses reaches the client as the tool error naming it, and nothing is written', async () => {
|
||||
const { owner, journey, inner } = await journeyWithBook();
|
||||
|
||||
const refused = await callTool(owner.id, 'edit_book_page', {
|
||||
journeyId: journey.id,
|
||||
spreadId: inner,
|
||||
ops: [
|
||||
{ op: 'background', color: '#101010' },
|
||||
{ op: 'place_photo', photoId: 987654, target: 'new' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(refused).toEqual({ error: "ops[1]: photo 987654 is not in this journey's gallery" });
|
||||
expect(sent()).toEqual([]);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
expect([res.body.book.version, res.body.book.document.spreads[2].background]).toEqual([1, null]);
|
||||
});
|
||||
|
||||
it('JBE2E-003: the book tools are gone while the Journey addon is off', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
await setAddonEnabled(orm, ADDON_IDS.JOURNEY, false);
|
||||
const names = await session(
|
||||
user.id,
|
||||
() => true,
|
||||
async (client) => (await client.listTools()).tools.map((tool) => tool.name),
|
||||
);
|
||||
expect(names.filter((name) => BOOK_TOOLS.includes(name))).toEqual([]);
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -123,7 +123,6 @@ const { booksvc } = vi.hoisted(() => ({
|
||||
canOpen: vi.fn(),
|
||||
saveBook: vi.fn(),
|
||||
deleteBook: vi.fn(),
|
||||
broadcastSaved: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -175,7 +174,6 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
booksvc.canOpen.mockReset();
|
||||
booksvc.saveBook.mockReset();
|
||||
booksvc.deleteBook.mockReset();
|
||||
booksvc.broadcastSaved.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -359,7 +357,8 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.set('X-Socket-Id', 'sock-7')
|
||||
.send({ document: {} });
|
||||
expect(booksvc.broadcastSaved).toHaveBeenCalledWith(9, 1, { id: 3, version: 5 }, 'sock-7');
|
||||
// The service announces the save after its commit, leaving out this socket.
|
||||
expect(booksvc.saveBook.mock.calls[0][3]).toEqual({ socketId: 'sock-7' });
|
||||
});
|
||||
|
||||
it('409 with the current record in the body, not a bare status', async () => {
|
||||
@@ -371,7 +370,6 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe('Book was changed by someone else');
|
||||
expect(res.body.current.version).toBe(6);
|
||||
expect(booksvc.broadcastSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404 on saving into an inaccessible journey', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BucketList } from '../../../src/db/entities/BucketList.entity';
|
||||
import { CollectionLabels } from '../../../src/db/entities/CollectionLabels.entity';
|
||||
import { DawarichVisitSuggestions } from '../../../src/db/entities/DawarichVisitSuggestions.entity';
|
||||
import { JourneyBooks } from '../../../src/db/entities/JourneyBooks.entity';
|
||||
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
|
||||
import { TripJournalLinks } from '../../../src/db/entities/TripJournalLinks.entity';
|
||||
import { VacayHolidayCalendars } from '../../../src/db/entities/VacayHolidayCalendars.entity';
|
||||
@@ -15,13 +16,13 @@ import { NotificationsService } from '../../../src/nest/notifications/notificati
|
||||
import { addCollectionMember } from '../../helpers/factories/collections';
|
||||
import { inContext } from '../../helpers/factories/context';
|
||||
import { addJourneyContributor, linkTripToJourney } from '../../helpers/factories/journeys';
|
||||
import { createRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { createRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { addVacayPlanMember } from '../../helpers/factories/vacay';
|
||||
import { holidayCode } from './harness';
|
||||
import type { CaseGroup, ParityCase, Scene } from './types';
|
||||
import type { CaseGroup, ParityCase, RestCall, Scene } from './types';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { DawarichTrackSegment } from '@trek/shared';
|
||||
import { blankBook, blankSpread, type BookDocument, type DawarichTrackSegment } from '@trek/shared';
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
@@ -945,10 +946,112 @@ const collectionCases: ParityCase[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** The page ids of the scenes' books, in page order, so the tools can name them. */
|
||||
const BOOK_PAGES = ['sp-cover', 'sp-first', 'sp-inner', 'sp-last', 'sp-back'];
|
||||
|
||||
/** A blank book for the scene's journey, the same one each time it is asked for. */
|
||||
function bookOf(s: Scene) {
|
||||
let next = 0;
|
||||
return blankBook({ title: `Book ${s.n}`, newId: () => BOOK_PAGES[next++] ?? `sp-extra-${next}` });
|
||||
}
|
||||
|
||||
/** The scene's book with a second inner page after the first, so a page has somewhere to move. */
|
||||
function twoInnerBook(s: Scene): BookDocument {
|
||||
const book = bookOf(s);
|
||||
const second = { ...blankSpread('inner'), id: 'sp-inner-2' };
|
||||
return { ...book, spreads: [...book.spreads.slice(0, 3), second, ...book.spreads.slice(3)] };
|
||||
}
|
||||
|
||||
/** The scene's journey with its book at version 1, written as a row so the setup sends nothing. */
|
||||
async function seedBook(s: Scene, app: INestApplication, document: BookDocument = bookOf(s)): Promise<void> {
|
||||
await insertRow(ormOf(app), JourneyBooks, {
|
||||
journey: s.journey.id,
|
||||
title: `Book ${s.n}`,
|
||||
document: JSON.stringify(document),
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The Studio's save, the one route that writes a book: every book tool's twin.
|
||||
* It saves the scene's book as it stands, which sends the same event as any
|
||||
* change would; what each tool changes is pinned in its own suites.
|
||||
*/
|
||||
const saveBook = (baseVersion?: number): RestCall => ({
|
||||
method: 'put',
|
||||
path: (s) => `/api/journeys/${s.journey.id}/book`,
|
||||
body: (s) => ({ title: `Book ${s.n}`, document: bookOf(s), baseVersion }),
|
||||
});
|
||||
|
||||
/**
|
||||
* The Studio book's tools. Each write tells every member's tabs that the book
|
||||
* moved on; REST leaves out the tab that saved, MCP none, so the Studio the
|
||||
* assistant runs beside shows its change.
|
||||
*/
|
||||
const bookCases: ParityCase[] = [
|
||||
{
|
||||
tool: 'create_book',
|
||||
needs: ['journey'],
|
||||
args: onJourney,
|
||||
rest: saveBook(),
|
||||
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
||||
},
|
||||
{
|
||||
tool: 'add_book_pages',
|
||||
needs: ['journey'],
|
||||
setup: seedBook,
|
||||
args: (s) => ({ journeyId: s.journey.id, afterSpreadId: 'sp-inner' }),
|
||||
rest: saveBook(1),
|
||||
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
||||
},
|
||||
{
|
||||
// A real move: a move to where the page already is writes nothing and sends nothing.
|
||||
tool: 'move_book_page',
|
||||
needs: ['journey'],
|
||||
setup: (s, app) => seedBook(s, app, twoInnerBook(s)),
|
||||
args: (s) => ({ journeyId: s.journey.id, spreadId: 'sp-inner', toIndex: 3 }),
|
||||
rest: saveBook(1),
|
||||
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
||||
},
|
||||
{
|
||||
tool: 'delete_book_pages',
|
||||
needs: ['journey'],
|
||||
setup: seedBook,
|
||||
args: (s) => ({ journeyId: s.journey.id, spreadIds: ['sp-inner'] }),
|
||||
rest: saveBook(1),
|
||||
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
||||
},
|
||||
{
|
||||
tool: 'edit_book_page',
|
||||
needs: ['journey'],
|
||||
setup: seedBook,
|
||||
args: (s) => ({ journeyId: s.journey.id, spreadId: 'sp-inner', ops: [{ op: 'background', color: '#f4efe6' }] }),
|
||||
rest: saveBook(1),
|
||||
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
||||
},
|
||||
{
|
||||
tool: 'set_book_cover',
|
||||
needs: ['journey'],
|
||||
setup: seedBook,
|
||||
args: (s) => ({ journeyId: s.journey.id, title: `Lisbon ${s.n}` }),
|
||||
rest: saveBook(1),
|
||||
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
||||
},
|
||||
{
|
||||
// An undo puts the page back where it is, at the version the change left.
|
||||
tool: 'restore_book_page',
|
||||
needs: ['journey'],
|
||||
setup: seedBook,
|
||||
args: (s) => ({ journeyId: s.journey.id, pages: [{ spread: bookOf(s).spreads[2], index: 2 }], expectedVersion: 1 }),
|
||||
rest: saveBook(1),
|
||||
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const user: CaseGroup = {
|
||||
name: 'user',
|
||||
prefix: 'BCP-USER',
|
||||
cases: [...atlasCases, ...dawarichCases, ...journeyCases, ...vacayCases, ...collectionCases],
|
||||
cases: [...atlasCases, ...dawarichCases, ...journeyCases, ...vacayCases, ...collectionCases, ...bookCases],
|
||||
exempt: [
|
||||
{ tool: 'create_journey_share_link', reason: TOKEN_ONLY, needs: ['journey'], args: onJourney },
|
||||
{
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
* tool needs no harness wiring to be covered.
|
||||
*
|
||||
* What it holds: an input schema is self-contained (no $ref or $defs, which
|
||||
* several MCP clients do not resolve), and every id field is the positive
|
||||
* integer of @trek/shared's idSchema, the shared primitive the tools derive
|
||||
* their ids from.
|
||||
* several MCP clients do not resolve), every id field is the positive integer
|
||||
* of @trek/shared's idSchema, the shared primitive the tools derive their ids
|
||||
* from, and the book restore's schema, which carries whole pages, stays under a ceiling.
|
||||
*/
|
||||
import type { ToolOptions } from '../../../src/nest-mcp';
|
||||
import { getEntry, isMcpController, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
@@ -84,4 +84,13 @@ describe('MCP tool input schemas', () => {
|
||||
expect(tripIds.length).toBeGreaterThan(100);
|
||||
for (const prop of tripIds) expect(prop).toMatchObject({ type: 'integer', exclusiveMinimum: 0 });
|
||||
});
|
||||
|
||||
it('MCP-SCHEMA-003: restore_book_page, the one tool that takes whole book pages, stays under its size ceiling', () => {
|
||||
// Every client loads it with tools/list, though only an undo calls it. Measured at about 34 000
|
||||
// characters; a new element kind or field on a book page grows it, and this says so.
|
||||
const restore = [...schemas].find(([key]) => key.endsWith('#restore_book_page'))?.[1];
|
||||
const size = JSON.stringify(restore ?? null).length;
|
||||
expect(size).toBeGreaterThan(10_000);
|
||||
expect(size).toBeLessThan(40_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,6 +295,89 @@ describe('journey read tools', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('MCP-JRN-ENTRY-001: list_journey_entries keeps the days from/to name and names its journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = await seedJourney(h, 'Berlin 2026');
|
||||
for (const [entry_date, title] of [
|
||||
['2026-10-20', 'Before'],
|
||||
['2026-10-21', 'Yesterday'],
|
||||
['2026-10-22', 'Today'],
|
||||
] as const) {
|
||||
await h.client.callTool({
|
||||
name: 'create_journey_entry',
|
||||
arguments: { journeyId: journey.id, entry_date, title },
|
||||
});
|
||||
}
|
||||
const read = async (range: Record<string, string>) =>
|
||||
parseToolResult(
|
||||
await h.client.callTool({ name: 'list_journey_entries', arguments: { journeyId: journey.id, ...range } }),
|
||||
) as { entries: Array<{ title: string }>; journey: { id: number; title: string } };
|
||||
|
||||
const day = await read({ from: '2026-10-21', to: '2026-10-21' });
|
||||
expect(day.entries.map((e) => e.title)).toEqual(['Yesterday']);
|
||||
expect(day.journey).toEqual({ id: journey.id, title: 'Berlin 2026' });
|
||||
expect((await read({ from: '2026-10-21' })).entries.map((e) => e.title)).toEqual(['Yesterday', 'Today']);
|
||||
expect((await read({ to: '2026-10-20' })).entries.map((e) => e.title)).toEqual(['Before']);
|
||||
expect((await read({})).entries).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('MCP-JRN-ENTRY-002: list_journey_entries refuses a day that is no calendar day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = await seedJourney(h);
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_journey_entries',
|
||||
arguments: { journeyId: journey.id, from: '2026-02-30' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('MCP-JRN-ENTRY-003: get_journey_entry answers one entry with its photos and its journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = await seedJourney(h, 'Japan');
|
||||
const created = parseToolResult(
|
||||
await h.client.callTool({
|
||||
name: 'create_journey_entry',
|
||||
arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Arrival', story: '**Tokyo**' },
|
||||
}),
|
||||
) as { entry: { id: number } };
|
||||
const data = parseToolResult(
|
||||
await h.client.callTool({ name: 'get_journey_entry', arguments: { entryId: created.entry.id } }),
|
||||
) as { entry: { id: number; title: string; photos: unknown[] }; journey: { id: number; title: string } };
|
||||
expect(data.entry.id).toBe(created.entry.id);
|
||||
expect(data.entry.title).toBe('Arrival');
|
||||
expect(data.entry.photos).toEqual([]);
|
||||
expect(data.journey).toEqual({ id: journey.id, title: 'Japan' });
|
||||
});
|
||||
});
|
||||
|
||||
it('MCP-JRN-ENTRY-004: get_journey_entry does not show an entry of a journal the caller cannot see', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
let entryId = 0;
|
||||
await withHarness(other.id, async (h) => {
|
||||
const journey = await seedJourney(h, 'Private');
|
||||
entryId = (
|
||||
parseToolResult(
|
||||
await h.client.callTool({
|
||||
name: 'create_journey_entry',
|
||||
arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Mine' },
|
||||
}),
|
||||
) as { entry: { id: number } }
|
||||
).entry.id;
|
||||
});
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_journey_entry', arguments: { entryId } });
|
||||
expect(result.isError).toBe(true);
|
||||
const missing = await h.client.callTool({ name: 'get_journey_entry', arguments: { entryId: entryId + 1000 } });
|
||||
expect(missing.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('list_journey_contributors returns the contributor rows', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* JourneyBookEditService: the Studio book tools' reads and writes over the
|
||||
* shared edit engine. The suite takes the service from JourneyBookModule over a
|
||||
* snapshot test db, so the access reads, the gallery and entry checks, the
|
||||
* transaction around each write and the realtime fake are the ones the app
|
||||
* uses.
|
||||
*
|
||||
* What it pins: the rights are the save route's (members read, the owner and
|
||||
* editors write, a viewer gets 403, an outsider 404); every operation family
|
||||
* writes what it says; a refused operation writes and sends nothing; the
|
||||
* cover, first, last and back pages stay; a photo must come from the journey's
|
||||
* gallery; `previous_spreads` comes back only when asked for; a move answers
|
||||
* where the page stood and a move to where it is writes nothing; and a restore
|
||||
* lands only at the version it names, all the pages of one delete at once,
|
||||
* held to the gallery like an edit.
|
||||
*/
|
||||
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
|
||||
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
||||
import { catchDomainError, DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { JourneyBookEditService, type BookWriteAnswer } from '../../../src/nest/journey/journey-book-edit.service';
|
||||
import { JourneyBookModule } from '../../../src/nest/journey/journey-book.module';
|
||||
import { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { addJourneyContributor, makeJourney, makeJourneyEntry } from '../../helpers/factories/journeys';
|
||||
import { insertRow } from '../../helpers/factories/rows';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { bookPhotoElementSchema } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let orm: TestOrm;
|
||||
let edit: JourneyBookEditService;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [JourneyBookModule] });
|
||||
edit = t.get(JourneyBookEditService);
|
||||
orm = await createTestOrm(db);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
orm.clear();
|
||||
t.realtime.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
/** A photo in the journey's gallery, as the upload leaves it; its `trek_photos` id is what a book holds. */
|
||||
async function galleryPhoto(journeyId: number, ownerId: number): Promise<number> {
|
||||
const photoId = await insertRow(orm, TrekPhotos, { provider: 'local', owner: ownerId, file_path: 'journey/p.jpg' });
|
||||
await insertRow(orm, JourneyPhotos, { journey: journeyId, photo: photoId, sort_order: 0, created_at: Date.now() });
|
||||
return photoId;
|
||||
}
|
||||
|
||||
/** A journey titled Lisbon with its owner, an editor, a viewer and someone outside it, and a blank book. */
|
||||
async function crew() {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { user: editor } = await makeUser(orm);
|
||||
const { user: viewer } = await makeUser(orm);
|
||||
const { user: stranger } = await makeUser(orm);
|
||||
const journey = await makeJourney(orm, owner.id, { title: 'Lisbon' });
|
||||
await addJourneyContributor(orm, journey.id, editor.id, 'editor');
|
||||
await addJourneyContributor(orm, journey.id, viewer.id, 'viewer');
|
||||
const created = await edit.createBook(journey.id, owner.id, {});
|
||||
const idOf = (role: string) => created.pages.find((p) => p.role === role)?.id ?? `no ${role} page`;
|
||||
t.realtime.reset();
|
||||
return {
|
||||
owner,
|
||||
editor,
|
||||
viewer,
|
||||
stranger,
|
||||
journey,
|
||||
created,
|
||||
cover: idOf('cover'),
|
||||
first: idOf('first'),
|
||||
inner: idOf('inner'),
|
||||
everyone: [owner.id, editor.id, viewer.id].sort((a, b) => a - b),
|
||||
};
|
||||
}
|
||||
|
||||
/** The refusal a call ends in, as status, code and the words a tool answers. */
|
||||
async function refusal(pending: () => Promise<unknown>) {
|
||||
const err = await catchDomainError(pending);
|
||||
if (!(err instanceof DomainError)) throw new Error('the call was not refused');
|
||||
return { status: err.getStatus(), code: err.code, message: err.mcpMessage ?? err.publicMessage };
|
||||
}
|
||||
|
||||
/** What each user was sent, as [user, event type, left-out socket], in user order. */
|
||||
const sent = () =>
|
||||
t.realtime.broadcastToUserMock.mock.calls
|
||||
.map(([to, msg, sid]) => [to, (msg as { type: string }).type, sid] as const)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const saved = (users: number[]) => users.map((id) => [id, 'journey:book:saved', undefined] as const);
|
||||
|
||||
const roles = async (journeyId: number, userId: number) =>
|
||||
(await edit.overview(journeyId, userId)).spreads.map((s) => s.role);
|
||||
|
||||
const firstPage = (answer: BookWriteAnswer) => answer.pages[0];
|
||||
|
||||
describe('creating and reading a book', () => {
|
||||
it('JBEDIT-001: create_book lays down the five pages in the format asked for, titled after the journey, and tells every member', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { user: editor } = await makeUser(orm);
|
||||
const journey = await makeJourney(orm, owner.id, { title: 'Porto' });
|
||||
await addJourneyContributor(orm, journey.id, editor.id, 'editor');
|
||||
|
||||
const answer = await edit.createBook(journey.id, editor.id, { preset: 'a4-landscape' });
|
||||
|
||||
expect(answer.version).toBe(1);
|
||||
expect(answer.pages.map((p) => p.role)).toEqual(['cover', 'first', 'inner', 'last', 'back']);
|
||||
expect(answer.spread_ids).toEqual(answer.pages.map((p) => p.id));
|
||||
expect('previous_spreads' in answer).toBe(false);
|
||||
const book = await edit.overview(journey.id, owner.id);
|
||||
expect([book.exists, book.version, book.title, book.page.preset, book.page.pageWidth]).toEqual([
|
||||
true,
|
||||
1,
|
||||
'Porto',
|
||||
'a4-landscape',
|
||||
297,
|
||||
]);
|
||||
expect(sent()).toEqual(saved([owner.id, editor.id].sort((a, b) => a - b)));
|
||||
});
|
||||
|
||||
it('JBEDIT-002: a second book, a viewer and an outsider are refused, and nothing is sent for any of them', async () => {
|
||||
const { journey, viewer, stranger, owner } = await crew();
|
||||
|
||||
expect(await refusal(() => edit.createBook(journey.id, owner.id, { title: 'Again' }))).toMatchObject({
|
||||
status: 409,
|
||||
code: 'book_exists',
|
||||
});
|
||||
const bare = await makeJourney(orm, owner.id);
|
||||
await addJourneyContributor(orm, bare.id, viewer.id, 'viewer');
|
||||
expect(await refusal(() => edit.createBook(bare.id, viewer.id, {}))).toMatchObject({ status: 403 });
|
||||
expect(await refusal(() => edit.createBook(bare.id, stranger.id, {}))).toEqual({
|
||||
status: 404,
|
||||
code: 'not_found',
|
||||
message: 'Journey not found or access denied.',
|
||||
});
|
||||
expect(sent()).toEqual([]);
|
||||
expect((await edit.overview(journey.id, owner.id)).version).toBe(1);
|
||||
expect((await edit.overview(bare.id, owner.id)).exists).toBe(false);
|
||||
});
|
||||
|
||||
it('JBEDIT-003: any member reads the book and its pages, an outsider is told the journey is not there', async () => {
|
||||
const { journey, viewer, stranger, owner, inner } = await crew();
|
||||
const photo = await galleryPhoto(journey.id, owner.id);
|
||||
await edit.editPage(journey.id, owner.id, {
|
||||
spreadId: inner,
|
||||
ops: [{ op: 'place_photo', photoId: photo, target: 'new' }],
|
||||
});
|
||||
|
||||
const page = await edit.describePage(journey.id, viewer.id, inner);
|
||||
expect(page.role).toBe('inner');
|
||||
expect(page.elements.map((e) => [e.kind, e.photoId])).toEqual([['photo', photo]]);
|
||||
expect(page.text).toContain('Findings');
|
||||
expect((await edit.overview(journey.id, viewer.id)).spreads[2]?.photoIds).toEqual([photo]);
|
||||
const layouts = await edit.layouts(journey.id, viewer.id, 'single');
|
||||
expect([layouts.inner, layouts.single.length > 0, typeof layouts.notes.grids]).toEqual([[], true, 'string']);
|
||||
|
||||
expect(await refusal(() => edit.describePage(journey.id, viewer.id, 'sp-nowhere'))).toMatchObject({
|
||||
status: 404,
|
||||
code: 'book_page_missing',
|
||||
});
|
||||
expect(await refusal(() => edit.overview(journey.id, stranger.id))).toMatchObject({ status: 404 });
|
||||
const bare = await makeJourney(orm, owner.id);
|
||||
expect(await refusal(() => edit.describePage(bare.id, owner.id, inner))).toMatchObject({
|
||||
status: 404,
|
||||
code: 'book_missing',
|
||||
});
|
||||
const paper = [{ op: 'background' as const, color: null }];
|
||||
expect(await refusal(() => edit.editPage(bare.id, owner.id, { spreadId: inner, ops: paper }))).toMatchObject({
|
||||
status: 404,
|
||||
code: 'book_missing',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editing a page', () => {
|
||||
it('JBEDIT-004: edit_book_page places a gallery photo, answers the page with its findings, and is what GET reads', async () => {
|
||||
const { journey, editor, owner, inner, everyone } = await crew();
|
||||
const photo = await galleryPhoto(journey.id, owner.id);
|
||||
|
||||
const answer = await edit.editPage(journey.id, editor.id, {
|
||||
spreadId: inner,
|
||||
ops: [
|
||||
{ op: 'place_photo', photoId: photo, target: 'new' },
|
||||
{ op: 'background', color: '#f4efe6' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(answer.version).toBe(2);
|
||||
expect(answer.spread_ids).toEqual([inner]);
|
||||
expect(firstPage(answer)).toMatchObject({ id: inner, index: 2, photoIds: [photo], background: '#f4efe6' });
|
||||
expect(Array.isArray(answer.findings)).toBe(true);
|
||||
expect(answer.dropped).toEqual([]);
|
||||
expect('previous_spreads' in answer).toBe(false);
|
||||
const stored = await t.get(JourneyBookService).getBook(journey.id, owner.id);
|
||||
expect(stored?.version).toBe(2);
|
||||
expect(stored?.document.spreads[2]?.background).toBe('#f4efe6');
|
||||
expect(sent()).toEqual(saved(everyone));
|
||||
});
|
||||
|
||||
it('JBEDIT-005: previous_spreads holds the page as it was, only when the call asks for it', async () => {
|
||||
const { journey, owner, inner } = await crew();
|
||||
const photo = await galleryPhoto(journey.id, owner.id);
|
||||
await edit.editPage(journey.id, owner.id, {
|
||||
spreadId: inner,
|
||||
ops: [{ op: 'place_photo', photoId: photo, target: 'new' }],
|
||||
});
|
||||
|
||||
const answer = await edit.editPage(journey.id, owner.id, {
|
||||
spreadId: inner,
|
||||
ops: [{ op: 'background', color: '#101010' }],
|
||||
returnPrevious: true,
|
||||
});
|
||||
|
||||
expect(answer.previous_spreads).toHaveLength(1);
|
||||
const previous = answer.previous_spreads?.[0];
|
||||
expect([previous?.index, previous?.spread.id, previous?.spread.background]).toEqual([2, inner, null]);
|
||||
expect(previous?.spread.elements.map((e) => e.kind)).toEqual(['photo']);
|
||||
});
|
||||
|
||||
it('JBEDIT-006: a photo from outside the gallery, a locked element or a viewer is refused whole: nothing written, nothing sent', async () => {
|
||||
const { journey, owner, viewer, inner } = await crew();
|
||||
const other = await makeJourney(orm, owner.id);
|
||||
const foreign = await galleryPhoto(other.id, owner.id);
|
||||
const mine = await galleryPhoto(journey.id, owner.id);
|
||||
|
||||
expect(
|
||||
await refusal(() =>
|
||||
edit.editPage(journey.id, owner.id, {
|
||||
spreadId: inner,
|
||||
ops: [
|
||||
{ op: 'background', color: '#222222' },
|
||||
{ op: 'place_photo', photoId: foreign, target: 'new' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
status: 422,
|
||||
code: 'book_photo_not_in_gallery',
|
||||
message: `ops[1]: photo ${foreign} is not in this journey's gallery`,
|
||||
});
|
||||
|
||||
const books = t.get(JourneyBookService);
|
||||
const book = await books.getBook(journey.id, owner.id);
|
||||
if (!book) throw new Error('the crew has a book');
|
||||
const locked = { id: 'p-lock', kind: 'photo', frame: { x: 20, y: 20, w: 60, h: 40 }, locked: true, photoId: mine };
|
||||
const document = {
|
||||
...book.document,
|
||||
spreads: book.document.spreads.map((s) => (s.id === inner ? { ...s, elements: [locked] } : s)),
|
||||
};
|
||||
await books.saveBook(journey.id, owner.id, { title: book.title, document, baseVersion: book.version });
|
||||
t.realtime.reset();
|
||||
|
||||
expect(
|
||||
await refusal(() =>
|
||||
edit.editPage(journey.id, owner.id, { spreadId: inner, ops: [{ op: 'frame', elementId: 'p-lock', x: 0 }] }),
|
||||
),
|
||||
).toMatchObject({ status: 422, code: 'book_locked' });
|
||||
const paper = [{ op: 'background' as const, color: null }];
|
||||
expect(await refusal(() => edit.editPage(journey.id, viewer.id, { spreadId: inner, ops: paper }))).toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
|
||||
const after = await books.getBook(journey.id, owner.id);
|
||||
expect([after?.version, after?.document.spreads[2]?.background]).toEqual([2, null]);
|
||||
expect(sent()).toEqual([]);
|
||||
});
|
||||
|
||||
it('JBEDIT-007: set_book_cover puts a gallery photo and the title on the cover, keeping what it replaced on request', async () => {
|
||||
const { journey, owner, cover } = await crew();
|
||||
const photo = await galleryPhoto(journey.id, owner.id);
|
||||
|
||||
const answer = await edit.setCover(journey.id, owner.id, {
|
||||
photoId: photo,
|
||||
title: 'Lisbon 2026',
|
||||
returnPrevious: true,
|
||||
});
|
||||
|
||||
expect(answer.spread_ids).toEqual([cover]);
|
||||
expect(firstPage(answer)).toMatchObject({ role: 'cover', photoIds: [photo] });
|
||||
expect(firstPage(answer)?.texts).toContain('Lisbon 2026');
|
||||
expect(answer.previous_spreads?.map((p) => [p.index, p.spread.elements.length])).toEqual([[0, 0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adding, moving, deleting and restoring pages', () => {
|
||||
it('JBEDIT-008: add_book_pages keeps new pages inside the covers and ties them to an entry of this journey only', async () => {
|
||||
const { journey, owner, cover } = await crew();
|
||||
const entry = await makeJourneyEntry(orm, journey.id, owner.id);
|
||||
const elsewhere = await makeJourney(orm, owner.id);
|
||||
const foreignEntry = await makeJourneyEntry(orm, elsewhere.id, owner.id);
|
||||
|
||||
const answer = await edit.addPages(journey.id, owner.id, { afterSpreadId: cover, count: 2, entryId: entry.id });
|
||||
|
||||
expect(answer.pages.map((p) => [p.index, p.role, p.entryId])).toEqual([
|
||||
[2, 'inner', entry.id],
|
||||
[3, 'inner', entry.id],
|
||||
]);
|
||||
expect(await roles(journey.id, owner.id)).toEqual(['cover', 'first', 'inner', 'inner', 'inner', 'last', 'back']);
|
||||
expect(
|
||||
await refusal(() => edit.addPages(journey.id, owner.id, { afterSpreadId: cover, entryId: foreignEntry.id })),
|
||||
).toMatchObject({ status: 422, code: 'book_entry_not_found' });
|
||||
expect((await edit.overview(journey.id, owner.id)).version).toBe(2);
|
||||
});
|
||||
|
||||
it('JBEDIT-009: the cover, first, last and back pages are neither moved nor deleted; an inner page is', async () => {
|
||||
const { journey, owner, cover, first, inner, everyone } = await crew();
|
||||
|
||||
expect(await refusal(() => edit.deletePages(journey.id, owner.id, { spreadIds: [cover] }))).toMatchObject({
|
||||
status: 422,
|
||||
code: 'book_protected_spread',
|
||||
});
|
||||
expect(await refusal(() => edit.movePage(journey.id, owner.id, { spreadId: first, toIndex: 2 }))).toMatchObject({
|
||||
status: 422,
|
||||
code: 'book_protected_spread',
|
||||
});
|
||||
await edit.addPages(journey.id, owner.id, { afterSpreadId: inner });
|
||||
t.realtime.reset();
|
||||
const moved = await edit.movePage(journey.id, owner.id, { spreadId: inner, toIndex: 3 });
|
||||
expect(firstPage(moved)).toMatchObject({ id: inner, index: 3 });
|
||||
expect([moved.from_index, moved.version]).toEqual([2, 3]);
|
||||
expect(sent()).toEqual(saved(everyone));
|
||||
|
||||
// Already there: nothing is written, the version stays and nobody is told.
|
||||
t.realtime.reset();
|
||||
const stay = await edit.movePage(journey.id, owner.id, { spreadId: inner, toIndex: 3 });
|
||||
expect([stay.from_index, stay.version, firstPage(stay)?.index]).toEqual([3, 3, 3]);
|
||||
expect(sent()).toEqual([]);
|
||||
expect((await edit.overview(journey.id, owner.id)).version).toBe(3);
|
||||
|
||||
const gone = await edit.deletePages(journey.id, owner.id, { spreadIds: [inner] });
|
||||
expect([gone.spread_ids, gone.pages, 'previous_spreads' in gone]).toEqual([[inner], [], false]);
|
||||
expect(await roles(journey.id, owner.id)).toEqual(['cover', 'first', 'inner', 'last', 'back']);
|
||||
});
|
||||
|
||||
it('JBEDIT-010: a deleted page comes back with restore_book_page at the version the delete left, never over a later save', async () => {
|
||||
const { journey, owner, inner } = await crew();
|
||||
const gone = await edit.deletePages(journey.id, owner.id, { spreadIds: [inner], returnPrevious: true });
|
||||
const previous = gone.previous_spreads?.[0];
|
||||
if (!previous) throw new Error('the delete kept no page');
|
||||
expect(previous.index).toBe(2);
|
||||
|
||||
const stale = await refusal(() =>
|
||||
edit.restorePage(journey.id, owner.id, { pages: [previous], expectedVersion: 1 }),
|
||||
);
|
||||
expect(stale).toMatchObject({ status: 409, code: 'book_conflict' });
|
||||
|
||||
const back = await edit.restorePage(journey.id, owner.id, { pages: [previous], expectedVersion: gone.version });
|
||||
expect(firstPage(back)).toMatchObject({ id: inner, index: 2, role: 'inner' });
|
||||
expect(back.version).toBe(gone.version + 1);
|
||||
});
|
||||
|
||||
it('JBEDIT-011: several pages deleted in one call come back in one restore, each where it was', async () => {
|
||||
const { journey, owner, inner, everyone } = await crew();
|
||||
const added = await edit.addPages(journey.id, owner.id, { afterSpreadId: inner, count: 3 });
|
||||
const before = (await edit.overview(journey.id, owner.id)).spreads.map((s) => s.id);
|
||||
const doomed = [added.spread_ids[2], inner, added.spread_ids[0]].filter((id): id is string => !!id);
|
||||
|
||||
const gone = await edit.deletePages(journey.id, owner.id, { spreadIds: doomed, returnPrevious: true });
|
||||
|
||||
expect(gone.previous_spreads?.map((p) => p.index)).toEqual([2, 3, 5]);
|
||||
expect((await edit.overview(journey.id, owner.id)).spreads).toHaveLength(before.length - 3);
|
||||
const previous = gone.previous_spreads ?? [];
|
||||
t.realtime.reset();
|
||||
const back = await edit.restorePage(journey.id, owner.id, { pages: previous, expectedVersion: gone.version });
|
||||
expect(back.version).toBe(gone.version + 1);
|
||||
expect(back.pages.map((p) => [p.id, p.index])).toEqual(previous.map((p) => [p.spread.id, p.index]));
|
||||
expect((await edit.overview(journey.id, owner.id)).spreads.map((s) => s.id)).toEqual(before);
|
||||
expect(sent()).toEqual(saved(everyone));
|
||||
});
|
||||
|
||||
it('JBEDIT-012: a restore brings no photo that is neither in the gallery nor in the book, and writes nothing then', async () => {
|
||||
const { journey, owner, inner } = await crew();
|
||||
const photo = await galleryPhoto(journey.id, owner.id);
|
||||
const other = await makeJourney(orm, owner.id);
|
||||
const foreign = await galleryPhoto(other.id, owner.id);
|
||||
const placed = await edit.editPage(journey.id, owner.id, {
|
||||
spreadId: inner,
|
||||
ops: [{ op: 'place_photo', photoId: photo, target: 'new' }],
|
||||
returnPrevious: true,
|
||||
});
|
||||
const page = placed.previous_spreads?.[0];
|
||||
if (!page) throw new Error('the edit kept no page');
|
||||
const frame = bookPhotoElementSchema.parse({
|
||||
id: 'p-stray',
|
||||
kind: 'photo',
|
||||
frame: { x: 10, y: 10, w: 50, h: 50 },
|
||||
photoId: foreign,
|
||||
});
|
||||
const stray = { index: page.index, spread: { ...page.spread, elements: [frame] } };
|
||||
t.realtime.reset();
|
||||
|
||||
expect(
|
||||
await refusal(() => edit.restorePage(journey.id, owner.id, { pages: [stray], expectedVersion: placed.version })),
|
||||
).toMatchObject({ status: 422, code: 'book_photo_not_in_gallery' });
|
||||
expect((await edit.overview(journey.id, owner.id)).version).toBe(placed.version);
|
||||
expect(sent()).toEqual([]);
|
||||
|
||||
// The page as the edit found it comes back: an honest undo always passes.
|
||||
const undone = await edit.restorePage(journey.id, owner.id, { pages: [page], expectedVersion: placed.version });
|
||||
expect(firstPage(undone)?.photoIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* suggest_book_photos (JBPHOTO-*): photos of a journey's gallery for its book,
|
||||
* through JourneyBookPhotosMcp and JourneyBookPhotosService. Taken from
|
||||
* JourneyBookModule over a snapshot test db; the photo bytes come from a
|
||||
* stubbed resolver and are measured on the calling thread.
|
||||
*
|
||||
* What it pins: the tool waits for the Journey addon and only reads; any
|
||||
* member may ask and an outsider is told the journey is not there; the picks
|
||||
* follow the entries (their day, their place, their id) and the book (a photo
|
||||
* on a page says so); a video is never suggested; a photo whose provider does
|
||||
* not answer is still suggested and counted under signals_pending; a link to
|
||||
* a dismissed entry names nothing; an entry of another journey is refused; the
|
||||
* photos an entry narrows to are measured first, the rest is not waited for.
|
||||
*/
|
||||
import { JourneyEntryPhotos } from '../../../src/db/entities/JourneyEntryPhotos.entity';
|
||||
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
|
||||
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { JourneyBookPhotosMcp } from '../../../src/nest/journey/journey-book-photos.mcp';
|
||||
import { JourneyBookModule } from '../../../src/nest/journey/journey-book.module';
|
||||
import { JourneyBookMcp } from '../../../src/nest/journey/journey-book.mcp';
|
||||
import { PhotoResolverService, type PhotoSignalSource } from '../../../src/nest/memories/photo-resolver.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { addJourneyContributor, makeJourney, makeJourneyEntry } from '../../helpers/factories/journeys';
|
||||
import { insertRow } from '../../helpers/factories/rows';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { bookPhotoSuggestionsSchema } from '@trek/shared';
|
||||
|
||||
import { Jimp } from 'jimp';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const resolver = { readForSignals: vi.fn<(photoId: number, maxBytes: number) => Promise<PhotoSignalSource>>() };
|
||||
let t: TestModule;
|
||||
let orm: TestOrm;
|
||||
let mcp: JourneyBookPhotosMcp;
|
||||
let jpeg: Buffer;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [JourneyBookModule],
|
||||
overrides: [{ provide: PhotoResolverService, useValue: resolver }],
|
||||
});
|
||||
mcp = t.get(JourneyBookPhotosMcp);
|
||||
orm = await createTestOrm(db);
|
||||
jpeg = Buffer.from(await new Jimp({ width: 400, height: 300, color: 0x6699ccff }).getBuffer('image/jpeg'));
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
orm.clear();
|
||||
vi.restoreAllMocks();
|
||||
resolver.readForSignals.mockReset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }> }) {
|
||||
return bookPhotoSuggestionsSchema.parse(JSON.parse(result.content[0]?.text ?? ''));
|
||||
}
|
||||
|
||||
/** A photo in the journey's gallery; answers its trek_photos id and its gallery row id. */
|
||||
async function galleryPhoto(journeyId: number, ownerId: number, over: { taken_at?: string; media_type?: string } = {}) {
|
||||
const photoId = await insertRow(orm, TrekPhotos, {
|
||||
provider: 'local',
|
||||
owner: ownerId,
|
||||
file_path: 'journey/p.jpg',
|
||||
...over,
|
||||
});
|
||||
const galleryId = await insertRow(orm, JourneyPhotos, {
|
||||
journey: journeyId,
|
||||
photo: photoId,
|
||||
sort_order: 0,
|
||||
created_at: Date.now(),
|
||||
});
|
||||
return { photoId, galleryId };
|
||||
}
|
||||
|
||||
describe('JourneyBookPhotosMcp', () => {
|
||||
it('JBPHOTO-001: the tool waits for the Journey addon and reads in the journey group', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const entry = getEntry(JourneyBookPhotosMcp as unknown as ClassRef, 'suggestBookPhotos');
|
||||
if (entry?.kind !== 'tool') throw new Error('suggestBookPhotos is no tool');
|
||||
expect(entry.options.name).toBe('suggest_book_photos');
|
||||
expect(entry.options.access).toEqual({ group: 'journey', mode: 'read' });
|
||||
expect(entry.options.annotations?.readOnlyHint).toBe(true);
|
||||
const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled');
|
||||
enabled.mockResolvedValue(false);
|
||||
expect(await entry.options.when?.(ctx(user.id), mcp)).toBe(false);
|
||||
enabled.mockResolvedValue(true);
|
||||
expect(await entry.options.when?.(ctx(user.id), mcp)).toBe(true);
|
||||
});
|
||||
|
||||
it('JBPHOTO-002: any member may ask, an outsider is told the journey is not there', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { user: viewer } = await makeUser(orm);
|
||||
const { user: stranger } = await makeUser(orm);
|
||||
const journey = await makeJourney(orm, owner.id);
|
||||
await addJourneyContributor(orm, journey.id, viewer.id, 'viewer');
|
||||
resolver.readForSignals.mockResolvedValue({ kind: 'original', bytes: jpeg });
|
||||
const { photoId } = await galleryPhoto(journey.id, owner.id, { taken_at: '2026-07-03T10:00:00' });
|
||||
|
||||
const answer = read(await mcp.suggestBookPhotos({ journeyId: journey.id }, ctx(viewer.id)));
|
||||
expect(answer.groups.map((g) => [g.day, g.picks.map((p) => p.photoId)])).toEqual([['2026-07-03', [photoId]]]);
|
||||
expect(answer.signals_pending).toBe(0);
|
||||
await expect(mcp.suggestBookPhotos({ journeyId: journey.id }, ctx(stranger.id))).rejects.toThrow(
|
||||
'Journey not found',
|
||||
);
|
||||
await expect(mcp.suggestBookPhotos({ journeyId: 999999 }, ctx(owner.id))).rejects.toThrow('Journey not found');
|
||||
});
|
||||
|
||||
it('JBPHOTO-003: picks follow the entries and the book, leave videos out and count what is still unmeasured', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const journey = await makeJourney(orm, owner.id);
|
||||
const alfama = await makeJourneyEntry(orm, journey.id, owner.id, {
|
||||
entry_date: '2026-07-03',
|
||||
location_name: 'Alfama',
|
||||
location_lat: 38.7118,
|
||||
location_lng: -9.13,
|
||||
});
|
||||
const linked = await galleryPhoto(journey.id, owner.id);
|
||||
await insertRow(orm, JourneyEntryPhotos, {
|
||||
entry: alfama.id,
|
||||
journeyPhoto: linked.galleryId,
|
||||
sort_order: 0,
|
||||
created_at: Date.now(),
|
||||
});
|
||||
const cover = await galleryPhoto(journey.id, owner.id, { taken_at: '2026-07-04T10:00:00' });
|
||||
const offline = await galleryPhoto(journey.id, owner.id, { taken_at: '2026-07-04T15:00:00' });
|
||||
await galleryPhoto(journey.id, owner.id, { taken_at: '2026-07-04T12:00:00', media_type: 'video' });
|
||||
// A dismissed entry is not listed by get_journey, so its link must not name the group either.
|
||||
const dismissed = await makeJourneyEntry(orm, journey.id, owner.id, {
|
||||
entry_date: '2026-07-04',
|
||||
location_name: 'Hidden',
|
||||
dismissed: 1,
|
||||
});
|
||||
await insertRow(orm, JourneyEntryPhotos, {
|
||||
entry: dismissed.id,
|
||||
journeyPhoto: offline.galleryId,
|
||||
sort_order: 0,
|
||||
created_at: Date.now(),
|
||||
});
|
||||
resolver.readForSignals.mockImplementation((photoId) =>
|
||||
Promise.resolve<PhotoSignalSource>(
|
||||
photoId === offline.photoId ? { kind: 'unavailable' } : { kind: 'original', bytes: jpeg },
|
||||
),
|
||||
);
|
||||
const books = t.get(JourneyBookMcp);
|
||||
await books.createBook({ journeyId: journey.id }, ctx(owner.id));
|
||||
await books.setBookCover({ journeyId: journey.id, photoId: cover.photoId }, ctx(owner.id));
|
||||
|
||||
const answer = read(await mcp.suggestBookPhotos({ journeyId: journey.id }, ctx(owner.id)));
|
||||
expect(answer.groups.map((g) => [g.day, g.place, g.entryId, g.picks.map((p) => p.photoId)])).toEqual([
|
||||
['2026-07-03', 'Alfama', alfama.id, [linked.photoId]],
|
||||
['2026-07-04', null, null, [cover.photoId, offline.photoId]],
|
||||
]);
|
||||
expect(answer.groups[1]?.picks[0]?.why).toEqual(['landscape', 'in the book already']);
|
||||
expect(answer.groups[1]?.picks[1]?.why).toEqual(['not in the book yet']);
|
||||
expect(answer.signals_pending).toBe(1);
|
||||
|
||||
const one = read(await mcp.suggestBookPhotos({ journeyId: journey.id, entryId: alfama.id }, ctx(owner.id)));
|
||||
expect(one.groups.map((g) => g.picks.map((p) => p.photoId))).toEqual([[linked.photoId]]);
|
||||
const other = await makeJourney(orm, owner.id);
|
||||
const elsewhere = await makeJourneyEntry(orm, other.id, owner.id);
|
||||
const refused = mcp.suggestBookPhotos({ journeyId: journey.id, entryId: elsewhere.id }, ctx(owner.id));
|
||||
await expect(refused).rejects.toMatchObject({ status: 404, message: `No entry ${elsewhere.id} in this journey` });
|
||||
});
|
||||
|
||||
it('JBPHOTO-004: the photos an entry narrows to are measured first, and the rest of the gallery is not waited for', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const journey = await makeJourney(orm, owner.id);
|
||||
const day = await makeJourneyEntry(orm, journey.id, owner.id, { entry_date: '2026-07-05' });
|
||||
const elsewhere = await galleryPhoto(journey.id, owner.id, { taken_at: '2026-07-01T10:00:00' });
|
||||
const asked = await galleryPhoto(journey.id, owner.id, { taken_at: '2026-07-05T10:00:00' });
|
||||
resolver.readForSignals.mockImplementation((photoId) =>
|
||||
Promise.resolve<PhotoSignalSource>(
|
||||
photoId === asked.photoId ? { kind: 'original', bytes: jpeg } : { kind: 'unavailable' },
|
||||
),
|
||||
);
|
||||
|
||||
const answer = read(await mcp.suggestBookPhotos({ journeyId: journey.id, entryId: day.id }, ctx(owner.id)));
|
||||
expect(answer.groups.map((g) => g.picks.map((p) => p.photoId))).toEqual([[asked.photoId]]);
|
||||
expect(answer.signals_pending).toBe(0);
|
||||
expect(resolver.readForSignals.mock.calls[0]?.[0]).toBe(asked.photoId);
|
||||
await vi.waitFor(() => expect(resolver.readForSignals).toHaveBeenCalledWith(elsewhere.photoId, expect.any(Number)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* JourneyBookMcp: the Studio book over MCP, on JourneyBookEditService and the
|
||||
* rights of PUT /api/journeys/:id/book. Taken from JourneyBookModule over a
|
||||
* snapshot test db with the realtime fake, so a write's event is the one the
|
||||
* app sends.
|
||||
*
|
||||
* What it pins: every tool waits for the Journey addon and says whether it
|
||||
* reads or writes; the owner and an editor write, a viewer is refused and an
|
||||
* outsider is told the journey is not there; each write sends exactly one
|
||||
* journey:book:saved to every member, the actor among them and no tab left
|
||||
* out; and the answers are the shapes @trek/shared describes.
|
||||
*/
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { JourneyBookModule } from '../../../src/nest/journey/journey-book.module';
|
||||
import { JourneyBookMcp } from '../../../src/nest/journey/journey-book.mcp';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { addJourneyContributor, makeJourney } from '../../helpers/factories/journeys';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { bookOverviewSchema, bookPageDescriptionSchema, bookWriteResultSchema } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let orm: TestOrm;
|
||||
let mcp: JourneyBookMcp;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [JourneyBookModule] });
|
||||
mcp = t.get(JourneyBookMcp);
|
||||
orm = await createTestOrm(db);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
orm.clear();
|
||||
t.realtime.reset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }): unknown {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
/** Who was sent what, as [user, event type, left-out socket], in user order. */
|
||||
const sent = () =>
|
||||
t.realtime.broadcastToUserMock.mock.calls
|
||||
.map(([to, msg, sid]) => [to, (msg as { type: string }).type, sid] as const)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const READS = ['getBook', 'describeBookPage', 'listLayouts'];
|
||||
const WRITES = [
|
||||
'createBook',
|
||||
'addBookPages',
|
||||
'moveBookPage',
|
||||
'deleteBookPages',
|
||||
'editBookPage',
|
||||
'setBookCover',
|
||||
'restoreBookPage',
|
||||
];
|
||||
|
||||
describe('JourneyBookMcp', () => {
|
||||
it('JBMCP-001: every tool waits for the Journey addon and reads or writes in the journey group as it is annotated', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled');
|
||||
for (const method of [...READS, ...WRITES]) {
|
||||
const entry = getEntry(JourneyBookMcp as unknown as ClassRef, method);
|
||||
if (entry?.kind !== 'tool') throw new Error(`${method} is no tool`);
|
||||
const reads = READS.includes(method);
|
||||
expect(entry.options.access, method).toEqual({ group: 'journey', mode: reads ? 'read' : 'write' });
|
||||
expect(entry.options.annotations?.readOnlyHint, method).toBe(reads);
|
||||
enabled.mockResolvedValue(false);
|
||||
expect(await entry.options.when?.(ctx(user.id), mcp), method).toBe(false);
|
||||
enabled.mockResolvedValue(true);
|
||||
expect(await entry.options.when?.(ctx(user.id), mcp), method).toBe(true);
|
||||
}
|
||||
const remove = getEntry(JourneyBookMcp as unknown as ClassRef, 'deleteBookPages');
|
||||
expect(remove?.kind === 'tool' && remove.options.annotations?.destructiveHint).toBe(true);
|
||||
});
|
||||
|
||||
it('JBMCP-002: the owner and an editor write, each write sends one journey:book:saved to every member and skips no tab', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { user: editor } = await makeUser(orm);
|
||||
const { user: viewer } = await makeUser(orm);
|
||||
const journey = await makeJourney(orm, owner.id);
|
||||
await addJourneyContributor(orm, journey.id, editor.id, 'editor');
|
||||
await addJourneyContributor(orm, journey.id, viewer.id, 'viewer');
|
||||
const everyone = [owner.id, editor.id, viewer.id].sort((a, b) => a - b);
|
||||
const once = everyone.map((id) => [id, 'journey:book:saved', undefined] as const);
|
||||
|
||||
const created = bookWriteResultSchema.parse(read(await mcp.createBook({ journeyId: journey.id }, ctx(owner.id))));
|
||||
expect(sent()).toEqual(once);
|
||||
t.realtime.reset();
|
||||
|
||||
const inner = created.pages.find((p) => p.role === 'inner')?.id ?? 'no inner page';
|
||||
const edited = read(
|
||||
await mcp.editBookPage(
|
||||
{ journeyId: journey.id, spreadId: inner, ops: [{ op: 'background', color: '#eeeeee' }] },
|
||||
ctx(editor.id),
|
||||
),
|
||||
);
|
||||
expect(bookWriteResultSchema.parse(edited).version).toBe(2);
|
||||
expect(sent()).toEqual(once);
|
||||
});
|
||||
|
||||
it('JBMCP-003: a viewer reads but is refused a write, and an outsider is told the journey is not there', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { user: viewer } = await makeUser(orm);
|
||||
const { user: stranger } = await makeUser(orm);
|
||||
const journey = await makeJourney(orm, owner.id);
|
||||
await addJourneyContributor(orm, journey.id, viewer.id, 'viewer');
|
||||
const created = bookWriteResultSchema.parse(read(await mcp.createBook({ journeyId: journey.id }, ctx(owner.id))));
|
||||
const cover = created.pages[0]?.id ?? 'no cover';
|
||||
t.realtime.reset();
|
||||
|
||||
const overview = bookOverviewSchema.parse(read(await mcp.getBook({ journeyId: journey.id }, ctx(viewer.id))));
|
||||
expect(overview.spreads).toHaveLength(5);
|
||||
const page = read(await mcp.describeBookPage({ journeyId: journey.id, spreadId: cover }, ctx(viewer.id)));
|
||||
expect(bookPageDescriptionSchema.parse(page).role).toBe('cover');
|
||||
|
||||
await expect(mcp.setBookCover({ journeyId: journey.id, title: 'Mine' }, ctx(viewer.id))).rejects.toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
await expect(mcp.getBook({ journeyId: journey.id }, ctx(stranger.id))).rejects.toThrow('Journey not found');
|
||||
await expect(mcp.createBook({ journeyId: journey.id }, ctx(stranger.id))).rejects.toThrow('Journey not found');
|
||||
expect(sent()).toEqual([]);
|
||||
});
|
||||
|
||||
it('JBMCP-004: pages are added, moved, deleted and put back through the tools, each answer in its contract', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const journey = await makeJourney(orm, owner.id);
|
||||
const as = ctx(owner.id);
|
||||
const created = bookWriteResultSchema.parse(read(await mcp.createBook({ journeyId: journey.id }, as)));
|
||||
const inner = created.pages[2]?.id ?? 'no inner page';
|
||||
|
||||
const layouts = read(await mcp.listLayouts({ journeyId: journey.id, role: 'inner' }, as)) as { single: unknown[] };
|
||||
expect(layouts.single).toEqual([]);
|
||||
const added = bookWriteResultSchema.parse(
|
||||
read(await mcp.addBookPages({ journeyId: journey.id, afterSpreadId: inner, count: 2 }, as)),
|
||||
);
|
||||
expect(added.pages.map((p) => p.index)).toEqual([3, 4]);
|
||||
const moved = bookWriteResultSchema.parse(
|
||||
read(await mcp.moveBookPage({ journeyId: journey.id, spreadId: inner, toIndex: 4 }, as)),
|
||||
);
|
||||
expect(moved.pages.map((p) => [p.id, p.index])).toEqual([[inner, 4]]);
|
||||
expect(moved.from_index).toBe(2);
|
||||
const gone = bookWriteResultSchema.parse(
|
||||
read(await mcp.deleteBookPages({ journeyId: journey.id, spreadIds: [inner], returnPrevious: true }, as)),
|
||||
);
|
||||
const previous = gone.previous_spreads?.[0];
|
||||
if (!previous) throw new Error('the delete kept no page');
|
||||
|
||||
const back = bookWriteResultSchema.parse(
|
||||
read(await mcp.restoreBookPage({ journeyId: journey.id, pages: [previous], expectedVersion: gone.version }, as)),
|
||||
);
|
||||
expect(back.pages.map((p) => [p.id, p.index])).toEqual([[inner, 4]]);
|
||||
expect(back.version).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { JourneyBooks } from '../../../src/db/entities/JourneyBooks.entity';
|
||||
import { Journeys } from '../../../src/db/entities/Journeys.entity';
|
||||
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
||||
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
||||
import { catchDomainError, DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
import { createTestJourneyBooksRepo } from '../../helpers/journey-share-repos';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow';
|
||||
import { normalizeBookDocument } from '@trek/shared';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -393,18 +395,225 @@ describe('concurrency', () => {
|
||||
|
||||
// -- Broadcasting -------------------------------------------------------------
|
||||
|
||||
describe('broadcastSaved', () => {
|
||||
it('sends the version, not the document, and excludes the saver', async () => {
|
||||
/**
|
||||
* Who heard what, recorded off the domain's fan-out: the event, its payload,
|
||||
* the socket left out, and whether a transaction was still open at the time.
|
||||
*/
|
||||
async function recordSends() {
|
||||
const em = (await sharedTestOrm(testDb)).em;
|
||||
const sent: Array<{ event: string; data: Record<string, unknown>; skips: unknown; inTx: boolean }> = [];
|
||||
const spy = vi.spyOn(domain, 'broadcastJourneyEvent').mockImplementation(async (_journeyId, event, data, skips) => {
|
||||
sent.push({ event, data, skips, inTx: em.isInTransaction() });
|
||||
});
|
||||
return { sent, restore: () => spy.mockRestore() };
|
||||
}
|
||||
|
||||
describe('saveBook announces', () => {
|
||||
it('JBOOK-001: a save that lands, after its commit, with the version and the saver, leaving out the saving tab', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const saved = (await books.saveBook(journey.id, user.id, { title: 'T', document: doc() }))!;
|
||||
const record = 'record' in saved ? saved.record : null;
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
await books.saveBook(journey.id, user.id, { title: 'T', document: doc() }, { socketId: 'socket-7' });
|
||||
expect(rec.sent).toEqual([
|
||||
{ event: 'journey:book:saved', data: { version: 1, savedBy: user.id }, skips: 'socket-7', inTx: false },
|
||||
]);
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
});
|
||||
|
||||
const spy = vi.spyOn(domain, 'broadcastJourneyEvent').mockImplementation(async () => {});
|
||||
await books.broadcastSaved(journey.id, user.id, record!, 'socket-7');
|
||||
it('JBOOK-002: nothing for a conflict or a refused save, since nothing was written', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: guest } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
addJourneyContributor(testDb, journey.id, guest.id, 'viewer');
|
||||
await books.saveBook(journey.id, owner.id, { title: 'T', document: doc('one') });
|
||||
await books.saveBook(journey.id, owner.id, { title: 'T', document: doc('two'), baseVersion: 1 });
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const stale = await books.saveBook(journey.id, owner.id, { title: 'T', document: doc('x'), baseVersion: 1 });
|
||||
expect(stale && 'conflict' in stale).toBe(true);
|
||||
expect(await books.saveBook(journey.id, guest.id, { title: 'T', document: doc('y') })).toBeNull();
|
||||
expect(rec.sent).toEqual([]);
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(journey.id, 'journey:book:saved', { version: 1, savedBy: user.id }, 'socket-7');
|
||||
spy.mockRestore();
|
||||
// -- Editing (the book tools' write) ------------------------------------------
|
||||
|
||||
describe('editBook', () => {
|
||||
const edited = (title: string) => () => ({ title, document: normalizeBookDocument(doc(title)), result: title });
|
||||
|
||||
it('JBOOK-003: creates the book a journey lacks, at version 1, and tells every tab, the asking one too', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const seen: Array<number | null> = [];
|
||||
const out = await books.editBook(journey.id, user.id, (current) => {
|
||||
seen.push(current?.version ?? null);
|
||||
return edited('first')();
|
||||
});
|
||||
expect(seen).toEqual([null]);
|
||||
expect(out.record.version).toBe(1);
|
||||
expect(out.result).toBe('first');
|
||||
expect(rec.sent).toEqual([
|
||||
{ event: 'journey:book:saved', data: { version: 1, savedBy: user.id }, skips: undefined, inTx: false },
|
||||
]);
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('JBOOK-004: edits what is stored now and bumps its version, the record coming back as GET reads it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
await books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc('one') });
|
||||
await books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc('two'), baseVersion: 1 });
|
||||
|
||||
const out = await books.editBook(journey.id, user.id, (current) => ({
|
||||
title: current?.title ?? '',
|
||||
document: normalizeBookDocument({ ...doc('three'), spreads: current?.document.spreads ?? [] }),
|
||||
result: current?.document.title,
|
||||
}));
|
||||
|
||||
expect(out.result).toBe('two');
|
||||
expect(out.record.version).toBe(3);
|
||||
expect(await books.getBook(journey.id, user.id)).toEqual(out.record);
|
||||
expect(out.record.title).toBe('Iceland');
|
||||
expect(out.record.document.title).toBe('three');
|
||||
});
|
||||
|
||||
it('JBOOK-005: an edit that throws rolls back: nothing written, nobody told', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const refusal = new DomainError(422, 'ops[0]: no element p-9');
|
||||
const thrown = await catchDomainError(() =>
|
||||
books.editBook(journey.id, user.id, () => {
|
||||
throw refusal;
|
||||
}),
|
||||
);
|
||||
expect(thrown).toBe(refusal);
|
||||
expect(rec.sent).toEqual([]);
|
||||
const stored = await books.getBook(journey.id, user.id);
|
||||
expect([stored?.version, stored?.document.title]).toEqual([1, 'one']);
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('JBOOK-006: refuses a moved book with 409 when a version is expected, writing and sending nothing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
|
||||
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const edit = vi.fn(edited('undo'));
|
||||
const thrown = await catchDomainError(() => books.editBook(journey.id, user.id, edit, { expectedVersion: 1 }));
|
||||
expect(thrown instanceof DomainError && [thrown.getStatus(), thrown.mcpMessage]).toEqual([
|
||||
409,
|
||||
'The book was changed since that version. Read it again with get_book before changing it.',
|
||||
]);
|
||||
expect(edit).not.toHaveBeenCalled();
|
||||
expect(rec.sent).toEqual([]);
|
||||
expect((await books.getBook(journey.id, user.id))?.version).toBe(2);
|
||||
|
||||
// At the version it names, the same write lands.
|
||||
const out = await books.editBook(journey.id, user.id, edited('undo'), { expectedVersion: 2 });
|
||||
expect(out.record.version).toBe(3);
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('JBOOK-007: a viewer is refused with 403 and someone outside the journey with 404, before any edit runs', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: guest } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
addJourneyContributor(testDb, journey.id, guest.id, 'viewer');
|
||||
const edit = vi.fn(edited('nope'));
|
||||
|
||||
const asGuest = await catchDomainError(() => books.editBook(journey.id, guest.id, edit));
|
||||
const asStranger = await catchDomainError(() => books.editBook(journey.id, stranger.id, edit));
|
||||
|
||||
expect(asGuest instanceof DomainError && asGuest.getStatus()).toBe(403);
|
||||
expect(asStranger instanceof DomainError && [asStranger.getStatus(), asStranger.mcpMessage]).toEqual([
|
||||
404,
|
||||
'Journey not found or access denied.',
|
||||
]);
|
||||
expect(edit).not.toHaveBeenCalled();
|
||||
expect(await books.getBook(journey.id, owner.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('JBOOK-008: against a Studio save at the same moment one wins and the other is told the book moved', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
|
||||
|
||||
const [tool, studio] = await Promise.all([
|
||||
catchDomainError(() => books.editBook(journey.id, user.id, edited('tool'), { expectedVersion: 1 })),
|
||||
books.saveBook(journey.id, user.id, { title: 'T', document: doc('studio'), baseVersion: 1 }),
|
||||
]);
|
||||
|
||||
const toolLost = tool instanceof DomainError && tool.getStatus() === 409;
|
||||
const studioLost = studio !== null && 'conflict' in studio;
|
||||
expect([toolLost, studioLost].filter(Boolean)).toHaveLength(1);
|
||||
const stored = await books.getBook(journey.id, user.id);
|
||||
expect(stored?.version).toBe(2);
|
||||
expect(stored?.document.title).toBe(toolLost ? 'studio' : 'tool');
|
||||
});
|
||||
|
||||
it('JBOOK-009: an undo against a journey whose book is gone answers 409 with no current book, creating none', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const edit = vi.fn(edited('undo'));
|
||||
const thrown = await catchDomainError(() => books.editBook(journey.id, user.id, edit, { expectedVersion: 3 }));
|
||||
expect(thrown instanceof DomainError && [thrown.getStatus(), thrown.code, thrown.details]).toEqual([
|
||||
409,
|
||||
'book_conflict',
|
||||
{ current: null },
|
||||
]);
|
||||
expect(edit).not.toHaveBeenCalled();
|
||||
expect(rec.sent).toEqual([]);
|
||||
expect(await books.getBook(journey.id, user.id)).toBeNull();
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('JBOOK-010: an edit that says it changed nothing writes nothing, keeps the version and tells nobody', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
// The edit may read what it checks against before it answers.
|
||||
const out = await books.editBook(journey.id, user.id, async (current) => {
|
||||
await Promise.resolve();
|
||||
return { ...edited('ignored')(), unchanged: true, result: current?.version };
|
||||
});
|
||||
expect([out.result, out.record.version, out.record.document.title]).toEqual([1, 1, 'one']);
|
||||
expect(rec.sent).toEqual([]);
|
||||
expect((await books.getBook(journey.id, user.id))?.document.title).toBe('one');
|
||||
|
||||
// Without a stored book there is nothing to leave alone: the book is made.
|
||||
const bare = createJourney(testDb, user.id);
|
||||
const made = await books.editBook(bare.id, user.id, () => ({ ...edited('new')(), unchanged: true }));
|
||||
expect(made.record.version).toBe(1);
|
||||
expect(rec.sent).toHaveLength(1);
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -990,24 +990,28 @@ describe('JourneyController', () => {
|
||||
|
||||
// The record comes back bare, not in an envelope — the contract in
|
||||
// shared/src/book/book-store.schema.ts is the record itself.
|
||||
const broadcastSaved = vi.fn();
|
||||
const saveBook = vi.fn().mockReturnValue({ record: { id: 3, version: 5 } });
|
||||
expect(
|
||||
await ctl(svc(), { saveBook, broadcastSaved }).saveBook(
|
||||
await ctl(svc(), { saveBook }).saveBook(
|
||||
user,
|
||||
'9',
|
||||
{ title: 'T', document: { v: 1 }, baseVersion: 4 } as never,
|
||||
'sock-7',
|
||||
),
|
||||
).toEqual({ id: 3, version: 5 });
|
||||
expect(saveBook).toHaveBeenCalledWith(9, 1, { title: 'T', document: { v: 1 }, baseVersion: 4 });
|
||||
// Forwarded so the client that just saved does not process its own change.
|
||||
expect(broadcastSaved).toHaveBeenCalledWith(9, 1, { id: 3, version: 5 }, 'sock-7');
|
||||
// The socket goes to the service, which announces the save after its
|
||||
// commit and leaves out the tab that saved, so it does not echo its change.
|
||||
expect(saveBook).toHaveBeenCalledWith(
|
||||
9,
|
||||
1,
|
||||
{ title: 'T', document: { v: 1 }, baseVersion: 4 },
|
||||
{ socketId: 'sock-7' },
|
||||
);
|
||||
|
||||
// A missing title is '' rather than undefined: the column is NOT NULL and
|
||||
// an untitled book is an ordinary thing to have.
|
||||
const untitled = vi.fn().mockReturnValue({ record: { id: 3, version: 1 } });
|
||||
await ctl(svc(), { saveBook: untitled, broadcastSaved: vi.fn() }).saveBook(user, '9', { document: {} } as never);
|
||||
await ctl(svc(), { saveBook: untitled }).saveBook(user, '9', { document: {} } as never);
|
||||
expect(untitled.mock.calls[0][2].title).toBe('');
|
||||
});
|
||||
|
||||
@@ -1017,18 +1021,13 @@ describe('JourneyController', () => {
|
||||
* make showing what the other version is a second round trip, at exactly
|
||||
* the moment someone is worried about losing work.
|
||||
*/
|
||||
const broadcastSaved = vi.fn();
|
||||
const saveBook = vi.fn().mockReturnValue({ conflict: { id: 3, version: 6, title: 'Theirs' } });
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { document: {}, baseVersion: 4 } as never),
|
||||
),
|
||||
await thrownAsync(() => ctl(svc(), { saveBook }).saveBook(user, '9', { document: {}, baseVersion: 4 } as never)),
|
||||
).toEqual({
|
||||
status: 409,
|
||||
body: { error: 'Book was changed by someone else', current: { id: 3, version: 6, title: 'Theirs' } },
|
||||
});
|
||||
// Nothing was written, so nobody is told anything moved.
|
||||
expect(broadcastSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('book: delete answers 204, 403 for a viewer, or 404 out of reach', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
bookSpreadSchema,
|
||||
bookTextElementSchema,
|
||||
} from './book.schema';
|
||||
import { PHOTO_PICK } from './photo-pick';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -233,14 +234,26 @@ export const createBookArgs = {
|
||||
title: z.string().max(MAX_BOOK_TITLE).optional(),
|
||||
preset: bookPageSetupSchema.shape.preset.unwrap().optional(),
|
||||
};
|
||||
export const restoreBookPageArgs = {
|
||||
spread: bookSpreadSchema,
|
||||
/** Where it goes back when it is gone; a page still in the book is replaced where it is. */
|
||||
/**
|
||||
* A page as it was before a write, for the assistant's undo: `index` is where
|
||||
* it goes back when it is gone, a page still in the book is replaced where it is.
|
||||
*/
|
||||
export const bookPreviousSpreadSchema = z.object({
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS - 1),
|
||||
spread: bookSpreadSchema,
|
||||
});
|
||||
export type BookPreviousSpread = z.infer<typeof bookPreviousSpreadSchema>;
|
||||
|
||||
/**
|
||||
* The pages a write answered under `previous_spreads`, put back in one call
|
||||
* (several deleted pages are one undo), lowest index first.
|
||||
*/
|
||||
export const restoreBookPageArgs = {
|
||||
pages: z.array(bookPreviousSpreadSchema).min(1).max(BOOK_OP_LIMITS.pagesPerCall),
|
||||
expectedVersion: z.number().int().nonnegative(),
|
||||
};
|
||||
export const describeBookPageArgs = { spreadId: bookSpreadIdSchema };
|
||||
@@ -252,7 +265,7 @@ export const suggestBookPhotosArgs = {
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
purpose: z.enum(['page', 'cover']).optional(),
|
||||
limit: z.number().int().min(1).max(60).optional(),
|
||||
limit: z.number().int().min(1).max(PHOTO_PICK.maxLimit).optional(),
|
||||
};
|
||||
|
||||
export const editBookPageRequestSchema = z.strictObject(editBookPageArgs);
|
||||
@@ -357,18 +370,19 @@ export const bookPageDescriptionSchema = z.object({
|
||||
});
|
||||
export type BookPageDescription = z.infer<typeof bookPageDescriptionSchema>;
|
||||
|
||||
/** A page as it was before a write, for the assistant's undo. */
|
||||
export const bookPreviousSpreadSchema = z.object({
|
||||
index: z.number().int().nonnegative(),
|
||||
spread: bookSpreadSchema,
|
||||
});
|
||||
|
||||
/** What a book write answers. `previous_spreads` only when the call asked with `returnPrevious`. */
|
||||
/**
|
||||
* What a book write answers. `dropped` names the elements whose content left
|
||||
* the page (a layout, a hard remove). `previous_spreads` only when the call
|
||||
* asked with `returnPrevious`; `from_index` only from a move, where the page
|
||||
* stood before it, so a move can be taken back.
|
||||
*/
|
||||
export const bookWriteResultSchema = z.object({
|
||||
version: z.number().int().nonnegative(),
|
||||
spread_ids: z.array(z.string()),
|
||||
pages: z.array(bookSpreadSummarySchema),
|
||||
findings: z.array(bookFindingSchema).max(BOOK_OP_LIMITS.findings),
|
||||
dropped: z.array(z.string()).max(MAX_SPREAD_ELEMENTS * 2),
|
||||
previous_spreads: z.array(bookPreviousSpreadSchema).optional(),
|
||||
from_index: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
export type BookWriteResult = z.infer<typeof bookWriteResultSchema>;
|
||||
|
||||
@@ -126,6 +126,11 @@ describe('add, move and delete', () => {
|
||||
expect(ids(doc)).toEqual(['sp-cover', 'sp-first', 'sp-in2', 'sp-in3', 'sp-in1', 'sp-last', 'sp-back']);
|
||||
});
|
||||
|
||||
it('BOOKPG-023: answers the very same document for a move to where the page already is', () => {
|
||||
const before = book(3);
|
||||
expect(ok(moveBookPage(before, { spreadId: 'sp-in2', toIndex: 3 }))).toBe(before);
|
||||
});
|
||||
|
||||
it('BOOKPG-009: refuses to move a fixed page or to move a spread outside the covers', () => {
|
||||
for (const id of ['sp-cover', 'sp-first', 'sp-last', 'sp-back']) {
|
||||
expect(refused(moveBookPage(book(2), { spreadId: id, toIndex: 2 }))).toBe('protected_spread');
|
||||
@@ -155,7 +160,7 @@ describe('restore', () => {
|
||||
it('BOOKPG-012: puts a page back in its place when the book still has it', () => {
|
||||
const before = book(2);
|
||||
const changed = { ...(before.spreads[2] as BookSpread), background: '#112233' };
|
||||
const doc = ok(restoreBookPage(before, { spread: changed, index: 5, expectedVersion: 3 }));
|
||||
const doc = ok(restoreBookPage(before, { pages: [{ spread: changed, index: 5 }], expectedVersion: 3 }));
|
||||
expect(ids(doc)).toEqual(ids(before));
|
||||
expect(doc.spreads[2]?.background).toBe('#112233');
|
||||
});
|
||||
@@ -164,30 +169,95 @@ describe('restore', () => {
|
||||
const before = book(2);
|
||||
const gone = before.spreads[3] as BookSpread;
|
||||
const without = ok(deleteBookPages(before, { spreadIds: [gone.id] }));
|
||||
expect(ids(ok(restoreBookPage(without, { spread: gone, index: 3, expectedVersion: 1 })))).toEqual(ids(before));
|
||||
const clamped = ok(restoreBookPage(without, { spread: gone, index: 0, expectedVersion: 1 }));
|
||||
const back = ok(restoreBookPage(without, { pages: [{ spread: gone, index: 3 }], expectedVersion: 1 }));
|
||||
expect(ids(back)).toEqual(ids(before));
|
||||
const clamped = ok(restoreBookPage(without, { pages: [{ spread: gone, index: 0 }], expectedVersion: 1 }));
|
||||
expect(clamped.spreads[2]?.id).toBe(gone.id);
|
||||
});
|
||||
|
||||
it('BOOKPG-014: refuses a page that is not a page', () => {
|
||||
expect(
|
||||
refused(restoreBookPage(book(1), { spread: { id: 'x', role: 'middle' }, index: 2, expectedVersion: 1 })),
|
||||
).toBe('invalid_op');
|
||||
const notAPage = { pages: [{ spread: { id: 'x', role: 'middle' }, index: 2 }], expectedVersion: 1 };
|
||||
expect(refused(restoreBookPage(book(1), notAPage))).toBe('invalid_op');
|
||||
expect(refused(restoreBookPage(book(1), { pages: [], expectedVersion: 1 }))).toBe('invalid_op');
|
||||
});
|
||||
|
||||
it('BOOKPG-019: never makes a second cover, and never turns a page into another kind', () => {
|
||||
const doc = book(1);
|
||||
const asCover = { ...(doc.spreads[2] as BookSpread), role: 'cover' as const };
|
||||
expect(refused(restoreBookPage(doc, { spread: asCover, index: 2, expectedVersion: 1 }))).toBe('invalid_op');
|
||||
expect(refused(restoreBookPage(doc, { pages: [{ spread: asCover, index: 2 }], expectedVersion: 1 }))).toBe(
|
||||
'invalid_op',
|
||||
);
|
||||
for (const role of ['cover', 'first', 'last', 'back'] as const) {
|
||||
const extra = { ...blankSpread(role), id: `sp-extra-${role}` };
|
||||
expect(refused(restoreBookPage(doc, { spread: extra, index: 2, expectedVersion: 1 }))).toBe('protected_spread');
|
||||
expect(refused(restoreBookPage(doc, { pages: [{ spread: extra, index: 2 }], expectedVersion: 1 }))).toBe(
|
||||
'protected_spread',
|
||||
);
|
||||
}
|
||||
const lost = { ...doc, spreads: doc.spreads.filter((s) => s.role !== 'back') };
|
||||
const back = doc.spreads[doc.spreads.length - 1] as BookSpread;
|
||||
const restored = ok(restoreBookPage(lost, { spread: back, index: lost.spreads.length, expectedVersion: 1 }));
|
||||
const restored = ok(
|
||||
restoreBookPage(lost, { pages: [{ spread: back, index: lost.spreads.length }], expectedVersion: 1 }),
|
||||
);
|
||||
expect(ids(restored)).toEqual(ids(doc));
|
||||
});
|
||||
|
||||
it('BOOKPG-024: puts several deleted pages back in one call, lowest index first whatever order they come in', () => {
|
||||
const before = book(4);
|
||||
const without = ok(deleteBookPages(before, { spreadIds: ['sp-in1', 'sp-in3', 'sp-in4'] }));
|
||||
const previous = before.spreads.flatMap((spread, index) =>
|
||||
['sp-in1', 'sp-in3', 'sp-in4'].includes(spread.id) ? [{ index, spread }] : [],
|
||||
);
|
||||
const result = restoreBookPage(without, { pages: [...previous].reverse(), expectedVersion: 2 });
|
||||
expect(ids(ok(result))).toEqual(ids(before));
|
||||
expect(result.ok && result.spreadIds).toEqual(['sp-in1', 'sp-in3', 'sp-in4']);
|
||||
});
|
||||
|
||||
it('BOOKPG-025: refuses a photo that is neither in the gallery nor in the book, and takes one the book holds', () => {
|
||||
const base = book(2);
|
||||
const frame = (id: string, photoId: number) =>
|
||||
bookPhotoElementSchema.parse({ id, kind: 'photo', frame: { x: 10, y: 10, w: 80, h: 80 }, photoId });
|
||||
const withPhoto = {
|
||||
...base,
|
||||
spreads: base.spreads.map((s) => (s.id === 'sp-in1' ? { ...s, elements: [frame('p-kept', 7)] } : s)),
|
||||
};
|
||||
const page = withPhoto.spreads[3] as BookSpread;
|
||||
const photos = [{ photoId: 1 }];
|
||||
const stray = { ...page, elements: [frame('p-stray', 99)] };
|
||||
const args = (spread: BookSpread) => ({ pages: [{ spread, index: 3 }], expectedVersion: 1 });
|
||||
expect(refused(restoreBookPage(withPhoto, args(stray), { photos }))).toBe('photo_not_in_gallery');
|
||||
const fromBook = { ...page, elements: [frame('p-again', 7)] };
|
||||
expect(ok(restoreBookPage(withPhoto, args(fromBook), { photos })).spreads[3]?.elements[0]?.id).toBe('p-again');
|
||||
const fromGallery = { ...page, elements: [frame('p-new', 1)] };
|
||||
expect(ok(restoreBookPage(withPhoto, args(fromGallery), { photos })).spreads[3]?.elements[0]?.id).toBe('p-new');
|
||||
// Without a gallery in the context the engine cannot tell, and leaves it to the caller.
|
||||
expect(ok(restoreBookPage(withPhoto, args(stray))).spreads[3]?.elements[0]?.id).toBe('p-stray');
|
||||
});
|
||||
|
||||
it('BOOKPG-026: keeps a locked element as it is on a page put back in place, and adds no locked one', () => {
|
||||
const base = book(1);
|
||||
const lockedText = bookTextElementSchema.parse({
|
||||
id: 't-lock',
|
||||
kind: 'text',
|
||||
frame: { x: 20, y: 20, w: 100, h: 20 },
|
||||
text: 'Kept',
|
||||
locked: true,
|
||||
});
|
||||
const doc = {
|
||||
...base,
|
||||
spreads: base.spreads.map((s) => (s.id === 'sp-in1' ? { ...s, elements: [lockedText] } : s)),
|
||||
};
|
||||
const page = doc.spreads[2] as BookSpread;
|
||||
const args = (spread: BookSpread) => ({ pages: [{ spread, index: 2 }], expectedVersion: 1 });
|
||||
// The same element with its keys in another order is the same element.
|
||||
const backwards = Object.fromEntries(Object.entries(lockedText).reverse()) as BookTextElement;
|
||||
const reordered = { ...page, background: '#223344', elements: [backwards] };
|
||||
expect(ok(restoreBookPage(doc, args(reordered))).spreads[2]?.background).toBe('#223344');
|
||||
const changed = { ...page, elements: [{ ...lockedText, text: 'Changed' }] };
|
||||
expect(refused(restoreBookPage(doc, args(changed)))).toBe('locked');
|
||||
expect(refused(restoreBookPage(doc, args({ ...page, elements: [] })))).toBe('locked');
|
||||
const newLock = { ...lockedText, id: 't-new' };
|
||||
expect(refused(restoreBookPage(doc, args({ ...page, elements: [lockedText, newLock] })))).toBe('locked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the cover', () => {
|
||||
|
||||
@@ -91,6 +91,8 @@ export function moveBookPage(doc: BookDocument, args: unknown): BookEditResult {
|
||||
if (toIndex < first || toIndex >= limit) {
|
||||
throw failOp('index_out_of_range', `inner pages sit at index ${first} to ${limit - 1}`, null);
|
||||
}
|
||||
// A page already there: the very same document back, so a caller can tell nothing changed.
|
||||
if (toIndex === from) return { ok: true, doc, spreadIds: [spreadId], created: [], dropped: [] };
|
||||
const spreads = doc.spreads.filter((_, i) => i !== from);
|
||||
spreads.splice(toIndex, 0, doc.spreads[from] as BookSpread);
|
||||
return { ok: true, doc: { ...doc, spreads }, spreadIds: [spreadId], created: [], dropped: [] };
|
||||
@@ -121,46 +123,112 @@ export function deleteBookPages(doc: BookDocument, args: unknown): BookEditResul
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a page back as it was, for the assistant's undo: in its place when the
|
||||
* book still has a page with its id, else inserted at `index`, kept inside the
|
||||
* covers when it is an inner spread. The version check is the server's.
|
||||
* Put pages back as they were, for the assistant's undo: each in its place
|
||||
* when the book still has a page with its id, else inserted at its `index`,
|
||||
* kept inside the covers when it is an inner spread. The version check is the
|
||||
* server's. Several pages deleted at once go back lowest index first, so each
|
||||
* index means what it meant before the delete.
|
||||
*
|
||||
* Never a second cover: a page still in the book is only replaced by one of
|
||||
* the same role, and a cover, first, last or back page comes back only into a
|
||||
* book that has lost its own. Several pages deleted at once go back lowest
|
||||
* index first, so each index means what it meant before the delete.
|
||||
* book that has lost its own. A restore is no way around the rules of an
|
||||
* edit: with a gallery in the context, a photo must be in it or already in the
|
||||
* book, and a page still in the book keeps every locked element exactly as it
|
||||
* is, adding none. An honest undo always passes both, since the change it
|
||||
* takes back could touch neither and anything saved since fails the version.
|
||||
*/
|
||||
export function restoreBookPage(doc: BookDocument, args: unknown): BookEditResult {
|
||||
export function restoreBookPage(doc: BookDocument, args: unknown, ctx: BookOpsContext = {}): BookEditResult {
|
||||
const parsed = restoreBookPageRequestSchema.safeParse(args);
|
||||
if (!parsed.success) return { ok: false, error: invalidOp(parsed.error, null) };
|
||||
const { spread, index } = parsed.data;
|
||||
const pages = [...parsed.data.pages].sort((a, b) => a.index - b.index);
|
||||
return caught(() => {
|
||||
const at = doc.spreads.findIndex((s) => s.id === spread.id);
|
||||
if (at >= 0) {
|
||||
const current = doc.spreads[at] as BookSpread;
|
||||
if (current.role !== spread.role) {
|
||||
throw failOp(
|
||||
'invalid_op',
|
||||
`${spread.id} is the ${current.role} page now and cannot become the ${spread.role}`,
|
||||
null,
|
||||
);
|
||||
}
|
||||
const spreads = doc.spreads.slice();
|
||||
spreads[at] = spread;
|
||||
return { ok: true, doc: { ...doc, spreads }, spreadIds: [spread.id], created: [], dropped: [] };
|
||||
let cur = doc;
|
||||
for (const { spread, index } of pages) {
|
||||
restorablePhotos(spread, cur, ctx);
|
||||
cur = putBack(cur, spread, index);
|
||||
}
|
||||
if (spread.role !== 'inner' && doc.spreads.some((s) => s.role === spread.role)) {
|
||||
throw failOp('protected_spread', `the book already has its ${spread.role} page`, null);
|
||||
}
|
||||
if (doc.spreads.length >= MAX_SPREADS)
|
||||
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
|
||||
const { first, limit } = innerSpreadBounds(doc.spreads);
|
||||
const to = spread.role === 'inner' ? Math.min(Math.max(index, first), limit) : Math.min(index, doc.spreads.length);
|
||||
const spreads = [...doc.spreads.slice(0, to), spread, ...doc.spreads.slice(to)];
|
||||
return { ok: true, doc: { ...doc, spreads }, spreadIds: [spread.id], created: [], dropped: [] };
|
||||
const restored = [...new Set(pages.map((p) => p.spread.id))];
|
||||
return { ok: true, doc: cur, spreadIds: restored, created: [], dropped: [] };
|
||||
});
|
||||
}
|
||||
|
||||
/** One page back into the book: replaced where it is, or inserted at `index`. */
|
||||
function putBack(doc: BookDocument, spread: BookSpread, index: number): BookDocument {
|
||||
const at = doc.spreads.findIndex((s) => s.id === spread.id);
|
||||
if (at >= 0) {
|
||||
const current = doc.spreads[at] as BookSpread;
|
||||
if (current.role !== spread.role) {
|
||||
throw failOp(
|
||||
'invalid_op',
|
||||
`${spread.id} is the ${current.role} page now and cannot become the ${spread.role}`,
|
||||
null,
|
||||
);
|
||||
}
|
||||
sameLocks(current, spread);
|
||||
const spreads = doc.spreads.slice();
|
||||
spreads[at] = spread;
|
||||
return { ...doc, spreads };
|
||||
}
|
||||
if (spread.role !== 'inner' && doc.spreads.some((s) => s.role === spread.role)) {
|
||||
throw failOp('protected_spread', `the book already has its ${spread.role} page`, null);
|
||||
}
|
||||
if (doc.spreads.length >= MAX_SPREADS) {
|
||||
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
|
||||
}
|
||||
const { first, limit } = innerSpreadBounds(doc.spreads);
|
||||
const to = spread.role === 'inner' ? Math.min(Math.max(index, first), limit) : Math.min(index, doc.spreads.length);
|
||||
return { ...doc, spreads: [...doc.spreads.slice(0, to), spread, ...doc.spreads.slice(to)] };
|
||||
}
|
||||
|
||||
/** The photos a page shows: its photo frames, placed or parked, and the pictures on its map markers. */
|
||||
function photoIdsOf(spread: BookSpread): number[] {
|
||||
return [...spread.elements, ...spread.parked].flatMap((e) => {
|
||||
if (e.kind === 'photo') return e.photoId == null ? [] : [e.photoId];
|
||||
if (e.kind === 'map') return e.points.flatMap((p) => (p.photoId == null ? [] : [p.photoId]));
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/** With a gallery known, every photo of a page coming back is in it or somewhere in the book already. */
|
||||
function restorablePhotos(spread: BookSpread, doc: BookDocument, ctx: BookOpsContext): void {
|
||||
if (!ctx.photos) return;
|
||||
const known = new Set([...ctx.photos.map((p) => p.photoId), ...doc.spreads.flatMap(photoIdsOf)]);
|
||||
const stray = photoIdsOf(spread).find((id) => !known.has(id));
|
||||
if (stray !== undefined) {
|
||||
throw failOp('photo_not_in_gallery', `photo ${stray} is neither in this journey's gallery nor in the book`, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Key order aside, the same value: a parsed and a stored element list their fields in different orders. */
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>).filter(([, v]) => v !== undefined);
|
||||
entries.sort(([a], [b]) => a.localeCompare(b));
|
||||
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? 'null';
|
||||
}
|
||||
|
||||
/** A page replaced in place keeps each of its locked elements as it is and gains no new one. */
|
||||
function sameLocks(current: BookSpread, spread: BookSpread): void {
|
||||
const lockedOf = (s: BookSpread) =>
|
||||
new Map([...s.elements, ...s.parked].filter((e) => e.locked).map((e) => [e.id, e] as const));
|
||||
const before = lockedOf(current);
|
||||
const after = new Map([...spread.elements, ...spread.parked].map((e) => [e.id, e] as const));
|
||||
for (const [id, el] of before) {
|
||||
const back = after.get(id);
|
||||
if (!back || canonical(back) !== canonical(el)) {
|
||||
throw failOp('locked', `${id} is locked and stays as it is`, null, id);
|
||||
}
|
||||
}
|
||||
for (const [id] of lockedOf(spread)) {
|
||||
if (!before.has(id)) {
|
||||
throw failOp('locked', `${id} is not locked on the page now and cannot come back locked`, null, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isBoundTo = (el: BookElement, source: 'journey.title' | 'journey.subtitle') =>
|
||||
el.kind === 'text' && el.binding?.source === source;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user