Add threaded page comments: data model, API, and comment policy (#91)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m19s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 4m55s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m5s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-11 21:42:29 +02:00
parent 4b55fb92ac
commit 4549d6d13f
12 changed files with 794 additions and 2 deletions

View File

@ -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;

View File

@ -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[]

View File

@ -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,

View File

@ -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<PageCommentsView> {
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<CommentView> {
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<CommentView> {
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<void> {
await this.comments.delete(request.user!, id);
}
@Post('comments/:id/resolve')
@AuthenticatedOnly()
async resolve(@Param('id') id: string, @Req() request: AuthedRequest): Promise<CommentView> {
return this.comments.setResolved(request.user!, id, true);
}
@Delete('comments/:id/resolve')
@AuthenticatedOnly()
async unresolve(@Param('id') id: string, @Req() request: AuthedRequest): Promise<CommentView> {
return this.comments.setResolved(request.user!, id, false);
}
}

View File

@ -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<string, User> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let pageId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
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<string> {
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<void> {
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<void> {
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('<strong>question</strong>');
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 <script>alert(1)</script> <img src=x onerror=alert(2)>' })
.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('&lt;script&gt;');
expect(view.html).not.toContain('<script>');
expect(view.html).not.toContain('<img');
await api().delete(`/api/v1/comments/${view.id}`).set('Cookie', cookies.owner!).expect(204);
});
it('hides comments with the trashed page and removes them on purge', async () => {
const page2 = await makePage('doomed');
const created = await api()
.post(`/api/v1/pages/${page2}/comments`)
.set('Cookie', cookies.editor!)
.send({ body: 'about to vanish' })
.expect(201);
await prisma.page.update({
where: { id: page2 },
data: { deletedAt: new Date(), deletedBy: users.owner!.id },
});
// Trash hides: the list 404s, and so does every comment mutation.
await api().get(`/api/v1/pages/${page2}/comments`).set('Cookie', cookies.editor!).expect(404);
await api()
.patch(`/api/v1/comments/${(created.body as CommentView).id}`)
.set('Cookie', cookies.editor!)
.send({ body: 'necromancy' })
.expect(404);
// Purge removes the rows (FK cascade).
await prisma.page.delete({ where: { id: page2 } });
expect(await prisma.comment.count({ where: { pageId: page2 } })).toBe(0);
});
});

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PermissionsModule } from '../permissions/permissions.module';
import { CommentsController } from './comments.controller';
import { CommentsService } from './comments.service';
@Module({
imports: [PermissionsModule],
controllers: [CommentsController],
providers: [CommentsService],
exports: [CommentsService],
})
export class CommentsModule {}

View File

@ -0,0 +1,219 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
docToHtml,
markdownToDoc,
pondSettingsSchema,
type CommentListFilter,
type CommentThreadView,
type CommentView,
type CreateCommentInput,
type PageCommentsView,
} from '@dorfteich/shared';
import { Comment, Page, User } from '@prisma/client';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
type CommentWithAuthor = Comment & {
author: { id: string; username: string; displayName: string } | null;
};
/**
* Threaded page comments (issue #91). Reading follows page read; writing
* follows the pond's `commentPolicy` every reader, or pond-wide editors
* only (permissions.md §Non-page objects). The 404-vs-403 convention
* (issue #60) applies: no read access hides existence entirely, a failed
* write policy on a readable page is an explicit 403.
*/
@Injectable()
export class CommentsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
) {}
/** Comments live on live pages only — trash hides them (ADR 0013). */
private async livePage(pageId: string): Promise<Page> {
const page = await this.prisma.page.findFirst({ where: { id: pageId, deletedAt: null } });
if (!page) throw new NotFoundException();
return page;
}
private async mayComment(user: User, page: Page): Promise<boolean> {
const pond = await this.prisma.pond.findFirst({
where: { id: page.pondId, deletedAt: null },
});
if (!pond) return false;
const settings = pondSettingsSchema.parse(pond.settings ?? {});
const action = settings.commentPolicy === 'editors' ? 'write' : 'read';
return this.permissions.canAccessPage(user, page, action);
}
/** Loads a comment and proves the caller may read its (live) page. */
private async readableComment(user: User, commentId: string): Promise<CommentWithAuthor> {
const comment = await this.prisma.comment.findUnique({
where: { id: commentId },
include: { author: { select: { id: true, username: true, displayName: true } } },
});
if (!comment) throw new NotFoundException();
const page = await this.livePage(comment.pageId);
if (!(await this.permissions.canAccessPage(user, page, 'read'))) {
throw new NotFoundException();
}
return comment;
}
async list(pageId: string, filter: CommentListFilter): Promise<PageCommentsView> {
// The controller's permission decorator already proved page read; the
// live-page check keeps trash hidden.
await this.livePage(pageId);
const comments = (await this.prisma.comment.findMany({
where: { pageId },
orderBy: { createdAt: 'asc' },
include: { author: { select: { id: true, username: true, displayName: true } } },
})) as CommentWithAuthor[];
const roots = comments.filter((comment) => comment.parentId === null);
const repliesByRoot = new Map<string, CommentWithAuthor[]>();
for (const comment of comments) {
if (!comment.parentId) continue;
const list = repliesByRoot.get(comment.parentId) ?? [];
list.push(comment);
repliesByRoot.set(comment.parentId, list);
}
const threads: CommentThreadView[] = roots
.filter((root) =>
filter === 'all'
? true
: filter === 'resolved'
? root.resolvedAt !== null
: root.resolvedAt === null,
)
.map((root) => ({
root: CommentsService.viewOf(root),
replies: (repliesByRoot.get(root.id) ?? []).map(CommentsService.viewOf),
resolved: root.resolvedAt !== null,
// Resolved threads arrive collapsed by default (issue #91 AC).
collapsed: root.resolvedAt !== null,
}));
return {
threads,
openCount: roots.filter((root) => root.resolvedAt === null).length,
resolvedCount: roots.filter((root) => root.resolvedAt !== null).length,
};
}
async create(user: User, pageId: string, input: CreateCommentInput): Promise<CommentView> {
const page = await this.livePage(pageId);
if (!(await this.mayComment(user, page))) {
throw new ForbiddenException({ code: 'comments_editors_only' });
}
let parentId: string | null = null;
if (input.parentId) {
const parent = await this.prisma.comment.findFirst({
where: { id: input.parentId, pageId },
});
// Replies attach to thread roots only — a reply to a reply is a
// client bug, not something to silently reparent.
if (!parent || parent.parentId !== null) {
throw new BadRequestException({ code: 'comment_parent_invalid' });
}
parentId = parent.id;
}
const created = await this.prisma.comment.create({
data: {
pageId,
parentId,
authorId: user.id,
body: input.body,
// Anchors mark a document position — meaningful on roots only.
anchor: parentId ? null : (input.anchor ?? null),
},
include: { author: { select: { id: true, username: true, displayName: true } } },
});
return CommentsService.viewOf(created as CommentWithAuthor);
}
async update(user: User, commentId: string, body: string): Promise<CommentView> {
const comment = await this.readableComment(user, commentId);
if (comment.authorId !== user.id) {
throw new ForbiddenException({ code: 'comment_not_author' });
}
const updated = await this.prisma.comment.update({
where: { id: commentId },
data: { body, editedAt: new Date() },
include: { author: { select: { id: true, username: true, displayName: true } } },
});
return CommentsService.viewOf(updated as CommentWithAuthor);
}
async delete(user: User, commentId: string): Promise<void> {
const comment = await this.readableComment(user, commentId);
const isAdmin =
user.isSiteAdmin ||
(await this.permissions.hasPondRole(
user,
(await this.livePage(comment.pageId)).pondId,
'pond_admin',
));
if (!isAdmin) {
if (comment.authorId !== user.id) {
throw new ForbiddenException({ code: 'comment_not_author' });
}
// Authors may not take other people's replies down with their root.
if (comment.parentId === null) {
const replies = await this.prisma.comment.count({ where: { parentId: commentId } });
if (replies > 0) throw new ConflictException({ code: 'comment_has_replies' });
}
}
// Roots cascade their replies (FK ON DELETE CASCADE).
await this.prisma.comment.delete({ where: { id: commentId } });
}
async setResolved(user: User, commentId: string, resolved: boolean): Promise<CommentView> {
const comment = await this.readableComment(user, commentId);
if (comment.parentId !== null) {
throw new BadRequestException({ code: 'comment_not_root' });
}
const page = await this.livePage(comment.pageId);
if (!(await this.mayComment(user, page))) {
throw new ForbiddenException({ code: 'comments_editors_only' });
}
const updated = await this.prisma.comment.update({
where: { id: commentId },
data: resolved
? { resolvedAt: new Date(), resolvedBy: user.id }
: { resolvedAt: null, resolvedBy: null },
include: { author: { select: { id: true, username: true, displayName: true } } },
});
return CommentsService.viewOf(updated as CommentWithAuthor);
}
private static viewOf(comment: CommentWithAuthor): CommentView {
return {
id: comment.id,
pageId: comment.pageId,
parentId: comment.parentId,
author: comment.author,
body: comment.body,
// Same sanitizing pipeline as pages: Markdown in, inert HTML out —
// smuggled tags become escaped text (security.md, issue #91 AC).
html: docToHtml(markdownToDoc(comment.body)),
anchor: comment.anchor,
createdAt: comment.createdAt.toISOString(),
editedAt: comment.editedAt?.toISOString() ?? null,
resolvedAt: comment.resolvedAt?.toISOString() ?? null,
};
}
}

