dorfteich/packages/shared/src/comments.ts
Claude Fable 5 4549d6d13f
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
Add threaded page comments: data model, API, and comment policy (#91)
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
2026-07-11 21:42:29 +02:00

78 lines
2.4 KiB
TypeScript

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