From 4549d6d13f4d33b33594d0d39a121dc00fb7a2e0 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 21:42:29 +0200 Subject: [PATCH] Add threaded page comments: data model, API, and comment policy (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New comments table (thread via parent_id to the root, optional document anchor on roots, resolved_at/by; page purge cascades, trash hides) with a CommentsService enforcing the permission model: reading follows page read, writing follows the new pond setting commentPolicy (readers | editors) — 404 hides unreadable pages, 403 marks a failed write policy. Endpoints: threaded list per page with an open/resolved filter (resolved threads arrive collapsed by default), create (root or reply — replies attach to roots only and carry no anchor), edit own, delete own (roots with replies are admin-only, cascade), resolve/unresolve on roots for everyone who may comment. Bodies are Markdown rendered through the shared sanitizing pipeline; smuggled markup arrives as escaped text (fixture test). The UI lands with #92. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .../20260711220000_comments/migration.sql | 26 ++ apps/api/prisma/schema.prisma | 32 ++ apps/api/src/app.module.ts | 2 + apps/api/src/comments/comments.controller.ts | 85 +++++ apps/api/src/comments/comments.e2e.db.test.ts | 321 ++++++++++++++++++ apps/api/src/comments/comments.module.ts | 14 + apps/api/src/comments/comments.service.ts | 219 ++++++++++++ packages/shared/i18n/de/errors.json | 7 +- packages/shared/i18n/en/errors.json | 7 +- packages/shared/src/comments.ts | 77 +++++ packages/shared/src/index.ts | 1 + packages/shared/src/ponds.ts | 5 + 12 files changed, 794 insertions(+), 2 deletions(-) create mode 100644 apps/api/prisma/migrations/20260711220000_comments/migration.sql create mode 100644 apps/api/src/comments/comments.controller.ts create mode 100644 apps/api/src/comments/comments.e2e.db.test.ts create mode 100644 apps/api/src/comments/comments.module.ts create mode 100644 apps/api/src/comments/comments.service.ts create mode 100644 packages/shared/src/comments.ts diff --git a/apps/api/prisma/migrations/20260711220000_comments/migration.sql b/apps/api/prisma/migrations/20260711220000_comments/migration.sql new file mode 100644 index 0000000..cd91ac6 --- /dev/null +++ b/apps/api/prisma/migrations/20260711220000_comments/migration.sql @@ -0,0 +1,26 @@ +-- Threaded page comments (issue #91). + +CREATE TABLE "comments" ( + "id" TEXT NOT NULL, + "page_id" TEXT NOT NULL, + "parent_id" TEXT, + "author_id" TEXT, + "body" TEXT NOT NULL, + "anchor" TEXT, + "resolved_at" TIMESTAMP(3), + "resolved_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "edited_at" TIMESTAMP(3), + + CONSTRAINT "comments_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "comments_page_id_created_at_idx" ON "comments"("page_id", "created_at"); +CREATE INDEX "comments_parent_id_idx" ON "comments"("parent_id"); + +ALTER TABLE "comments" ADD CONSTRAINT "comments_page_id_fkey" + FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_fkey" + FOREIGN KEY ("parent_id") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_fkey" + FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index bf58bdf..dbf602c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -50,6 +50,7 @@ model User { attachments Attachment[] conversionJobs ConversionJob[] auditEntries AuditEntry[] + comments Comment[] @@map("users") } @@ -79,6 +80,36 @@ model AuditEntry { @@map("audit_log") } +/// Threaded page comments (issue #91, data-model.md §Comments). Threads are +/// one level deep: roots carry the optional document anchor and the resolve +/// state, replies reference the root via `parentId`. Purging a page cascades +/// its comments; trashing merely hides them (the list endpoint resolves the +/// page as a live page). Authors survive pseudonymization ("deleted user"); +/// only a hard user delete nulls them. +model Comment { + id String @id @default(uuid()) + pageId String @map("page_id") + parentId String? @map("parent_id") + authorId String? @map("author_id") + /// Markdown; rendered through the shared sanitizing pipeline on read. + body String + /// Opaque serialized document position (roots only). + anchor String? + resolvedAt DateTime? @map("resolved_at") + resolvedBy String? @map("resolved_by") + createdAt DateTime @default(now()) @map("created_at") + editedAt DateTime? @map("edited_at") + + page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) + parent Comment? @relation("thread", fields: [parentId], references: [id], onDelete: Cascade) + replies Comment[] @relation("thread") + author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) + + @@index([pageId, createdAt]) + @@index([parentId]) + @@map("comments") +} + enum PondType { PERSONAL SHARED @@ -191,6 +222,7 @@ model Page { pendingContributors PagePendingContributor[] labels PageLabel[] outgoingLinks PageLink[] @relation("outgoingLinks") + comments Comment[] incomingLinks PageLink[] @relation("incomingLinks") conversionJobs ConversionJob[] diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8cc0a18..55dfc1f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { AdminModule } from './admin/admin.module'; import { AuditModule } from './audit/audit.module'; import { AuthModule } from './auth/auth.module'; import { ApiExceptionFilter } from './common/api-exception.filter'; +import { CommentsModule } from './comments/comments.module'; import { CompactionModule } from './compaction/compaction.module'; import { AppConfig } from './config/app-config.service'; import { ConfigModule } from './config/config.module'; @@ -47,6 +48,7 @@ import { VersionsModule } from './versions/versions.module'; PermissionsModule, PondsModule, PagesModule, + CommentsModule, FilesModule, TrashModule, CompactionModule, diff --git a/apps/api/src/comments/comments.controller.ts b/apps/api/src/comments/comments.controller.ts new file mode 100644 index 0000000..18d3016 --- /dev/null +++ b/apps/api/src/comments/comments.controller.ts @@ -0,0 +1,85 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { + commentListQuerySchema, + createCommentInputSchema, + updateCommentInputSchema, + type CommentListQuery, + type CommentView, + type CreateCommentInput, + type PageCommentsView, + type UpdateCommentInput, +} from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { AuthenticatedOnly, RequiresPagePermission } from '../permissions/permission.decorators'; +import { CommentsService } from './comments.service'; + +/** + * Threaded page comments (issue #91). The page-scoped routes prove page + * read through the shared guard; the comment-scoped ones resolve their + * page (and the 404-vs-403 semantics) inside the service. + */ +@Controller() +export class CommentsController { + constructor(private readonly comments: CommentsService) {} + + @Get('pages/:pageId/comments') + @RequiresPagePermission('read', { idParam: 'pageId' }) + async list( + @Param('pageId') pageId: string, + @Query(new ZodValidationPipe(commentListQuerySchema)) query: CommentListQuery, + ): Promise { + return this.comments.list(pageId, query.filter); + } + + @Post('pages/:pageId/comments') + @RequiresPagePermission('read', { idParam: 'pageId' }) // write policy: service + async create( + @Param('pageId') pageId: string, + @Body(new ZodValidationPipe(createCommentInputSchema)) input: CreateCommentInput, + @Req() request: AuthedRequest, + ): Promise { + return this.comments.create(request.user!, pageId, input); + } + + @Patch('comments/:id') + @AuthenticatedOnly() + async update( + @Param('id') id: string, + @Body(new ZodValidationPipe(updateCommentInputSchema)) input: UpdateCommentInput, + @Req() request: AuthedRequest, + ): Promise { + return this.comments.update(request.user!, id, input.body); + } + + @Delete('comments/:id') + @HttpCode(204) + @AuthenticatedOnly() + async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.comments.delete(request.user!, id); + } + + @Post('comments/:id/resolve') + @AuthenticatedOnly() + async resolve(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + return this.comments.setResolved(request.user!, id, true); + } + + @Delete('comments/:id/resolve') + @AuthenticatedOnly() + async unresolve(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + return this.comments.setResolved(request.user!, id, false); + } +} diff --git a/apps/api/src/comments/comments.e2e.db.test.ts b/apps/api/src/comments/comments.e2e.db.test.ts new file mode 100644 index 0000000..2b9030d --- /dev/null +++ b/apps/api/src/comments/comments.e2e.db.test.ts @@ -0,0 +1,321 @@ +import { randomUUID } from 'node:crypto'; + +import { INestApplication } from '@nestjs/common'; +import type { CommentView, PageCommentsView } from '@dorfteich/shared'; +import { PrismaClient, User } from '@prisma/client'; +import request from 'supertest'; +import * as Y from 'yjs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * Threaded page comments end to end (issue #91): CRUD with the permission + * matrix and the pond's comment policy, resolve semantics with the + * default-collapsed list response, the shared escaping pipeline, and the + * trash/purge behavior. + */ +describe.skipIf(!hasTestDb)('comments (e2e, issue #91)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'kommentare sind gespraech 1'; + const users: Record = {}; + const cookies: Record = {}; + let pondId: string; + let pageId: string; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string): Promise { + const service = app.get(UsersService); + const username = `cm-${handle}-${suffix}`; + const user = await service.createUser({ + username, + email: `${username}@example.org`, + displayName: `Cm ${handle}`, + password, + locale: 'en', + }); + await service.markEmailVerified(user.id); + users[handle] = user; + cookies[handle] = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + } + + async function makePage(slug: string): Promise { + const id = randomUUID(); + await prisma.page.create({ + data: { + id, + pondId, + title: slug, + slug, + ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())), + sortKey: `a${slug}`, + createdBy: users.owner!.id, + }, + }); + return id; + } + + async function grant(handle: string, role: 'EDITOR' | 'READER'): Promise { + await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'USER', + subjectId: users[handle]!.id, + role, + scopeType: 'POND', + effect: 'ALLOW', + createdBy: users.owner!.id, + }, + }); + } + + async function setPolicy(policy: 'readers' | 'editors'): Promise { + const pond = await prisma.pond.findUniqueOrThrow({ where: { id: pondId } }); + await prisma.pond.update({ + where: { id: pondId }, + data: { settings: { ...(pond.settings as object), commentPolicy: policy } }, + }); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + for (const handle of ['owner', 'editor', 'reader', 'outsider']) await makeUser(handle); + + const pond = await prisma.pond.create({ + data: { + slug: `cm-pond-${suffix}`, + name: 'Comment Pond', + type: 'SHARED', + ownerId: users.owner!.id, + }, + }); + pondId = pond.id; + await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'USER', + subjectId: users.owner!.id, + role: 'POND_ADMIN', + scopeType: 'POND', + effect: 'ALLOW', + createdBy: users.owner!.id, + }, + }); + await grant('editor', 'EDITOR'); + await grant('reader', 'READER'); + pageId = await makePage('discussion'); + }); + + afterAll(async () => { + const ids = Object.values(users).map((u) => u.id); + await prisma.comment.deleteMany({ where: { page: { pondId } } }); + await prisma.roleGrant.deleteMany({ where: { pondId } }); + await prisma.page.deleteMany({ where: { pondId } }); + await prisma.pond.deleteMany({ where: { id: pondId } }); + await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } }); + await prisma.session.deleteMany({ where: { userId: { in: ids } } }); + await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('runs thread CRUD under the permission matrix (readers policy)', async () => { + // Reader may comment when the policy allows readers (the default). + const rootRes = await api() + .post(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.reader!) + .send({ body: 'A **question** from a reader', anchor: 'para-1' }) + .expect(201); + const root = rootRes.body as CommentView; + expect(root.html).toContain('question'); + expect(root.anchor).toBe('para-1'); + + // Editor replies; the reply refuses to carry an anchor. + const replyRes = await api() + .post(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.editor!) + .send({ body: 'An answer', parentId: root.id, anchor: 'ignored' }) + .expect(201); + expect((replyRes.body as CommentView).anchor).toBeNull(); + + // Replies to replies are rejected. + await api() + .post(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.reader!) + .send({ body: 'nested', parentId: (replyRes.body as CommentView).id }) + .expect(400); + + // The outsider sees nothing at all — 404, not 403 (issue #60). + await api() + .get(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.outsider!) + .expect(404); + await api() + .post(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.outsider!) + .send({ body: 'sneaky' }) + .expect(404); + + // Edit: only the author. + await api() + .patch(`/api/v1/comments/${root.id}`) + .set('Cookie', cookies.editor!) + .send({ body: 'hijacked' }) + .expect(403); + const edited = await api() + .patch(`/api/v1/comments/${root.id}`) + .set('Cookie', cookies.reader!) + .send({ body: 'A *clarified* question' }) + .expect(200); + expect((edited.body as CommentView).editedAt).not.toBeNull(); + + // Delete: the author cannot take replies down with the root … + await api().delete(`/api/v1/comments/${root.id}`).set('Cookie', cookies.reader!).expect(409); + // … but a pond admin can delete any thread (cascade). + await api().delete(`/api/v1/comments/${root.id}`).set('Cookie', cookies.owner!).expect(204); + const after = await api() + .get(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.reader!) + .expect(200); + expect((after.body as PageCommentsView).threads).toHaveLength(0); + }); + + it('enforces the editors-only policy', async () => { + await setPolicy('editors'); + await api() + .post(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.reader!) + .send({ body: 'readers barred' }) + .expect(403); + const res = await api() + .post(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.editor!) + .send({ body: 'editors pass' }) + .expect(201); + // Reading stays open to readers regardless of the write policy. + await api().get(`/api/v1/pages/${pageId}/comments`).set('Cookie', cookies.reader!).expect(200); + // Resolve follows the same policy: readers barred, editors allowed. + await api() + .post(`/api/v1/comments/${(res.body as CommentView).id}/resolve`) + .set('Cookie', cookies.reader!) + .expect(403); + await api() + .post(`/api/v1/comments/${(res.body as CommentView).id}/resolve`) + .set('Cookie', cookies.editor!) + .expect(201); + await api() + .delete(`/api/v1/comments/${(res.body as CommentView).id}`) + .set('Cookie', cookies.editor!) + .expect(204); + await setPolicy('readers'); + }); + + it('filters resolved threads and collapses them by default', async () => { + const open = await api() + .post(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.reader!) + .send({ body: 'still open' }) + .expect(201); + const done = await api() + .post(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.reader!) + .send({ body: 'done soon' }) + .expect(201); + await api() + .post(`/api/v1/comments/${(done.body as CommentView).id}/resolve`) + .set('Cookie', cookies.reader!) + .expect(201); + + const all = ( + await api().get(`/api/v1/pages/${pageId}/comments`).set('Cookie', cookies.reader!).expect(200) + ).body as PageCommentsView; + expect(all.openCount).toBe(1); + expect(all.resolvedCount).toBe(1); + const resolvedThread = all.threads.find((t) => t.resolved)!; + expect(resolvedThread.collapsed).toBe(true); + expect(all.threads.find((t) => !t.resolved)!.collapsed).toBe(false); + + const onlyOpen = ( + await api() + .get(`/api/v1/pages/${pageId}/comments?filter=open`) + .set('Cookie', cookies.reader!) + .expect(200) + ).body as PageCommentsView; + expect(onlyOpen.threads).toHaveLength(1); + expect(onlyOpen.threads[0]?.root.body).toBe('still open'); + + // Unresolve re-opens the thread. + await api() + .delete(`/api/v1/comments/${(done.body as CommentView).id}/resolve`) + .set('Cookie', cookies.reader!) + .expect(200); + const reopened = ( + await api() + .get(`/api/v1/pages/${pageId}/comments?filter=resolved`) + .set('Cookie', cookies.reader!) + .expect(200) + ).body as PageCommentsView; + expect(reopened.threads).toHaveLength(0); + + // Cleanup for the next test. + await api() + .delete(`/api/v1/comments/${(open.body as CommentView).id}`) + .set('Cookie', cookies.owner!); + await api() + .delete(`/api/v1/comments/${(done.body as CommentView).id}`) + .set('Cookie', cookies.owner!); + }); + + it('renders bodies through the shared escaping pipeline', async () => { + const res = await api() + .post(`/api/v1/pages/${pageId}/comments`) + .set('Cookie', cookies.reader!) + .send({ body: 'look ' }) + .expect(201); + const view = res.body as CommentView; + // Smuggled markup arrives as escaped, inert text — assert the escaped + // form is present, never `not.toContain` on words inside it (#82 lesson). + expect(view.html).toContain('<script>'); + expect(view.html).not.toContain('