View File

@ -90,5 +90,10 @@
},
"labelColor": "Bitte gib eine Farbe wie #a1b2c3 ein.",
"tooLong": "Die Eingabe ist zu lang."
}
},
"comments_editors_only": "Kommentare sind in diesem Teich auf Bearbeitende beschränkt.",
"comment_not_author": "Nur die Autorin/der Autor kann diesen Kommentar ändern.",
"comment_has_replies": "Dieser Kommentar hat Antworten — den ganzen Thread kann nur eine Teich-Administration löschen.",
"comment_parent_invalid": "Antworten müssen sich auf einen Kommentar der obersten Ebene derselben Seite beziehen.",
"comment_not_root": "Nur Kommentare der obersten Ebene können als erledigt markiert werden."
}

View File

@ -90,5 +90,10 @@
},
"labelColor": "Please enter a colour like #a1b2c3.",
"tooLong": "The input is too long."
}
},
"comments_editors_only": "Comments on this pond are limited to editors.",
"comment_not_author": "Only the author can change this comment.",
"comment_has_replies": "This comment has replies — only a pond admin can delete the whole thread.",
"comment_parent_invalid": "Replies must reference a top-level comment on the same page.",
"comment_not_root": "Only top-level comments can be resolved."
}

View File

@ -0,0 +1,77 @@
import { z } from 'zod';
/**
* Comments on pages (issue #91, data-model.md §Comments): threaded
* discussions with resolve semantics. Reading follows page read; writing
* requires page read plus the pond's `commentPolicy`
* (permissions.md §Non-page objects).
*/
/** Who may write comments: every reader, or pond-wide editors only. */
export const COMMENT_POLICIES = ['readers', 'editors'] as const;
export type CommentPolicy = (typeof COMMENT_POLICIES)[number];
const commentBodySchema = z
.string()
.trim()
.min(1, 'validation.required')
.max(10_000, 'validation.tooLong');
export const createCommentInputSchema = z.object({
/** Markdown; the api renders it through the shared sanitizing pipeline. */
body: commentBodySchema,
/** Reply target: a thread root's id. Absent = new thread. */
parentId: z.string().uuid().nullish(),
/** Opaque serialized position in the document (thread roots only). */
anchor: z.string().max(2_000).nullish(),
});
export type CreateCommentInput = z.infer<typeof createCommentInputSchema>;
export const updateCommentInputSchema = z.object({
body: commentBodySchema,
});
export type UpdateCommentInput = z.infer<typeof updateCommentInputSchema>;
export const COMMENT_LIST_FILTERS = ['all', 'open', 'resolved'] as const;
export type CommentListFilter = (typeof COMMENT_LIST_FILTERS)[number];
export const commentListQuerySchema = z.object({
filter: z.enum(COMMENT_LIST_FILTERS).default('all'),
});
export type CommentListQuery = z.infer<typeof commentListQuerySchema>;
export interface CommentAuthorView {
id: string;
username: string;
displayName: string;
}
export interface CommentView {
id: string;
pageId: string;
parentId: string | null;
/** Null only after a hard account deletion; pseudonymized authors remain. */
author: CommentAuthorView | null;
/** The raw Markdown — what the edit form loads. */
body: string;
/** Sanitized render of `body` (same pipeline as pages). */
html: string;
anchor: string | null;
createdAt: string;
editedAt: string | null;
resolvedAt: string | null;
}
export interface CommentThreadView {
root: CommentView;
replies: CommentView[];
resolved: boolean;
/** Resolved threads arrive collapsed by default (issue #91 AC). */
collapsed: boolean;
}
export interface PageCommentsView {
threads: CommentThreadView[];
openCount: number;
resolvedCount: number;
}

View File

@ -3,6 +3,7 @@ export * from './api-error';
export * from './auth';
export * from './backup-status';
export * from './collab-token';
export * from './comments';
export * from './editor-schema';
export * from './env';
export * from './conversion';

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import { COMMENT_POLICIES } from './comments';
/**
* Pond schemas and views shared between api and web (issue #21).
* Ponds are the top-level content container; every self-registered
@ -35,6 +37,8 @@ export type PondFonts = z.infer<typeof pondFontsSchema>;
export const pondSettingsSchema = z.object({
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
fonts: pondFontsSchema.default({}),
/** Who may write comments (issue #91): every reader, or editors only. */
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
});
export type PondSettings = z.infer<typeof pondSettingsSchema>;
@ -56,6 +60,7 @@ export const updatePondInputSchema = z
description: z.string().trim().max(500, 'validation.tooLong'),
sidebarSort: z.enum(SIDEBAR_SORT_MODES),
fonts: pondFontsSchema,
commentPolicy: z.enum(COMMENT_POLICIES),
})
.partial();
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;