Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 3m53s
CD / Deploy to Test (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m16s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Has been cancelled
Pages form a tree via a nullable parent_id self-relation (SetNull backstop; the real trash/purge semantics follow with #107). Slugs and URLs stay flat and pond-unique, so moving a page never breaks links. - Shared: generic parent-id tree helpers in tree.ts (labels re-export them; buildLabelTree keeps its name-sorted behavior), MAX_PAGE_DEPTH=6, parentId on PageView, createPageInputSchema.parentId (nullish), repositionPageInputSchema.parentId (optional; absent = keep parent). - API: create validates the parent (same pond, live, depth); PATCH /pages/:id/position reparents atomically with the placement, rejecting cycles (page_cycle) and depth violations (page_depth_exceeded); GET /ponds/:id/pages nulls parentId when the caller may not read the parent, so hidden page ids never leak. - New error codes translated de+en; hierarchy.db.test.ts covers create, 404s, depth, cycle, atomic reparent, and the permission nulling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
130 lines
4.6 KiB
TypeScript
130 lines
4.6 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
import { buildTree, nodeDepth } from './tree';
|
|
|
|
/**
|
|
* Label schemas, views, and hierarchy helpers shared between api and web
|
|
* (issue #43). Labels organize pages within a pond and form a tree via
|
|
* `parentId` (data-model.md §labels). They later scope permissions (M5):
|
|
* a grant on a label applies to it and all its descendants, so the tree
|
|
* helpers below are the single implementation both the label API and the
|
|
* permission resolver (permissions.md §resolution) build on.
|
|
*/
|
|
|
|
/**
|
|
* Maximum nesting depth of the label tree, counted as levels: a root label is
|
|
* level 1, its child level 2, and so on. Moving or creating a label whose
|
|
* deepest node would exceed this is rejected (issue #43 acceptance criteria).
|
|
*/
|
|
export const MAX_LABEL_DEPTH = 6;
|
|
|
|
/** A hex colour like `#a1b2c3`; the picker in #44 constrains the input further. */
|
|
export const labelColorSchema = z
|
|
.string()
|
|
.trim()
|
|
.regex(/^#[0-9a-fA-F]{6}$/, 'validation.labelColor')
|
|
.transform((value) => value.toLowerCase());
|
|
|
|
/** Default colour for labels created without an explicit one (a neutral slate). */
|
|
export const DEFAULT_LABEL_COLOR = '#64748b';
|
|
|
|
export const labelNameSchema = z
|
|
.string()
|
|
.trim()
|
|
.min(1, 'validation.required')
|
|
.max(60, 'validation.tooLong');
|
|
|
|
export const createLabelInputSchema = z.object({
|
|
name: labelNameSchema,
|
|
color: labelColorSchema.optional(),
|
|
/** Parent label id for a nested label; omitted/null creates a root label. */
|
|
parentId: z.string().min(1).nullish(),
|
|
});
|
|
export type CreateLabelInput = z.infer<typeof createLabelInputSchema>;
|
|
|
|
/** Rename and/or recolour a label; both fields optional (at least one is used). */
|
|
export const updateLabelInputSchema = z
|
|
.object({
|
|
name: labelNameSchema,
|
|
color: labelColorSchema,
|
|
})
|
|
.partial();
|
|
export type UpdateLabelInput = z.infer<typeof updateLabelInputSchema>;
|
|
|
|
/** Move a label to a new parent (or to the root with `null`). */
|
|
export const moveLabelInputSchema = z.object({
|
|
parentId: z.string().min(1).nullable(),
|
|
});
|
|
export type MoveLabelInput = z.infer<typeof moveLabelInputSchema>;
|
|
|
|
/** Assign a label to a page. */
|
|
export const assignLabelInputSchema = z.object({
|
|
labelId: z.string().min(1),
|
|
});
|
|
export type AssignLabelInput = z.infer<typeof assignLabelInputSchema>;
|
|
|
|
/** A single label as the api returns it (flat; the tree is built from these). */
|
|
export interface LabelView {
|
|
id: string;
|
|
pondId: string;
|
|
parentId: string | null;
|
|
name: string;
|
|
color: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/** A label with its children nested — the shape `GET /ponds/:id/labels` returns. */
|
|
export interface LabelTreeNode extends LabelView {
|
|
children: LabelTreeNode[];
|
|
}
|
|
|
|
/** Sorts siblings by name, case-insensitively, for a stable tree/picker order. */
|
|
function byName(a: LabelView, b: LabelView): number {
|
|
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
}
|
|
|
|
/**
|
|
* Nests a flat list of a pond's labels into a forest of {@link LabelTreeNode}s,
|
|
* siblings sorted by name. A label whose `parentId` is not present in the list
|
|
* (should not happen within one pond) is treated as a root, so no rows are ever
|
|
* dropped. Reused by the sidebar tree, label pickers, and the permission
|
|
* resolver, which all need the same hierarchy.
|
|
*/
|
|
export function buildLabelTree(labels: LabelView[]): LabelTreeNode[] {
|
|
return buildTree(labels, byName);
|
|
}
|
|
|
|
/**
|
|
* Flattens a label tree back into a depth-first list of {@link LabelView}s, in
|
|
* the same sibling order {@link buildLabelTree} produced. Handy where a flat
|
|
* list is needed again (chip lookup, subtree filtering, an indented picker)
|
|
* after fetching the tree from `GET /ponds/:id/labels`.
|
|
*/
|
|
export function flattenLabelTree(nodes: LabelTreeNode[]): LabelView[] {
|
|
const flat: LabelView[] = [];
|
|
const walk = (node: LabelTreeNode): void => {
|
|
const { children: _children, ...view } = node;
|
|
flat.push(view);
|
|
for (const child of node.children) walk(child);
|
|
};
|
|
for (const node of nodes) walk(node);
|
|
return flat;
|
|
}
|
|
|
|
/**
|
|
* The subtree/ancestor/depth walks moved to the generic tree helpers (issue
|
|
* #106) — labels and pages share one implementation. Re-exported here so the
|
|
* label API keeps its established import surface; `labelDepth` keeps its name
|
|
* because permission resolution documents it (permissions.md §label scope).
|
|
*/
|
|
export { collectAncestorIds, collectSubtreeIds, subtreeHeight } from './tree';
|
|
|
|
/**
|
|
* Depth of a label as a 1-based level (a root label is 1). Derived from the
|
|
* ancestor chain, so it is bounded even if the input is malformed.
|
|
*/
|
|
export function labelDepth(labels: LabelView[], labelId: string): number {
|
|
return nodeDepth(labels, labelId);
|
|
}
|