dorfteich/packages/shared/src/pages.ts
Claude Fable 5 488d0d06f1
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m40s
CI / Build container images (pull_request) Successful in 4m34s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m0s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#205: classification inherits down the tree; lowering is a guarded, audited act
New pages take max(instance default, parent level); moving a subtree
under a higher-classified parent raises every member below that level.
No move-like path (reposition, trash-promote, purge-promote) lowers a
level as a side effect — pinned by test. Raising is ordinary editorial
work; lowering requires the dedicated capability canLowerClassification
(pond-wide Pond Admin) in the central permission model. Both directions
are audited (page.classification_raised/_lowered, catalogue v1.1) with
old value, new value, actor and page.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:12:26 +02:00

200 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof createPageInputSchema>;
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<typeof updatePageInputSchema>;
/**
* 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<typeof repositionPageInputSchema>;
/**
* 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<typeof toggleTaskInputSchema>;
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<typeof pageDeleteQuerySchema>;
export const savePageStateInputSchema = z.object({
/** Base64-encoded Yjs state (`Y.encodeStateAsUpdate`). */
state: z.string().min(1, 'validation.required'),
});
export type SavePageStateInput = z.infer<typeof savePageStateInputSchema>;
/** 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<typeof createVersionInputSchema>;
/** 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;
}