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
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:
parent
1bda137ca4
commit
a3a012c41d
@ -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;
|
||||||
@ -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
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
116
apps/api/src/labels/labels.controller.ts
Normal file
116
apps/api/src/labels/labels.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/labels/labels.module.ts
Normal file
14
apps/api/src/labels/labels.module.ts
Normal 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 {}
|
||||||
200
apps/api/src/labels/labels.service.db.test.ts
Normal file
200
apps/api/src/labels/labels.service.db.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
284
apps/api/src/labels/labels.service.ts
Normal file
284
apps/api/src/labels/labels.service.ts
Normal 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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
102
packages/shared/src/labels.test.ts
Normal file
102
packages/shared/src/labels.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
191
packages/shared/src/labels.ts
Normal file
191
packages/shared/src/labels.ts
Normal 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());
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user