diff --git a/apps/api/prisma/migrations/20260709082418_labels/migration.sql b/apps/api/prisma/migrations/20260709082418_labels/migration.sql new file mode 100644 index 0000000..de41b09 --- /dev/null +++ b/apps/api/prisma/migrations/20260709082418_labels/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3ca1e14..c463446 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -78,6 +78,7 @@ model Pond { usage PondUsage? pages Page[] attachments Attachment[] + labels Label[] @@index([ownerId]) @@map("ponds") @@ -108,6 +109,7 @@ model Page { attachments Attachment[] versions PageVersion[] pendingContributors PagePendingContributor[] + labels PageLabel[] @@unique([pondId, slug]) @@index([pondId]) @@ -204,6 +206,48 @@ model PageContentCache { @@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 { USER POND diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 75cee38..9d8b98a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -10,6 +10,7 @@ import { AppConfig } from './config/app-config.service'; import { ConfigModule } from './config/config.module'; import { FilesModule } from './files/files.module'; import { HealthModule } from './health/health.module'; +import { LabelsModule } from './labels/labels.module'; import { MailModule } from './mail/mail.module'; import { PagesModule } from './pages/pages.module'; import { PondsModule } from './ponds/ponds.module'; @@ -34,6 +35,7 @@ import { VersionsModule } from './versions/versions.module'; TrashModule, CompactionModule, VersionsModule, + LabelsModule, AuthModule, AdminModule, LoggerModule.forRootAsync({ diff --git a/apps/api/src/labels/labels.controller.ts b/apps/api/src/labels/labels.controller.ts new file mode 100644 index 0000000..e8e22cd --- /dev/null +++ b/apps/api/src/labels/labels.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.labels.unassign(request.user!, pageId, labelId); + } +} diff --git a/apps/api/src/labels/labels.module.ts b/apps/api/src/labels/labels.module.ts new file mode 100644 index 0000000..e5e8ca9 --- /dev/null +++ b/apps/api/src/labels/labels.module.ts @@ -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 {} diff --git a/apps/api/src/labels/labels.service.db.test.ts b/apps/api/src/labels/labels.service.db.test.ts new file mode 100644 index 0000000..1084cc3 --- /dev/null +++ b/apps/api/src/labels/labels.service.db.test.ts @@ -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 { + 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, + ); + }); +}); diff --git a/apps/api/src/labels/labels.service.ts b/apps/api/src/labels/labels.service.ts new file mode 100644 index 0000000..899982e --- /dev/null +++ b/apps/api/src/labels/labels.service.ts @@ -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(pondId: string, fn: (tx: Tx) => Promise): Promise { + 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 { + 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 { + 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