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
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>
200 lines
7.7 KiB
TypeScript
200 lines
7.7 KiB
TypeScript
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;
|
||
}
|