import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; // node10 resolution: the API can only reach the shared package ROOT (the // `exports` map is ignored) — see packages/shared/src/index.ts for why the // TASK-0094 contracts are re-exported there. import { parseMentions } from "@factory/shared"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EventBus } from "../../common/event-bus.js"; import { ApiError } from "../../common/errors.js"; import { actorLevel, assertCan, assertRole } from "../../lib/authorize.js"; import { parsePagination } from "../../lib/pagination.js"; import { EVENTS } from "../../constants.js"; import { NotificationsService } from "../notifications/notifications.service.js"; import type { CreateCommentInput, ListCommentsInput, ReactionInput, UpdateCommentInput } from "./dto.js"; const COMMENT_INCLUDE = { author: { select: { id: true, name: true, displayName: true, avatarUrl: true } }, replies: { orderBy: { createdAt: "asc" as const }, include: { author: { select: { id: true, name: true, displayName: true, avatarUrl: true } } } } } as const; @Injectable() export class CommentsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus, @Inject(NotificationsService) private readonly notifications: NotificationsService ) {} private async resolveTarget(input: { targetType: "block" | "record" | "page"; targetId: string; }): Promise<{ workspaceId: string; pageId: string | null; blockId: string | null; recordId: string | null }> { if (input.targetType === "block") { const block = await this.prisma.block.findUnique({ where: { id: input.targetId }, select: { id: true, pageId: true, workspaceId: true, deletedAt: true } }); if (!block || block.deletedAt) throw ApiError.notFound("Block not found."); return { workspaceId: block.workspaceId, pageId: block.pageId, blockId: block.id, recordId: null }; } if (input.targetType === "record") { const record = await this.prisma.databaseRecord.findUnique({ where: { id: input.targetId }, select: { id: true, workspaceId: true, pageId: true, archivedAt: true } }); if (!record || record.archivedAt) throw ApiError.notFound("Record not found."); return { workspaceId: record.workspaceId, pageId: record.pageId, blockId: null, recordId: record.id }; } const page = await this.prisma.page.findUnique({ where: { id: input.targetId }, select: { id: true, workspaceId: true, deletedAt: true, trashedAt: true } }); if (!page || page.deletedAt || page.trashedAt) throw ApiError.notFound("Page not found."); return { workspaceId: page.workspaceId, pageId: page.id, blockId: null, recordId: null }; } async list(userId: string, query: ListCommentsInput) { const { cursor, limit } = parsePagination(query); const where: Record = {}; if (query.blockId) { const block = await this.prisma.block.findUnique({ where: { id: query.blockId }, select: { workspaceId: true, pageId: true } }); if (!block) throw ApiError.notFound("Block not found."); await assertCan(this.prisma, { workspaceId: block.workspaceId, userId, resource: { pageId: block.pageId } }, "VIEW"); where.blockId = query.blockId; } else if (query.recordId) { const record = await this.prisma.databaseRecord.findUnique({ where: { id: query.recordId }, select: { workspaceId: true, pageId: true, databaseId: true, id: true } }); if (!record) throw ApiError.notFound("Record not found."); where.recordId = query.recordId; await assertCan(this.prisma, { workspaceId: record.workspaceId, userId, resource: { databaseId: record.databaseId, recordId: record.id } }, "VIEW"); } else if (query.pageId) { const page = await this.prisma.page.findUnique({ where: { id: query.pageId }, select: { workspaceId: true, id: true } }); if (!page) throw ApiError.notFound("Page not found."); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); where.pageId = query.pageId; } else { throw ApiError.validation("Provide blockId, recordId or pageId.", [{ issue: "target required" }]); } const threads = await this.prisma.comment.findMany({ where: { ...where, ...(cursor ? { id: { gt: cursor.id } } : {}) }, include: COMMENT_INCLUDE, orderBy: { createdAt: "asc" }, take: limit + 1 }); const page = threads.slice(0, limit); const next = threads.length > limit ? page[page.length - 1] : undefined; const reactions = await this.prisma.commentReaction.findMany({ where: { commentId: { in: page.map((t) => t.id) } }, include: {} }); const byThread = new Map(); for (const r of reactions) { if (!r.commentId) continue; (byThread.get(r.commentId) ?? byThread.set(r.commentId, []).get(r.commentId)!).push({ emoji: r.emoji, userId: r.userId }); } return { data: page.map((t) => ({ ...t, reactions: byThread.get(t.id) ?? [] })), nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id, position: next.id })).toString("base64url") : null }; } async create(userId: string, input: CreateCommentInput, req: Request) { const target = await this.resolveTarget(input); // replying requires the same permission as a new comment await assertCan(this.prisma, { workspaceId: target.workspaceId, userId, resource: { pageId: target.pageId ?? undefined } }, "COMMENT"); const parsed = parseMentions(input.body); if (input.parentId) { const parent = await this.prisma.comment.findUnique({ where: { id: input.parentId } }); if (!parent || parent.workspaceId !== target.workspaceId) throw ApiError.notFound("Parent comment not found."); const reply = await this.prisma.commentReply.create({ data: { commentId: parent.id, body: input.body, mentionIds: parsed.users, createdBy: userId } }); // Mention fan-out (users + teams → per member, deduped) — the reply // link points at the THREAD (the thread is the unit the deep link // opens, R4). await this.materializeMentions(userId, input.body, target.workspaceId, { link: { type: "comment", id: parent.id, pageId: target.pageId ?? undefined } }); // COMMENT notifications for the thread's participants (R4) — the // people already in the thread who are NOT the replier (and not the // mention recipients of THIS reply, who already got a MENTION row // for the same target — one row per (user, target), not two). await this.notifyParticipants(parent.id, userId, input.body, target.pageId); await this.audit.audit(req, req.auth, { action: "comment.reply_created", resourceType: "comment_reply", resourceId: reply.id, workspaceId: target.workspaceId, metadata: { parentCommentId: parent.id } }); await this.events.publish(EVENTS.COMMENT_CREATED, { workspaceId: target.workspaceId, commentId: parent.id, replyId: reply.id, pageId: target.pageId, blockId: target.blockId, recordId: target.recordId, actorId: userId, body: input.body }); return { ...reply, parentCommentId: parent.id }; } const comment = await this.prisma.comment.create({ data: { workspaceId: target.workspaceId, pageId: target.pageId, blockId: target.blockId, recordId: target.recordId, body: input.body, mentionIds: parsed.users, pageMentionIds: [...parsed.pages, ...parsed.databases], createdBy: userId }, include: COMMENT_INCLUDE }); await this.materializeMentions(userId, input.body, target.workspaceId, { link: { type: "comment", id: comment.id, pageId: target.pageId ?? undefined } }); await this.audit.audit(req, req.auth, { action: EVENTS.COMMENT_CREATED, resourceType: "comment", resourceId: comment.id, workspaceId: target.workspaceId, pageId: target.pageId ?? undefined, metadata: { targetType: input.targetType, targetId: input.targetId } }); await this.events.publish(EVENTS.COMMENT_CREATED, { workspaceId: target.workspaceId, commentId: comment.id, pageId: target.pageId, blockId: target.blockId, recordId: target.recordId, actorId: userId, body: input.body }); return comment; } /** * Mention fan-out for a comment/reply body (R4): * - each mentioned USER (except the actor) → a MENTION Notification row * (+ the MentionNotification record — the "mentions of me" surface). * Dedup: an existing UNREAD MENTION row for the same (user, target) * is not duplicated (check-then-insert; the race window is * acceptable — two rapid mentions = two rows, correct anyway). * - each mentioned TEAM → one MENTION row per member (deduped per * (user, target) across users+teams, so a user mentioned directly AND * via a team gets exactly one row — the acceptance's dedup assert). * - @page / @database mentions: stored on the row only (no fan-out — * recorded: pages/databases have no user to notify). */ private async materializeMentions( mentionerId: string, body: string, workspaceId: string, // `pageId` (optional) rides along in the stored link so the web inbox // can deep-link to the comment's page without a comment→page lookup // endpoint (TASK-0094 R5 — recorded). ref: { link: { type: string; id: string; pageId?: string } } ): Promise { const parsed = parseMentions(body); const userTargets = new Set(parsed.users); if (parsed.teams.length > 0) { const members = await this.prisma.teamMember.findMany({ where: { teamId: { in: parsed.teams }, userId: { not: mentionerId } }, select: { userId: true } }); for (const m of members) userTargets.add(m.userId); } if (userTargets.size === 0) return; // One dedup pass for the whole batch (the (user, target) pair). Match on // the target id only (a JSON-equality match would miss rows written // before the link gained `pageId`). const existingRows = await this.prisma.notification.findMany({ where: { userId: { in: [...userTargets] }, type: "MENTION", readAt: null }, select: { userId: true, link: true } }); const already = new Set( existingRows .filter((n) => (n.link as { type?: string; id?: string } | null)?.id === ref.link.id) .map((n) => n.userId) ); const recipients = [...userTargets].filter((id) => !already.has(id)); if (recipients.length === 0) return; const preview = body.slice(0, 160); await this.prisma.$transaction(async (tx) => { for (const userId of recipients) { const mention = await tx.mentionNotification.create({ data: { userId, mentionerId, targetType: "comment", targetId: ref.link.id, context: { bodyPreview: body.slice(0, 120) } as object } }); await this.notifications.notify({ workspaceId, userId, type: "MENTION", title: "You were mentioned in a comment", body: preview, link: ref.link, actorId: mentionerId }); void mention; } }); } /** * COMMENT notifications for a thread's participants on a new reply (R4): * participant set = the thread's distinct commenters + the resolver * (Phase-3 rule), minus the replier (in-app, a user doesn't notify * themselves) and minus the mention recipients of THIS reply (they * already got a MENTION row for the same target — one row per * (user, target), not two). */ private async notifyParticipants( threadId: string, actorId: string, replyBody: string, pageId: string | null ): Promise { const thread = await this.prisma.comment.findUnique({ where: { id: threadId }, include: { replies: { select: { createdBy: true } }, author: { select: { id: true } } } }); if (!thread) return; // The people who just received a MENTION row for this target (from // materializeMentions on the SAME reply body) — they don't ALSO get a // COMMENT row for it. const justMentioned = new Set(parseMentions(replyBody).users); const participants = new Set([ thread.author.id, ...thread.replies.map((r) => r.createdBy), ...(thread.resolvedBy ? [thread.resolvedBy] : []) ]); participants.delete(actorId); const recipients = [...participants].filter((id) => !justMentioned.has(id)); if (recipients.length === 0) return; // The stored link (pageId optional — old rows predate it; the web falls // back to the page list). Dedup by the thread id only: a JSON-equality // match would miss rows written before the link gained `pageId`. const link: { type: string; id: string; pageId?: string } = { type: "comment", id: threadId }; if (pageId) link.pageId = pageId; const existingRows = await this.prisma.notification.findMany({ where: { userId: { in: recipients }, type: "COMMENT", readAt: null }, select: { userId: true, link: true } }); const already = new Set( existingRows .filter((n) => (n.link as { type?: string; id?: string } | null)?.id === threadId) .map((n) => n.userId) ); const fresh = recipients.filter((id) => !already.has(id)); if (fresh.length === 0) return; const threadBody = thread.body.slice(0, 160); for (const userId of fresh) { await this.notifications.notify({ workspaceId: thread.workspaceId, userId, type: "COMMENT", title: "New reply in a comment thread", body: threadBody, link, actorId }); } } async update(commentId: string, userId: string, input: UpdateCommentInput, req: Request) { const comment = await this.prisma.comment.findUnique({ where: { id: commentId } }); if (!comment) throw ApiError.notFound("Comment not found."); if (comment.createdBy !== userId) { await assertRole(this.prisma, { workspaceId: comment.workspaceId, userId }, ["OWNER", "ADMIN"]); } const updated = await this.prisma.comment.update({ where: { id: commentId }, data: { body: input.body, mentionIds: parseMentions(input.body).users } }); await this.audit.audit(req, req.auth, { action: "comment.updated", resourceType: "comment", resourceId: commentId, workspaceId: comment.workspaceId }); return updated; } async remove(commentId: string, userId: string, req: Request) { const comment = await this.prisma.comment.findUnique({ where: { id: commentId } }); if (!comment) throw ApiError.notFound("Comment not found."); if (comment.createdBy !== userId) { await assertRole(this.prisma, { workspaceId: comment.workspaceId, userId }, ["OWNER", "ADMIN"]); } await this.prisma.comment.delete({ where: { id: commentId } }); await this.audit.audit(req, req.auth, { action: "comment.deleted", resourceType: "comment", resourceId: commentId, workspaceId: comment.workspaceId }); await this.events.publish(EVENTS.COMMENT_DELETED, { workspaceId: comment.workspaceId, commentId, pageId: comment.pageId, actorId: userId }); } /** Toggle a reaction (add if absent, remove if present). */ async toggleReaction(commentId: string, userId: string, input: ReactionInput, req: Request) { const comment = await this.prisma.comment.findUnique({ where: { id: commentId } }); if (!comment) throw ApiError.notFound("Comment not found."); await assertCan(this.prisma, { workspaceId: comment.workspaceId, userId, resource: { pageId: comment.pageId ?? undefined } }, "VIEW"); // (Postgres unique indexes treat NULLs as distinct — use findFirst) const existing = await this.prisma.commentReaction.findFirst({ where: { commentId, replyId: null, userId, emoji: input.emoji } }); if (existing) { await this.prisma.commentReaction.delete({ where: { id: existing.id } }); await this.publishReaction(comment, userId, input.emoji, false); return { emoji: input.emoji, active: false }; } await this.prisma.commentReaction.create({ data: { commentId, userId, emoji: input.emoji } }); await this.publishReaction(comment, userId, input.emoji, true); return { emoji: input.emoji, active: true }; } /** The `comment.reaction` fan-out event (R1: the frame's `reacted` kind). */ private async publishReaction( comment: { workspaceId: string; id: string; pageId: string | null }, userId: string, emoji: string, active: boolean ): Promise { await this.events.publish(EVENTS.COMMENT_REACTION, { workspaceId: comment.workspaceId, commentId: comment.id, pageId: comment.pageId, actorId: userId, emoji, active }); } /** Resolve a thread (author or anyone with edit on the page). */ async resolve(commentId: string, userId: string, req: Request) { return this.setResolved(commentId, userId, req, true); } /** Reopen a resolved thread (same guard as resolve — R1: the `reopened` kind). */ async reopen(commentId: string, userId: string, req: Request) { return this.setResolved(commentId, userId, req, false); } private async setResolved(commentId: string, userId: string, req: Request, toResolved: boolean) { const comment = await this.prisma.comment.findUnique({ where: { id: commentId } }); if (!comment) throw ApiError.notFound("Comment not found."); // TASK-0127: actor-aware level (an API key resolves at its scope cap in // its own workspace, never the creator's grants). const level = await actorLevel(this.prisma, { workspaceId: comment.workspaceId, userId, resource: { pageId: comment.pageId ?? undefined } }); if (comment.createdBy !== userId && level !== "EDIT" && level !== "FULL_ACCESS") { throw ApiError.forbidden("Only the author or an editor can resolve this thread.", [ { reason: "requires_EDIT" } ]); } const isResolved = comment.resolvedAt !== null; // Idempotent: resolving a resolved / reopening an open thread is a no-op. if (isResolved === toResolved) return comment; const updated = await this.prisma.comment.update({ where: { id: commentId }, data: { resolvedAt: toResolved ? new Date() : null, resolvedBy: toResolved ? userId : null, status: toResolved ? "resolved" : "open" } }); await this.audit.audit(req, req.auth, { action: toResolved ? "comment.resolved" : "comment.reopened", resourceType: "comment", resourceId: commentId, workspaceId: comment.workspaceId }); // The `comment.resolved` / `comment.reopened` fan-out events (R1: the // frame's resolved/reopened kinds — the thread state flips in the panel). await this.events.publish( toResolved ? EVENTS.COMMENT_RESOLVED : EVENTS.COMMENT_REOPENED, { workspaceId: comment.workspaceId, commentId, pageId: comment.pageId, actorId: userId } ); return updated; } }