Hierarchical labels: model, CRUD API, and validation #43

Closed
opened 2026-07-04 14:52:20 +02:00 by fable-5 · 1 comment
Collaborator

Context

Labels organize pages within a pond and can be hierarchical (vision); they later scope permissions (M5), so their semantics must be solid now.

Scope

Prisma models labels (with parent_id) and page_labels per data-model.md; endpoints: label CRUD under /ponds/:id/labels (create with optional parent, rename, recolor, move in hierarchy, delete with page-assignment handling — deletion detaches assignments after a confirmation flag), assign/unassign labels to pages. Validation: cycle prevention on move, unique name per (pond, parent), depth limit 6.

Acceptance criteria

  • cycle creation via move is rejected (unit test)
  • deleting a label with assignments requires ?force=true and detaches them
  • assignment endpoints reject labels from a different pond
  • label tree endpoint returns the hierarchy in one call (used by sidebar and pickers)

Technical notes

  • data-model.md §labels; permissions.md (label grants inherit to descendants — the tree query written here is reused there, put it in packages/shared or a service both can use).

Dependencies

Depends on #23.

Size: ~1.5 days


Conventions: English code/comments, clear human-readable code, no hard-coded UI strings (ADR 0012, add de and en), permission checks only via the shared guard (docs/architecture/permissions.md). Read the referenced ADRs before starting.

## Context Labels organize pages within a pond and can be hierarchical (vision); they later scope permissions (M5), so their semantics must be solid now. ## Scope Prisma models `labels` (with `parent_id`) and `page_labels` per data-model.md; endpoints: label CRUD under `/ponds/:id/labels` (create with optional parent, rename, recolor, move in hierarchy, delete with page-assignment handling — deletion detaches assignments after a confirmation flag), assign/unassign labels to pages. Validation: cycle prevention on move, unique name per (pond, parent), depth limit 6. ## Acceptance criteria - [ ] cycle creation via move is rejected (unit test) - [ ] deleting a label with assignments requires `?force=true` and detaches them - [ ] assignment endpoints reject labels from a different pond - [ ] label tree endpoint returns the hierarchy in one call (used by sidebar and pickers) ## Technical notes - data-model.md §labels; permissions.md (label grants inherit to descendants — the tree query written here is reused there, put it in `packages/shared` or a service both can use). ## Dependencies Depends on #23. **Size**: ~1.5 days --- *Conventions: English code/comments, clear human-readable code, no hard-coded UI strings (ADR 0012, add `de` **and** `en`), permission checks only via the shared guard (docs/architecture/permissions.md). Read the referenced ADRs before starting.*
fable-5 added this to the M4 — Organization & search milestone 2026-07-04 14:52:20 +02:00
fable-5 added the
backend
label 2026-07-04 14:52:20 +02:00
Collaborator

Implemented in a3a012c41dad642fb26377edb0776ba49d1e3a01 (Claude Opus 4.8). Pipeline green (CI: lint/typecheck/test, auth-e2e, container build; CD: deploy Test → smoke → promote Int).

Live-verified on Test (test.dorfteich.cloud) end to end:

  • Tree endpoint returns the hierarchy in one call (GET /ponds/:id/labels) — root → child nested. ✓
  • Cycle on move rejected: moving a root under its own descendant → 409 label_cycle. ✓
  • Delete gating: deleting a label with an assigned page → 409 label_has_pages (details.count); ?force=true204, and the delete cascades the subtree and detaches the page assignment (page's label list back to []). ✓
  • Assign/unassign work; assigning a label from a different pond is rejected (400 label_wrong_pond, covered by the DB test).

Design notes

  • Reusable hierarchy walk lives in packages/shared/labels.ts (buildLabelTree, collectSubtreeIds, collectAncestorIds, labelDepth, subtreeHeight) — the same tree logic the M5 permission resolver will reuse (permissions.md: a grant on a label applies to all descendants).
  • Uniqueness per (pond, parent, name), cycle prevention, and the depth limit (6) are enforced inside a per-pond pg_advisory_xact_lock transaction, so root-label uniqueness holds despite Postgres treating NULL parents as distinct in the unique index.
  • Prisma cascade: deleting a label removes its whole subtree and all page_labels; page purge also removes assignments.
  • Access gated through InterimAccessService on the owning pond (see = read labels, modify = manage) — M5 (#53) swaps this for real grants.
  • Endpoints follow the existing pages pattern: create/list under /ponds/:id/labels, mutations by id under /labels/:id.

DB e2e (labels.service.db.test.ts, runs in CI) covers all acceptance criteria plus depth limit, cascade, idempotent assign/unassign, and outsider access denial. Label UI (tree management, assignment, sidebar filter) is #44.

Implemented in `a3a012c41dad642fb26377edb0776ba49d1e3a01` (Claude Opus 4.8). Pipeline green (CI: lint/typecheck/test, auth-e2e, container build; CD: deploy Test → smoke → promote Int). **Live-verified on Test** (`test.dorfteich.cloud`) end to end: - Tree endpoint returns the hierarchy in one call (`GET /ponds/:id/labels`) — root → child nested. ✓ - Cycle on move rejected: moving a root under its own descendant → `409 label_cycle`. ✓ - Delete gating: deleting a label with an assigned page → `409 label_has_pages` (`details.count`); `?force=true` → `204`, and the delete cascades the subtree and detaches the page assignment (page's label list back to `[]`). ✓ - Assign/unassign work; assigning a label from a different pond is rejected (`400 label_wrong_pond`, covered by the DB test). **Design notes** - Reusable hierarchy walk lives in `packages/shared/labels.ts` (`buildLabelTree`, `collectSubtreeIds`, `collectAncestorIds`, `labelDepth`, `subtreeHeight`) — the same tree logic the M5 permission resolver will reuse (permissions.md: a grant on a label applies to all descendants). - Uniqueness per (pond, parent, name), cycle prevention, and the depth limit (6) are enforced inside a per-pond `pg_advisory_xact_lock` transaction, so root-label uniqueness holds despite Postgres treating `NULL` parents as distinct in the unique index. - Prisma cascade: deleting a label removes its whole subtree and all `page_labels`; page purge also removes assignments. - Access gated through `InterimAccessService` on the owning pond (see = read labels, modify = manage) — M5 (#53) swaps this for real grants. - Endpoints follow the existing pages pattern: create/list under `/ponds/:id/labels`, mutations by id under `/labels/:id`. DB e2e (`labels.service.db.test.ts`, runs in CI) covers all acceptance criteria plus depth limit, cascade, idempotent assign/unassign, and outsider access denial. Label UI (tree management, assignment, sidebar filter) is #44.
Sign in to join this conversation.
No project
No Assignees
2 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#43
No description provided.