feat(journey): the assistant designs the whole book as one proposal

design_book, revise_book_design and apply_book_design work on a draft, the
apply keeps the old book as a revision for undo, skips pages changed since,
and broadcasts once after the commit. Book level tools set the page setup,
duplicate pages and save layouts.
This commit is contained in:
Maurice
2026-10-22 21:25:46 +00:00
parent 09ffc7d946
commit bda07d6a3e
55 changed files with 4327 additions and 37 deletions
+24
View File
@@ -1,9 +1,14 @@
import { bookElementIdSchema, bookSpreadIdSchema } from '../book/book-ops.schema';
import { idParamSchema, idSchema } from '../common/primitives.schema';
import { assistantPageRenderSchema, assistantPageViewSchema } from './assistant-vision.schema';
import {
assistantCardSchema,
assistantCardWriteSchema,
ATLAS_APPLIED_ENTITIES,
ATLAS_VERB_KEYS,
BOOK_APPLIED_ENTITIES,
BOOK_DESIGN_VERB_KEYS,
BOOK_VERB_KEYS,
BOOKING_APPLIED_ENTITIES,
BOOKING_VERB_KEYS,
BUDGET_APPLIED_ENTITIES,
@@ -116,6 +121,8 @@ export const ASSISTANT_CHANGE_VERB_KEYS = [
...VACAY_VERB_KEYS,
...JOURNEY_VERB_KEYS,
...PACKING_VERB_KEYS,
...BOOK_VERB_KEYS,
...BOOK_DESIGN_VERB_KEYS,
] as const;
export const assistantChangeVerbKeySchema = z.enum(ASSISTANT_CHANGE_VERB_KEYS);
export type AssistantChangeVerbKey = z.infer<typeof assistantChangeVerbKeySchema>;
@@ -202,6 +209,9 @@ export const assistantFocusSchema = z
dayId: idSchema.optional(),
placeIds: z.array(idSchema).max(20).optional(),
view: assistantMapViewSchema.optional(),
/** In the Studio: the page (spread) on screen, and the elements selected on it. */
spreadId: bookSpreadIdSchema.optional(),
elementIds: z.array(bookElementIdSchema).max(30).optional(),
})
.strict();
export type AssistantFocus = z.infer<typeof assistantFocusSchema>;
@@ -220,6 +230,8 @@ export const assistantChatRequestSchema = assistantClientCtxSchema
text: z.string().trim().min(1).max(32_000),
focus: assistantFocusSchema.optional(),
mentions: z.array(assistantMentionSchema).max(50).optional(),
/** The client draws a Studio page when the turn asks for one (the Studio's chat, assistant-vision.schema.ts). */
renders: z.boolean().optional(),
})
.strict();
export type AssistantChatRequest = z.infer<typeof assistantChatRequestSchema>;
@@ -249,6 +261,8 @@ export type AssistantDecision = z.infer<typeof assistantDecisionSchema>;
export const assistantDecisionsRequestSchema = assistantClientCtxSchema
.extend({
decisions: z.array(assistantDecisionSchema).min(1).max(50),
/** As on a chat turn: the client draws a Studio page when the turn asks for one. */
renders: z.boolean().optional(),
})
.strict();
export type AssistantDecisionsRequest = z.infer<typeof assistantDecisionsRequestSchema>;
@@ -382,6 +396,7 @@ const assistantAppliedEntitySchema = z.enum([
...VACAY_APPLIED_ENTITIES,
...JOURNEY_APPLIED_ENTITIES,
...PACKING_APPLIED_ENTITIES,
...BOOK_APPLIED_ENTITIES,
]);
/** An applied change as the server writes it: what it touched, and until when it can be undone. */
@@ -579,6 +594,8 @@ function messagePartSchema<
z.object({ type: z.literal('data-trek-card'), data: card }),
// Only on a user message.
z.object({ type: z.literal('data-trek-mentions'), data: assistantMentionsSchema }),
// A Studio page the turn looked at: what it was and its description, never the image.
z.object({ type: z.literal('data-trek-page-view'), data: assistantPageViewSchema }),
]);
}
@@ -679,6 +696,13 @@ export const uiStreamChunkSchema = z.discriminatedUnion('type', [
data: assistantActivitySchema,
transient: z.literal(true).optional(),
}),
// A Studio page the turn asks the Studio tab to draw and upload; the message never keeps it.
z.object({
type: z.literal('data-trek-render-request'),
data: assistantPageRenderSchema,
transient: z.literal(true).optional(),
}),
z.object({ type: z.literal('data-trek-page-view'), data: assistantPageViewSchema }),
z.object({
type: z.literal('data-trek-quota'),
data: z.object({ used: z.number().int().nonnegative(), limit: z.number().int().nonnegative() }),
@@ -0,0 +1,90 @@
import { ASSISTANT_CHANGE_VERB_KEYS, assistantChangePreviewSchema } from '../assistant.schema';
import { BOOK_DESIGN_CARD_LIMITS, bookDesignCardSchema } from './book-design.card';
import { BOOK_DESIGN_VERB_KEYS } from './book-design.contract';
import { storedTrekCardSchema, trekCardSchema } from './index';
import { describe, expect, it } from 'vitest';
// BKDCARD-001 to BKDCARD-004
function card(over: Record<string, unknown> = {}) {
return {
kind: 'bookDesign',
change: 'proposed',
journeyId: 4,
designId: 'd-1',
baseVersion: 7,
style: 'editorial',
paper: '#faf8f4',
ink: '#1a1a1a',
accent: '#b45309',
heading: 'playfair',
body: 'garamond',
spreads: [
{
spreadId: 'sp-1',
index: 0,
action: 'replace',
beat: 'cover',
chapter: null,
entryId: null,
photoCount: 1,
severe: 0,
warn: 0,
},
{
spreadId: 'sp-9',
index: 3,
action: 'insert',
beat: 'hero',
chapter: 'Alfama',
entryId: 12,
photoCount: 1,
severe: 0,
warn: 1,
},
],
more: 0,
leftOut: 3,
revisionId: null,
skipped: [],
...over,
};
}
describe('the book design card', () => {
it('BKDCARD-001: reads a design card through the union, as itself', () => {
const parsed = trekCardSchema.parse(card());
expect(parsed.kind).toBe('bookDesign');
expect(bookDesignCardSchema.parse(parsed).spreads).toHaveLength(2);
expect(storedTrekCardSchema.parse(card({ change: 'applied', revisionId: 5 }))).toMatchObject({ revisionId: 5 });
});
it('BKDCARD-002: a field past its bound, an unknown style or reason, reads as no card', () => {
const page = card().spreads[1];
const longChapter = [{ ...page, chapter: 'x'.repeat(BOOK_DESIGN_CARD_LIMITS.chapter + 1) }];
expect(storedTrekCardSchema.parse(card({ spreads: longChapter }))).toBeNull();
expect(storedTrekCardSchema.parse(card({ style: 'baroque' }))).toBeNull();
expect(storedTrekCardSchema.parse(card({ skipped: [{ spreadId: 'sp-1', reason: 'tired' }] }))).toBeNull();
const many = Array.from({ length: BOOK_DESIGN_CARD_LIMITS.spreads + 1 }, () => page);
expect(storedTrekCardSchema.parse(card({ spreads: many }))).toBeNull();
});
it('BKDCARD-003: rides on a change preview with its own verb', () => {
const preview = assistantChangePreviewSchema.parse({
toolCallId: 'call_1',
verbKey: 'designBook',
subject: '',
rows: [],
dayId: null,
placeIds: [],
undoable: true,
card: card(),
});
expect([preview.verbKey, preview.card?.kind]).toEqual(['designBook', 'bookDesign']);
});
it('BKDCARD-004: names the verbs of the book-level changes in the shared enum', () => {
expect(ASSISTANT_CHANGE_VERB_KEYS).toEqual(expect.arrayContaining([...BOOK_DESIGN_VERB_KEYS]));
});
});
@@ -0,0 +1,66 @@
import { BOOK_DESIGN_SKIP_REASONS } from '../../book/book-design-tools.schema';
import { bookDesignIdSchema, bookSpreadIdSchema } from '../../book/book-ops.schema';
import { BOOK_FONTS_IDS } from '../../book/book.schema';
import { BOOK_DESIGN_STYLES } from '../../book/design/design-directives.schema';
import { BOOK_DESIGN_ACTIONS } from '../../book/design/design-merge';
import { BOOK_DESIGN_BEATS } from '../../book/design/design-pacing';
import { idSchema } from '../../common/primitives.schema';
import { cardCountSchema, cardHexColorSchema, cardText } from './card-parts';
import { z } from 'zod';
/**
* A whole-book design as the Studio's chat shows it: the book's look (style,
* paper, ink, accent, the two typefaces) and the plan page by page, what
* each page becomes and how many findings the server's check left on it.
* `none` is the answer of `design_book` (a draft, nothing proposed yet),
* `proposed` the held `apply_book_design`, `applied` its receipt with the
* revision that takes it back and the pages it left as they were. The pages
* themselves are drawn from the draft (GET /api/journeys/:id/book-designs/:designId),
* never carried in the card.
*/
export const BOOK_DESIGN_CARD_LIMITS = { spreads: 120, chapter: 40 } as const;
export const BOOK_DESIGN_CARD_CHANGES = ['none', 'proposed', 'applied'] as const;
/** One page of the plan as a card names it. */
export const bookDesignCardSpreadSchema = z.object({
spreadId: bookSpreadIdSchema,
index: cardCountSchema,
action: z.enum(BOOK_DESIGN_ACTIONS),
beat: z.enum(BOOK_DESIGN_BEATS),
chapter: cardText(BOOK_DESIGN_CARD_LIMITS.chapter).nullable(),
entryId: idSchema.nullable(),
photoCount: cardCountSchema,
severe: cardCountSchema,
warn: cardCountSchema,
});
export type BookDesignCardSpread = z.infer<typeof bookDesignCardSpreadSchema>;
export const bookDesignCardSchema = z.object({
kind: z.literal('bookDesign'),
change: z.enum(BOOK_DESIGN_CARD_CHANGES),
journeyId: idSchema,
designId: bookDesignIdSchema,
/** The book's version the draft was made against; null for a journey without a book. */
baseVersion: cardCountSchema.nullable(),
style: z.enum(BOOK_DESIGN_STYLES),
paper: cardHexColorSchema,
ink: cardHexColorSchema,
accent: cardHexColorSchema,
heading: z.enum(BOOK_FONTS_IDS),
body: z.enum(BOOK_FONTS_IDS),
spreads: z.array(bookDesignCardSpreadSchema).max(BOOK_DESIGN_CARD_LIMITS.spreads),
/** Plan pages past the ones listed. */
more: cardCountSchema,
/** Photos of the journey the design left out. */
leftOut: cardCountSchema,
/** Once applied: the revision the undo restores. */
revisionId: idSchema.nullable(),
/** Once applied: the pages the apply left as they were, and why. */
skipped: z
.array(z.object({ spreadId: bookSpreadIdSchema, reason: z.enum(BOOK_DESIGN_SKIP_REASONS) }))
.max(BOOK_DESIGN_CARD_LIMITS.spreads),
});
export type BookDesignCard = z.infer<typeof bookDesignCardSchema>;
@@ -0,0 +1,19 @@
import { bookDesignCardSchema } from './book-design.card';
export * from './book-design.card';
/**
* What the assistant's whole-book design brings to the chat: the card of a
* design and its proposal, and the verbs of the book-level changes (the
* design applied, the page format and numbers, a page copied, a layout kept
* or taken away). They touch pages of the book, so they name the photo
* book's own entity (`book_page`, book.contract.ts) and add none.
*/
export const BOOK_DESIGN_CARD_SCHEMAS = [bookDesignCardSchema] as const;
export const BOOK_DESIGN_VERB_KEYS = [
'designBook',
'setBookSetup',
'duplicateBookPage',
'saveBookLayout',
'removeBookLayout',
] as const;
+6
View File
@@ -1,4 +1,6 @@
import { ATLAS_CARD_SCHEMAS } from './atlas.contract';
import { BOOK_DESIGN_CARD_SCHEMAS } from './book-design.contract';
import { BOOK_CARD_SCHEMAS } from './book.contract';
import { BOOKING_CARD_SCHEMAS } from './booking.contract';
import { BUDGET_CARD_SCHEMAS } from './budget.contract';
import { COLLAB_CARD_SCHEMAS } from './collab.contract';
@@ -13,6 +15,8 @@ import { z } from 'zod';
export * from './card-parts';
export * from './atlas.contract';
export * from './book.contract';
export * from './book-design.contract';
export * from './booking.contract';
export * from './budget.contract';
export * from './collab.contract';
@@ -41,6 +45,8 @@ export const trekCardSchema = z.discriminatedUnion('kind', [
...VACAY_CARD_SCHEMAS,
...JOURNEY_CARD_SCHEMAS,
...PACKING_CARD_SCHEMAS,
...BOOK_CARD_SCHEMAS,
...BOOK_DESIGN_CARD_SCHEMAS,
]);
export type TrekCard = z.infer<typeof trekCardSchema>;
export type TrekCardKind = TrekCard['kind'];
@@ -0,0 +1,100 @@
import {
BOOK_DESIGN_LIMITS,
applyBookDesignRequestSchema,
bookDesignAnswerSchema,
bookDesignApplyResultSchema,
bookDesignDraftResponseSchema,
bookDesignSelectionRequestSchema,
designBookRequestSchema,
restoreBookLayoutRequestSchema,
restoreBookRevisionRequestSchema,
reviseBookDesignRequestSchema,
} from './book-design-tools.schema';
import { removeBookLayoutRequestSchema, setBookSetupRequestSchema } from './book-ops.schema';
import { blankBook } from './book-pages';
import { describe, expect, it } from 'vitest';
// BKDTOOL-001 to BKDTOOL-006
const row = {
spreadId: 'sp-1',
index: 0,
action: 'replace',
beat: 'cover',
chapter: null,
entryId: null,
photoIds: [3],
findings: { severe: 0, warn: 1 },
};
const answer = {
design_id: 'd-1',
journey_id: 4,
base_version: 7,
expires_at: '2026-10-22T18:00:00.000Z',
plan: [row],
more_rows: 0,
selected: ['sp-1'],
style: { style: 'minimal', paper: '#ffffff', ink: '#1a1a1a', accent: '#336699', heading: 'inter', body: 'inter' },
photos: { used: 3, left_out: [{ photoId: 9, reason: 'near_duplicate' }], left_out_more: 0 },
findings: [],
notes: ['the book holds 150 spreads'],
};
describe('the design tools', () => {
it('BKDTOOL-001: design_book takes no directives or bounded ones, and nothing it does not know', () => {
expect(designBookRequestSchema.parse({}).directives).toBeUndefined();
const parsed = designBookRequestSchema.parse({ directives: { style: 'editorial', pages: { max: 12 } } });
expect(parsed.directives).toMatchObject({ style: 'editorial', density: 'balanced', keep: 'locked' });
expect(designBookRequestSchema.safeParse({ directives: { mood: 'calm' } }).success).toBe(false);
expect(designBookRequestSchema.safeParse({ journeyId: 3 }).success).toBe(false);
});
it('BKDTOOL-002: a revision names its draft and page and takes the ops edit_book_page takes', () => {
const ops = [{ op: 'background', color: '#f4efe6' }];
expect(reviseBookDesignRequestSchema.safeParse({ designId: 'd-1', spreadId: 'sp-1', ops }).success).toBe(true);
expect(reviseBookDesignRequestSchema.safeParse({ designId: 'd-1', spreadId: 'sp-1', ops: [] }).success).toBe(false);
expect(reviseBookDesignRequestSchema.safeParse({ designId: '../d', spreadId: 'sp-1', ops }).success).toBe(false);
expect(applyBookDesignRequestSchema.safeParse({ designId: 'd-1' }).success).toBe(true);
expect(applyBookDesignRequestSchema.safeParse({ designId: 'd-1', expectedVersion: -1 }).success).toBe(false);
});
it('BKDTOOL-003: the undo targets name what they put back and the version they need', () => {
expect(restoreBookRevisionRequestSchema.safeParse({ revisionId: 3, expectedVersion: 8 }).success).toBe(true);
expect(restoreBookRevisionRequestSchema.safeParse({ revisionId: 3 }).success).toBe(false);
const layout = { layout: '{"id":"ly-1"}', expectedVersion: 2 };
expect(restoreBookLayoutRequestSchema.safeParse(layout).success).toBe(true);
expect(restoreBookLayoutRequestSchema.safeParse({ layout: '', expectedVersion: 2 }).success).toBe(false);
expect(setBookSetupRequestSchema.safeParse({ safe: 6, expectedVersion: 4 }).success).toBe(true);
expect(removeBookLayoutRequestSchema.safeParse({ layoutId: 'ly-1', expectedVersion: 4 }).success).toBe(true);
});
it('BKDTOOL-004: the selection is a closed list of page ids, as long as a book and its drops', () => {
const ids = Array.from({ length: BOOK_DESIGN_LIMITS.selection }, (_, i) => `sp-${i}`);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ids }).success).toBe(true);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [...ids, 'sp-x'] }).success).toBe(false);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ['sp 1'] }).success).toBe(false);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [], all: true }).success).toBe(false);
});
it('BKDTOOL-005: a design answer reads as itself, its plan rows bounded', () => {
expect(bookDesignAnswerSchema.parse(answer)).toEqual(answer);
const long = { ...answer, plan: [{ ...row, chapter: 'x'.repeat(81) }] };
expect(bookDesignAnswerSchema.safeParse(long).success).toBe(false);
const draft = { designId: 'd-1', baseVersion: null, expiresAt: answer.expires_at, plan: [row], selection: [] };
const response = { ...draft, document: blankBook({ title: 'Lisbon' }) };
expect(bookDesignDraftResponseSchema.safeParse(response).success).toBe(true);
});
it('BKDTOOL-006: an apply answers a book write with its revision (none for a new book) and the pages applied and left', () => {
const write = { version: 8, spread_ids: ['sp-1'], pages: [], findings: [], dropped: [] };
const applied = { ...write, design_id: 'd-1', revision_id: 12, applied: ['sp-1'], skipped: [] };
expect(bookDesignApplyResultSchema.safeParse(applied).success).toBe(true);
expect(bookDesignApplyResultSchema.safeParse({ ...applied, revision_id: null }).success).toBe(true);
const skipped = [{ spreadId: 'sp-2', reason: 'changed_since' }];
expect(bookDesignApplyResultSchema.safeParse({ ...applied, skipped }).success).toBe(true);
const unknown = [{ spreadId: 'sp-2', reason: 'tired' }];
expect(bookDesignApplyResultSchema.safeParse({ ...applied, skipped: unknown }).success).toBe(false);
});
});
+185
View File
@@ -0,0 +1,185 @@
import { idSchema } from '../common/primitives.schema';
import {
bookDesignIdSchema,
bookFindingSchema,
bookSpreadIdSchema,
bookSpreadSummarySchema,
bookWriteResultSchema,
editBookPageArgs,
} from './book-ops.schema';
import { BOOK_FONTS_IDS, MAX_SPREADS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
import { BOOK_DESIGN_STYLES, bookDesignDirectivesSchema } from './design/design-directives.schema';
import { BOOK_DESIGN_ACTIONS } from './design/design-merge';
import { BOOK_DESIGN_BEATS } from './design/design-pacing';
import { z } from 'zod';
/**
* The whole-book design as the assistant's tools take and answer it
* (journey-studio-wp13.md section 6): `design_book` composes a draft the
* server keeps, `revise_book_design` changes one page of it, `get_book_design`
* reads it again, `apply_book_design` writes the chosen pages in one
* transaction and keeps the book as it was as a revision, which
* `restore_book_revision` puts back. The draft's selection travels over REST
* for the proposal card. Plain shapes for the tools, strict request schemas
* beside them, as in book-ops.schema.ts.
*/
export const BOOK_DESIGN_LIMITS = {
/** Plan rows one answer lists; the rest are counted. */
planRows: 150,
/** Photos left out one answer names. */
leftOut: 60,
findings: 40,
notes: 20,
/** Pages of the book an apply answers with; every applied id is still named. */
pages: 24,
/** Pages a selection names at most: every page of a book and every page it drops. */
selection: MAX_SPREADS * 2,
/** Revisions a journey keeps, the newest first. */
revisions: 5,
} as const;
/** Why a page the design would change was not changed by its apply. */
export const BOOK_DESIGN_SKIP_REASONS = ['excluded', 'changed_since', 'photo_gone'] as const;
export type BookDesignSkipReason = (typeof BOOK_DESIGN_SKIP_REASONS)[number];
const version = z.number().int().nonnegative();
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/);
export const designBookArgs = { directives: bookDesignDirectivesSchema.optional() };
export const getBookDesignArgs = { designId: bookDesignIdSchema };
/** One page of a draft changed with the operations `edit_book_page` takes. */
export const reviseBookDesignArgs = {
designId: bookDesignIdSchema,
spreadId: bookSpreadIdSchema,
ops: editBookPageArgs.ops,
};
export const applyBookDesignArgs = { designId: bookDesignIdSchema, expectedVersion: version.optional() };
/** The assistant's undo of an applied design: the book put back as the apply found it. */
export const restoreBookRevisionArgs = { revisionId: idSchema, expectedVersion: version };
/**
* The assistant's undo of `remove_book_layout`: the layout it answered under
* `previous_layout`, as JSON, parsed again like every argument. A string keeps
* the tool's schema small; every client loads it with tools/list.
*/
export const restoreBookLayoutArgs = { layout: z.string().min(2).max(400_000), expectedVersion: version };
export const designBookRequestSchema = z.strictObject(designBookArgs);
export const getBookDesignRequestSchema = z.strictObject(getBookDesignArgs);
export const reviseBookDesignRequestSchema = z.strictObject(reviseBookDesignArgs);
export const applyBookDesignRequestSchema = z.strictObject(applyBookDesignArgs);
export const restoreBookRevisionRequestSchema = z.strictObject(restoreBookRevisionArgs);
export const restoreBookLayoutRequestSchema = z.strictObject(restoreBookLayoutArgs);
export type DesignBookRequest = z.infer<typeof designBookRequestSchema>;
export type GetBookDesignRequest = z.infer<typeof getBookDesignRequestSchema>;
export type ReviseBookDesignRequest = z.infer<typeof reviseBookDesignRequestSchema>;
export type ApplyBookDesignRequest = z.infer<typeof applyBookDesignRequestSchema>;
export type RestoreBookRevisionRequest = z.infer<typeof restoreBookRevisionRequestSchema>;
export type RestoreBookLayoutRequest = z.infer<typeof restoreBookLayoutRequestSchema>;
/** One page of a design's plan, as an answer and the draft route carry it. */
export const bookDesignPlanRowSchema = z.object({
spreadId: bookSpreadIdSchema,
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS * 2),
action: z.enum(BOOK_DESIGN_ACTIONS),
beat: z.enum(BOOK_DESIGN_BEATS),
chapter: z.string().max(80).nullable(),
entryId: z.number().int().positive().nullable(),
photoIds: z.array(z.number().int().positive()).max(MAX_SPREAD_ELEMENTS),
findings: z.object({ severe: z.number().int().nonnegative(), warn: z.number().int().nonnegative() }),
});
export type BookDesignPlanRowOut = z.infer<typeof bookDesignPlanRowSchema>;
/** The book's look a design chose: its style, colours and two typefaces. */
export const bookDesignStyleSchema = z.object({
style: z.enum(BOOK_DESIGN_STYLES),
paper: hex,
ink: hex,
accent: hex,
heading: z.enum(BOOK_FONTS_IDS),
body: z.enum(BOOK_FONTS_IDS),
});
export type BookDesignStyleOut = z.infer<typeof bookDesignStyleSchema>;
/** `design_book` and `get_book_design`: the draft in words, nothing of the book written. */
export const bookDesignAnswerSchema = z.object({
design_id: bookDesignIdSchema,
journey_id: idSchema,
/** The book's version the draft was made against; null for a journey without a book. */
base_version: version.nullable(),
/** When the server forgets the draft (ISO); design_book makes it again. */
expires_at: z.string(),
plan: z.array(bookDesignPlanRowSchema).max(BOOK_DESIGN_LIMITS.planRows),
/** Plan rows past those listed. */
more_rows: z.number().int().nonnegative(),
/** The pages apply_book_design writes unless the person takes some out. */
selected: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
style: bookDesignStyleSchema,
photos: z.object({
used: z.number().int().nonnegative(),
left_out: z
.array(z.object({ photoId: z.number().int().positive(), reason: z.string().max(40) }))
.max(BOOK_DESIGN_LIMITS.leftOut),
left_out_more: z.number().int().nonnegative(),
}),
findings: z.array(bookFindingSchema).max(BOOK_DESIGN_LIMITS.findings),
notes: z.array(z.string().max(300)).max(BOOK_DESIGN_LIMITS.notes),
});
export type BookDesignAnswer = z.infer<typeof bookDesignAnswerSchema>;
/** `revise_book_design`: the page of the draft as it is now, with its findings. */
export const bookDesignRevisionAnswerSchema = z.object({
design_id: bookDesignIdSchema,
page: bookSpreadSummarySchema,
action: z.enum(BOOK_DESIGN_ACTIONS),
findings: z.array(bookFindingSchema).max(BOOK_DESIGN_LIMITS.findings),
});
export type BookDesignRevisionAnswer = z.infer<typeof bookDesignRevisionAnswerSchema>;
/** A page the apply left as it was, and why. */
export const bookDesignSkippedSchema = z.object({
spreadId: bookSpreadIdSchema,
reason: z.enum(BOOK_DESIGN_SKIP_REASONS),
});
export type BookDesignSkipped = z.infer<typeof bookDesignSkippedSchema>;
/**
* `apply_book_design`: a book write's answer (the first pages it changed),
* the revision that takes it back, the pages it changed and the ones it left.
*/
export const bookDesignApplyResultSchema = bookWriteResultSchema.extend({
design_id: bookDesignIdSchema,
/** The revision that takes the apply back; null when the journey had no book before it. */
revision_id: idSchema.nullable(),
applied: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
skipped: z.array(bookDesignSkippedSchema).max(BOOK_DESIGN_LIMITS.selection),
});
export type BookDesignApplyResult = z.infer<typeof bookDesignApplyResultSchema>;
/** PUT /api/journeys/:journeyId/book-designs/:designId/selection: the pages the apply writes. */
export const bookDesignSelectionRequestSchema = z.strictObject({
spreadIds: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
});
export type BookDesignSelectionRequest = z.infer<typeof bookDesignSelectionRequestSchema>;
export const bookDesignSelectionResponseSchema = z.object({
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
});
export type BookDesignSelectionResponse = z.infer<typeof bookDesignSelectionResponseSchema>;
/** GET /api/journeys/:journeyId/book-designs/:designId: the draft for the proposal card's pages. */
export const bookDesignDraftResponseSchema = z.object({
designId: bookDesignIdSchema,
baseVersion: version.nullable(),
expiresAt: z.string(),
document: bookDocumentSchema,
plan: z.array(bookDesignPlanRowSchema).max(BOOK_DESIGN_LIMITS.selection),
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
});
export type BookDesignDraftResponse = z.infer<typeof bookDesignDraftResponseSchema>;