Page hierarchy: schema, parentId in page API, reparent via position endpoint #106

Closed
opened 2026-07-14 09:32:42 +02:00 by fable-5 · 1 comment
Collaborator

Context

Pages become a tree: every page can have child pages (Confluence-style), mirroring the existing label hierarchy (Label.parentId, self-relation, max depth 6). Slugs and all URLs stay flat and pond-unique (@@unique([pondId, slug]) unchanged) — parentId is pure organization, so moving a page never breaks wikilinks, public URLs, or the relative hrefs in cached public HTML.

Scope

  • Prisma migration: nullable parent_id on pages with self-relation "PageHierarchy", onDelete: SetNull, index on parent_id (apps/api/prisma/schema.prisma).
  • packages/shared/src/pages.ts: MAX_PAGE_DEPTH = 6; parentId: string | null on PageView/PageListItemView; createPageInputSchema gains optional nullish parentId; repositionPageInputSchema gains optional parentId (one atomic endpoint for reorder + reparent).
  • Generalize the tree helpers in packages/shared/src/labels.ts (collectSubtreeIds, collectAncestorIds, labelDepth, subtreeHeight, buildLabelTree) to a { id; parentId } element type so pages reuse them (e.g. shared tree.ts + buildPageTree).
  • PagesService.create (apps/api/src/pages/pages.service.ts): validate parentId — same pond, not trashed, resulting depth ≤ 6 (page_depth_exceeded).
  • PagesService.reposition: when parentId is present, reparent atomically with the sort-key placement. Reject moving a page under itself or its own descendant (page_cycle, via collectSubtreeIds) and depth violations (parent depth + subtreeHeight of the moved subtree ≤ 6).
  • PagesService.list: include parentId, but null it in the response when the parent is not in the caller's readable set (the readable set is already computed for filtering) — no UUID leak; the client shows such pages at root.
  • Sibling ordering needs no new machinery: the list stays flat in the pond's global sort order; per-sibling-group order falls out of it. Manual mode: fractional keys between two siblings preserve group order; the existing pond-wide rebalance is unaffected.
  • New error codes page_cycle, page_depth_exceeded in the errors i18n namespace (de+en).

Acceptance criteria

  • Creating a page with a valid parentId persists it; creating without stays root-level; parent from another pond / trashed parent / depth 7 are rejected with the right codes.
  • PATCH /pages/:id/position with parentId reparents + places atomically; moving a page under its own descendant returns 4xx page_cycle.
  • GET /ponds/:id/pages carries parentId; a label-restricted reader whose parent page is unreadable sees parentId: null for the child.
  • DB e2e tests cover create/reposition/depth/cycle/permission-nulling (extend pages and reposition db test suites).

Technical notes

  • Mirror LabelsService.move for cycle/depth checks.
  • onDelete: SetNull is the FK backstop; purge/trash semantics are refined in the follow-up delete-modes issue.
  • Migration naming: timestamped folder in apps/api/prisma/migrations/ as usual.

Dependencies

None.

Size

~1.5 days.

## Context Pages become a tree: every page can have child pages (Confluence-style), mirroring the existing label hierarchy (`Label.parentId`, self-relation, max depth 6). Slugs and all URLs stay **flat and pond-unique** (`@@unique([pondId, slug])` unchanged) — `parentId` is pure organization, so moving a page never breaks wikilinks, public URLs, or the relative `href`s in cached public HTML. ## Scope - Prisma migration: nullable `parent_id` on `pages` with self-relation `"PageHierarchy"`, `onDelete: SetNull`, index on `parent_id` (`apps/api/prisma/schema.prisma`). - `packages/shared/src/pages.ts`: `MAX_PAGE_DEPTH = 6`; `parentId: string | null` on `PageView`/`PageListItemView`; `createPageInputSchema` gains optional nullish `parentId`; `repositionPageInputSchema` gains optional `parentId` (one atomic endpoint for reorder + reparent). - Generalize the tree helpers in `packages/shared/src/labels.ts` (`collectSubtreeIds`, `collectAncestorIds`, `labelDepth`, `subtreeHeight`, `buildLabelTree`) to a `{ id; parentId }` element type so pages reuse them (e.g. shared `tree.ts` + `buildPageTree`). - `PagesService.create` (`apps/api/src/pages/pages.service.ts`): validate `parentId` — same pond, not trashed, resulting depth ≤ 6 (`page_depth_exceeded`). - `PagesService.reposition`: when `parentId` is present, reparent atomically with the sort-key placement. Reject moving a page under itself or its own descendant (`page_cycle`, via `collectSubtreeIds`) and depth violations (parent depth + `subtreeHeight` of the moved subtree ≤ 6). - `PagesService.list`: include `parentId`, but **null it in the response when the parent is not in the caller's readable set** (the readable set is already computed for filtering) — no UUID leak; the client shows such pages at root. - Sibling ordering needs no new machinery: the list stays flat in the pond's global sort order; per-sibling-group order falls out of it. Manual mode: fractional keys between two siblings preserve group order; the existing pond-wide rebalance is unaffected. - New error codes `page_cycle`, `page_depth_exceeded` in the errors i18n namespace (de+en). ## Acceptance criteria - [ ] Creating a page with a valid `parentId` persists it; creating without stays root-level; parent from another pond / trashed parent / depth 7 are rejected with the right codes. - [ ] `PATCH /pages/:id/position` with `parentId` reparents + places atomically; moving a page under its own descendant returns 4xx `page_cycle`. - [ ] `GET /ponds/:id/pages` carries `parentId`; a label-restricted reader whose parent page is unreadable sees `parentId: null` for the child. - [ ] DB e2e tests cover create/reposition/depth/cycle/permission-nulling (extend `pages` and reposition db test suites). ## Technical notes - Mirror `LabelsService.move` for cycle/depth checks. - `onDelete: SetNull` is the FK backstop; purge/trash semantics are refined in the follow-up delete-modes issue. - Migration naming: timestamped folder in `apps/api/prisma/migrations/` as usual. ## Dependencies None. ## Size ~1.5 days.
fable-5 added this to the M12 — Page hierarchy & knowledge graph milestone 2026-07-14 09:32:42 +02:00
fable-5 added the
backend
label 2026-07-14 09:32:42 +02:00
Author
Collaborator

Implemented in eb6b0d5. pages.parent_id self-relation (SetNull backstop, index, migration 20260714000000_page_hierarchy); generic tree helpers extracted to packages/shared/src/tree.ts (labels re-export them); MAX_PAGE_DEPTH = 6; createPageInputSchema.parentId; PATCH /pages/:id/position reparents atomically (absent = keep, null = root) with 409 page_cycle / page_depth_exceeded; the list nulls parentId when the caller may not read the parent. Covered by hierarchy.db.test.ts (7 tests) — create/404s/depth/cycle/atomic reparent/permission nulling all green.

Implemented in eb6b0d5. `pages.parent_id` self-relation (SetNull backstop, index, migration `20260714000000_page_hierarchy`); generic tree helpers extracted to `packages/shared/src/tree.ts` (labels re-export them); `MAX_PAGE_DEPTH = 6`; `createPageInputSchema.parentId`; `PATCH /pages/:id/position` reparents atomically (absent = keep, null = root) with 409 `page_cycle` / `page_depth_exceeded`; the list nulls `parentId` when the caller may not read the parent. Covered by `hierarchy.db.test.ts` (7 tests) — create/404s/depth/cycle/atomic reparent/permission nulling all green.
Sign in to join this conversation.
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: stwaidele/dorfteich#106
No description provided.