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[]
|
attachments Attachment[]
|
||||||
conversionJobs ConversionJob[]
|
conversionJobs ConversionJob[]
|
||||||
auditEntries AuditEntry[]
|
auditEntries AuditEntry[]
|
||||||
|
comments Comment[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -79,6 +80,36 @@ model AuditEntry {
|
|||||||
@@map("audit_log")
|
@@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 {
|
enum PondType {
|
||||||
PERSONAL
|
PERSONAL
|
||||||
SHARED
|
SHARED
|
||||||
@ -191,6 +222,7 @@ model Page {
|
|||||||
pendingContributors PagePendingContributor[]
|
pendingContributors PagePendingContributor[]
|
||||||
labels PageLabel[]
|
labels PageLabel[]
|
||||||
outgoingLinks PageLink[] @relation("outgoingLinks")
|
outgoingLinks PageLink[] @relation("outgoingLinks")
|
||||||
|
comments Comment[]
|
||||||
incomingLinks PageLink[] @relation("incomingLinks")
|
incomingLinks PageLink[] @relation("incomingLinks")
|
||||||
conversionJobs ConversionJob[]
|
conversionJobs ConversionJob[]
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { AdminModule } from './admin/admin.module';
|
|||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||||
|
import { CommentsModule } from './comments/comments.module';
|
||||||
import { CompactionModule } from './compaction/compaction.module';
|
import { CompactionModule } from './compaction/compaction.module';
|
||||||
import { AppConfig } from './config/app-config.service';
|
import { AppConfig } from './config/app-config.service';
|
||||||
import { ConfigModule } from './config/config.module';
|
import { ConfigModule } from './config/config.module';
|
||||||
@ -47,6 +48,7 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
PermissionsModule,
|
PermissionsModule,
|
||||||
PondsModule,
|
PondsModule,
|
||||||
PagesModule,
|
PagesModule,
|
||||||
|
CommentsModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
TrashModule,
|
TrashModule,
|
||||||
CompactionModule,
|
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.",
|
"labelColor": "Bitte gib eine Farbe wie #a1b2c3 ein.",
|
||||||
"tooLong": "Die Eingabe ist zu lang."
|
"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.",
|
"labelColor": "Please enter a colour like #a1b2c3.",
|
||||||
"tooLong": "The input is too long."
|
"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 './auth';
|
||||||
export * from './backup-status';
|
export * from './backup-status';
|
||||||
export * from './collab-token';
|
export * from './collab-token';
|
||||||
|
export * from './comments';
|
||||||
export * from './editor-schema';
|
export * from './editor-schema';
|
||||||
export * from './env';
|
export * from './env';
|
||||||
export * from './conversion';
|
export * from './conversion';
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { COMMENT_POLICIES } from './comments';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pond schemas and views shared between api and web (issue #21).
|
* Pond schemas and views shared between api and web (issue #21).
|
||||||
* Ponds are the top-level content container; every self-registered
|
* 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({
|
export const pondSettingsSchema = z.object({
|
||||||
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
|
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
|
||||||
fonts: pondFontsSchema.default({}),
|
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>;
|
export type PondSettings = z.infer<typeof pondSettingsSchema>;
|
||||||
|
|
||||||
@ -56,6 +60,7 @@ export const updatePondInputSchema = z
|
|||||||
description: z.string().trim().max(500, 'validation.tooLong'),
|
description: z.string().trim().max(500, 'validation.tooLong'),
|
||||||
sidebarSort: z.enum(SIDEBAR_SORT_MODES),
|
sidebarSort: z.enum(SIDEBAR_SORT_MODES),
|
||||||
fonts: pondFontsSchema,
|
fonts: pondFontsSchema,
|
||||||
|
commentPolicy: z.enum(COMMENT_POLICIES),
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;
|
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user