import { z } from 'zod'; /** * Page schemas and views shared between api and web (issue #23). A page * carries a Yjs document from day one (ADR 0003); in M2 its state is * saved wholesale over REST as a base64 string. */ /** * VS-NfD classification levels of a page (ADR 0022), lowest first. The * field is a marking, not a protection mechanism: permissions ignore it, * and separating levels is the platform's job (one instance per level). * API representations carry the lowercase value; the Prisma enum stores * the uppercase spelling. */ export const PAGE_CLASSIFICATIONS = ['unclassified', 'vs_nfd'] as const; export type PageClassification = (typeof PAGE_CLASSIFICATIONS)[number]; /** * The official marking wording (ADR 0022). Deliberately NOT translated: * a marking is a fixed formula, so it stays identical in every locale — * only the surrounding UI labels are i18n'd. `null` = no marking at all * (unclassified content shows nothing, per ADR 0022). */ export function classificationMarking(classification: PageClassification): string | null { return classification === 'vs_nfd' ? 'VS – NUR FÜR DEN DIENSTGEBRAUCH' : null; } /** Ordering of levels: the index in {@link PAGE_CLASSIFICATIONS} (lowest * first) — the tree invariant (#205) and archive-level statements (#210) * compare through this, never through string comparison. */ export function classificationRank(classification: PageClassification): number { return PAGE_CLASSIFICATIONS.indexOf(classification); } /** The highest level among `values` (ADR 0022 #210: "the highest * classification contained"); `unclassified` for an empty list. */ export function highestClassification(values: PageClassification[]): PageClassification { return values.reduce( (max, value) => (classificationRank(value) > classificationRank(max) ? value : max), 'unclassified' as PageClassification, ); } export const pageTitleSchema = z .string() .trim() .min(1, 'validation.required') .max(200, 'validation.tooLong'); export const pageSlugSchema = z .string() .trim() .min(1, 'validation.required') .max(60, 'validation.tooLong'); /** * Maximum nesting depth of the page tree (issue #106), counted as levels like * {@link MAX_LABEL_DEPTH}: a root page is level 1. Creating or moving a page * whose deepest descendant would exceed this is rejected. */ export const MAX_PAGE_DEPTH = 6; export const createPageInputSchema = z.object({ title: pageTitleSchema, /** Parent page id for a nested page (issue #106); omitted/null creates at * the root level. Must be a live page of the same pond. */ parentId: z.string().min(1).nullish(), }); export type CreatePageInput = z.infer; export const updatePageInputSchema = z .object({ title: pageTitleSchema, slug: pageSlugSchema, /** VS-NfD level (#205): raising is ordinary editorial work (any * writer); lowering needs the dedicated capability * (`canLowerClassification`) and is audited. */ classification: z.enum(PAGE_CLASSIFICATIONS), }) .partial(); export type UpdatePageInput = z.infer; /** * Move a page in the manual sidebar order (issue #45): place it between the * `afterId` page (its new predecessor) and the `beforeId` page (its new * successor); either is `null` at an end of the list. The server recomputes * only the moved page's `sort_key`. */ export const repositionPageInputSchema = z.object({ afterId: z.string().min(1).nullable(), beforeId: z.string().min(1).nullable(), /** * New parent for the page (issue #106): a page id nests it, `null` moves it * to the root level, and an absent field keeps the current parent — so the * one endpoint covers plain reordering, drag-onto-a-page, and the "Move * to…" dialog atomically. Cycles and depth violations are rejected. */ parentId: z.string().min(1).nullable().optional(), }); export type RepositionPageInput = z.infer; /** * What happens to a page's live children when it is trashed (issue #107): * `promote` re-attaches them to the deleted page's parent (the default — * nothing disappears but the page itself); `subtree` trashes every live * descendant along with it, which requires write permission on all of them. */ /** Body of `POST /pages/:id/tasks/:taskId` (issue #153). */ export const toggleTaskInputSchema = z.object({ checked: z.boolean() }); export type ToggleTaskInput = z.infer; export const PAGE_DELETE_MODES = ['promote', 'subtree'] as const; export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number]; export const pageDeleteQuerySchema = z.object({ mode: z.enum(PAGE_DELETE_MODES).default('promote'), }); export type PageDeleteQuery = z.infer; export const savePageStateInputSchema = z.object({ /** Base64-encoded Yjs state (`Y.encodeStateAsUpdate`). */ state: z.string().min(1, 'validation.required'), }); export type SavePageStateInput = z.infer; /** Max Yjs document size (operations.md §Limits) — a fixed operational * ceiling, not a per-pond/user quota. */ export const MAX_PAGE_DOCUMENT_BYTES = 5 * 1024 * 1024; export interface PageView { id: string; pondId: string; /** * Parent page in the tree (issue #106), or `null` at the root. In list * responses the server nulls this when the caller may not read the parent, * so a permission-sliced view never leaks a hidden page's id — the child * then simply renders at the root level. */ parentId: string | null; title: string; slug: string; sortKey: string; /** VS-NfD marking level (ADR 0022, issue #204) — part of the metadata * every page response already carries, so no channel needs an extra * request to render the marking. */ classification: PageClassification; createdAt: string; updatedAt: string; deletedAt: string | null; } /** What `GET /pages/:id` returns: page meta plus its base64 Yjs state. */ export interface PageStateView extends PageView { state: string; } /** * A page in the sidebar list (`GET /ponds/:id/pages`) with the ids of the * labels assigned to it (issue #44). The sidebar maps these to colours/names * via the pond's label tree for chips and filters by them (descendant-inclusive * via the shared tree helpers) — so no per-label detail is repeated here. */ export interface PageListItemView extends PageView { labelIds: string[]; } /** Why a version snapshot exists (ADR 0013): automatic (session end / active * interval), a named manual snapshot, or the automatic pre-restore snapshot. */ export type PageVersionTrigger = 'auto' | 'manual' | 'pre_restore'; export const createVersionInputSchema = z.object({ // Optional since #125: Ctrl/Cmd+S snapshots without asking for a name. label: z.string().trim().min(1, 'validation.required').max(100, 'validation.tooLong').optional(), }); export type CreateVersionInput = z.infer; /** A version in the history list (issue #41; no snapshot bytes). */ export interface PageVersionView { id: string; pageId: string; trigger: PageVersionTrigger; label: string | null; /** Editor for manual/pre-restore versions; null for automatic snapshots. */ createdBy: string | null; /** Users who edited since the previous version. */ contributorIds: string[]; /** Display names for contributorIds; deleted accounts are omitted. */ contributors: { id: string; name: string }[]; createdAt: string; } /** A single version with its rendered content, for viewing/diffing (issue #42). */ export interface PageVersionContentView extends PageVersionView { /** Read-only HTML render of the snapshot. */ html: string; /** Markdown of the snapshot, for the diff against the current page. */ markdown: string; }