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
78 lines
2.4 KiB
TypeScript
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;
|
|
}
|