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
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:
parent
4b55fb92ac
commit
4549d6d13f
@ -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;
|
||||
@ -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[]
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
85
apps/api/src/comments/comments.controller.ts
Normal file
85
apps/api/src/comments/comments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
321
apps/api/src/comments/comments.e2e.db.test.ts
Normal file
321
apps/api/src/comments/comments.e2e.db.test.ts
Normal 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('<script>');
|
||||
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);
|
||||
});
|
||||
});
|
||||
14
apps/api/src/comments/comments.module.ts
Normal file
14
apps/api/src/comments/comments.module.ts
Normal 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 {}
|
||||
219
apps/api/src/comments/comments.service.ts
Normal file
219
apps/api/src/comments/comments.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
77
packages/shared/src/comments.ts
Normal file
77
packages/shared/src/comments.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user