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
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
229 lines
8.6 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|