dorfteich/apps/api/src/comments/comments.service.ts
Claude Fable 5 67fb01fe2b
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m26s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m49s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m27s
CI / Import/export fidelity gate (push) Successful in 46s
Notify watchers about page changes and comments, with an in-app center (#94)
New notifications table (payload denormalized for join-free rendering;
mailed_at already prepares the #95 digests). Generation fans page events
out to page and pond watchers, excluding the actors, and re-checks page
read permission per watcher at delivery time — a revoked watcher gets
nothing. Sources: named version snapshots (api), new comments (api), and
the collab server's automatic session-close snapshots — announced over a
new pg NOTIFY channel (the reverse of the established api→collab bus)
consumed by a dedicated LISTEN client in the api, since the collab server
has no permission resolution of its own. API: paginated list (unread
first via nulls-first ordering), mark read, mark all read. UI: bell with
unread badge in the top bar (30 s polling, no push in v1) and a dropdown
whose entries navigate and mark themselves read; comment notifications
deep-link with ?comments=1, which now opens the comments panel on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 23:20:25 +02:00

229 lines
8.6 KiB
TypeScript

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';
import { NotificationsService } from '../notifications/notifications.service';
import { WatchesService } from '../watches/watches.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,
private readonly watches: WatchesService,
private readonly notifications: NotificationsService,
) {}
/** 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 } } },
});
// Commenting subscribes the author to the page (issue #93) —
// preference-gated, never fatal for the comment itself.
await this.watches.autoWatchPage(user, pageId, 'comment').catch(() => {});
// Watchers learn about the new comment (issue #94); never fatal either.
await this.notifications.fanoutPageEvent('comment_added', pageId, [user.id]);
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,
};
}
}