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 { 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 { 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 { 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 { // 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(); 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 { 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 { 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 { 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 { 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, }; } }