diff --git a/packages/api/src/utils/notifications.ts b/packages/api/src/utils/notifications.ts new file mode 100644 index 00000000..d898e1dc --- /dev/null +++ b/packages/api/src/utils/notifications.ts @@ -0,0 +1,131 @@ +import { env } from "next-runtime-env"; + +import type { dbClient } from "@kan/db/client"; +import * as cardRepo from "@kan/db/repository/card.repo"; +import * as memberRepo from "@kan/db/repository/member.repo"; +import * as notificationRepo from "@kan/db/repository/notification.repo"; +import * as userRepo from "@kan/db/repository/user.repo"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { sendEmail } from "@kan/email"; +import { parseMentionsFromHTML } from "@kan/shared/utils"; + +/** + * Sends mention notification emails to mentioned members + * Only sends emails for new mentions (checks notification table to avoid duplicates) + */ +export async function sendMentionEmails({ + db, + cardPublicId, + commentHtml, + commenterUserId, + commentId, +}: { + db: dbClient; + cardPublicId: string; + commentHtml: string; + commenterUserId: string; + commentId?: number; +}) { + try { + // Parse mentions from HTML + const mentionPublicIds = parseMentionsFromHTML(commentHtml); + if (mentionPublicIds.length === 0) return; + + // Get card with board information + const card = await cardRepo.getWithListAndMembersByPublicId(db, cardPublicId); + if (!card?.list.board) return; + + const board = card.list.board; + const boardName = board.name; + const cardTitle = card.title; + const cardId = card.id; + + // Get workspace ID from workspace publicId + const workspace = await workspaceRepo.getByPublicId( + db, + board.workspace.publicId, + ); + if (!workspace?.id) return; + + const workspaceId = workspace.id; + + // Get commenter information + const commenter = await userRepo.getById(db, commenterUserId); + if (!commenter) return; + + const commenterName = commenter.name ?? commenter.email; + + // Get mentioned members with full details (filtered by workspace) + const membersWithDetails = await memberRepo.getByPublicIdsWithUsers( + db, + mentionPublicIds, + workspaceId, + ); + + // Filter out the commenter + const membersToNotify = membersWithDetails.filter( + (member) => member.user?.id !== commenterUserId, + ); + + if (membersToNotify.length === 0) return; + + const baseUrl = env("NEXT_PUBLIC_BASE_URL"); + const cardUrl = `${baseUrl}/cards/${cardPublicId}`; + + // Send emails to all mentioned members (only if notification doesn't exist) + await Promise.all( + membersToNotify.map(async (member) => { + const userId = member.user?.id; + const email = member.user?.email ?? member.email; + if (!userId || !email) return; + + try { + // Check if notification already exists for this mention + const notificationExists = await notificationRepo.exists(db, { + userId, + cardId, + type: "mention", + }); + + // If notification already exists, skip sending email + if (notificationExists) { + return; + } + + // Create notification record + await notificationRepo.create(db, { + type: "mention", + userId, + cardId, + commentId, + }); + + // Send email + await sendEmail( + email, + `${commenterName} mentioned you in a comment on ${cardTitle}`, + "MENTION", + { + commenterName, + boardName, + cardTitle, + cardUrl, + }, + ); + } catch (error) { + console.error("Failed to send mention email:", { + email, + cardPublicId, + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); + } catch (error) { + console.error("Error sending mention emails:", { + cardPublicId, + error: error instanceof Error ? error.message : String(error), + }); + } +} +