dorfteich/packages/shared/src/labels.ts
Claude Fable 5 eb6b0d5d02
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
Page hierarchy: parentId, create-under-parent, reparent (#106)
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>
2026-07-14 09:57:47 +02:00

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);
}