Add hierarchical labels: model, CRUD API, and validation (#43)
All checks were successful
CD / Build and push images (push) Successful in 2m56s
CI / Lint, typecheck, test (push) Successful in 2m5s
CI / Auth e2e pack (push) Successful in 2m26s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s

Introduce pond-scoped hierarchical labels as the foundation for M4
organization and, later, M5 label-scoped permissions.

- shared: `labels.ts` with the label schemas/views and the pure tree
  helpers (buildLabelTree, collectSubtreeIds, collectAncestorIds,
  labelDepth, subtreeHeight). These are the single hierarchy walk the
  label API and the future permission resolver both build on
  (permissions.md: a grant on a label applies to all its descendants).
- prisma: `Label` (self-referential parent_id, unique per (pond, parent,
  name), cascade to subtree) and `PageLabel` assignment table; migration.
- api: `LabelsService` + controller. Tree endpoint returns the hierarchy
  in one call; create/rename/recolor/move/delete and page assign/unassign.
  Validation: cycle prevention on move, depth limit 6, unique name per
  (pond, parent) — enforced under a per-pond advisory lock so root-label
  uniqueness holds despite Postgres treating NULL parents as distinct.
  Delete cascades the subtree and requires `?force=true` when pages are
  assigned. Assignment rejects labels from a different pond. Access gated
  through InterimAccessService on the owning pond.
- i18n: label error codes and the colour validation message (de + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
Claude Opus 4.8 2026-07-09 10:30:17 +02:00
parent 1bda137ca4
commit a3a012c41d
12 changed files with 1007 additions and 0 deletions

View File

@ -0,0 +1,41 @@
-- CreateTable
CREATE TABLE "labels" (
"id" TEXT NOT NULL,
"pond_id" TEXT NOT NULL,
"parent_id" TEXT,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "labels_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "page_labels" (
"page_id" TEXT NOT NULL,
"label_id" TEXT NOT NULL,
CONSTRAINT "page_labels_pkey" PRIMARY KEY ("page_id","label_id")
);
-- CreateIndex
CREATE INDEX "labels_pond_id_idx" ON "labels"("pond_id");
-- CreateIndex
CREATE UNIQUE INDEX "labels_pond_id_parent_id_name_key" ON "labels"("pond_id", "parent_id", "name");
-- CreateIndex
CREATE INDEX "page_labels_label_id_idx" ON "page_labels"("label_id");
-- AddForeignKey
ALTER TABLE "labels" ADD CONSTRAINT "labels_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "labels" ADD CONSTRAINT "labels_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "labels"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "page_labels" ADD CONSTRAINT "page_labels_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "page_labels" ADD CONSTRAINT "page_labels_label_id_fkey" FOREIGN KEY ("label_id") REFERENCES "labels"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -78,6 +78,7 @@ model Pond {
usage PondUsage? usage PondUsage?
pages Page[] pages Page[]
attachments Attachment[] attachments Attachment[]
labels Label[]
@@index([ownerId]) @@index([ownerId])
@@map("ponds") @@map("ponds")
@ -108,6 +109,7 @@ model Page {
attachments Attachment[] attachments Attachment[]
versions PageVersion[] versions PageVersion[]
pendingContributors PagePendingContributor[] pendingContributors PagePendingContributor[]
labels PageLabel[]
@@unique([pondId, slug]) @@unique([pondId, slug])
@@index([pondId]) @@index([pondId])
@ -204,6 +206,48 @@ model PageContentCache {
@@map("page_content_cache") @@map("page_content_cache")
} }
/// A hierarchical label within a pond (data-model.md §labels, issue #43).
/// Labels organize pages and later scope permissions (M5): a grant on a label
/// applies to it and all its descendants, so the hierarchy must be sound now.
/// `parentId` builds the tree (max 6 levels, enforced in the service); cycles
/// are rejected at write time. Names are unique per (pond, parent) — the
/// unique index below covers nested labels; root-label uniqueness (parent_id
/// NULL, which Postgres treats as distinct) is enforced in the service under a
/// per-pond advisory lock. Deleting a label cascades to its whole subtree and
/// detaches page assignments; the service gates that behind `?force=true`.
model Label {
id String @id @default(uuid())
pondId String @map("pond_id")
parentId String? @map("parent_id")
name String
color String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
pond Pond @relation(fields: [pondId], references: [id])
parent Label? @relation("LabelHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Label[] @relation("LabelHierarchy")
pages PageLabel[]
@@unique([pondId, parentId, name])
@@index([pondId])
@@map("labels")
}
/// Assignment of a label to a page (data-model.md §labels). Cascades on both
/// sides: purging a page or deleting a label removes the assignment.
model PageLabel {
pageId String @map("page_id")
labelId String @map("label_id")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
label Label @relation(fields: [labelId], references: [id], onDelete: Cascade)
@@id([pageId, labelId])
@@index([labelId])
@@map("page_labels")
}
enum QuotaSubjectType { enum QuotaSubjectType {
USER USER
POND POND

View File

@ -10,6 +10,7 @@ import { AppConfig } from './config/app-config.service';
import { ConfigModule } from './config/config.module'; import { ConfigModule } from './config/config.module';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { LabelsModule } from './labels/labels.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
import { PagesModule } from './pages/pages.module'; import { PagesModule } from './pages/pages.module';
import { PondsModule } from './ponds/ponds.module'; import { PondsModule } from './ponds/ponds.module';
@ -34,6 +35,7 @@ import { VersionsModule } from './versions/versions.module';
TrashModule, TrashModule,
CompactionModule, CompactionModule,
VersionsModule, VersionsModule,
LabelsModule,
AuthModule, AuthModule,
AdminModule, AdminModule,
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({

View File

@ -0,0 +1,116 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
} from '@nestjs/common';
import {
AssignLabelInput,
CreateLabelInput,
LabelTreeNode,
LabelView,
MoveLabelInput,
UpdateLabelInput,
assignLabelInputSchema,
createLabelInputSchema,
moveLabelInputSchema,
updateLabelInputSchema,
} from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { LabelsService } from './labels.service';
/**
* Hierarchical label management (issue #43). Access rules live in
* InterimAccessService, resolved per owning pond inside the service.
*/
@Controller()
export class LabelsController {
constructor(private readonly labels: LabelsService) {}
/** The pond's label hierarchy in one call — sidebar tree and pickers. */
@Get('ponds/:pondId/labels')
async list(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<LabelTreeNode[]> {
return this.labels.list(request.user!, pondId);
}
@Post('ponds/:pondId/labels')
async create(
@Param('pondId') pondId: string,
@Body(new ZodValidationPipe(createLabelInputSchema)) input: CreateLabelInput,
@Req() request: AuthedRequest,
): Promise<LabelView> {
return this.labels.create(request.user!, pondId, input);
}
/** Rename and/or recolour a label. */
@Patch('labels/:id')
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateLabelInputSchema)) input: UpdateLabelInput,
@Req() request: AuthedRequest,
): Promise<LabelView> {
return this.labels.update(request.user!, id, input);
}
/** Move a label (with its subtree) to a new parent or to the root. */
@Post('labels/:id/move')
async move(
@Param('id') id: string,
@Body(new ZodValidationPipe(moveLabelInputSchema)) input: MoveLabelInput,
@Req() request: AuthedRequest,
): Promise<LabelView> {
return this.labels.move(request.user!, id, input);
}
/** Delete a label and its subtree; `?force=true` confirms detaching pages. */
@Delete('labels/:id')
@HttpCode(204)
async remove(
@Param('id') id: string,
@Query('force') force: string | undefined,
@Req() request: AuthedRequest,
): Promise<void> {
await this.labels.remove(request.user!, id, force === 'true');
}
/** A page's assigned labels. */
@Get('pages/:pageId/labels')
async pageLabels(
@Param('pageId') pageId: string,
@Req() request: AuthedRequest,
): Promise<LabelView[]> {
return this.labels.pageLabels(request.user!, pageId);
}
/** Assign a label to a page (idempotent). */
@Post('pages/:pageId/labels')
async assign(
@Param('pageId') pageId: string,
@Body(new ZodValidationPipe(assignLabelInputSchema)) input: AssignLabelInput,
@Req() request: AuthedRequest,
): Promise<LabelView[]> {
return this.labels.assign(request.user!, pageId, input.labelId);
}
/** Unassign a label from a page (idempotent). */
@Delete('pages/:pageId/labels/:labelId')
@HttpCode(204)
async unassign(
@Param('pageId') pageId: string,
@Param('labelId') labelId: string,
@Req() request: AuthedRequest,
): Promise<void> {
await this.labels.unassign(request.user!, pageId, labelId);
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PondsModule } from '../ponds/ponds.module';
import { LabelsController } from './labels.controller';
import { LabelsService } from './labels.service';
@Module({
imports: [PondsModule],
controllers: [LabelsController],
providers: [LabelsService],
exports: [LabelsService],
})
export class LabelsModule {}

View File

@ -0,0 +1,200 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
INestApplication,
NotFoundException,
} from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { LabelsService } from './labels.service';
describe.skipIf(!hasTestDb)('LabelsService (db, issue #43)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let labels: LabelsService;
const suffix = uniqueSuffix();
let owner: User;
let outsider: User;
let pondId: string;
let otherPondId: string;
const pageIds: string[] = [];
async function createPage(pond = pondId): Promise<string> {
const id = randomUUID();
await prisma.page.create({
data: {
id,
pondId: pond,
title: 'Labelled',
slug: `p-${id.slice(0, 8)}`,
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
sortKey: 'a0',
createdBy: owner.id,
},
});
pageIds.push(id);
return id;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
labels = app.get(LabelsService);
owner = await prisma.user.create({
data: {
username: `lbl-owner-${suffix}`,
email: `lbl-owner-${suffix}@example.test`,
displayName: 'Label Owner',
},
});
outsider = await prisma.user.create({
data: {
username: `lbl-out-${suffix}`,
email: `lbl-out-${suffix}@example.test`,
displayName: 'Label Outsider',
},
});
const pond = await prisma.pond.create({
data: { slug: `lbl-pond-${suffix}`, name: 'Label Pond', type: 'PERSONAL', ownerId: owner.id },
});
pondId = pond.id;
const other = await prisma.pond.create({
data: {
slug: `lbl-other-${suffix}`,
name: 'Other Pond',
type: 'PERSONAL',
ownerId: owner.id,
},
});
otherPondId = other.id;
});
afterAll(async () => {
if (pageIds.length > 0) await prisma.page.deleteMany({ where: { id: { in: pageIds } } });
await prisma.label.deleteMany({ where: { pondId: { in: [pondId, otherPondId] } } });
await prisma.pond.deleteMany({ where: { id: { in: [pondId, otherPondId] } } });
await prisma.user.deleteMany({ where: { id: { in: [owner.id, outsider.id] } } });
await prisma.$disconnect();
await app.close();
});
it('returns the hierarchy in one call, siblings sorted by name', async () => {
const root = await labels.create(owner, pondId, { name: 'Zeta' });
const alpha = await labels.create(owner, pondId, { name: 'Alpha' });
const child = await labels.create(owner, pondId, { name: 'Child', parentId: alpha.id });
const tree = await labels.list(owner, pondId);
expect(tree.map((n) => n.name)).toEqual(['Alpha', 'Zeta']); // roots sorted
const alphaNode = tree.find((n) => n.id === alpha.id)!;
expect(alphaNode.children.map((c) => c.id)).toEqual([child.id]);
// Cleanup for later cases that count on a clean pond tree.
await prisma.label.deleteMany({ where: { id: { in: [root.id, alpha.id, child.id] } } });
});
it('rejects a duplicate name under the same parent (including roots)', async () => {
const a = await labels.create(owner, pondId, { name: 'Dup' });
await expect(labels.create(owner, pondId, { name: 'Dup' })).rejects.toBeInstanceOf(
ConflictException,
);
// Same name is fine under a different parent.
const nested = await labels.create(owner, pondId, { name: 'Dup', parentId: a.id });
expect(nested.parentId).toBe(a.id);
await prisma.label.deleteMany({ where: { id: { in: [a.id, nested.id] } } });
});
it('rejects a move that would create a cycle', async () => {
const a = await labels.create(owner, pondId, { name: 'CycA' });
const b = await labels.create(owner, pondId, { name: 'CycB', parentId: a.id });
// Moving A under its own descendant B is a cycle.
await expect(labels.move(owner, a.id, { parentId: b.id })).rejects.toBeInstanceOf(
ConflictException,
);
// Moving A under itself is also rejected.
await expect(labels.move(owner, a.id, { parentId: a.id })).rejects.toBeInstanceOf(
ConflictException,
);
await prisma.label.deleteMany({ where: { id: { in: [a.id, b.id] } } });
});
it('enforces the depth limit of 6 levels', async () => {
const ids: string[] = [];
let parentId: string | null = null;
for (let level = 1; level <= 6; level += 1) {
const created = await labels.create(owner, pondId, {
name: `L${level}`,
parentId: parentId ?? undefined,
});
ids.push(created.id);
parentId = created.id;
}
await expect(
labels.create(owner, pondId, { name: 'L7', parentId: parentId ?? undefined }),
).rejects.toBeInstanceOf(ConflictException);
await prisma.label.deleteMany({ where: { id: { in: ids } } });
});
it('deleting a label with assignments requires force and detaches them', async () => {
const label = await labels.create(owner, pondId, { name: 'HasPages' });
const pageId = await createPage();
await labels.assign(owner, pageId, label.id);
// Without force: refused, assignment intact.
await expect(labels.remove(owner, label.id, false)).rejects.toBeInstanceOf(ConflictException);
expect(await prisma.pageLabel.count({ where: { labelId: label.id } })).toBe(1);
// With force: label gone, assignment detached.
await labels.remove(owner, label.id, true);
expect(await prisma.label.findUnique({ where: { id: label.id } })).toBeNull();
expect(await prisma.pageLabel.count({ where: { pageId } })).toBe(0);
});
it('deleting a parent cascades to its subtree and their assignments', async () => {
const parent = await labels.create(owner, pondId, { name: 'Parent' });
const child = await labels.create(owner, pondId, { name: 'Kid', parentId: parent.id });
const pageId = await createPage();
await labels.assign(owner, pageId, child.id);
await labels.remove(owner, parent.id, true);
expect(await prisma.label.findUnique({ where: { id: child.id } })).toBeNull();
expect(await prisma.pageLabel.count({ where: { pageId } })).toBe(0);
});
it('rejects assigning a label from a different pond', async () => {
const foreign = await labels.create(owner, otherPondId, { name: 'Foreign' });
const pageId = await createPage();
await expect(labels.assign(owner, pageId, foreign.id)).rejects.toBeInstanceOf(
BadRequestException,
);
await prisma.label.deleteMany({ where: { id: foreign.id } });
});
it('assign is idempotent and returns the page labels', async () => {
const label = await labels.create(owner, pondId, { name: 'Idem' });
const pageId = await createPage();
await labels.assign(owner, pageId, label.id);
const after = await labels.assign(owner, pageId, label.id);
expect(after.map((l) => l.id)).toEqual([label.id]);
expect(await prisma.pageLabel.count({ where: { pageId } })).toBe(1);
await labels.unassign(owner, pageId, label.id);
expect(await prisma.pageLabel.count({ where: { pageId } })).toBe(0);
// Unassign again is a no-op, not an error.
await labels.unassign(owner, pageId, label.id);
await prisma.label.deleteMany({ where: { id: label.id } });
});
it('hides labels from users who cannot see the pond', async () => {
await expect(labels.list(outsider, pondId)).rejects.toBeInstanceOf(NotFoundException);
await expect(labels.create(outsider, pondId, { name: 'Nope' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
});

View File

@ -0,0 +1,284 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CreateLabelInput,
DEFAULT_LABEL_COLOR,
LabelTreeNode,
LabelView,
MAX_LABEL_DEPTH,
MoveLabelInput,
UpdateLabelInput,
buildLabelTree,
collectSubtreeIds,
labelDepth,
subtreeHeight,
} from '@dorfteich/shared';
import { Label, Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service';
/** Transaction client type, so the locked helpers can read and write atomically. */
type Tx = Prisma.TransactionClient;
/**
* Hierarchical labels within a pond (issue #43, data-model.md §labels). Access
* is gated through {@link InterimAccessService} on the owning pond: seeing a
* pond lets you read its labels, modifying it lets you manage them. The tree
* logic (cycle prevention, depth limit) lives in `@dorfteich/shared` so the M5
* permission resolver reuses the exact same hierarchy walk (permissions.md).
*
* Writes that depend on the current tree (uniqueness, cycle, depth checks) run
* inside a transaction holding a per-pond advisory lock, so check-then-write is
* atomic even for root labels whose (pond, NULL parent, name) uniqueness the
* database cannot enforce, because Postgres treats NULLs in a unique index as
* distinct.
*/
@Injectable()
export class LabelsService {
constructor(
private readonly prisma: PrismaService,
private readonly access: InterimAccessService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(LabelsService.name);
}
viewOf(label: Label): LabelView {
return {
id: label.id,
pondId: label.pondId,
parentId: label.parentId,
name: label.name,
color: label.color,
createdAt: label.createdAt.toISOString(),
updatedAt: label.updatedAt.toISOString(),
};
}
/** Serializes label mutations per pond so tree invariants hold under races. */
private async withPondLock<T>(pondId: string, fn: (tx: Tx) => Promise<T>): Promise<T> {
return this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${`label:${pondId}`}))::text`;
return fn(tx);
});
}
private async requireVisiblePond(user: User, pondId: string): Promise<Pond> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanSee(user, pond);
return pond;
}
private async requireModifiablePond(user: User, pondId: string): Promise<Pond> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanModify(user, pond);
return pond;
}
/** Loads a label and asserts the user may manage its pond; 404 if either is missing. */
private async requireModifiableLabel(
user: User,
labelId: string,
): Promise<Label & { pond: Pond }> {
const label = await this.prisma.label.findUnique({
where: { id: labelId },
include: { pond: true },
});
if (!label || label.pond.deletedAt) throw new NotFoundException();
this.access.assertCanModify(user, label.pond);
return label;
}
private async allPondLabels(tx: Tx | PrismaService, pondId: string): Promise<LabelView[]> {
const labels = await tx.label.findMany({ where: { pondId } });
return labels.map((label) => this.viewOf(label));
}
/** Rejects a duplicate name among siblings under `parentId` (case-sensitive,
* matching the database unique index). `excludeId` skips the label itself on
* rename/move. Must run inside {@link withPondLock}. */
private assertNameFree(
labels: LabelView[],
parentId: string | null,
name: string,
excludeId?: string,
): void {
const clash = labels.some(
(l) => l.id !== excludeId && l.parentId === parentId && l.name === name,
);
if (clash) throw new ConflictException({ code: 'label_name_taken' });
}
/** The whole label hierarchy of a pond in one call (issue #43 acceptance
* criterion) the sidebar tree and every label picker read this. */
async list(user: User, pondId: string): Promise<LabelTreeNode[]> {
await this.requireVisiblePond(user, pondId);
return buildLabelTree(await this.allPondLabels(this.prisma, pondId));
}
async create(user: User, pondId: string, input: CreateLabelInput): Promise<LabelView> {
await this.requireModifiablePond(user, pondId);
const name = input.name;
const parentId = input.parentId ?? null;
const label = await this.withPondLock(pondId, async (tx) => {
const labels = await this.allPondLabels(tx, pondId);
if (parentId !== null) {
const parent = labels.find((l) => l.id === parentId);
// Parent must exist in this pond; a foreign or unknown id reads as 404.
if (!parent) throw new NotFoundException();
if (labelDepth(labels, parentId) + 1 > MAX_LABEL_DEPTH) {
throw new ConflictException({
code: 'label_depth_exceeded',
details: { max: [String(MAX_LABEL_DEPTH)] },
});
}
}
this.assertNameFree(labels, parentId, name);
return tx.label.create({
data: { pondId, parentId, name, color: input.color ?? DEFAULT_LABEL_COLOR },
});
});
this.logger.info({ labelId: label.id, pondId, userId: user.id }, 'audit: label created');
return this.viewOf(label);
}
/** Rename and/or recolour a label (issue #43). */
async update(user: User, labelId: string, input: UpdateLabelInput): Promise<LabelView> {
const existing = await this.requireModifiableLabel(user, labelId);
const pondId = existing.pondId;
const label = await this.withPondLock(pondId, async (tx) => {
if (input.name !== undefined && input.name !== existing.name) {
const labels = await this.allPondLabels(tx, pondId);
this.assertNameFree(labels, existing.parentId, input.name, labelId);
}
return tx.label.update({
where: { id: labelId },
data: { name: input.name, color: input.color },
});
});
return this.viewOf(label);
}
/** Move a label (with its subtree) to a new parent or to the root (issue #43).
* Rejects cycles and any move that would push the subtree past the depth limit. */
async move(user: User, labelId: string, input: MoveLabelInput): Promise<LabelView> {
const existing = await this.requireModifiableLabel(user, labelId);
const pondId = existing.pondId;
const parentId = input.parentId;
const label = await this.withPondLock(pondId, async (tx) => {
const labels = await this.allPondLabels(tx, pondId);
if (parentId !== null) {
const parent = labels.find((l) => l.id === parentId);
if (!parent) throw new NotFoundException();
// A label may not become a descendant of itself.
if (collectSubtreeIds(labels, labelId).has(parentId)) {
throw new ConflictException({ code: 'label_cycle' });
}
const newParentLevel = labelDepth(labels, parentId);
if (newParentLevel + subtreeHeight(labels, labelId) > MAX_LABEL_DEPTH) {
throw new ConflictException({
code: 'label_depth_exceeded',
details: { max: [String(MAX_LABEL_DEPTH)] },
});
}
}
this.assertNameFree(labels, parentId, existing.name, labelId);
return tx.label.update({ where: { id: labelId }, data: { parentId } });
});
this.logger.info({ labelId, pondId, parentId, userId: user.id }, 'audit: label moved');
return this.viewOf(label);
}
/**
* Delete a label and its whole subtree (issue #43). If any label in the
* subtree still has pages assigned, the caller must pass `force` to confirm
* detaching them; otherwise the delete is refused with a distinct error so
* the UI can prompt. The cascade on `page_labels`/`labels` removes the
* assignments and descendants.
*/
async remove(user: User, labelId: string, force: boolean): Promise<void> {
const existing = await this.requireModifiableLabel(user, labelId);
const pondId = existing.pondId;
await this.withPondLock(pondId, async (tx) => {
const labels = await this.allPondLabels(tx, pondId);
const subtree = [...collectSubtreeIds(labels, labelId)];
const assigned = await tx.pageLabel.count({ where: { labelId: { in: subtree } } });
if (assigned > 0 && !force) {
throw new ConflictException({
code: 'label_has_pages',
details: { count: [String(assigned)] },
});
}
// Deleting the root cascades to the subtree and to all page assignments.
await tx.label.delete({ where: { id: labelId } });
});
this.logger.info({ labelId, pondId, userId: user.id, force }, 'audit: label deleted');
}
/** Loads a live page and asserts the user may edit it (for assignment ops). */
private async requireModifiablePage(
user: User,
pageId: string,
): Promise<{ id: string; pondId: string }> {
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
include: { pond: true },
});
if (!page) throw new NotFoundException();
this.access.assertCanModify(user, page.pond);
return page;
}
/** A page's assigned labels (issue #43) — read requires seeing the pond. */
async pageLabels(user: User, pageId: string): Promise<LabelView[]> {
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
include: { pond: true },
});
if (!page) throw new NotFoundException();
this.access.assertCanSee(user, page.pond);
const rows = await this.prisma.pageLabel.findMany({
where: { pageId },
include: { label: true },
});
return rows.map((row) => this.viewOf(row.label));
}
/** Assign a label to a page (idempotent). Rejects labels from another pond. */
async assign(user: User, pageId: string, labelId: string): Promise<LabelView[]> {
const page = await this.requireModifiablePage(user, pageId);
const label = await this.prisma.label.findUnique({ where: { id: labelId } });
if (!label || label.pondId !== page.pondId) {
throw new BadRequestException({ code: 'label_wrong_pond' });
}
await this.prisma.pageLabel.upsert({
where: { pageId_labelId: { pageId, labelId } },
create: { pageId, labelId },
update: {},
});
return this.pageLabels(user, pageId);
}
/** Remove a label from a page (idempotent). */
async unassign(user: User, pageId: string, labelId: string): Promise<void> {
await this.requireModifiablePage(user, pageId);
await this.prisma.pageLabel.deleteMany({ where: { pageId, labelId } });
}
}

View File

@ -23,6 +23,11 @@
"page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).", "page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).",
"invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.", "invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.",
"page_trashed": "Diese Seite wurde in den Papierkorb verschoben.", "page_trashed": "Diese Seite wurde in den Papierkorb verschoben.",
"label_name_taken": "Ein Label mit diesem Namen existiert auf dieser Ebene bereits.",
"label_cycle": "Ein Label kann nicht in seinen eigenen Teilbaum verschoben werden.",
"label_depth_exceeded": "Labels lassen sich höchstens {{max}} Ebenen tief verschachteln.",
"label_has_pages": "Diesem Label sind noch Seiten zugeordnet; bitte bestätigen, um sie zu lösen.",
"label_wrong_pond": "Dieses Label gehört zu einem anderen Teich.",
"unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.", "unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.",
"file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).", "file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).",
"network": "Der Server war nicht erreichbar.", "network": "Der Server war nicht erreichbar.",
@ -45,6 +50,7 @@
"displayName": { "displayName": {
"required": "Bitte gib einen Anzeigenamen ein." "required": "Bitte gib einen Anzeigenamen ein."
}, },
"labelColor": "Bitte gib eine Farbe wie #a1b2c3 ein.",
"tooLong": "Die Eingabe ist zu lang." "tooLong": "Die Eingabe ist zu lang."
} }
} }

View File

@ -23,6 +23,11 @@
"page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).", "page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).",
"invalid_page_state": "The submitted page content is invalid.", "invalid_page_state": "The submitted page content is invalid.",
"page_trashed": "This page has been moved to the trash.", "page_trashed": "This page has been moved to the trash.",
"label_name_taken": "A label with this name already exists at this level.",
"label_cycle": "A label cannot be moved into its own subtree.",
"label_depth_exceeded": "Labels can be nested at most {{max}} levels deep.",
"label_has_pages": "This label still has pages assigned; confirm to detach them.",
"label_wrong_pond": "This label belongs to a different pond.",
"unsupported_file_type": "This file type is not supported.", "unsupported_file_type": "This file type is not supported.",
"file_too_large": "The file is too large (limit: {{limitBytes}} bytes).", "file_too_large": "The file is too large (limit: {{limitBytes}} bytes).",
"network": "The server could not be reached.", "network": "The server could not be reached.",
@ -45,6 +50,7 @@
"displayName": { "displayName": {
"required": "Please enter a display name." "required": "Please enter a display name."
}, },
"labelColor": "Please enter a colour like #a1b2c3.",
"tooLong": "The input is too long." "tooLong": "The input is too long."
} }
} }

View File

@ -6,6 +6,7 @@ export * from './env';
export * from './files'; export * from './files';
export * from './health'; export * from './health';
export * from './i18n-tools'; export * from './i18n-tools';
export * from './labels';
export * from './pages'; export * from './pages';
export * from './ponds'; export * from './ponds';
export * from './quotas'; export * from './quotas';

View File

@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import {
LabelView,
buildLabelTree,
collectAncestorIds,
collectSubtreeIds,
labelDepth,
subtreeHeight,
} from './labels';
/** Minimal label row; timestamps are irrelevant to the tree helpers. */
function label(id: string, parentId: string | null, name = id): LabelView {
return {
id,
pondId: 'pond',
parentId,
name,
color: '#64748b',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};
}
/**
* Tree used across the cases:
* a z
* b y
* d
* c
*/
const tree: LabelView[] = [
label('a', null, 'Alpha'),
label('b', 'a', 'Bravo'),
label('c', 'a', 'Charlie'),
label('d', 'b', 'Delta'),
label('z', null, 'Zulu'),
label('y', 'z', 'Yankee'),
];
describe('buildLabelTree (issue #43)', () => {
it('nests children under parents and sorts siblings and roots by name', () => {
const roots = buildLabelTree(tree);
expect(roots.map((r) => r.id)).toEqual(['a', 'z']); // Alpha before Zulu
const a = roots.find((r) => r.id === 'a')!;
expect(a.children.map((c) => c.id)).toEqual(['b', 'c']); // Bravo before Charlie
const b = a.children.find((c) => c.id === 'b')!;
expect(b.children.map((c) => c.id)).toEqual(['d']);
});
it('treats an orphaned parent reference as a root instead of dropping the row', () => {
const roots = buildLabelTree([label('x', 'missing')]);
expect(roots.map((r) => r.id)).toEqual(['x']);
});
});
describe('collectSubtreeIds (issue #43)', () => {
it('returns the label and all its descendants', () => {
expect(collectSubtreeIds(tree, 'a')).toEqual(new Set(['a', 'b', 'c', 'd']));
expect(collectSubtreeIds(tree, 'b')).toEqual(new Set(['b', 'd']));
expect(collectSubtreeIds(tree, 'd')).toEqual(new Set(['d']));
});
it('detects that a new parent inside the subtree would form a cycle', () => {
// Moving 'a' under 'd' is illegal: 'd' is in a's subtree.
expect(collectSubtreeIds(tree, 'a').has('d')).toBe(true);
// Moving 'c' under 'z' is fine: 'z' is not in c's subtree.
expect(collectSubtreeIds(tree, 'c').has('z')).toBe(false);
});
it('terminates on a malformed cycle in the input', () => {
const cyclic = [label('p', 'q'), label('q', 'p')];
expect(collectSubtreeIds(cyclic, 'p')).toEqual(new Set(['p', 'q']));
});
});
describe('collectAncestorIds + labelDepth (issue #43)', () => {
it('walks ancestors nearest-first', () => {
expect(collectAncestorIds(tree, 'd')).toEqual(['b', 'a']);
expect(collectAncestorIds(tree, 'a')).toEqual([]);
});
it('reports depth as a 1-based level', () => {
expect(labelDepth(tree, 'a')).toBe(1);
expect(labelDepth(tree, 'b')).toBe(2);
expect(labelDepth(tree, 'd')).toBe(3);
});
it('terminates on a malformed cycle', () => {
const cyclic = [label('p', 'q'), label('q', 'p')];
expect(collectAncestorIds(cyclic, 'p')).toEqual(['q']);
});
});
describe('subtreeHeight (issue #43)', () => {
it('measures the deepest branch in levels', () => {
expect(subtreeHeight(tree, 'a')).toBe(3); // a → b → d
expect(subtreeHeight(tree, 'b')).toBe(2); // b → d
expect(subtreeHeight(tree, 'c')).toBe(1); // leaf
});
});

View File

@ -0,0 +1,191 @@
import { z } from 'zod';
/**
* 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[] {
const nodes = new Map<string, LabelTreeNode>();
for (const label of labels) nodes.set(label.id, { ...label, children: [] });
const roots: LabelTreeNode[] = [];
for (const node of nodes.values()) {
const parent = node.parentId ? nodes.get(node.parentId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
for (const node of nodes.values()) node.children.sort(byName);
roots.sort(byName);
return roots;
}
/** Indexes labels by id → parentId for the ancestor/descendant walks below. */
function parentIndex(labels: LabelView[]): Map<string, string | null> {
const index = new Map<string, string | null>();
for (const label of labels) index.set(label.id, label.parentId);
return index;
}
/**
* Ids of a label and all its descendants (its whole subtree). Used to reject a
* move that would create a cycle (the new parent may not be inside the subtree)
* and to gather the labels a delete removes. Robust against malformed cycles in
* the input: every id is visited at most once.
*/
export function collectSubtreeIds(labels: LabelView[], rootId: string): Set<string> {
const childrenOf = new Map<string, string[]>();
for (const label of labels) {
if (!label.parentId) continue;
const siblings = childrenOf.get(label.parentId) ?? [];
siblings.push(label.id);
childrenOf.set(label.parentId, siblings);
}
const subtree = new Set<string>();
const stack = [rootId];
while (stack.length > 0) {
const id = stack.pop()!;
if (subtree.has(id)) continue;
subtree.add(id);
for (const child of childrenOf.get(id) ?? []) stack.push(child);
}
return subtree;
}
/**
* Ancestor ids of a label, nearest first (its parent, grandparent, ). This is
* what permission resolution needs: a grant on any ancestor of a page's label
* applies to the label too (permissions.md §label scope). Stops on a missing
* parent or a cycle, so it always terminates.
*/
export function collectAncestorIds(labels: LabelView[], labelId: string): string[] {
const parents = parentIndex(labels);
const ancestors: string[] = [];
const seen = new Set<string>([labelId]);
let current = parents.get(labelId) ?? null;
while (current && !seen.has(current)) {
ancestors.push(current);
seen.add(current);
current = parents.get(current) ?? null;
}
return ancestors;
}
/**
* 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 collectAncestorIds(labels, labelId).length + 1;
}
/**
* Height of a label's subtree in levels (a leaf is 1, a label with children 2,
* ). Combined with a target parent's depth it tells us whether a move keeps
* the whole subtree within {@link MAX_LABEL_DEPTH}.
*/
export function subtreeHeight(labels: LabelView[], rootId: string): number {
const childrenOf = new Map<string, LabelView[]>();
for (const label of labels) {
if (!label.parentId) continue;
const siblings = childrenOf.get(label.parentId) ?? [];
siblings.push(label);
childrenOf.set(label.parentId, siblings);
}
const heightFrom = (id: string, seen: Set<string>): number => {
if (seen.has(id)) return 0;
seen.add(id);
const children = childrenOf.get(id) ?? [];
let max = 0;
for (const child of children) max = Math.max(max, heightFrom(child.id, seen));
return max + 1;
};
return heightFrom(rootId, new Set());
}