diff --git a/apps/queue/src/notifications/services/channels/__tests__/email.test.ts b/apps/queue/src/notifications/services/channels/__tests__/email.test.ts index 441964ccb..cd7c6abdc 100644 --- a/apps/queue/src/notifications/services/channels/__tests__/email.test.ts +++ b/apps/queue/src/notifications/services/channels/__tests__/email.test.ts @@ -3,20 +3,21 @@ */ import { Constants } from "@courselit/common-models"; -import { getNotificationMessageAndHref } from "@courselit/common-logic"; +import { getNotificationEmailContent } from "@courselit/common-logic"; +import { JSDOM } from "jsdom"; import { addMailJob } from "../../../../domain/handler"; import { EmailChannel } from "../email"; jest.mock("@courselit/common-logic", () => ({ - getNotificationMessageAndHref: jest.fn(), + getNotificationEmailContent: jest.fn(), })); jest.mock("../../../../domain/handler", () => ({ addMailJob: jest.fn(), })); -const mockedGetNotificationMessageAndHref = - getNotificationMessageAndHref as jest.Mock; +const mockedGetNotificationEmailContent = + getNotificationEmailContent as jest.Mock; const mockedAddMailJob = addMailJob as jest.Mock; function makePayload(overrides: Partial = {}): any { @@ -53,6 +54,21 @@ function makePayload(overrides: Partial = {}): any { }; } +function getVisibleEmailDocument(html: string) { + const document = new JSDOM(html).window.document; + document + .querySelectorAll("[data-skip-in-text]") + .forEach((element) => element.remove()); + document + .querySelectorAll("br") + .forEach((element) => element.replaceWith(" ")); + return document; +} + +function getVisibleEmailText(document: Document) { + return document.body.textContent?.replace(/\s+/g, " ").trim() || ""; +} + describe("EmailChannel", () => { beforeEach(() => { jest.clearAllMocks(); @@ -61,7 +77,9 @@ describe("EmailChannel", () => { process.env.MULTITENANT = "true"; process.env.EMAIL_FROM = "hello@courselit.test"; - mockedGetNotificationMessageAndHref.mockResolvedValue({ + mockedGetNotificationEmailContent.mockResolvedValue({ + subject: + "Test Instructor granted your request to join Test Course community", message: "Test Instructor granted your request to join Test Course community", href: "https://school.courselit.test/community/post", @@ -71,7 +89,7 @@ describe("EmailChannel", () => { it("renders a notification email with actor avatar, CTA, footer unsubscribe, branding, and unsubscribe headers", async () => { await new EmailChannel().send(makePayload()); - expect(mockedGetNotificationMessageAndHref).toHaveBeenCalledWith( + expect(mockedGetNotificationEmailContent).toHaveBeenCalledWith( expect.objectContaining({ recipientPermissions: ["course:manage_any"], }), @@ -134,7 +152,9 @@ describe("EmailChannel", () => { }); it("renders dynamic notification text as text instead of Markdown links", async () => { - mockedGetNotificationMessageAndHref.mockResolvedValue({ + mockedGetNotificationEmailContent.mockResolvedValue({ + subject: + "Test Instructor replied to [a post](https://evil.example)", message: "Test Instructor replied to [a post](https://evil.example)", href: "https://school.courselit.test/community/post", @@ -243,7 +263,8 @@ describe("EmailChannel", () => { }); it("does not send when notification details do not include a message and href", async () => { - mockedGetNotificationMessageAndHref.mockResolvedValue({ + mockedGetNotificationEmailContent.mockResolvedValue({ + subject: "", message: "", href: "", }); @@ -252,4 +273,83 @@ describe("EmailChannel", () => { expect(mockedAddMailJob).not.toHaveBeenCalled(); }); + + it("renders conversation details and uses a discussion CTA", async () => { + mockedGetNotificationEmailContent.mockResolvedValue({ + subject: "Test Instructor commented on a post", + message: "Test Instructor commented on a post", + href: "https://school.courselit.test/community/post", + threadTitle: "A discussion title", + parentAuthorName: "Jamie", + parentText: "The parent comment", + commentText: "A new comment\nwith another line", + conversationLabel: "New reply", + replyContext: { + community: { + communityId: "community-id", + postId: "post-id", + parentCommentId: "comment-id", + }, + }, + }); + + await new EmailChannel().send(makePayload()); + + const mail = mockedAddMailJob.mock.calls[0][0]; + const document = getVisibleEmailDocument(mail.body); + const visibleText = getVisibleEmailText(document); + + expect(mail.subject).toBe("Test Instructor commented on a post"); + expect(visibleText).toContain("Test Instructor · New reply"); + expect(visibleText).toContain("Jamie · Earlier comment"); + expect(visibleText).toContain("The parent comment"); + expect(visibleText).toContain("A new comment with another line"); + expect(visibleText).not.toContain( + "Test Instructor commented on a post", + ); + expect( + Array.from(document.querySelectorAll("div")).some( + (element) => + element.textContent?.includes("Earlier comment") && + element + .getAttribute("style") + ?.includes("background-color:#f7f7f7"), + ), + ).toBe(true); + expect(visibleText).toContain("View discussion"); + expect(visibleText).not.toContain("View notification"); + }); + + it("labels original post context in new-comment emails", async () => { + mockedGetNotificationEmailContent.mockResolvedValue({ + subject: "Test Instructor commented on your post", + message: "Test Instructor commented on your post", + href: "https://school.courselit.test/community/post", + threadTitle: "A discussion title", + parentAuthorName: "Jamie", + parentText: "The original post body", + parentLabel: "Original post", + commentText: "A new comment", + conversationLabel: "New comment", + replyContext: { + community: { + communityId: "community-id", + postId: "post-id", + parentCommentId: "comment-id", + }, + }, + }); + + await new EmailChannel().send(makePayload()); + + const mail = mockedAddMailJob.mock.calls[0][0]; + const visibleText = getVisibleEmailText( + getVisibleEmailDocument(mail.body), + ); + + expect(visibleText).toContain("Jamie · Original post"); + expect(visibleText).toContain("The original post body"); + expect(visibleText).toContain("A new comment"); + expect(visibleText).not.toContain("Earlier comment"); + }); }); diff --git a/apps/queue/src/notifications/services/channels/__tests__/get-notification-email-content.test.ts b/apps/queue/src/notifications/services/channels/__tests__/get-notification-email-content.test.ts new file mode 100644 index 000000000..1ce6fce11 --- /dev/null +++ b/apps/queue/src/notifications/services/channels/__tests__/get-notification-email-content.test.ts @@ -0,0 +1,585 @@ +/** + * @jest-environment node + */ + +import { + getNotificationEmailContent, + type NotificationEntityResolver, +} from "@courselit/common-logic"; +import { Constants } from "@courselit/common-models"; +import { + CommunityPostSchema, + CommunitySchema, + CourseSchema, + ProductDiscussionCommentSchema, + ProductDiscussionReplySchema, +} from "@courselit/orm-models"; +import mongoose from "mongoose"; + +const CommunityModel: mongoose.Model = + (mongoose.models.Community as mongoose.Model) || + mongoose.model("Community", CommunitySchema); +const CommunityPostModel: mongoose.Model = + (mongoose.models.CommunityPost as mongoose.Model) || + mongoose.model("CommunityPost", CommunityPostSchema); +const CourseModel: mongoose.Model = + (mongoose.models.Course as mongoose.Model) || + mongoose.model("Course", CourseSchema); +const ProductDiscussionCommentModel: mongoose.Model = + (mongoose.models.ProductDiscussionComment as mongoose.Model) || + mongoose.model("ProductDiscussionComment", ProductDiscussionCommentSchema); +const ProductDiscussionReplyModel: mongoose.Model = + (mongoose.models.ProductDiscussionReply as mongoose.Model) || + mongoose.model("ProductDiscussionReply", ProductDiscussionReplySchema); + +function createResolver(): NotificationEntityResolver { + return { + getCommunity: jest + .fn() + .mockResolvedValue({ communityId: "community-1", name: "General" }), + getPost: jest.fn().mockResolvedValue({ + postId: "post-1", + title: "Welcome to the community", + userId: "author-1", + communityId: "community-1", + content: "The full post body", + }), + getComment: jest.fn().mockResolvedValue({ + commentId: "comment-1", + userId: "parent-author", + content: "A parent comment", + postId: "post-1", + communityId: "community-1", + replies: [ + { + replyId: "reply-1", + userId: "reply-author", + content: "A reply body", + parentReplyId: "reply-0", + }, + { + replyId: "reply-0", + userId: "grandparent-author", + content: "An earlier reply", + }, + ], + }), + getCourse: jest.fn().mockResolvedValue({ + courseId: "course-1", + title: "Course discussion", + slug: "course-discussion", + }), + getDiscussionComment: jest.fn().mockResolvedValue({ + commentId: "discussion-comment-1", + userId: "discussion-author", + productId: "course-1", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-1", + content: { + type: "doc", + content: [{ type: "paragraph", text: "A course comment" }], + }, + }), + getDiscussionReply: jest.fn().mockResolvedValue({ + replyId: "discussion-reply-1", + commentId: "discussion-comment-1", + userId: "discussion-reply-author", + productId: "course-1", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-1", + content: { + type: "doc", + content: [{ type: "paragraph", text: "A course reply" }], + }, + }), + }; +} + +describe("getNotificationEmailContent", () => { + afterEach(async () => { + await Promise.all([ + CommunityModel.deleteMany({}), + CommunityPostModel.deleteMany({}), + CourseModel.deleteMany({}), + ProductDiscussionCommentModel.deleteMany({}), + ProductDiscussionReplyModel.deleteMany({}), + ]); + }); + + it("uses the queue-side default resolver when no resolver is supplied", async () => { + const domainId = new mongoose.Types.ObjectId(); + await CommunityModel.create({ + domain: domainId, + communityId: "community-default", + name: "General", + slug: "general", + pageId: "page-default", + }); + await CommunityPostModel.create({ + domain: domainId, + postId: "post-default", + title: "Default resolver post", + userId: "author-1", + communityId: "community-default", + content: "This is the full post body.", + }); + + const content = await getNotificationEmailContent({ + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + entityId: "post-default", + actorName: "Alex", + recipientUserId: "recipient-1", + domainId, + }); + + expect(content).toMatchObject({ + commentText: "This is the full post body.", + threadTitle: "Default resolver post", + replyContext: { + community: { + communityId: "community-default", + postId: "post-default", + }, + }, + }); + }); + + it("does not create an email for a deleted community post", async () => { + const domainId = new mongoose.Types.ObjectId(); + await CommunityModel.create({ + domain: domainId, + communityId: "community-deleted-post", + name: "General", + slug: "general", + pageId: "page-deleted-post", + }); + await CommunityPostModel.create({ + domain: domainId, + postId: "post-deleted", + title: "Deleted post", + userId: "author-1", + communityId: "community-deleted-post", + content: "This text must not be emailed.", + deleted: true, + }); + + const content = await getNotificationEmailContent({ + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + entityId: "post-deleted", + actorName: "Alex", + recipientUserId: "recipient-1", + domainId, + }); + + expect(content).toEqual({ subject: "", message: "", href: "" }); + }); + + it("uses the queue-side default resolver for a product-discussion reply", async () => { + const domainId = new mongoose.Types.ObjectId(); + await CourseModel.create({ + domain: domainId, + courseId: "course-default", + title: "Default resolver course", + slug: "default-resolver-course", + cost: 0, + costType: "free", + privacy: Constants.ProductAccessType.PUBLIC, + type: Constants.CourseType.COURSE, + creatorId: "creator-1", + }); + await ProductDiscussionCommentModel.create({ + domain: domainId, + commentId: "discussion-comment-default", + userId: "parent-author", + productId: "course-default", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-default", + content: "The parent discussion comment.", + }); + await ProductDiscussionReplyModel.create({ + domain: domainId, + replyId: "discussion-reply-default", + commentId: "discussion-comment-default", + userId: "reply-author", + productId: "course-default", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-default", + content: "The product-discussion reply.", + }); + + const content = await getNotificationEmailContent({ + activityType: + Constants.ActivityType.COURSE_DISCUSSION_COMMENT_CREATED, + entityId: "discussion-reply-default", + actorName: "Alex", + recipientUserId: "recipient-1", + domainId, + metadata: { + eventType: "reply_created", + courseId: "course-default", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-default", + commentId: "discussion-comment-default", + replyId: "discussion-reply-default", + }, + }); + + expect(content).toMatchObject({ + commentText: "The product-discussion reply.", + parentText: "The parent discussion comment.", + threadTitle: "Default resolver course", + conversationLabel: "New reply", + replyContext: { + product: { + productId: "course-default", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-default", + commentId: "discussion-comment-default", + parentReplyId: "discussion-reply-default", + }, + }, + }); + }); + + it("does not create an email for a deleted product-discussion reply", async () => { + const domainId = new mongoose.Types.ObjectId(); + await CourseModel.create({ + domain: domainId, + courseId: "course-deleted-reply", + title: "Deleted reply course", + slug: "deleted-reply-course", + cost: 0, + costType: "free", + privacy: Constants.ProductAccessType.PUBLIC, + type: Constants.CourseType.COURSE, + creatorId: "creator-1", + }); + await ProductDiscussionCommentModel.create({ + domain: domainId, + commentId: "discussion-comment-deleted-reply", + userId: "parent-author", + productId: "course-deleted-reply", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-deleted-reply", + content: "A parent comment.", + }); + await ProductDiscussionReplyModel.create({ + domain: domainId, + replyId: "discussion-reply-deleted", + commentId: "discussion-comment-deleted-reply", + userId: "reply-author", + productId: "course-deleted-reply", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-deleted-reply", + content: "This text must not be emailed.", + deleted: true, + }); + + const content = await getNotificationEmailContent({ + activityType: + Constants.ActivityType.COURSE_DISCUSSION_COMMENT_CREATED, + entityId: "discussion-reply-deleted", + actorName: "Alex", + recipientUserId: "recipient-1", + domainId, + metadata: { + eventType: "reply_created", + courseId: "course-deleted-reply", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-deleted-reply", + commentId: "discussion-comment-deleted-reply", + replyId: "discussion-reply-deleted", + }, + }); + + expect(content).toEqual({ subject: "", message: "", href: "" }); + }); + + it("adds the post body and a top-level community reply context", async () => { + const resolver = createResolver(); + (resolver.getPost as jest.Mock).mockResolvedValue({ + postId: "post-1", + title: "Welcome to the community", + userId: "author-1", + communityId: "community-1", + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "First line" }, + { type: "hardBreak" }, + { type: "text", text: "Second line" }, + ], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Third paragraph" }], + }, + ], + }, + }); + + const content = await getNotificationEmailContent({ + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + entityId: "post-1", + actorName: "Alex", + recipientUserId: "recipient-1", + resolver, + }); + + expect(content).toMatchObject({ + subject: "Alex created a post 'Welcome to the commu...' in General", + commentText: "First line\nSecond line\n\nThird paragraph", + threadTitle: "Welcome to the community", + conversationLabel: "New post", + replyContext: { + community: { communityId: "community-1", postId: "post-1" }, + }, + }); + }); + + it("adds the reply body, parent context, and reply coordinates", async () => { + const content = await getNotificationEmailContent({ + activityType: Constants.ActivityType.COMMUNITY_REPLY_CREATED, + entityId: "reply-1", + entityTargetId: "comment-1", + actorName: "Alex", + recipientUserId: "recipient-1", + resolver: createResolver(), + resolveUserName: jest.fn().mockResolvedValue("Jamie"), + }); + + expect(content).toMatchObject({ + commentText: "A reply body", + parentText: "An earlier reply", + parentAuthorName: "Jamie", + threadTitle: "Welcome to the community", + conversationLabel: "New reply", + replyContext: { + community: { + communityId: "community-1", + postId: "post-1", + parentCommentId: "comment-1", + parentReplyId: "reply-1", + }, + }, + }); + }); + + it("does not include a deleted parent reply in the email context", async () => { + const resolver = createResolver(); + (resolver.getComment as jest.Mock).mockResolvedValue({ + commentId: "comment-1", + userId: "parent-author", + content: "A parent comment", + postId: "post-1", + communityId: "community-1", + replies: [ + { + replyId: "reply-1", + userId: "reply-author", + content: "An active reply body", + parentReplyId: "reply-deleted", + }, + { + replyId: "reply-deleted", + userId: "deleted-parent-author", + content: "This text must not be emailed.", + deleted: true, + }, + ], + }); + + const content = await getNotificationEmailContent({ + activityType: Constants.ActivityType.COMMUNITY_REPLY_CREATED, + entityId: "reply-1", + entityTargetId: "comment-1", + actorName: "Alex", + recipientUserId: "recipient-1", + resolver, + resolveUserName: jest.fn(), + }); + + expect(content).toMatchObject({ + commentText: "An active reply body", + parentText: undefined, + parentAuthorName: undefined, + }); + }); + + it("adds the comment body and comment reply coordinates", async () => { + const content = await getNotificationEmailContent({ + activityType: Constants.ActivityType.COMMUNITY_COMMENT_CREATED, + entityId: "comment-1", + actorName: "Alex", + recipientUserId: "recipient-1", + resolver: createResolver(), + resolveUserName: jest.fn().mockResolvedValue("Post author"), + }); + + expect(content).toMatchObject({ + commentText: "A parent comment", + parentText: "The full post body", + parentAuthorName: "Post author", + parentLabel: "Original post", + threadTitle: "Welcome to the community", + conversationLabel: "New comment", + replyContext: { + community: { + communityId: "community-1", + postId: "post-1", + parentCommentId: "comment-1", + }, + }, + }); + }); + + it("extracts course-discussion reply text and its reply context", async () => { + const content = await getNotificationEmailContent({ + activityType: + Constants.ActivityType.COURSE_DISCUSSION_COMMENT_CREATED, + entityId: "discussion-reply-1", + actorName: "Alex", + recipientUserId: "recipient-1", + resolver: createResolver(), + metadata: { + eventType: "reply_created", + courseId: "course-1", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-1", + commentId: "discussion-comment-1", + replyId: "discussion-reply-1", + }, + }); + + expect(content).toMatchObject({ + commentText: "A course reply", + threadTitle: "Course discussion", + conversationLabel: "New reply", + replyContext: { + product: { + productId: "course-1", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-1", + commentId: "discussion-comment-1", + parentReplyId: "discussion-reply-1", + }, + }, + }); + }); + + it("extracts course-discussion comment text and its reply context", async () => { + const content = await getNotificationEmailContent({ + activityType: + Constants.ActivityType.COURSE_DISCUSSION_COMMENT_CREATED, + entityId: "discussion-comment-1", + actorName: "Alex", + recipientUserId: "recipient-1", + resolver: createResolver(), + metadata: { + eventType: "comment_created", + courseId: "course-1", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-1", + commentId: "discussion-comment-1", + }, + }); + + expect(content).toMatchObject({ + commentText: "A course comment", + threadTitle: "Course discussion", + conversationLabel: "New comment", + replyContext: { + product: { + productId: "course-1", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-1", + commentId: "discussion-comment-1", + }, + }, + }); + }); + + it("preserves paragraph and hard-break boundaries in rich discussion content", async () => { + const resolver = createResolver(); + (resolver.getDiscussionComment as jest.Mock).mockResolvedValue({ + commentId: "discussion-comment-1", + userId: "discussion-author", + productId: "course-1", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-1", + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "First line" }, + { type: "hardBreak" }, + { type: "text", text: "Second line" }, + ], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Third paragraph" }], + }, + ], + }, + }); + + const content = await getNotificationEmailContent({ + activityType: + Constants.ActivityType.COURSE_DISCUSSION_COMMENT_CREATED, + entityId: "discussion-comment-1", + actorName: "Alex", + recipientUserId: "recipient-1", + resolver, + metadata: { + eventType: "comment_created", + courseId: "course-1", + entityType: Constants.ProductDiscussionEntityType.LESSON, + entityId: "lesson-1", + commentId: "discussion-comment-1", + }, + }); + + expect(content.commentText).toBe( + "First line\nSecond line\n\nThird paragraph", + ); + }); + + it("reuses resolved entities while deriving the subject and rich content", async () => { + const resolver = createResolver(); + + await getNotificationEmailContent({ + activityType: Constants.ActivityType.COMMUNITY_REPLY_CREATED, + entityId: "reply-1", + entityTargetId: "comment-1", + actorName: "Alex", + recipientUserId: "recipient-1", + resolver, + }); + + expect(resolver.getComment).toHaveBeenCalledTimes(1); + expect(resolver.getPost).toHaveBeenCalledTimes(1); + expect(resolver.getCommunity).toHaveBeenCalledTimes(1); + }); + + it("keeps non-conversation notification content unchanged", async () => { + const content = await getNotificationEmailContent({ + activityType: Constants.ActivityType.ENROLLED, + entityId: "course-1", + actorName: "Alex", + recipientUserId: "recipient-1", + resolver: createResolver(), + }); + + expect(content).toEqual({ + subject: "Alex enrolled in Course discussion", + message: "Alex enrolled in Course discussion", + href: "/dashboard/product/course-1/customers", + }); + }); +}); diff --git a/apps/queue/src/notifications/services/channels/email.ts b/apps/queue/src/notifications/services/channels/email.ts index e98afdb17..92c0d0b9e 100644 --- a/apps/queue/src/notifications/services/channels/email.ts +++ b/apps/queue/src/notifications/services/channels/email.ts @@ -1,4 +1,4 @@ -import { getNotificationMessageAndHref } from "@courselit/common-logic"; +import { getNotificationEmailContent } from "@courselit/common-logic"; import { renderEmailToHtml } from "@courselit/email-editor"; import { getEmailFrom } from "@courselit/utils"; import { addMailJob } from "../../../domain/handler"; @@ -7,6 +7,7 @@ import { getUnsubLink } from "../../../utils/get-unsub-link"; import { ChannelPayload, NotificationChannel } from "./types"; import { getDomainId } from "../../../observability/posthog"; import { buildNotificationEmailTemplate } from "./notification-email-template"; +import UserModel from "../../../domain/model/user"; function getActorAvatarUrl(actor: ChannelPayload["actor"]) { return actor?.avatar?.file || actor?.avatar?.thumbnail || undefined; @@ -27,7 +28,7 @@ export class EmailChannel implements NotificationChannel { payload.actor?.email || payload.actor?.userId || "Someone"; - const notificationDetails = await getNotificationMessageAndHref({ + const notificationDetails = await getNotificationEmailContent({ activityType: payload.activityType, entityId: payload.entityId, actorName, @@ -37,6 +38,16 @@ export class EmailChannel implements NotificationChannel { metadata: payload.metadata, hrefPrefix: getSiteUrl(payload.domain), domainId: payload.domain?._id, + resolveUserName: async (userId) => { + const user = await (UserModel as any) + .findOne( + { domain: payload.domain?._id, userId }, + { _id: 0, name: 1 }, + ) + .lean(); + + return user?.name as string | undefined; + }, }); if (!notificationDetails.message || !notificationDetails.href) { @@ -54,6 +65,13 @@ export class EmailChannel implements NotificationChannel { message: notificationDetails.message, notificationUrl: notificationDetails.href, unsubscribeUrl, + commentText: notificationDetails.commentText, + parentText: notificationDetails.parentText, + parentAuthorName: notificationDetails.parentAuthorName, + parentLabel: notificationDetails.parentLabel, + threadTitle: notificationDetails.threadTitle, + conversationLabel: notificationDetails.conversationLabel, + isConversation: Boolean(notificationDetails.replyContext), hideCourseLitBranding: payload.domain.settings?.hideCourseLitBranding, }), @@ -66,7 +84,7 @@ export class EmailChannel implements NotificationChannel { email: process.env.EMAIL_FROM || "", }), domainId: getDomainId(payload.domain?._id), - subject: notificationDetails.message, + subject: notificationDetails.subject, body, headers: { "List-Unsubscribe": `<${unsubscribeUrl}>`, diff --git a/apps/queue/src/notifications/services/channels/notification-email-template.ts b/apps/queue/src/notifications/services/channels/notification-email-template.ts index ee789d04d..c6b94d1f2 100644 --- a/apps/queue/src/notifications/services/channels/notification-email-template.ts +++ b/apps/queue/src/notifications/services/channels/notification-email-template.ts @@ -6,6 +6,14 @@ interface NotificationEmailTemplateInput { message: string; notificationUrl: string; unsubscribeUrl: string; + commentText?: string; + parentText?: string; + parentAuthorName?: string; + parentLabel?: string; + threadTitle?: string; + conversationLabel?: string; + isConversation?: boolean; + showReplyByEmailHint?: boolean; hideCourseLitBranding?: boolean; } @@ -15,6 +23,10 @@ function encodePlainTextForMarkdown(value: string) { .join(""); } +function encodePlainTextWithLineBreaks(value: string) { + return value.split(/\r?\n/).map(encodePlainTextForMarkdown).join(" \n"); +} + function getSafeAvatarUrl(actorAvatarUrl?: string) { if (!actorAvatarUrl) { return; @@ -40,6 +52,14 @@ export function buildNotificationEmailTemplate({ message, notificationUrl, unsubscribeUrl, + commentText, + parentText, + parentAuthorName, + parentLabel, + threadTitle, + conversationLabel, + isConversation, + showReplyByEmailHint, hideCourseLitBranding, }: NotificationEmailTemplateInput): Email { const safeActorAvatarUrl = getSafeAvatarUrl(actorAvatarUrl); @@ -63,31 +83,109 @@ export function buildNotificationEmailTemplate({ }); } - content.push( - { + content.push({ + blockType: "text", + settings: { + content: isConversation + ? `**${encodePlainTextForMarkdown(actorName)}** · ${encodePlainTextForMarkdown(conversationLabel || "New activity")}` + : `**${encodePlainTextForMarkdown(actorName)}**`, + fontSize: "14px", + lineHeight: "1.4", + paddingTop: safeActorAvatarUrl ? "0px" : "24px", + paddingBottom: isConversation ? "4px" : "10px", + }, + }); + + if (!isConversation) { + content.push({ + blockType: "text", + settings: { + content: encodePlainTextForMarkdown(message), + fontSize: "16px", + lineHeight: "1.6", + paddingTop: "8px", + paddingBottom: "8px", + }, + }); + } + + if (threadTitle) { + content.push({ + blockType: "text", + settings: { + content: isConversation + ? `**${encodePlainTextForMarkdown(threadTitle)}**` + : encodePlainTextForMarkdown(threadTitle), + fontSize: isConversation ? "18px" : "14px", + lineHeight: "1.5", + foregroundColor: isConversation ? "#000000" : "#666666", + paddingTop: isConversation ? "0px" : "12px", + paddingBottom: isConversation ? "20px" : "16px", + }, + }); + } + + if (parentText) { + const contextLabel = parentLabel || "Earlier comment"; + content.push({ + blockType: "text", + settings: { + content: parentAuthorName + ? `**${encodePlainTextForMarkdown(parentAuthorName)}** · ${encodePlainTextForMarkdown(contextLabel)}` + : encodePlainTextForMarkdown(contextLabel), + fontSize: "13px", + lineHeight: "1.5", + foregroundColor: "#666666", + backgroundColor: "#f7f7f7", + paddingTop: "16px", + paddingBottom: "4px", + }, + }); + content.push({ blockType: "text", settings: { - content: `**${encodePlainTextForMarkdown(actorName)}**`, + content: encodePlainTextWithLineBreaks(parentText), fontSize: "14px", - lineHeight: "1.4", - paddingTop: safeActorAvatarUrl ? "0px" : "24px", - paddingBottom: "10px", + lineHeight: "1.5", + foregroundColor: "#666666", + backgroundColor: "#f7f7f7", + paddingTop: "0px", + paddingBottom: "16px", }, - }, - { + }); + } + + if (commentText) { + content.push({ blockType: "text", settings: { - content: encodePlainTextForMarkdown(message), + content: encodePlainTextWithLineBreaks(commentText), fontSize: "16px", lineHeight: "1.6", + paddingTop: parentText ? "24px" : "8px", + paddingBottom: "16px", + }, + }); + } + + if (showReplyByEmailHint) { + content.push({ + blockType: "text", + settings: { + content: "You can reply to this email to respond directly", + fontSize: "13px", + foregroundColor: "#666666", paddingTop: "8px", - paddingBottom: "8px", + paddingBottom: "4px", }, - }, + }); + } + + content.push( { blockType: "link", settings: { - text: "View notification", + text: isConversation ? "View discussion" : "View notification", url: notificationUrl, alignment: "center", isButton: true, @@ -97,7 +195,7 @@ export function buildNotificationEmailTemplate({ buttonPaddingX: "18px", buttonPaddingY: "10px", paddingTop: "12px", - paddingBottom: "56px", + paddingBottom: isConversation ? "32px" : "56px", }, }, { diff --git a/apps/web/components/public/product-discussions/panel.tsx b/apps/web/components/public/product-discussions/panel.tsx index ad7b09382..40ad94942 100644 --- a/apps/web/components/public/product-discussions/panel.tsx +++ b/apps/web/components/public/product-discussions/panel.tsx @@ -1117,7 +1117,7 @@ function getComposerContentError(content: TextEditorContent) { } if ( - extractTextFromTextEditorContent(content).length > + extractTextFromTextEditorContent(content).trim().length > MAX_DISCUSSION_TEXT_LENGTH ) { return COURSE_DISCUSSIONS_CONTENT_TOO_LONG; diff --git a/apps/web/graphql/communities/helpers.ts b/apps/web/graphql/communities/helpers.ts index 5e40b7508..ce81cb3bf 100644 --- a/apps/web/graphql/communities/helpers.ts +++ b/apps/web/graphql/communities/helpers.ts @@ -409,7 +409,7 @@ export async function getCommunityReportContent({ } return { - content: extractTextFromTextEditorContent(content.content), + content: extractTextFromTextEditorContent(content.content).trim(), id: contentId, media: content.media, }; diff --git a/apps/web/graphql/courses/__tests__/logic.test.ts b/apps/web/graphql/courses/__tests__/logic.test.ts index 3fbf30e1e..b118fe2a9 100644 --- a/apps/web/graphql/courses/__tests__/logic.test.ts +++ b/apps/web/graphql/courses/__tests__/logic.test.ts @@ -15,6 +15,7 @@ import PageModel from "@models/Page"; import constants from "@/config/constants"; import { responses } from "@/config/strings"; import { Constants as CommonConstants } from "@courselit/common-models"; +import { extractTextFromTextEditorContent } from "@courselit/utils"; import { getCourse, getCoursesAsAdmin, @@ -436,6 +437,28 @@ describe("product discussion validation foundation", () => { ).toThrow(responses.invalid_input); }); + it("preserves paragraph and hard-break boundaries when extracting editor text", () => { + expect( + extractTextFromTextEditorContent({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "First line" }, + { type: "hardBreak" }, + { type: "text", text: "Second line" }, + ], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Third paragraph" }], + }, + ], + }), + ).toBe("First line\nSecond line\n\nThird paragraph"); + }); + it("records allowed rate limit events and rejects actions over the limit", async () => { const input = { domain: testDomain._id, @@ -1309,6 +1332,38 @@ describe("product discussion comment and reply logic", () => { ).rejects.toThrow(responses.action_not_allowed); }); + it("rejects duplicate discussion content with different paragraph formatting", async () => { + await createDiscussionComment({ + ctx, + productId: courseId, + entityType: CommonConstants.ProductDiscussionEntityType.LESSON, + entityId: lessonId, + content: doc("Duplicate content"), + }); + + await expect( + createDiscussionComment({ + ctx, + productId: courseId, + entityType: CommonConstants.ProductDiscussionEntityType.LESSON, + entityId: lessonId, + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Duplicate" }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "content" }], + }, + ], + }, + }), + ).rejects.toThrow(responses.action_not_allowed); + }); + it("marks liked comments/replies and redacts deleted content in learner lists", async () => { const comment = await createDiscussionComment({ ctx, diff --git a/apps/web/graphql/product-discussions/helpers.ts b/apps/web/graphql/product-discussions/helpers.ts index b83548ce6..9038fb3cb 100644 --- a/apps/web/graphql/product-discussions/helpers.ts +++ b/apps/web/graphql/product-discussions/helpers.ts @@ -151,8 +151,8 @@ export function validateDiscussionContent(content: unknown): TextEditorContent { throw new Error(responses.invalid_input); } - const text = extractTextFromTextEditorContent(doc); - if (!text.trim() || text.length > MAX_DISCUSSION_TEXT_LENGTH) { + const text = extractTextFromTextEditorContent(doc).trim(); + if (!text || text.length > MAX_DISCUSSION_TEXT_LENGTH) { throw new Error(responses.invalid_input); } diff --git a/apps/web/graphql/product-discussions/logic.ts b/apps/web/graphql/product-discussions/logic.ts index f11543721..f84886ea1 100644 --- a/apps/web/graphql/product-discussions/logic.ts +++ b/apps/web/graphql/product-discussions/logic.ts @@ -1333,7 +1333,11 @@ async function incrementLikesCount({ function getContentFingerprint(content: TextEditorContent) { return Buffer.from( - extractTextFromTextEditorContent(content).trim().toLowerCase(), + extractTextFromTextEditorContent(content) + .normalize("NFKC") + .trim() + .toLowerCase() + .replace(/\s+/g, " "), ).toString("base64"); } diff --git a/apps/web/graphql/product-discussions/types.ts b/apps/web/graphql/product-discussions/types.ts index 5684eac81..e935010bc 100644 --- a/apps/web/graphql/product-discussions/types.ts +++ b/apps/web/graphql/product-discussions/types.ts @@ -316,7 +316,7 @@ const productDiscussionReport = new GraphQLObjectType({ replyId: report.contentId, }); if (!item) return null; - return extractTextFromTextEditorContent(item.content); + return extractTextFromTextEditorContent(item.content).trim(); }, }, authorName: { diff --git a/docs/wip/community-replies-from-email.md b/docs/wip/community-replies-from-email.md new file mode 100644 index 000000000..54d90e9e8 --- /dev/null +++ b/docs/wip/community-replies-from-email.md @@ -0,0 +1,114 @@ +# Issue #811 — Enhanced conversation email notifications & reply-by-email + +## Context + +GitHub issue [#811](https://github.com/codelitdev/courselit/issues/811): notification emails for Community Discussions and Product Discussions are today one-line summaries ("X commented on your post 'Y' in Z") with a "View notification" button — no comment content, no thread context. The issue asks for: + +1. **Richer notification emails** — author, thread title, parent-comment context, the comment content itself, and a clear CTA. +2. **Reply-by-email** — reply tokens embedded in the Reply-To address on a dedicated reply domain; inbound provider webhooks (vendor-agnostic: SES, Postmark, Mailgun, SendGrid) post replies back into the right thread, attributed to the right user, quoted text stripped, participants notified as usual. + +MVP out of scope: attachments, rich HTML reply parsing, email reactions, advanced threading. + +## Verified architecture facts + +- **Pipeline**: `recordActivity()` (`apps/web/lib/record-activity.ts`) → JWT POST to queue app → `dispatch-notification` BullMQ worker (`apps/queue/src/notifications/worker/dispatch-notification.ts`) fans out per `metadata.forUserIds` gated by `NotificationPreferenceModel` → `AppChannel` / `EmailChannel` → `mail` queue → nodemailer SMTP. +- **`EmailChannel`** (`apps/queue/src/notifications/services/channels/email.ts`): calls `getNotificationMessageAndHref()` (`packages/common-logic/src/utils/get-notification-message-and-href.ts`), builds email via `buildNotificationEmailTemplate()` (`apps/queue/src/notifications/services/channels/notification-email-template.ts`), passes arbitrary `headers` to `addMailJob` (already sets `List-Unsubscribe`) — so `Reply-To` needs no mail-pipeline changes. +- **The queue already re-fetches entities at send time** via `createNotificationEntityResolver()` (`packages/common-logic/src/utils/notification-entity-resolver.ts`, lazy mongoose models from `@courselit/orm-models`); `getComment()` already returns comment content, postId, communityId, and embedded replies with content. Verified. +- **Community**: comment/reply content is a **plain string**; `postComment()` (`apps/web/graphql/communities/logic.ts:1686`) handles both comment and reply, enforcing membership + `MembershipRole.COMMENT`, auto-subscribe, and `recordActivity`. +- **Product discussions** (separate collections): content is `TextEditorContent`; `createDiscussionComment()`/`createDiscussionReply()` (`apps/web/graphql/product-discussions/logic.ts`) enforce enrollment, content validation, and rate limits. Converters already exist and are verified: `normalizeTextEditorContent` / `extractTextFromTextEditorContent` in `@courselit/utils`. +- Both create-functions need only a synthesized `GQLContext` `{ user, subdomain, address }` (`apps/web/models/GQLContext.ts`) — an inbound handler reuses them wholesale, inheriting all permission checks, rate limits, subscriptions, and notification fan-out. +- **Constraint**: RFC 5321 caps the email local-part at 64 octets → a JWT cannot fit in Reply-To → opaque DB token (precedent: `DownloadLinkSchema` with TTL in orm-models). +- Tenant for inbound webhooks must come from the token (providers post to one shared URL; the `proxy.ts` domain header is host-based). +- Tests: jest + `@shelf/jest-mongodb` in both `apps/web` and `apps/queue`; reference: `apps/web/graphql/communities/__tests__/logic.test.ts` (node env, real in-memory-Mongo models, `jest.mock("@/services/queue")`). + +--- + +## Phase 1 — Richer emails (Part A, independently shippable — tracked as [#825](https://github.com/codelitdev/courselit/issues/825)) + +**Decision: re-fetch content in the queue worker (thin metadata)** — the resolver pattern already exists and fetches comment content; zero changes to `recordActivity` callers, no payload bloat in Redis, no stale content. The APP channel stays byte-for-byte unchanged. + +**Constraint: content richness is per-channel.** The in-app notification panel must stay compact (current one-liners with `truncate(20)` titles) to avoid bloating the UI — only emails get the full content blocks. This falls out of the design: the panel/APP channel keeps `getNotificationMessageAndHref()` as-is; only `EmailChannel` adopts `getNotificationEmailContent()`. + +1. **Extend `NotificationEntityResolver`** (`packages/common-logic/src/utils/notification-entity-resolver.ts`) with optional `getDiscussionComment(commentId, domainId)` and `getDiscussionReply(replyId, domainId)` using the same lazy-model pattern with product-discussion schemas from orm-models. Also add `content: 1` to the `getPost()` projection (community post `content` is `TextEditorContent | string` — handle both when extracting text). +2. **New `packages/common-logic/src/utils/get-notification-email-content.ts`** (export from the barrel): + - Returns `NotificationEmailContent { subject, message, href, commentText?, parentText?, parentAuthorName?, threadTitle?, replyContext? }`. + - Internally calls `getNotificationMessageAndHref()` for `message`/`href` (single source of truth). + - Populates extras only for the five conversation activity types: + - `COMMUNITY_POST_CREATED` — post body excerpt (string or via `extractTextFromTextEditorContent`) + post title; `replyContext.community = { communityId, postId }` (no `parentCommentId` — an email reply becomes a top-level comment on the post). + - `COMMUNITY_COMMENT_CREATED` — comment content + post title; `replyContext.community = { communityId, postId, parentCommentId: entityId }`. + - `COMMUNITY_REPLY_CREATED` — reply content, parent (reply or comment) excerpt; `replyContext.community = { …, parentCommentId: commentId, parentReplyId: replyId }`. + - `COURSE_DISCUSSION_COMMENT_CREATED` (eventType comment_created | reply_created) — content via `extractTextFromTextEditorContent`; course title; `replyContext.product = { productId, entityType, entityId, commentId, parentReplyId? }`. + - Caps: commentText ~1000 chars, parentText ~200 chars. Optional `resolveUserName(userId)` callback for parent-author names (queue passes a UserModel lookup). + - All other activity types: output identical to today. + - `ReplyByEmailContext` = thread coordinates consumed by Phase 2 token minting. +3. **Extend the template** (`apps/queue/src/notifications/services/channels/notification-email-template.ts`): parent-context block (small gray "In reply to X: …"), comment-body block (15px, line breaks preserved — verify markdown hard-break behavior in `renderEmailToHtml`), CTA text "View discussion" for conversation types, and a "You can reply to this email to respond directly" footer only when reply-by-email is active. All content through the existing `encodePlainTextForMarkdown`. +4. **Wire `EmailChannel`** (`apps/queue/src/notifications/services/channels/email.ts:30`): swap `getNotificationMessageAndHref` → `getNotificationEmailContent`; pass new fields to the template; subject = `content.subject`. + - **Subject strategy**: keep `subject = one-line message` (actor + action + truncated title — good inbox scanability). Alternative `Re: ""` for mail-client threading noted; can revisit. + +## Phase 2 — Reply tokens + Reply-To (Part B) + +**Decision: opaque DB token** (an HMAC `id.sig` scheme can't fit the thread coordinates in 64 octets anyway). 20 random bytes → 32-char string; `reply+<token>` ≈ 38 octets. + +5. **New schema `packages/orm-models/src/models/email-reply-token.ts`** (mirror `download-link.ts`, export from index): + - `{ domain, token (unique), userId, kind: "community"|"product", community?: {communityId, postId, parentCommentId?, parentReplyId?}, product?: {productId, entityType, entityId, commentId, parentReplyId?}, contextKey, expiresAt (TTL index), createdAt }` (`parentCommentId` absent = reply targets the post itself as a top-level comment). + - Unique index `{ domain, userId, contextKey }` (precomputed context string) → minting is an idempotent upsert. + - Model registrations: `apps/queue/src/domain/model/email-reply-token.ts` and `apps/web/models/EmailReplyToken.ts`. +6. **Minting service `apps/queue/src/notifications/services/email-reply-token.ts`**: + - `mintReplyToken({ domainId, userId, context })` — upsert, sliding 30-day `expiresAt`, returns token. + - `isReplyByEmailEnabled()` = `!!process.env.INBOUND_EMAIL_DOMAIN`; `buildReplyToAddress(token)` = `` `reply+${token}@${INBOUND_EMAIL_DOMAIN}` ``. + - In `EmailChannel.send`: when `replyContext` exists and feature enabled, add `"Reply-To"` to the existing headers object. Env absent → no Reply-To, everything else identical (self-hosters unaffected). Tokens are multi-use (standard reply-address semantics). +7. **Env vars**: `INBOUND_EMAIL_DOMAIN` (queue + web; presence = feature on), `INBOUND_EMAIL_WEBHOOK_SECRET` (web), `MAILGUN_WEBHOOK_SIGNING_KEY` (web, optional). TTL is a constant (30 d), not env. + +## Phase 3 — Inbound endpoint + provider abstraction (Part C) + +8. **Adapter layer, new dir `apps/web/lib/inbound-email/`**: + - `types.ts` — `NormalizedInboundEmail { to[], from, subject?, textBody, strippedReply?, messageId? }`; `InboundEmailAdapter { provider, verify({rawBody, headers, searchParams}), parse({rawBody, contentType}) }`. + - `providers/postmark.ts` (JSON; `StrippedTextReply`), `providers/mailgun.ts` (form-encoded; `stripped-text`; HMAC of timestamp+token with signing key + 5-min freshness = replay protection), `providers/sendgrid.ts` (multipart, phase 3b), `providers/index.ts` registry. + - **SES deferred** (needs SNS confirmation handling + S3 body fetch; the interface accommodates it later). Order: Postmark → Mailgun → SendGrid → SES. + - `extract-reply-text.ts` — provider `strippedReply` when present, else `email-reply-parser` npm fallback on `textBody`; trim + 5000-char cap. (Add dep to `apps/web/package.json`.) +9. **Processing pipeline `apps/web/lib/inbound-email/process-inbound-email.ts`** — returns `{ok:true} | {ok:false, reason}`: + 1. Find `reply+<token>@$INBOUND_EMAIL_DOMAIN` among recipients → else `no_reply_address`. + 2. Token lookup + expiry check → `invalid_token`. + 3. Load Domain (from token) + `User.findOne({ domain, userId: token.userId, active: true })`. + 4. **Sender check**: parsed From must equal `user.email` (case-insensitive) → `sender_mismatch` (token possession alone is insufficient — emails get forwarded). + 5. Extract reply text → `empty_reply` if blank. + 6. Rate limit via existing `assertRateLimit` (`apps/web/lib/assert-rate-limit.ts`), key `inbound-email:<domain>:<userId>`. + 7. Synthesize `ctx = { user, subdomain: domain, address: "" }`; dispatch: + - community → `postComment({ ctx, communityId, postId, content: replyText, parentCommentId?, parentReplyId? })` (no `parentCommentId` in token → top-level comment on the post) + - product → `createDiscussionReply({ ctx, productId, entityType, entityId, commentId, parentReplyId, content: normalizeTextEditorContent(replyText) })` + - All checks (membership, enrollment, deleted content, rate limits) re-enforced inside; `recordActivity` inside → participants re-notified as usual. + 8. Thrown errors → logged rejection. **Policy: silent drop with logging, no bounce emails** (bounces leak info and can loop); courtesy-failure email is a future enhancement. +10. **Route `apps/web/app/api/inbound-email/[provider]/route.ts`**: + - `POST`; `await req.text()` first (raw body for HMAC — deliberately better than the payment-webhook precedent, which skips real verification). + - Check `INBOUND_EMAIL_WEBHOOK_SECRET` (query param / basic auth) → 401 on mismatch; unknown provider → 404. + - `adapter.verify()` → `adapter.parse()` → `processInboundEmail()`. + - **Always 200 after auth passes** (even on rejection) so providers don't retry-loop; log reasons via `@/services/logger`. Ignores the `domain` header — tenant comes from the token. Confirm `proxy.ts` passes this path through for the apex hostname. + +## Phase 4 — Docs + +11. Document env vars + provider setup (reply-domain MX → provider inbound → webhook URL) in `docs/` and the self-hosting env reference. + +--- + +## Decisions taken (flag if you disagree) + +| Decision | Choice | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Content enrichment location | Queue-side re-fetch via existing resolver (no metadata changes) | +| Token format | Opaque DB token, 30-day sliding TTL, multi-use per (recipient, thread position) | +| Provider order | Postmark → Mailgun → SendGrid → SES (first two ship pre-stripped reply text) | +| Subject line | Keep one-line summary as subject (vs `Re: "<title>"`) | +| Per-channel sizing | Notification panel stays compact (unchanged one-liners); only email shows full content blocks | +| Rejection UX | Silent drop + server log; no bounce emails in MVP | +| Rich treatment scope | 5 conversation activity types: `COMMUNITY_POST_CREATED` (post body excerpt; email reply → top-level comment), the 2 community comment/reply types, and `COURSE_DISCUSSION_COMMENT_CREATED` (comment + reply eventTypes) | + +## Verification + +- **Unit**: `getNotificationEmailContent` with a stub resolver (one test per conversation type + one non-conversation type asserting unchanged output); template block presence/absence tests; token-minting idempotency + disabled-env tests (queue jest); `process-inbound-email` tests modeled on `communities/__tests__/logic.test.ts` — happy paths (community + product), expired token, sender mismatch, empty reply, non-member; adapter parse/verify tests with fixture payloads (`__tests__/fixtures/{postmark,mailgun}.json`). +- **E2E local**: run web + queue with a dev SMTP inbox; post a comment in the UI → inspect rich email + Reply-To header; then simulate inbound: + ``` + curl -X POST "http://localhost:3000/api/inbound-email/postmark?secret=$INBOUND_EMAIL_WEBHOOK_SECRET" \ + -H 'Content-Type: application/json' -d @fixtures/postmark.json + ``` + (fixture `To` = minted `reply+<token>@…`, `From` = recipient email) → verify the reply appears in the thread and re-notification fires. +- Run existing suites in `apps/web` and `apps/queue` to catch regressions. diff --git a/packages/common-logic/src/index.ts b/packages/common-logic/src/index.ts index 1c01ac737..634ad6918 100644 --- a/packages/common-logic/src/index.ts +++ b/packages/common-logic/src/index.ts @@ -1,4 +1,5 @@ export * from "./utils/convert-filters-to-db-conditions"; export * from "./utils/course-management-access"; export * from "./utils/get-notification-message-and-href"; +export * from "./utils/get-notification-email-content"; export { default as jwtUtils } from "./utils/jwt-utils"; diff --git a/packages/common-logic/src/utils/get-notification-email-content.ts b/packages/common-logic/src/utils/get-notification-email-content.ts new file mode 100644 index 000000000..1f4e9ecc6 --- /dev/null +++ b/packages/common-logic/src/utils/get-notification-email-content.ts @@ -0,0 +1,326 @@ +import { + ActivityType, + Constants, + type ReplyByEmailContext, +} from "@courselit/common-models"; +import { extractTextFromTextEditorContent, truncate } from "@courselit/utils"; +import { + getNotificationMessageAndHref, + type NotificationEntityResolver, +} from "./get-notification-message-and-href"; +import { createNotificationEntityResolver } from "./notification-entity-resolver"; + +const COMMENT_TEXT_LIMIT = 1000; +const PARENT_TEXT_LIMIT = 200; +const defaultNotificationEntityResolver = createNotificationEntityResolver(); + +export type { ReplyByEmailContext } from "@courselit/common-models"; + +export interface NotificationEmailContent { + subject: string; + message: string; + href: string; + commentText?: string; + parentText?: string; + parentAuthorName?: string; + parentLabel?: string; + threadTitle?: string; + conversationLabel?: "New post" | "New comment" | "New reply"; + replyContext?: ReplyByEmailContext; +} + +interface GetNotificationEmailContentOptions { + activityType: ActivityType; + entityId: string; + actorName: string; + recipientUserId: string; + recipientPermissions?: string[]; + resolver?: NotificationEntityResolver; + entityTargetId?: string; + metadata?: Record<string, unknown>; + hrefPrefix?: string; + domainId?: unknown; + resolveUserName?: (userId: string) => Promise<string | undefined>; +} + +export async function getNotificationEmailContent( + options: GetNotificationEmailContentOptions, +): Promise<NotificationEmailContent> { + const resolver = createCachedNotificationEntityResolver( + options.resolver || defaultNotificationEntityResolver, + ); + const { message, href } = await getNotificationMessageAndHref({ + ...options, + resolver, + }); + const content: NotificationEmailContent = { + subject: message, + message, + href, + }; + if (!message || !href) { + return content; + } + + switch (options.activityType) { + case Constants.ActivityType.COMMUNITY_POST_CREATED: { + const post = await resolver.getPost( + options.entityId, + options.domainId, + ); + if (!post) return content; + + return { + ...content, + commentText: excerpt(post.content, COMMENT_TEXT_LIMIT), + threadTitle: post.title, + conversationLabel: "New post", + replyContext: { + community: { + communityId: post.communityId, + postId: post.postId, + }, + }, + }; + } + + case Constants.ActivityType.COMMUNITY_COMMENT_CREATED: { + const comment = await resolver.getComment( + options.entityId, + options.domainId, + ); + if (!comment) return content; + const post = await resolver.getPost( + comment.postId, + options.domainId, + ); + if (!post) return content; + + return { + ...content, + commentText: excerpt(comment.content, COMMENT_TEXT_LIMIT), + parentText: excerpt(post.content, PARENT_TEXT_LIMIT), + parentAuthorName: options.resolveUserName + ? await options.resolveUserName(post.userId) + : undefined, + parentLabel: "Original post", + threadTitle: post.title, + conversationLabel: "New comment", + replyContext: { + community: { + communityId: comment.communityId, + postId: comment.postId, + parentCommentId: comment.commentId, + }, + }, + }; + } + + case Constants.ActivityType.COMMUNITY_REPLY_CREATED: + case Constants.ActivityType.COMMUNITY_COMMENT_REPLIED: { + const commentId = + options.entityTargetId || + (options.metadata?.commentId as string); + if (!commentId) return content; + const comment = await resolver.getComment( + commentId, + options.domainId, + ); + const reply = comment?.replies.find( + (candidate) => + candidate.replyId === options.entityId && + !candidate.deleted, + ); + if (!comment || !reply) return content; + const parent = reply.parentReplyId + ? comment.replies.find( + (candidate) => + candidate.replyId === reply.parentReplyId && + !candidate.deleted, + ) + : comment; + const post = await resolver.getPost( + comment.postId, + options.domainId, + ); + if (!post) return content; + + return { + ...content, + commentText: excerpt(reply.content, COMMENT_TEXT_LIMIT), + parentText: parent + ? excerpt(parent.content, PARENT_TEXT_LIMIT) + : undefined, + parentAuthorName: + parent && options.resolveUserName + ? await options.resolveUserName(parent.userId) + : undefined, + threadTitle: post.title, + conversationLabel: "New reply", + replyContext: { + community: { + communityId: comment.communityId, + postId: comment.postId, + parentCommentId: comment.commentId, + parentReplyId: reply.replyId, + }, + }, + }; + } + + case Constants.ActivityType.COURSE_DISCUSSION_COMMENT_CREATED: + return await getCourseDiscussionEmailContent( + { ...options, resolver }, + content, + ); + + default: + return content; + } +} + +async function getCourseDiscussionEmailContent( + options: GetNotificationEmailContentOptions, + content: NotificationEmailContent, +): Promise<NotificationEmailContent> { + const resolver = options.resolver || defaultNotificationEntityResolver; + const productId = options.metadata?.courseId as string | undefined; + const entityType = options.metadata?.entityType as string | undefined; + const entityId = options.metadata?.entityId as string | undefined; + const commentId = options.metadata?.commentId as string | undefined; + const replyId = options.metadata?.replyId as string | undefined; + + if ( + !resolver || + !productId || + !entityId || + !commentId || + entityType !== Constants.ProductDiscussionEntityType.LESSON + ) { + return content; + } + + if ( + (replyId && !resolver.getDiscussionReply) || + (!replyId && !resolver.getDiscussionComment) + ) { + return content; + } + + const discussionReply = replyId + ? await resolver.getDiscussionReply!(replyId, options.domainId) + : undefined; + const discussionEntity = replyId + ? discussionReply + : await resolver.getDiscussionComment!(commentId, options.domainId); + if (!discussionEntity) { + return { subject: "", message: "", href: "" }; + } + + const course = await resolver.getCourse(productId, options.domainId); + if (!course) return content; + + let parentText: string | undefined; + let parentAuthorName: string | undefined; + if (discussionReply) { + const parent = discussionReply.parentReplyId + ? await resolver.getDiscussionReply?.( + discussionReply.parentReplyId, + options.domainId, + ) + : await resolver.getDiscussionComment?.( + commentId, + options.domainId, + ); + parentText = parent + ? excerpt(parent.content, PARENT_TEXT_LIMIT) + : undefined; + parentAuthorName = + parent && options.resolveUserName + ? await options.resolveUserName(parent.userId) + : undefined; + } + + return { + ...content, + commentText: excerpt(discussionEntity.content, COMMENT_TEXT_LIMIT), + parentText, + parentAuthorName, + threadTitle: course.title, + conversationLabel: replyId ? "New reply" : "New comment", + replyContext: { + product: { + productId, + entityType, + entityId, + commentId, + ...(replyId ? { parentReplyId: replyId } : {}), + }, + }, + }; +} + +function excerpt(value: unknown, maxLength: number): string { + return truncate(extractTextFromTextEditorContent(value), maxLength).trim(); +} + +function createCachedNotificationEntityResolver( + resolver: NotificationEntityResolver, +): NotificationEntityResolver { + const cache = new Map<string, Promise<unknown>>(); + const cached = <T>(key: string, resolve: () => Promise<T>): Promise<T> => { + const existing = cache.get(key); + if (existing) { + return existing as Promise<T>; + } + + const result = resolve(); + cache.set(key, result); + return result; + }; + const key = (type: string, id: string, domainId?: unknown) => + `${type}:${String(domainId ?? "")}:${id}`; + + return { + getCommunity: (communityId, domainId) => + cached(key("community", communityId, domainId), () => + resolver.getCommunity(communityId, domainId), + ), + getPost: (postId, domainId) => + cached(key("post", postId, domainId), () => + resolver.getPost(postId, domainId), + ), + getComment: (commentId, domainId) => + cached(key("comment", commentId, domainId), () => + resolver.getComment(commentId, domainId), + ), + getCourse: (courseId, domainId) => + cached(key("course", courseId, domainId), () => + resolver.getCourse(courseId, domainId), + ), + ...(resolver.getDiscussionComment + ? { + getDiscussionComment: ( + commentId: string, + domainId?: unknown, + ) => + cached( + key("discussion-comment", commentId, domainId), + () => + resolver.getDiscussionComment!( + commentId, + domainId, + ), + ), + } + : {}), + ...(resolver.getDiscussionReply + ? { + getDiscussionReply: (replyId: string, domainId?: unknown) => + cached(key("discussion-reply", replyId, domainId), () => + resolver.getDiscussionReply!(replyId, domainId), + ), + } + : {}), + }; +} diff --git a/packages/common-logic/src/utils/get-notification-message-and-href.ts b/packages/common-logic/src/utils/get-notification-message-and-href.ts index d08d80209..e0e0614ee 100644 --- a/packages/common-logic/src/utils/get-notification-message-and-href.ts +++ b/packages/common-logic/src/utils/get-notification-message-and-href.ts @@ -2,6 +2,7 @@ import { ActivityType, COMMUNITY_HEART_EMOJI, Constants, + type ProductDiscussionEntityType, } from "@courselit/common-models"; import { truncate } from "@courselit/utils"; import { createNotificationEntityResolver } from "./notification-entity-resolver"; @@ -12,6 +13,7 @@ export interface NotificationReplyEntity { userId: string; content: string; parentReplyId?: string; + deleted?: boolean; } export interface NotificationCommentEntity { @@ -28,6 +30,7 @@ export interface NotificationPostEntity { title: string; userId: string; communityId: string; + content?: unknown; } export interface NotificationCommunityEntity { @@ -42,6 +45,21 @@ export interface NotificationCourseEntity { creatorId?: string; } +export interface NotificationDiscussionCommentEntity { + commentId: string; + userId: string; + productId: string; + entityType: ProductDiscussionEntityType; + entityId: string; + content: unknown; +} + +export interface NotificationDiscussionReplyEntity + extends NotificationDiscussionCommentEntity { + replyId: string; + parentReplyId?: string; +} + export interface NotificationEntityResolver { getCommunity( communityId: string, @@ -59,6 +77,14 @@ export interface NotificationEntityResolver { courseId: string, domainId?: unknown, ): Promise<NotificationCourseEntity | null>; + getDiscussionComment?( + commentId: string, + domainId?: unknown, + ): Promise<NotificationDiscussionCommentEntity | null>; + getDiscussionReply?( + replyId: string, + domainId?: unknown, + ): Promise<NotificationDiscussionReplyEntity | null>; } const defaultNotificationEntityResolver = createNotificationEntityResolver(); @@ -156,13 +182,17 @@ export async function getNotificationMessageAndHref({ return { message: "", href: "" }; } - const reply = comment.replies.find((r) => r.replyId === entityId); + const reply = comment.replies.find( + (r) => r.replyId === entityId && !r.deleted, + ); if (!reply) { return { message: "", href: "" }; } const parentReply = reply.parentReplyId - ? comment.replies.find((r) => r.replyId === reply.parentReplyId) + ? comment.replies.find( + (r) => r.replyId === reply.parentReplyId && !r.deleted, + ) : undefined; const [post, community] = await Promise.all([ diff --git a/packages/common-logic/src/utils/notification-entity-resolver.ts b/packages/common-logic/src/utils/notification-entity-resolver.ts index 75be886f6..5524f4a1c 100644 --- a/packages/common-logic/src/utils/notification-entity-resolver.ts +++ b/packages/common-logic/src/utils/notification-entity-resolver.ts @@ -4,7 +4,10 @@ import { CommunityPostSchema, CommunitySchema, CourseSchema, + ProductDiscussionCommentSchema, + ProductDiscussionReplySchema, } from "@courselit/orm-models"; +import type { ProductDiscussionEntityType } from "@courselit/common-models"; import type { NotificationEntityResolver } from "./get-notification-message-and-href"; export interface CreateNotificationEntityResolverOptions { @@ -38,6 +41,7 @@ export function createNotificationEntityResolver( { ...getDomainQuery(domainId ?? defaultDomainId), postId, + deleted: false, }, { _id: 0, @@ -45,6 +49,7 @@ export function createNotificationEntityResolver( title: 1, userId: 1, communityId: 1, + content: 1, }, ) .lean<{ @@ -52,6 +57,7 @@ export function createNotificationEntityResolver( title: string; userId: string; communityId: string; + content?: unknown; } | null>(); }, async getComment(commentId, domainId) { @@ -60,6 +66,7 @@ export function createNotificationEntityResolver( { ...getDomainQuery(domainId ?? defaultDomainId), commentId, + deleted: false, }, { _id: 0, @@ -82,6 +89,7 @@ export function createNotificationEntityResolver( userId: string; content: string; parentReplyId?: string; + deleted?: boolean; }>; } | null>(); @@ -95,12 +103,14 @@ export function createNotificationEntityResolver( content: comment.content, postId: comment.postId, communityId: comment.communityId, - replies: (comment.replies || []).map((reply) => ({ - replyId: reply.replyId, - userId: reply.userId, - content: reply.content, - parentReplyId: reply.parentReplyId, - })), + replies: (comment.replies || []) + .filter((reply) => !reply.deleted) + .map((reply) => ({ + replyId: reply.replyId, + userId: reply.userId, + content: reply.content, + parentReplyId: reply.parentReplyId, + })), }; }, async getCourse(courseId, domainId) { @@ -125,6 +135,64 @@ export function createNotificationEntityResolver( creatorId?: string; } | null>(); }, + async getDiscussionComment(commentId, domainId) { + return await getProductDiscussionCommentModel() + .findOne( + { + ...getDomainQuery(domainId ?? defaultDomainId), + commentId, + deleted: false, + }, + { + _id: 0, + commentId: 1, + userId: 1, + productId: 1, + entityType: 1, + entityId: 1, + content: 1, + }, + ) + .lean<{ + commentId: string; + userId: string; + productId: string; + entityType: ProductDiscussionEntityType; + entityId: string; + content: unknown; + } | null>(); + }, + async getDiscussionReply(replyId, domainId) { + return await getProductDiscussionReplyModel() + .findOne( + { + ...getDomainQuery(domainId ?? defaultDomainId), + replyId, + deleted: false, + }, + { + _id: 0, + replyId: 1, + commentId: 1, + parentReplyId: 1, + userId: 1, + productId: 1, + entityType: 1, + entityId: 1, + content: 1, + }, + ) + .lean<{ + replyId: string; + commentId: string; + parentReplyId?: string; + userId: string; + productId: string; + entityType: ProductDiscussionEntityType; + entityId: string; + content: unknown; + } | null>(); + }, }; } @@ -163,3 +231,19 @@ function getCourseModel(): mongoose.Model<any> { return (mongoose.models.Course || mongoose.model("Course", CourseSchema)) as mongoose.Model<any>; } + +function getProductDiscussionCommentModel(): mongoose.Model<any> { + return (mongoose.models.ProductDiscussionComment || + mongoose.model( + "ProductDiscussionComment", + ProductDiscussionCommentSchema, + )) as mongoose.Model<any>; +} + +function getProductDiscussionReplyModel(): mongoose.Model<any> { + return (mongoose.models.ProductDiscussionReply || + mongoose.model( + "ProductDiscussionReply", + ProductDiscussionReplySchema, + )) as mongoose.Model<any>; +} diff --git a/packages/common-models/src/email-reply-context.ts b/packages/common-models/src/email-reply-context.ts new file mode 100644 index 000000000..a3ecf5693 --- /dev/null +++ b/packages/common-models/src/email-reply-context.ts @@ -0,0 +1,17 @@ +import type { ProductDiscussionEntityType } from "./product-discussion"; + +export interface ReplyByEmailContext { + community?: { + communityId: string; + postId: string; + parentCommentId?: string; + parentReplyId?: string; + }; + product?: { + productId: string; + entityType: ProductDiscussionEntityType; + entityId: string; + commentId: string; + parentReplyId?: string; + }; +} diff --git a/packages/common-models/src/index.ts b/packages/common-models/src/index.ts index 10167858a..a371455d3 100644 --- a/packages/common-models/src/index.ts +++ b/packages/common-models/src/index.ts @@ -70,6 +70,7 @@ export * from "./notification-preference"; export * from "./course"; export * from "./activity-type"; export * from "./email-event-action"; +export type { ReplyByEmailContext } from "./email-reply-context"; export * from "./login-provider"; export * from "./features"; export * from "./product-discussion"; diff --git a/packages/utils/src/extract-text-from-text-editor-content.ts b/packages/utils/src/extract-text-from-text-editor-content.ts index 44c7ea923..94331d638 100644 --- a/packages/utils/src/extract-text-from-text-editor-content.ts +++ b/packages/utils/src/extract-text-from-text-editor-content.ts @@ -2,7 +2,7 @@ export default function extractTextFromTextEditorContent( content: unknown, ): string { if (typeof content === "string") { - return content; + return content.replace(/\r\n?/g, "\n"); } if (!content || typeof content !== "object") { @@ -10,10 +10,26 @@ export default function extractTextFromTextEditorContent( } const record = content as Record<string, unknown>; + if (record.type === "hardBreak") { + return "\n"; + } + const ownText = typeof record.text === "string" ? record.text : ""; const children = Array.isArray(record.content) - ? record.content.map(extractTextFromTextEditorContent).join("") - : ""; + ? record.content.map(extractTextFromTextEditorContent) + : []; - return `${ownText}${children}`; + return `${ownText}${children.join(getNodeSeparator(record.type))}`; +} + +function getNodeSeparator(nodeType: unknown): string { + switch (nodeType) { + case "doc": + return "\n\n"; + case "bulletList": + case "orderedList": + return "\n"; + default: + return ""; + } }