1import { Inject, Injectable } from "@nestjs/common";
2import type { Request } from "express";
3
4
5
6import { parseMentions } from "@factory/shared";
7import { PrismaService } from "../../common/prisma.service.js";
8import { AuditService } from "../../common/audit.service.js";
9import { EventBus } from "../../common/event-bus.js";
10import { ApiError } from "../../common/errors.js";
11import { actorLevel, assertCan, assertRole } from "../../lib/authorize.js";
12import { parsePagination } from "../../lib/pagination.js";
13import { EVENTS } from "../../constants.js";
14import { NotificationsService } from "../notifications/notifications.service.js";
15import type {
16 CreateCommentInput,
17 ListCommentsInput,
18 ReactionInput,
19 UpdateCommentInput
20} from "./dto.js";
21
22const COMMENT_INCLUDE = {
23 author: { select: { id: true, name: true, displayName: true, avatarUrl: true } },
24 replies: {
25 orderBy: { createdAt: "asc" as const },
26 include: {
27 author: { select: { id: true, name: true, displayName: true, avatarUrl: true } }
28 }
29 }
30} as const;
31
32@Injectable()
33export class CommentsService {
34 constructor(
35 @Inject(PrismaService) private readonly prisma: PrismaService,
36 @Inject(AuditService) private readonly audit: AuditService,
37 @Inject(EventBus) private readonly events: EventBus,
38 @Inject(NotificationsService) private readonly notifications: NotificationsService
39 ) {}
40
41 private async resolveTarget(input: {
42 targetType: "block" | "record" | "page";
43 targetId: string;
44 }): Promise<{ workspaceId: string; pageId: string | null; blockId: string | null; recordId: string | null }> {
45 if (input.targetType === "block") {
46 const block = await this.prisma.block.findUnique({
47 where: { id: input.targetId },
48 select: { id: true, pageId: true, workspaceId: true, deletedAt: true }
49 });
50 if (!block || block.deletedAt) throw ApiError.notFound("Block not found.");
51 return { workspaceId: block.workspaceId, pageId: block.pageId, blockId: block.id, recordId: null };
52 }
53 if (input.targetType === "record") {
54 const record = await this.prisma.databaseRecord.findUnique({
55 where: { id: input.targetId },
56 select: { id: true, workspaceId: true, pageId: true, archivedAt: true }
57 });
58 if (!record || record.archivedAt) throw ApiError.notFound("Record not found.");
59 return { workspaceId: record.workspaceId, pageId: record.pageId, blockId: null, recordId: record.id };
60 }
61 const page = await this.prisma.page.findUnique({
62 where: { id: input.targetId },
63 select: { id: true, workspaceId: true, deletedAt: true, trashedAt: true }
64 });
65 if (!page || page.deletedAt || page.trashedAt) throw ApiError.notFound("Page not found.");
66 return { workspaceId: page.workspaceId, pageId: page.id, blockId: null, recordId: null };
67 }
68
69 async list(userId: string, query: ListCommentsInput) {
70 const { cursor, limit } = parsePagination(query);
71 const where: Record<string, unknown> = {};
72 if (query.blockId) {
73 const block = await this.prisma.block.findUnique({ where: { id: query.blockId }, select: { workspaceId: true, pageId: true } });
74 if (!block) throw ApiError.notFound("Block not found.");
75 await assertCan(this.prisma, { workspaceId: block.workspaceId, userId, resource: { pageId: block.pageId } }, "VIEW");
76 where.blockId = query.blockId;
77 } else if (query.recordId) {
78 const record = await this.prisma.databaseRecord.findUnique({ where: { id: query.recordId }, select: { workspaceId: true, pageId: true, databaseId: true, id: true } });
79 if (!record) throw ApiError.notFound("Record not found.");
80 where.recordId = query.recordId;
81 await assertCan(this.prisma, { workspaceId: record.workspaceId, userId, resource: { databaseId: record.databaseId, recordId: record.id } }, "VIEW");
82 } else if (query.pageId) {
83 const page = await this.prisma.page.findUnique({ where: { id: query.pageId }, select: { workspaceId: true, id: true } });
84 if (!page) throw ApiError.notFound("Page not found.");
85 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
86 where.pageId = query.pageId;
87 } else {
88 throw ApiError.validation("Provide blockId, recordId or pageId.", [{ issue: "target required" }]);
89 }
90
91 const threads = await this.prisma.comment.findMany({
92 where: { ...where, ...(cursor ? { id: { gt: cursor.id } } : {}) },
93 include: COMMENT_INCLUDE,
94 orderBy: { createdAt: "asc" },
95 take: limit + 1
96 });
97 const page = threads.slice(0, limit);
98 const next = threads.length > limit ? page[page.length - 1] : undefined;
99 const reactions = await this.prisma.commentReaction.findMany({
100 where: { commentId: { in: page.map((t) => t.id) } },
101 include: {}
102 });
103 const byThread = new Map<string, { emoji: string; userId: string }[]>();
104 for (const r of reactions) {
105 if (!r.commentId) continue;
106 (byThread.get(r.commentId) ?? byThread.set(r.commentId, []).get(r.commentId)!).push({ emoji: r.emoji, userId: r.userId });
107 }
108 return {
109 data: page.map((t) => ({
110 ...t,
111 reactions: byThread.get(t.id) ?? []
112 })),
113 nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id, position: next.id })).toString("base64url") : null
114 };
115 }
116
117 async create(userId: string, input: CreateCommentInput, req: Request) {
118 const target = await this.resolveTarget(input);
119
120 await assertCan(this.prisma, { workspaceId: target.workspaceId, userId, resource: { pageId: target.pageId ?? undefined } }, "COMMENT");
121
122 const parsed = parseMentions(input.body);
123
124 if (input.parentId) {
125 const parent = await this.prisma.comment.findUnique({ where: { id: input.parentId } });
126 if (!parent || parent.workspaceId !== target.workspaceId) throw ApiError.notFound("Parent comment not found.");
127 const reply = await this.prisma.commentReply.create({
128 data: {
129 commentId: parent.id,
130 body: input.body,
131 mentionIds: parsed.users,
132 createdBy: userId
133 }
134 });
135
136
137
138 await this.materializeMentions(userId, input.body, target.workspaceId, {
139 link: { type: "comment", id: parent.id, pageId: target.pageId ?? undefined }
140 });
141
142
143
144
145 await this.notifyParticipants(parent.id, userId, input.body, target.pageId);
146 await this.audit.audit(req, req.auth, {
147 action: "comment.reply_created",
148 resourceType: "comment_reply",
149 resourceId: reply.id,
150 workspaceId: target.workspaceId,
151 metadata: { parentCommentId: parent.id }
152 });
153 await this.events.publish(EVENTS.COMMENT_CREATED, {
154 workspaceId: target.workspaceId,
155 commentId: parent.id,
156 replyId: reply.id,
157 pageId: target.pageId,
158 blockId: target.blockId,
159 recordId: target.recordId,
160 actorId: userId,
161 body: input.body
162 });
163 return { ...reply, parentCommentId: parent.id };
164 }
165
166 const comment = await this.prisma.comment.create({
167 data: {
168 workspaceId: target.workspaceId,
169 pageId: target.pageId,
170 blockId: target.blockId,
171 recordId: target.recordId,
172 body: input.body,
173 mentionIds: parsed.users,
174 pageMentionIds: [...parsed.pages, ...parsed.databases],
175 createdBy: userId
176 },
177 include: COMMENT_INCLUDE
178 });
179 await this.materializeMentions(userId, input.body, target.workspaceId, {
180 link: { type: "comment", id: comment.id, pageId: target.pageId ?? undefined }
181 });
182 await this.audit.audit(req, req.auth, {
183 action: EVENTS.COMMENT_CREATED,
184 resourceType: "comment",
185 resourceId: comment.id,
186 workspaceId: target.workspaceId,
187 pageId: target.pageId ?? undefined,
188 metadata: { targetType: input.targetType, targetId: input.targetId }
189 });
190 await this.events.publish(EVENTS.COMMENT_CREATED, {
191 workspaceId: target.workspaceId,
192 commentId: comment.id,
193 pageId: target.pageId,
194 blockId: target.blockId,
195 recordId: target.recordId,
196 actorId: userId,
197 body: input.body
198 });
199 return comment;
200 }
201
202
215 private async materializeMentions(
216 mentionerId: string,
217 body: string,
218 workspaceId: string,
219
220
221
222 ref: { link: { type: string; id: string; pageId?: string } }
223 ): Promise<void> {
224 const parsed = parseMentions(body);
225 const userTargets = new Set<string>(parsed.users);
226 if (parsed.teams.length > 0) {
227 const members = await this.prisma.teamMember.findMany({
228 where: { teamId: { in: parsed.teams }, userId: { not: mentionerId } },
229 select: { userId: true }
230 });
231 for (const m of members) userTargets.add(m.userId);
232 }
233 if (userTargets.size === 0) return;
234
235
236
237
238 const existingRows = await this.prisma.notification.findMany({
239 where: {
240 userId: { in: [...userTargets] },
241 type: "MENTION",
242 readAt: null
243 },
244 select: { userId: true, link: true }
245 });
246 const already = new Set(
247 existingRows
248 .filter((n) => (n.link as { type?: string; id?: string } | null)?.id === ref.link.id)
249 .map((n) => n.userId)
250 );
251 const recipients = [...userTargets].filter((id) => !already.has(id));
252 if (recipients.length === 0) return;
253
254 const preview = body.slice(0, 160);
255 await this.prisma.$transaction(async (tx) => {
256 for (const userId of recipients) {
257 const mention = await tx.mentionNotification.create({
258 data: {
259 userId,
260 mentionerId,
261 targetType: "comment",
262 targetId: ref.link.id,
263 context: { bodyPreview: body.slice(0, 120) } as object
264 }
265 });
266 await this.notifications.notify({
267 workspaceId,
268 userId,
269 type: "MENTION",
270 title: "You were mentioned in a comment",
271 body: preview,
272 link: ref.link,
273 actorId: mentionerId
274 });
275 void mention;
276 }
277 });
278 }
279
280
288 private async notifyParticipants(
289 threadId: string,
290 actorId: string,
291 replyBody: string,
292 pageId: string | null
293 ): Promise<void> {
294 const thread = await this.prisma.comment.findUnique({
295 where: { id: threadId },
296 include: {
297 replies: { select: { createdBy: true } },
298 author: { select: { id: true } }
299 }
300 });
301 if (!thread) return;
302
303
304
305
306 const justMentioned = new Set(parseMentions(replyBody).users);
307
308 const participants = new Set<string>([
309 thread.author.id,
310 ...thread.replies.map((r) => r.createdBy),
311 ...(thread.resolvedBy ? [thread.resolvedBy] : [])
312 ]);
313 participants.delete(actorId);
314 const recipients = [...participants].filter((id) => !justMentioned.has(id));
315 if (recipients.length === 0) return;
316
317
318
319
320 const link: { type: string; id: string; pageId?: string } = { type: "comment", id: threadId };
321 if (pageId) link.pageId = pageId;
322 const existingRows = await this.prisma.notification.findMany({
323 where: {
324 userId: { in: recipients },
325 type: "COMMENT",
326 readAt: null
327 },
328 select: { userId: true, link: true }
329 });
330 const already = new Set(
331 existingRows
332 .filter((n) => (n.link as { type?: string; id?: string } | null)?.id === threadId)
333 .map((n) => n.userId)
334 );
335 const fresh = recipients.filter((id) => !already.has(id));
336 if (fresh.length === 0) return;
337
338 const threadBody = thread.body.slice(0, 160);
339 for (const userId of fresh) {
340 await this.notifications.notify({
341 workspaceId: thread.workspaceId,
342 userId,
343 type: "COMMENT",
344 title: "New reply in a comment thread",
345 body: threadBody,
346 link,
347 actorId
348 });
349 }
350 }
351
352 async update(commentId: string, userId: string, input: UpdateCommentInput, req: Request) {
353 const comment = await this.prisma.comment.findUnique({ where: { id: commentId } });
354 if (!comment) throw ApiError.notFound("Comment not found.");
355 if (comment.createdBy !== userId) {
356 await assertRole(this.prisma, { workspaceId: comment.workspaceId, userId }, ["OWNER", "ADMIN"]);
357 }
358 const updated = await this.prisma.comment.update({
359 where: { id: commentId },
360 data: { body: input.body, mentionIds: parseMentions(input.body).users }
361 });
362 await this.audit.audit(req, req.auth, {
363 action: "comment.updated",
364 resourceType: "comment",
365 resourceId: commentId,
366 workspaceId: comment.workspaceId
367 });
368 return updated;
369 }
370
371 async remove(commentId: string, userId: string, req: Request) {
372 const comment = await this.prisma.comment.findUnique({ where: { id: commentId } });
373 if (!comment) throw ApiError.notFound("Comment not found.");
374 if (comment.createdBy !== userId) {
375 await assertRole(this.prisma, { workspaceId: comment.workspaceId, userId }, ["OWNER", "ADMIN"]);
376 }
377 await this.prisma.comment.delete({ where: { id: commentId } });
378 await this.audit.audit(req, req.auth, {
379 action: "comment.deleted",
380 resourceType: "comment",
381 resourceId: commentId,
382 workspaceId: comment.workspaceId
383 });
384 await this.events.publish(EVENTS.COMMENT_DELETED, {
385 workspaceId: comment.workspaceId,
386 commentId,
387 pageId: comment.pageId,
388 actorId: userId
389 });
390 }
391
392
393 async toggleReaction(commentId: string, userId: string, input: ReactionInput, req: Request) {
394 const comment = await this.prisma.comment.findUnique({ where: { id: commentId } });
395 if (!comment) throw ApiError.notFound("Comment not found.");
396 await assertCan(this.prisma, { workspaceId: comment.workspaceId, userId, resource: { pageId: comment.pageId ?? undefined } }, "VIEW");
397
398 const existing = await this.prisma.commentReaction.findFirst({
399 where: { commentId, replyId: null, userId, emoji: input.emoji }
400 });
401 if (existing) {
402 await this.prisma.commentReaction.delete({ where: { id: existing.id } });
403 await this.publishReaction(comment, userId, input.emoji, false);
404 return { emoji: input.emoji, active: false };
405 }
406 await this.prisma.commentReaction.create({
407 data: { commentId, userId, emoji: input.emoji }
408 });
409 await this.publishReaction(comment, userId, input.emoji, true);
410 return { emoji: input.emoji, active: true };
411 }
412
413
414 private async publishReaction(
415 comment: { workspaceId: string; id: string; pageId: string | null },
416 userId: string,
417 emoji: string,
418 active: boolean
419 ): Promise<void> {
420 await this.events.publish(EVENTS.COMMENT_REACTION, {
421 workspaceId: comment.workspaceId,
422 commentId: comment.id,
423 pageId: comment.pageId,
424 actorId: userId,
425 emoji,
426 active
427 });
428 }
429
430
431 async resolve(commentId: string, userId: string, req: Request) {
432 return this.setResolved(commentId, userId, req, true);
433 }
434
435
436 async reopen(commentId: string, userId: string, req: Request) {
437 return this.setResolved(commentId, userId, req, false);
438 }
439
440 private async setResolved(commentId: string, userId: string, req: Request, toResolved: boolean) {
441 const comment = await this.prisma.comment.findUnique({ where: { id: commentId } });
442 if (!comment) throw ApiError.notFound("Comment not found.");
443
444
445 const level = await actorLevel(this.prisma, {
446 workspaceId: comment.workspaceId,
447 userId,
448 resource: { pageId: comment.pageId ?? undefined }
449 });
450 if (comment.createdBy !== userId && level !== "EDIT" && level !== "FULL_ACCESS") {
451 throw ApiError.forbidden("Only the author or an editor can resolve this thread.", [
452 { reason: "requires_EDIT" }
453 ]);
454 }
455 const isResolved = comment.resolvedAt !== null;
456
457 if (isResolved === toResolved) return comment;
458 const updated = await this.prisma.comment.update({
459 where: { id: commentId },
460 data: {
461 resolvedAt: toResolved ? new Date() : null,
462 resolvedBy: toResolved ? userId : null,
463 status: toResolved ? "resolved" : "open"
464 }
465 });
466 await this.audit.audit(req, req.auth, {
467 action: toResolved ? "comment.resolved" : "comment.reopened",
468 resourceType: "comment",
469 resourceId: commentId,
470 workspaceId: comment.workspaceId
471 });
472
473
474 await this.events.publish(
475 toResolved ? EVENTS.COMMENT_RESOLVED : EVENTS.COMMENT_REOPENED,
476 {
477 workspaceId: comment.workspaceId,
478 commentId,
479 pageId: comment.pageId,
480 actorId: userId
481 }
482 );
483 return updated;
484 }
485}
486