diff --git a/app/(dashboard)/settings/preferences/dailyEmailSetting.tsx b/app/(dashboard)/settings/preferences/dailyEmailSetting.tsx new file mode 100644 index 000000000..62e4ddd63 --- /dev/null +++ b/app/(dashboard)/settings/preferences/dailyEmailSetting.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { toast } from "sonner"; +import { useSavingIndicator } from "@/components/hooks/useSavingIndicator"; +import { SavingIndicator } from "@/components/savingIndicator"; +import { useSession } from "@/components/useSession"; +import { api } from "@/trpc/react"; +import { SwitchSectionWrapper } from "../sectionWrapper"; + +const DailyEmailSetting = () => { + const { user } = useSession() ?? {}; + const savingIndicator = useSavingIndicator(); + const utils = api.useUtils(); + const { mutate: update } = api.user.update.useMutation({ + onSuccess: () => { + utils.user.currentUser.invalidate(); + savingIndicator.setState("saved"); + }, + onError: (error) => { + savingIndicator.setState("error"); + toast.error("Error updating preferences", { description: error.message }); + }, + }); + + const handleSwitchChange = (checked: boolean) => { + savingIndicator.setState("saving"); + update({ preferences: { allowDailyEmail: checked } }); + }; + + return ( +
+
+ +
+ + <> + +
+ ); +}; + +export default DailyEmailSetting; diff --git a/app/(dashboard)/settings/preferences/preferencesSetting.tsx b/app/(dashboard)/settings/preferences/preferencesSetting.tsx index 151074235..f5a894f0f 100644 --- a/app/(dashboard)/settings/preferences/preferencesSetting.tsx +++ b/app/(dashboard)/settings/preferences/preferencesSetting.tsx @@ -1,7 +1,10 @@ import { useSession } from "@/components/useSession"; import AutoAssignSetting from "./autoAssignSetting"; import ConfettiSetting from "./confettiSetting"; +import DailyEmailSetting from "./dailyEmailSetting"; import NextTicketPreviewSetting from "./nextTicketPreviewSetting"; +import VipMessageEmailSetting from "./vipMessageEmailSetting"; +import WeeklyEmailSetting from "./weeklyEmailSetting"; const PreferencesSetting = () => { const { user } = useSession() ?? {}; @@ -13,6 +16,9 @@ const PreferencesSetting = () => { + + + ); }; diff --git a/app/(dashboard)/settings/preferences/vipMessageEmailSetting.tsx b/app/(dashboard)/settings/preferences/vipMessageEmailSetting.tsx new file mode 100644 index 000000000..150dace20 --- /dev/null +++ b/app/(dashboard)/settings/preferences/vipMessageEmailSetting.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { toast } from "sonner"; +import { useSavingIndicator } from "@/components/hooks/useSavingIndicator"; +import { SavingIndicator } from "@/components/savingIndicator"; +import { useSession } from "@/components/useSession"; +import { api } from "@/trpc/react"; +import { SwitchSectionWrapper } from "../sectionWrapper"; + +const VipMessageEmailSetting = () => { + const { user } = useSession() ?? {}; + const savingIndicator = useSavingIndicator(); + const utils = api.useUtils(); + const { mutate: update } = api.user.update.useMutation({ + onSuccess: () => { + utils.user.currentUser.invalidate(); + savingIndicator.setState("saved"); + }, + onError: (error) => { + savingIndicator.setState("error"); + toast.error("Error updating preferences", { description: error.message }); + }, + }); + + const handleSwitchChange = (checked: boolean) => { + savingIndicator.setState("saving"); + update({ preferences: { allowVipMessageEmail: checked } }); + }; + + return ( +
+
+ +
+ + <> + +
+ ); +}; + +export default VipMessageEmailSetting; diff --git a/app/(dashboard)/settings/preferences/weeklyEmailSetting.tsx b/app/(dashboard)/settings/preferences/weeklyEmailSetting.tsx new file mode 100644 index 000000000..dcb21097f --- /dev/null +++ b/app/(dashboard)/settings/preferences/weeklyEmailSetting.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { toast } from "sonner"; +import { useSavingIndicator } from "@/components/hooks/useSavingIndicator"; +import { SavingIndicator } from "@/components/savingIndicator"; +import { useSession } from "@/components/useSession"; +import { api } from "@/trpc/react"; +import { SwitchSectionWrapper } from "../sectionWrapper"; + +const WeeklyEmailSetting = () => { + const { user } = useSession() ?? {}; + const savingIndicator = useSavingIndicator(); + const utils = api.useUtils(); + const { mutate: update } = api.user.update.useMutation({ + onSuccess: () => { + utils.user.currentUser.invalidate(); + savingIndicator.setState("saved"); + }, + onError: (error) => { + savingIndicator.setState("error"); + toast.error("Error updating preferences", { description: error.message }); + }, + }); + + const handleSwitchChange = (checked: boolean) => { + savingIndicator.setState("saving"); + update({ preferences: { allowWeeklyEmail: checked } }); + }; + + return ( +
+
+ +
+ + <> + +
+ ); +}; + +export default WeeklyEmailSetting; diff --git a/db/schema/userProfiles.ts b/db/schema/userProfiles.ts index c528d67b8..07677cc6e 100644 --- a/db/schema/userProfiles.ts +++ b/db/schema/userProfiles.ts @@ -27,6 +27,9 @@ export const userProfiles = pgTable("user_profiles", { confetti?: boolean; disableNextTicketPreview?: boolean; autoAssignOnReply?: boolean; + allowDailyEmail?: boolean; + allowWeeklyEmail?: boolean; + allowVipMessageEmail?: boolean; }>(), }).enableRLS(); diff --git a/jobs/generateDailyReports.ts b/jobs/generateDailyReports.ts index 826ed397e..b03398eca 100644 --- a/jobs/generateDailyReports.ts +++ b/jobs/generateDailyReports.ts @@ -1,18 +1,33 @@ -import { KnownBlock } from "@slack/web-api"; import { subHours } from "date-fns"; -import { aliasedTable, and, eq, gt, isNotNull, isNull, lt, sql } from "drizzle-orm"; +import { aliasedTable, and, eq, gt, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; import { db } from "@/db/client"; -import { conversationMessages, conversations, mailboxes, platformCustomers } from "@/db/schema"; +import { conversationMessages, conversations, mailboxes, platformCustomers, userProfiles } from "@/db/schema"; +import { authUsers } from "@/db/supabaseSchema/auth"; import { triggerEvent } from "@/jobs/trigger"; -import { getMailbox } from "@/lib/data/mailbox"; -import { postSlackMessage } from "@/lib/slack/client"; +import { DailyEmailReportTemplate } from "@/lib/emails/dailyEmailReportTemplate"; +import { env } from "@/lib/env"; +import { sentEmailViaResend } from "@/lib/resend/client"; +import { captureExceptionAndLog } from "@/lib/shared/sentry"; + +type DailyReportSkipped = { skipped: true; reason: string }; +type MailboxDailyReportSuccess = { + success: true; + openTicketCount: number; + answeredTicketCount: number; + openTicketsOverZeroCount: number; + answeredTicketsOverZeroCount: number; + avgReplyTimeResult?: string; + vipAvgReplyTime: string | null; + avgWaitTime?: string; +}; + +export type MailboxDailyEmailReportResult = DailyReportSkipped | MailboxDailyReportSuccess; export const TIME_ZONE = "America/New_York"; -export async function generateDailyReports() { +export async function generateDailyEmailReports() { const mailboxesList = await db.query.mailboxes.findMany({ columns: { id: true }, - where: and(isNotNull(mailboxes.slackBotToken), isNotNull(mailboxes.slackAlertChannel)), }); if (!mailboxesList.length) return; @@ -20,21 +35,29 @@ export async function generateDailyReports() { await triggerEvent("reports/daily", {}); } -export async function generateMailboxDailyReport() { - const mailbox = await getMailbox(); - if (!mailbox?.slackBotToken || !mailbox.slackAlertChannel) return; - - const blocks: KnownBlock[] = [ - { - type: "section", - text: { - type: "plain_text", - text: `Daily summary for ${mailbox.name}:`, - emoji: true, - }, - }, - ]; +export async function generateMailboxDailyEmailReport(): Promise { + try { + const mailbox = await db.query.mailboxes.findFirst({ + where: isNull(sql`${mailboxes.preferences}->>'disabled'`), + }); + if (!mailbox) return { skipped: true, reason: "No mailbox found" }; + + if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) { + return { skipped: true, reason: "Email not configured" }; + } + const result = await generateMailboxEmailReport({ mailbox }); + return result; + } catch (error) { + captureExceptionAndLog(error); + throw error; + } +} +export async function generateMailboxEmailReport({ + mailbox, +}: { + mailbox: typeof mailboxes.$inferSelect; +}): Promise { const endTime = new Date(); const startTime = subHours(endTime, 24); @@ -45,8 +68,6 @@ export async function generateMailboxDailyReport() { if (openTicketCount === 0) return { skipped: true, reason: "No open tickets" }; - const openCountMessage = `• Open tickets: ${openTicketCount.toLocaleString()}`; - const answeredTicketCount = await db .select({ count: sql`count(DISTINCT ${conversations.id})` }) .from(conversationMessages) @@ -61,8 +82,6 @@ export async function generateMailboxDailyReport() { ) .then((result) => Number(result[0]?.count || 0)); - const answeredCountMessage = `• Tickets answered: ${answeredTicketCount.toLocaleString()}`; - const openTicketsOverZeroCount = await db .select({ count: sql`count(*)` }) .from(conversations) @@ -76,10 +95,6 @@ export async function generateMailboxDailyReport() { ) .then((result) => Number(result[0]?.count || 0)); - const openTicketsOverZeroMessage = openTicketsOverZeroCount - ? `• Open tickets over $0: ${openTicketsOverZeroCount.toLocaleString()}` - : null; - const answeredTicketsOverZeroCount = await db .select({ count: sql`count(DISTINCT ${conversations.id})` }) .from(conversationMessages) @@ -96,10 +111,6 @@ export async function generateMailboxDailyReport() { ) .then((result) => Number(result[0]?.count || 0)); - const answeredTicketsOverZeroMessage = answeredTicketsOverZeroCount - ? `• Tickets answered over $0: ${answeredTicketsOverZeroCount.toLocaleString()}` - : null; - const formatTime = (seconds: number) => { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -123,11 +134,9 @@ export async function generateMailboxDailyReport() { lt(conversationMessages.createdAt, endTime), ), ); - const avgReplyTimeMessage = avgReplyTimeResult?.average - ? `• Average reply time: ${formatTime(avgReplyTimeResult.average)}` - : null; + const avgReplyTime = avgReplyTimeResult?.average ? formatTime(avgReplyTimeResult.average) : undefined; - let vipAvgReplyTimeMessage = null; + let vipAvgReplyTime = null; if (mailbox.vipThreshold) { const [vipReplyTimeResult] = await db .select({ @@ -150,9 +159,7 @@ export async function generateMailboxDailyReport() { gt(sql`CAST(${platformCustomers.value} AS INTEGER)`, (mailbox.vipThreshold ?? 0) * 100), ), ); - vipAvgReplyTimeMessage = vipReplyTimeResult?.average - ? `• VIP average reply time: ${formatTime(vipReplyTimeResult.average)}` - : null; + vipAvgReplyTime = vipReplyTimeResult?.average ? formatTime(vipReplyTimeResult.average) : null; } const [avgWaitTimeResult] = await db @@ -169,42 +176,53 @@ export async function generateMailboxDailyReport() { isNotNull(conversations.lastUserEmailCreatedAt), ), ); - const avgWaitTimeMessage = avgWaitTimeResult?.average - ? `• Average time existing open tickets have been open: ${formatTime(avgWaitTimeResult.average)}` - : null; - - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: [ - openCountMessage, - answeredCountMessage, - openTicketsOverZeroMessage, - answeredTicketsOverZeroMessage, - avgReplyTimeMessage, - vipAvgReplyTimeMessage, - avgWaitTimeMessage, - ] - .filter(Boolean) - .join("\n"), - }, + const avgWaitTime = avgWaitTimeResult?.average ? formatTime(avgWaitTimeResult.average) : undefined; + + const teamMembers = await db + .select({ + email: authUsers.email, + displayName: userProfiles.displayName, + }) + .from(userProfiles) + .innerJoin(authUsers, eq(userProfiles.id, authUsers.id)) + .where(or(isNull(userProfiles.preferences), sql`${userProfiles.preferences}->>'allowDailyEmail' != 'false'`)); + + if (teamMembers.length === 0) { + return { skipped: true, reason: "No team members found" }; + } + + const reactTemplate = DailyEmailReportTemplate({ + mailboxName: mailbox.name, + openTickets: openTicketCount || 0, + ticketsAnswered: answeredTicketCount || 0, + openTicketsOverZero: openTicketsOverZeroCount || 0, + ticketsAnsweredOverZero: answeredTicketsOverZeroCount || 0, + avgReplyTime: avgReplyTime || undefined, + vipAvgReplyTime: vipAvgReplyTime || undefined, + avgWaitTime: avgWaitTime || undefined, }); - await postSlackMessage(mailbox.slackBotToken, { - channel: mailbox.slackAlertChannel, - text: `Daily summary for ${mailbox.name}`, - blocks, + const emailResults = await sentEmailViaResend({ + memberList: teamMembers.filter((m) => !!m.email).map((m) => ({ email: m.email! })), + subject: `Daily summary for ${mailbox.name}`, + react: reactTemplate, }); + const failures = emailResults.filter((r) => !r.success); + if (failures.length > 0) { + captureExceptionAndLog( + new Error(`Daily report: failed to send ${failures.length}/${emailResults.length} daily emails`), + { extra: { failures } }, + ); + } return { success: true, - openCountMessage, - answeredCountMessage, - openTicketsOverZeroMessage, - answeredTicketsOverZeroMessage, - avgReplyTimeMessage, - vipAvgReplyTimeMessage, - avgWaitTimeMessage, + openTicketCount, + answeredTicketCount, + openTicketsOverZeroCount, + answeredTicketsOverZeroCount, + avgReplyTimeResult: avgReplyTimeResult?.average ? formatTime(avgReplyTimeResult.average) : undefined, + vipAvgReplyTime, + avgWaitTime, }; } diff --git a/jobs/generateWeeklyReports.ts b/jobs/generateWeeklyReports.ts index 875af4652..728505091 100644 --- a/jobs/generateWeeklyReports.ts +++ b/jobs/generateWeeklyReports.ts @@ -1,54 +1,56 @@ import { endOfWeek, startOfWeek, subWeeks } from "date-fns"; import { toZonedTime } from "date-fns-tz"; -import { assertDefined } from "@/components/utils/assert"; -import { mailboxes } from "@/db/schema"; +import { eq, isNull, or, sql } from "drizzle-orm"; +import { db } from "@/db/client"; +import { mailboxes, userProfiles } from "@/db/schema"; +import { authUsers } from "@/db/supabaseSchema/auth"; import { TIME_ZONE } from "@/jobs/generateDailyReports"; import { triggerEvent } from "@/jobs/trigger"; -import { getMailbox } from "@/lib/data/mailbox"; import { getMemberStats, MemberStats } from "@/lib/data/stats"; -import { getSlackUsersByEmail, postSlackMessage } from "@/lib/slack/client"; +import { WeeklyEmailReportTemplate } from "@/lib/emails/weeklyEmailReportTemplate"; +import { env } from "@/lib/env"; +import { sentEmailViaResend } from "@/lib/resend/client"; +import { captureExceptionAndLog } from "@/lib/shared/sentry"; const formatDateRange = (start: Date, end: Date) => { return `Week of ${start.toISOString().split("T")[0]} to ${end.toISOString().split("T")[0]}`; }; -export async function generateWeeklyReports() { - const mailbox = await getMailbox(); - if (!mailbox?.slackBotToken || !mailbox.slackAlertChannel) return; +export async function generateWeeklyEmailReports() { + const mailbox = await db.query.mailboxes.findFirst({ + where: isNull(sql`${mailboxes.preferences}->>'disabled'`), + }); + if (!mailbox) return; await triggerEvent("reports/weekly", {}); } -export const generateMailboxWeeklyReport = async () => { - const mailbox = await getMailbox(); - if (!mailbox) { - return; - } +type GenerateMailboxWeeklyEmailReportReturn = { skipped: true; reason: string } | "Email sent"; +export const generateMailboxWeeklyEmailReport = async (): Promise => { + try { + const mailbox = await db.query.mailboxes.findFirst({ + where: isNull(sql`${mailboxes.preferences}->>'disabled'`), + }); - // drizzle doesn't appear to do any type narrowing, even though we've filtered for non-null values - // @see https://github.com/drizzle-team/drizzle-orm/issues/2956 - if (!mailbox.slackBotToken || !mailbox.slackAlertChannel) { - return; - } + if (!mailbox) return { skipped: true, reason: "No mailbox found" }; - const result = await generateMailboxReport({ - mailbox, - slackBotToken: mailbox.slackBotToken, - slackAlertChannel: mailbox.slackAlertChannel, - }); + if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) { + return { skipped: true, reason: "Email not configured" }; + } - return result; + const result = await generateMailboxEmailReport({ mailbox }); + return result; + } catch (error) { + captureExceptionAndLog(error); + throw error; + } }; -export async function generateMailboxReport({ +export async function generateMailboxEmailReport({ mailbox, - slackBotToken, - slackAlertChannel, }: { mailbox: typeof mailboxes.$inferSelect; - slackBotToken: string; - slackAlertChannel: string; -}) { +}): Promise { const now = toZonedTime(new Date(), TIME_ZONE); const lastWeekStart = subWeeks(startOfWeek(now, { weekStartsOn: 0 }), 1); const lastWeekEnd = subWeeks(endOfWeek(now, { weekStartsOn: 0 }), 1); @@ -59,23 +61,19 @@ export async function generateMailboxReport({ }); if (!stats.length) { - return "No stats found"; + return { skipped: true, reason: "No stats found" }; } - const slackUsersByEmail = await getSlackUsersByEmail(slackBotToken); - - const allMembersData = processAllMembers(stats, slackUsersByEmail); + const allMembersData = processAllMembers(stats); - const tableData: { name: string; count: number; slackUserId?: string }[] = []; + const tableData: { name: string; count: number }[] = []; for (const member of stats) { - const name = member.displayName || `Unnamed user: ${member.id}`; - const slackUserId = slackUsersByEmail.get(assertDefined(member.email)); + const name = member.displayName || member.email || `Unnamed user: ${member.id}`; tableData.push({ name, count: member.replyCount, - slackUserId, }); } @@ -83,97 +81,57 @@ export async function generateMailboxReport({ const totalTicketsResolved = tableData.reduce((sum, agent) => sum + agent.count, 0); const activeUserCount = humanUsers.filter((user) => user.count > 0).length; - const peopleText = activeUserCount === 1 ? "person" : "people"; - - const blocks: any[] = [ - { - type: "section", - text: { - type: "plain_text", - text: `Last week in the ${mailbox.name} mailbox:`, - emoji: true, - }, - }, - ]; - - if (allMembersData.activeLines.length > 0) { - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: "*Team members:*", - }, - }); - - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: allMembersData.activeLines.join("\n"), - }, - }); - } - - if (allMembersData.inactiveList) { - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: `*No tickets answered:* ${allMembersData.inactiveList}`, - }, - }); + const teamMembers = await db + .select({ + email: authUsers.email, + displayName: userProfiles.displayName, + }) + .from(userProfiles) + .innerJoin(authUsers, eq(userProfiles.id, authUsers.id)) + .where(or(isNull(userProfiles.preferences), sql`${userProfiles.preferences}->>'allowWeeklyEmail' != 'false'`)); + + if (teamMembers.length === 0) { + return { skipped: true, reason: "No team members found" }; } - blocks.push({ type: "divider" }); - - const summaryParts = []; - if (totalTicketsResolved > 0) { - summaryParts.push("*Total replies:*"); - summaryParts.push(`${totalTicketsResolved.toLocaleString()} from ${activeUserCount} ${peopleText}`); - } - - if (summaryParts.length > 0) { - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: summaryParts.join("\n"), - }, - }); - } + const dateRange = formatDateRange(lastWeekStart, lastWeekEnd); + const reactTemplate = WeeklyEmailReportTemplate({ + mailboxName: mailbox.name, + dateRange, + teamMembers: allMembersData.activeMembers, + inactiveMembers: allMembersData.inactiveMembers, + totalReplies: totalTicketsResolved, + activeUserCount, + }); - await postSlackMessage(slackBotToken, { - channel: slackAlertChannel, - text: formatDateRange(lastWeekStart, lastWeekEnd), - blocks, + const emailResults = await sentEmailViaResend({ + memberList: teamMembers.filter((m) => !!m.email).map((m) => ({ email: m.email! })), + subject: `Weekly report for ${mailbox.name}`, + react: reactTemplate, }); + const failures = emailResults.filter((r) => !r.success); + if (failures.length > 0) { + captureExceptionAndLog( + new Error(`Weekly report: failed to send ${failures.length}/${emailResults.length} emails`), + { extra: { failures } }, + ); + } - return "Report sent"; + return "Email sent"; } -function processAllMembers(members: MemberStats, slackUsersByEmail: Map) { - const activeMembers = members.filter((member) => member.replyCount > 0).sort((a, b) => b.replyCount - a.replyCount); - const inactiveMembers = members.filter((member) => member.replyCount === 0); - - const activeLines = activeMembers.map((member) => { - const formattedCount = member.replyCount.toLocaleString(); - const slackUserId = slackUsersByEmail.get(member.email!); - const userName = slackUserId ? `<@${slackUserId}>` : member.displayName || member.email || "Unknown"; - - return `• ${userName}: ${formattedCount}`; - }); - - const inactiveList = - inactiveMembers.length > 0 - ? inactiveMembers - .map((member) => { - const slackUserId = slackUsersByEmail.get(member.email!); - const userName = slackUserId ? `<@${slackUserId}>` : member.displayName || member.email || "Unknown"; +function processAllMembers(members: MemberStats) { + const activeMembers = members + .filter((member) => member.replyCount > 0) + .sort((a, b) => b.replyCount - a.replyCount) + .map((member) => ({ + name: member.displayName || member.email || "Unknown", + count: member.replyCount, + })); - return userName; - }) - .join(", ") - : ""; + const inactiveMembers = members + .filter((member) => member.replyCount === 0) + .map((member) => member.displayName || member.email || "Unknown"); - return { activeLines, inactiveList }; + return { activeMembers, inactiveMembers }; } diff --git a/jobs/index.ts b/jobs/index.ts index 0fbabbd5f..30b0e4378 100644 --- a/jobs/index.ts +++ b/jobs/index.ts @@ -10,9 +10,9 @@ import { crawlWebsite } from "./crawlWebsite"; import { embeddingConversation } from "./embeddingConversation"; import { embeddingFaq } from "./embeddingFaq"; import { generateConversationSummaryEmbeddings } from "./generateConversationSummaryEmbeddings"; -import { generateDailyReports, generateMailboxDailyReport } from "./generateDailyReports"; +import { generateDailyEmailReports, generateMailboxDailyEmailReport } from "./generateDailyReports"; import { generateFilePreview } from "./generateFilePreview"; -import { generateMailboxWeeklyReport, generateWeeklyReports } from "./generateWeeklyReports"; +import { generateMailboxWeeklyEmailReport, generateWeeklyEmailReports } from "./generateWeeklyReports"; import { handleAutoResponse } from "./handleAutoResponse"; import { handleGmailWebhookEvent } from "./handleGmailWebhookEvent"; import { handleSlackAgentMessage } from "./handleSlackAgentMessage"; @@ -20,7 +20,7 @@ import { importGmailThreads } from "./importGmailThreads"; import { importRecentGmailThreads } from "./importRecentGmailThreads"; import { indexConversationMessage } from "./indexConversation"; import { mergeSimilarConversations } from "./mergeSimilarConversations"; -import { notifyVipMessage } from "./notifyVipMessage"; +import { notifyVipMessageEmail } from "./notifyVipMessage"; import { postEmailToGmail } from "./postEmailToGmail"; import { publishNewMessageEvent } from "./publishNewMessageEvent"; import { publishRequestHumanSupport } from "./publishRequestHumanSupport"; @@ -38,7 +38,7 @@ export const eventJobs = { generateConversationSummaryEmbeddings, mergeSimilarConversations, publishNewMessageEvent, - notifyVipMessage, + notifyVipMessageEmail, postEmailToGmail, handleAutoResponse, bulkUpdateConversations, @@ -47,8 +47,8 @@ export const eventJobs = { embeddingFaq, importRecentGmailThreads, importGmailThreads, - generateMailboxWeeklyReport, - generateMailboxDailyReport, + generateMailboxWeeklyEmailReport, + generateMailboxDailyEmailReport, crawlWebsite, suggestKnowledgeBankChanges, closeInactiveConversations, @@ -72,6 +72,6 @@ export const cronJobs = { }, "0 0 * * *": { renewMailboxWatches }, "0 0 * * 0": { scheduledWebsiteCrawl }, - "0 16 * * 0,2-6": { generateDailyReports }, - "0 16 * * 1": { generateWeeklyReports }, + "0 16 * * 0,2-6": { generateDailyEmailReports }, + "0 16 * * 1": { generateWeeklyEmailReports }, }; diff --git a/jobs/notifyVipMessage.ts b/jobs/notifyVipMessage.ts index d68b60393..07413d78f 100644 --- a/jobs/notifyVipMessage.ts +++ b/jobs/notifyVipMessage.ts @@ -1,17 +1,46 @@ -import { eq } from "drizzle-orm"; +import { eq, isNull, or, sql } from "drizzle-orm"; +import { htmlToText } from "html-to-text"; +import { getBaseUrl } from "@/components/constants"; import { db } from "@/db/client"; -import { conversationMessages, conversations } from "@/db/schema"; -import { ensureCleanedUpText } from "@/lib/data/conversationMessage"; -import { getMailbox } from "@/lib/data/mailbox"; -import { getPlatformCustomer } from "@/lib/data/platformCustomer"; +import { conversationMessages, conversations, mailboxes, platformCustomers, userProfiles } from "@/db/schema"; +import { authUsers } from "@/db/supabaseSchema/auth"; import { getBasicProfileById } from "@/lib/data/user"; -import { postVipMessageToSlack, updateVipMessageInSlack } from "@/lib/slack/vipNotifications"; +import { VipNotificationEmailTemplate } from "@/lib/emails/vipNotificationEmailTemplate"; +import { env } from "@/lib/env"; +import { sentEmailViaResend } from "@/lib/resend/client"; +import { captureExceptionAndLog } from "@/lib/shared/sentry"; import { assertDefinedOrRaiseNonRetriableError } from "./utils"; type MessageWithConversationAndMailbox = typeof conversationMessages.$inferSelect & { conversation: typeof conversations.$inferSelect; }; +const determineVipStatus = (customerValue: string | number | null, vipThreshold: number | null) => { + if (!customerValue || !vipThreshold) return false; + return Number(customerValue) / 100 >= vipThreshold; +}; + +const generateCleanedUpText = (html: string) => { + if (!html?.trim()) return ""; + const paragraphs = htmlToText(html, { + formatters: { + image: (elem, _walk, builder) => + builder.addInline(`![${elem.attribs?.alt || "image"}](${elem.attribs?.src})`, { noWordTransform: true }), + }, + wordwrap: false, + }) + .split(/\s*\n\s*/) + .filter((p) => p.trim().replace(/\s+/g, " ")); + return paragraphs.join("\n\n"); +}; + +const ensureCleanedUpText = async (m: typeof conversationMessages.$inferSelect) => { + if (m.cleanedUpText !== null) return m.cleanedUpText; + const cleaned = generateCleanedUpText(m.body ?? ""); + await db.update(conversationMessages).set({ cleanedUpText: cleaned }).where(eq(conversationMessages.id, m.id)); + return cleaned; +}; + async function fetchConversationMessage(messageId: number): Promise { const message = assertDefinedOrRaiseNonRetriableError( await db.query.conversationMessages.findFirst({ @@ -35,74 +64,97 @@ async function fetchConversationMessage(messageId: number): Promise { const conversation = assertDefinedOrRaiseNonRetriableError(message.conversation); - const mailbox = assertDefinedOrRaiseNonRetriableError(await getMailbox()); + const mailbox = await db.query.mailboxes.findFirst({ + where: isNull(sql`${mailboxes.preferences}->>'disabled'`), + }); + const mailboxRecord = assertDefinedOrRaiseNonRetriableError(mailbox); - if (conversation.isPrompt) { - return "Not posted, prompt conversation"; - } - if (!conversation.emailFrom) { - return "Not posted, anonymous conversation"; - } + if (conversation.isPrompt) return { skipped: true, reason: "Prompt conversation" }; + if (!conversation.emailFrom) return { skipped: true, reason: "Anonymous conversation" }; + if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) return { skipped: true, reason: "Email not configured" }; - const platformCustomer = await getPlatformCustomer(conversation.emailFrom); + const platformCustomerRecord = await db.query.platformCustomers.findFirst({ + where: eq(platformCustomers.email, conversation.emailFrom), + }); - // Early return if not VIP or Slack config missing - if (!platformCustomer?.isVip) return "Not posted, not a VIP customer"; - if (!mailbox.slackBotToken || !mailbox.vipChannelId) { - return "Not posted, mailbox not linked to Slack"; - } + const isVip = determineVipStatus(platformCustomerRecord?.value ?? null, mailboxRecord.vipThreshold ?? null); + if (!isVip) return { skipped: true, reason: "Not a VIP customer" }; + + const customerName = platformCustomerRecord?.name ?? conversation.emailFrom ?? "Unknown"; + const conversationLink = `${getBaseUrl()}/conversations?id=${conversation.slug}`; + const customerLinks = platformCustomerRecord?.links + ? Object.entries(platformCustomerRecord.links).map(([key, value]) => ({ label: key, url: value })) + : undefined; + + let originalMessage = ""; + let replyMessage: string | undefined; + let closedBy: string | undefined; - // If it's an agent reply updating an existing Slack message if (message.role !== "user" && message.responseToId) { - const originalMessage = await db.query.conversationMessages.findFirst({ + const originalMsg = await db.query.conversationMessages.findFirst({ where: eq(conversationMessages.id, message.responseToId), }); - if (originalMessage?.slackMessageTs) { - const originalCleanedUpText = originalMessage ? await ensureCleanedUpText(originalMessage) : ""; - const replyCleanedUpText = await ensureCleanedUpText(message); - - await updateVipMessageInSlack({ - conversation, - mailbox, - originalMessage: originalCleanedUpText, - replyMessage: replyCleanedUpText, - slackBotToken: mailbox.slackBotToken, - slackChannel: mailbox.vipChannelId, - slackMessageTs: originalMessage.slackMessageTs, - user: message.userId ? await getBasicProfileById(message.userId) : null, - email: true, - closed: conversation.status === "closed", - }); - return "Updated"; + if (originalMsg) { + originalMessage = await ensureCleanedUpText(originalMsg); + replyMessage = await ensureCleanedUpText(message); + if (message.userId) { + const user = await getBasicProfileById(message.userId); + closedBy = user?.displayName || user?.email || undefined; + } + } else { + return { skipped: true, reason: "Original message not found" }; } + } else if (message.role === "user") { + originalMessage = await ensureCleanedUpText(message); + } else { + return { skipped: true, reason: "Not a user message or reply to user" }; } - if (message.role !== "user") { - return "Not posted, not a user message and not a reply to a user message"; - } - - const cleanedUpText = await ensureCleanedUpText(message); + const teamMembers = await db + .select({ + email: authUsers.email, + displayName: userProfiles.displayName, + }) + .from(userProfiles) + .innerJoin(authUsers, eq(userProfiles.id, authUsers.id)) + .where(or(isNull(userProfiles.preferences), sql`${userProfiles.preferences}->>'allowVipMessageEmail' != 'false'`)); + + if (teamMembers.length === 0) return { skipped: true, reason: "No team members found" }; + + const reactTemplate = VipNotificationEmailTemplate({ + customerName, + customerEmail: conversation.emailFrom, + originalMessage, + replyMessage, + conversationLink, + customerLinks, + closed: conversation.status === "closed", + closedBy, + }); - const slackMessageTs = await postVipMessageToSlack({ - conversation, - mailbox, - message: cleanedUpText, - platformCustomer, - slackBotToken: mailbox.slackBotToken, - slackChannel: mailbox.vipChannelId, + const vipResults = await sentEmailViaResend({ + memberList: teamMembers.filter((m) => !!m.email).map((m) => ({ email: m.email! })), + subject: `VIP Customer: ${customerName}`, + react: reactTemplate, }); - await db - .update(conversationMessages) - .set({ slackMessageTs, slackChannel: mailbox.vipChannelId }) - .where(eq(conversationMessages.id, message.id)); - return "Posted"; + const failures = vipResults.filter((r) => !r.success); + if (failures.length > 0) { + captureExceptionAndLog( + new Error(`VIP notification: failed to send ${failures.length}/${vipResults.length} emails`), + { extra: { failures } }, + ); + } + + return "Email sent"; } -export const notifyVipMessage = async ({ messageId }: { messageId: number }) => { +export const notifyVipMessageEmail = async ({ messageId }: { messageId: number }): Promise => { const message = assertDefinedOrRaiseNonRetriableError(await fetchConversationMessage(messageId)); - return await handleVipSlackMessage(message); + return await handleVipEmailNotification(message); }; diff --git a/jobs/trigger.ts b/jobs/trigger.ts index 6675cdd6c..491254607 100644 --- a/jobs/trigger.ts +++ b/jobs/trigger.ts @@ -23,7 +23,7 @@ const events = { "generateConversationSummaryEmbeddings", "mergeSimilarConversations", "publishNewMessageEvent", - "notifyVipMessage", + "notifyVipMessageEmail", "categorizeConversationToIssueGroup", ], }, @@ -87,11 +87,11 @@ const events = { }, "reports/weekly": { data: z.object({}), - jobs: ["generateMailboxWeeklyReport"], + jobs: ["generateMailboxWeeklyEmailReport"], }, "reports/daily": { data: z.object({}), - jobs: ["generateMailboxDailyReport"], + jobs: ["generateMailboxDailyEmailReport"], }, "websites/crawl.create": { data: z.object({ diff --git a/lib/emails/dailyEmailReportTemplate.tsx b/lib/emails/dailyEmailReportTemplate.tsx new file mode 100644 index 000000000..9f479b693 --- /dev/null +++ b/lib/emails/dailyEmailReportTemplate.tsx @@ -0,0 +1,132 @@ +import { Body, Head, Hr, Html, Img, Link, Preview, Text } from "@react-email/components"; +import React from "react"; +import { getBaseUrl } from "@/components/constants"; + +type Props = { + mailboxName: string; + openTickets: number; + ticketsAnswered: number; + openTicketsOverZero: number; + ticketsAnsweredOverZero: number; + avgReplyTime?: string; + vipAvgReplyTime?: string; + avgWaitTime?: string; +}; + +const baseUrl = getBaseUrl(); + +export const DailyEmailReportTemplate = ({ + mailboxName, + openTickets, + ticketsAnswered, + openTicketsOverZero, + ticketsAnsweredOverZero, + avgReplyTime, + vipAvgReplyTime, + avgWaitTime, +}: Props) => ( + + + Daily summary for {mailboxName} + +
+
+
+ Daily Summary + {mailboxName} + + Here's today's summary of your mailbox activity. + +
+ + + + + + + + + + +
+
+
+ + Powered by + + Helper Logo + + +
+
+
+ + +); + +// Reusable summary row component for better readability & consistent spacing +const SummaryRow = ({ label, value, last = false }: { label: string; value: string | number; last?: boolean }) => ( + + + {label} + + + {value} + + +); diff --git a/lib/emails/vipNotificationEmailTemplate.tsx b/lib/emails/vipNotificationEmailTemplate.tsx new file mode 100644 index 000000000..ddaebeb28 --- /dev/null +++ b/lib/emails/vipNotificationEmailTemplate.tsx @@ -0,0 +1,145 @@ +import { Body, Head, Hr, Html, Img, Link, Preview, Text } from "@react-email/components"; +import React from "react"; +import { getBaseUrl } from "@/components/constants"; + +type Props = { + customerName: string; + customerEmail: string; + originalMessage: string; + replyMessage?: string; + conversationLink: string; + customerLinks?: { label: string; url: string }[]; + closed?: boolean; + closedBy?: string; +}; + +const baseUrl = getBaseUrl(); + +export const VipNotificationEmailTemplate = ({ + customerName, + customerEmail, + originalMessage, + replyMessage, + conversationLink, + customerLinks, + closed, + closedBy, +}: Props) => ( + + + VIP Customer: {customerName} + +
+
+ {/* Header */} +
+ ⭐ VIP Customer + + New message from {customerName} ({customerEmail}) + +
+ + {/* Message card */} +
+
+ Original message: + {originalMessage} + + {replyMessage && ( + <> +
+ Reply: + {replyMessage} + + )} +
+
+ + {/* Customer links */} + {customerLinks && customerLinks.length > 0 && ( +
+ + {customerLinks.map((link, index) => ( + + + {link.label} + + + ))} + +
+ )} + + {/* View link & status */} +
+ + + View in Helper → + + + {closed && closedBy && ( + ✓ Closed by {closedBy} + )} +
+ + {/* Footer */} +
+
+ + Powered by + + Helper Logo + + +
+
+
+ + +); diff --git a/lib/emails/weeklyEmailReportTemplate.tsx b/lib/emails/weeklyEmailReportTemplate.tsx new file mode 100644 index 000000000..1776c44cc --- /dev/null +++ b/lib/emails/weeklyEmailReportTemplate.tsx @@ -0,0 +1,142 @@ +import { Body, Head, Hr, Html, Img, Link, Preview, Text } from "@react-email/components"; +import React from "react"; +import { getBaseUrl } from "@/components/constants"; + +type Props = { + mailboxName: string; + dateRange: string; + teamMembers: { name: string; count: number }[]; + inactiveMembers: string[]; + totalReplies: number; + activeUserCount: number; +}; + +const baseUrl = getBaseUrl(); + +export const WeeklyEmailReportTemplate = ({ + mailboxName, + dateRange, + teamMembers, + inactiveMembers, + totalReplies, + activeUserCount, +}: Props) => { + const peopleText = activeUserCount === 1 ? "person" : "people"; + + return ( + + + Last week in the {mailboxName} mailbox + +
+
+
+ Weekly Report + {mailboxName} + {dateRange} + + Here's a summary of your team's activity this week. + +
+ + {/* Team members list */} + {teamMembers.length > 0 && ( +
+ Team members + + + {teamMembers.map((member, index) => ( + + + + + ))} + +
• {member.name} + {member.count.toLocaleString()} +
+
+ )} + + {/* Inactive members */} + {inactiveMembers.length > 0 && ( +
+ + No tickets answered: {inactiveMembers.join(", ")} + +
+ )} + + {/* Total activity block */} +
+
+ Total Activity + + {totalReplies.toLocaleString()} replies + + + from {activeUserCount} {peopleText} + +
+
+ + {/* Footer */} +
+
+ + Powered by + + Helper Logo + + +
+
+
+ + + ); +}; diff --git a/lib/resend/client.ts b/lib/resend/client.ts new file mode 100644 index 000000000..049cec68f --- /dev/null +++ b/lib/resend/client.ts @@ -0,0 +1,33 @@ +import type { ReactElement } from "react"; +import { Resend } from "resend"; +import { env } from "@/lib/env"; + +export type EmailSendResult = { success: true } | { success: false; email?: string; error?: unknown }; + +export const sentEmailViaResend = async ({ + memberList, + subject, + react, +}: { + memberList: { email: string }[]; + subject: string; + react: ReactElement; +}): Promise => { + const resend = new Resend(env.RESEND_API_KEY); + + const emailPromises = memberList.map(async (member) => { + try { + await resend.emails.send({ + from: env.RESEND_FROM_ADDRESS!, + to: member.email, + subject, + react, + }); + return { success: true }; + } catch (error) { + return { success: false, email: member.email, error } as const; + } + }); + const emailResults = await Promise.all(emailPromises); + return emailResults; +}; diff --git a/tests/e2e/settings/preferences.spec.ts b/tests/e2e/settings/preferences.spec.ts index b72e0192f..5d1d5d443 100644 --- a/tests/e2e/settings/preferences.spec.ts +++ b/tests/e2e/settings/preferences.spec.ts @@ -69,4 +69,55 @@ test.describe("Settings - User preferences", () => { await waitForSettingsSaved(page); await expect(autoAssignSwitch).toBeChecked({ checked: isEnabled }); }); + + test("should allow toggling Daily Email Reports on/off", async ({ page }) => { + const dailySetting = page.locator('section:has(h2:text("Daily Email Reports"))'); + const dailySwitch = page.locator('[aria-label="Daily Email Reports Switch"]'); + + await expect(dailySetting).toBeVisible(); + + const initiallyEnabled = await dailySwitch.isChecked(); + await dailySwitch.click(); + await waitForSettingsSaved(page); + await expect(dailySwitch).toBeChecked({ checked: !initiallyEnabled }); + + // Toggle back to original state to avoid test pollution + await dailySwitch.click(); + await waitForSettingsSaved(page); + await expect(dailySwitch).toBeChecked({ checked: initiallyEnabled }); + }); + + test("should allow toggling Weekly Email Reports on/off", async ({ page }) => { + const weeklySetting = page.locator('section:has(h2:text("Weekly Email Reports"))'); + const weeklySwitch = page.locator('[aria-label="Weekly Email Reports Switch"]'); + + await expect(weeklySetting).toBeVisible(); + + const initiallyEnabled = await weeklySwitch.isChecked(); + await weeklySwitch.click(); + await waitForSettingsSaved(page); + await expect(weeklySwitch).toBeChecked({ checked: !initiallyEnabled }); + + // Toggle back to original state to avoid test pollution + await weeklySwitch.click(); + await waitForSettingsSaved(page); + await expect(weeklySwitch).toBeChecked({ checked: initiallyEnabled }); + }); + + test("should allow toggling VIP Message Email Alerts on/off", async ({ page }) => { + const vipSetting = page.locator('section:has(h2:text("VIP Message Email Alerts"))'); + const vipSwitch = page.locator('[aria-label="VIP Message Email Alerts Switch"]'); + + await expect(vipSetting).toBeVisible(); + + const initiallyEnabled = await vipSwitch.isChecked(); + await vipSwitch.click(); + await waitForSettingsSaved(page); + await expect(vipSwitch).toBeChecked({ checked: !initiallyEnabled }); + + // Toggle back to original state to avoid test pollution + await vipSwitch.click(); + await waitForSettingsSaved(page); + await expect(vipSwitch).toBeChecked({ checked: initiallyEnabled }); + }); }); diff --git a/tests/jobs/generateDailyReports.test.ts b/tests/jobs/generateDailyReports.test.ts index f429284ac..11e7583b5 100644 --- a/tests/jobs/generateDailyReports.test.ts +++ b/tests/jobs/generateDailyReports.test.ts @@ -1,69 +1,70 @@ import { faker } from "@faker-js/faker"; +import { render } from "@react-email/render"; import { conversationFactory } from "@tests/support/factories/conversations"; import { platformCustomerFactory } from "@tests/support/factories/platformCustomers"; import { userFactory } from "@tests/support/factories/users"; +import { mockJobs } from "@tests/support/jobsUtils"; import { subHours } from "date-fns"; +import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { generateMailboxDailyReport } from "@/jobs/generateDailyReports"; -import { getMailbox } from "@/lib/data/mailbox"; -import { postSlackMessage } from "@/lib/slack/client"; +import { db } from "@/db/client"; +import { userProfiles } from "@/db/schema"; +import { generateDailyEmailReports, generateMailboxEmailReport } from "@/jobs/generateDailyReports"; +import { sentEmailViaResend } from "@/lib/resend/client"; -vi.mock("@/lib/data/mailbox", () => ({ - getMailbox: vi.fn(), +vi.mock("@/lib/resend/client", () => ({ + sentEmailViaResend: vi.fn(), })); +vi.mocked(sentEmailViaResend).mockResolvedValue([{ success: true }]); +const jobsMock = mockJobs(); -vi.mock("@/lib/slack/client", () => ({ - postSlackMessage: vi.fn(), -})); +const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const expectEmailTableRow = (html: string, label: string, value: string | number) => { + const v = String(value); + const rx = new RegExp( + `]*>\\s*]*>\\s*${escapeRegExp(label)}\\s*<\\/td>\\s*]*>\\s*${escapeRegExp(v)}\\s*<\\/td>\\s*<\\/tr>`, + "i", + ); + expect(rx.test(html)).toBe(true); +}; -describe("generateMailboxDailyReport", () => { +describe("generateDailyReports", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("skips when mailbox has no slack configuration", async () => { - vi.mocked(getMailbox).mockResolvedValue({ - id: 1, - name: "Test Mailbox", - slackBotToken: null, - slackAlertChannel: null, - vipThreshold: null, - } as any); + it("sends daily email reports for mailboxes", async () => { + await userFactory.createRootUser(); + + await userFactory.createRootUser(); - const result = await generateMailboxDailyReport(); + await generateDailyEmailReports(); - expect(result).toBeUndefined(); - expect(postSlackMessage).not.toHaveBeenCalled(); + expect(jobsMock.triggerEvent).toHaveBeenCalledTimes(1); + expect(jobsMock.triggerEvent).toHaveBeenCalledWith("reports/daily", {}); }); +}); - it("skips when there are no open tickets", async () => { - const { mailbox } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "test-token", - slackAlertChannel: "test-channel", - }, - }); +describe("generateMailboxEmailReport", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - vi.mocked(getMailbox).mockResolvedValue(mailbox); + it("skips when there are no open tickets", async () => { + const { mailbox } = await userFactory.createRootUser(); - const result = await generateMailboxDailyReport(); + const result = await generateMailboxEmailReport({ mailbox }); expect(result).toEqual({ skipped: true, reason: "No open tickets", }); - expect(postSlackMessage).not.toHaveBeenCalled(); + expect(sentEmailViaResend).not.toHaveBeenCalled(); }); it("calculates correct metrics for basic scenarios", async () => { - const { mailbox, user } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "test-token", - slackAlertChannel: "test-channel", - }, - }); - - vi.mocked(getMailbox).mockResolvedValue(mailbox); + const { mailbox, user } = await userFactory.createRootUser(); const endTime = new Date(); const midTime = subHours(endTime, 12); @@ -96,36 +97,39 @@ describe("generateMailboxDailyReport", () => { responseToId: userMsg2.id, }); - const result = await generateMailboxDailyReport(); + const result = await generateMailboxEmailReport({ mailbox }); expect(result).toEqual({ success: true, - openCountMessage: "• Open tickets: 2", - answeredCountMessage: "• Tickets answered: 2", - openTicketsOverZeroMessage: null, - answeredTicketsOverZeroMessage: null, - avgReplyTimeMessage: "• Average reply time: 1h 30m", - vipAvgReplyTimeMessage: null, - avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m", - }); - - expect(postSlackMessage).toHaveBeenCalledWith("test-token", { - channel: "test-channel", - text: `Daily summary for ${mailbox.name}`, - blocks: expect.any(Array), - }); + openTicketCount: 2, + answeredTicketCount: 2, + openTicketsOverZeroCount: 0, + answeredTicketsOverZeroCount: 0, + avgReplyTimeResult: "1h 30m", + vipAvgReplyTime: null, + avgWaitTime: "12h 0m", + }); + + expect(sentEmailViaResend).toHaveBeenCalledWith( + expect.objectContaining({ + subject: `Daily summary for ${mailbox.name}`, + memberList: expect.arrayContaining([{ email: user.email! }]), + react: expect.anything(), + }), + ); + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + const html = await render(call.react); + expectEmailTableRow(html, "Open tickets", 2); + expectEmailTableRow(html, "Tickets answered", 2); + expectEmailTableRow(html, "Open tickets over $0", 0); + expectEmailTableRow(html, "Tickets answered over $0", 0); + expectEmailTableRow(html, "Average reply time", "1h 30m"); + expectEmailTableRow(html, "VIP average reply time", "—"); + expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m"); }); it("calculates correct metrics with VIP customers", async () => { - const { mailbox, user } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "test-token", - slackAlertChannel: "test-channel", - vipThreshold: 100, - }, - }); - - vi.mocked(getMailbox).mockResolvedValue(mailbox); + const { mailbox, user } = await userFactory.createRootUser({ mailboxOverrides: { vipThreshold: 100 } }); const endTime = new Date(); const midTime = subHours(endTime, 12); @@ -169,29 +173,32 @@ describe("generateMailboxDailyReport", () => { responseToId: vipUserMsg.id, }); - const result = await generateMailboxDailyReport(); + const result = await generateMailboxEmailReport({ mailbox }); expect(result).toEqual({ success: true, - openCountMessage: "• Open tickets: 2", - answeredCountMessage: "• Tickets answered: 2", - openTicketsOverZeroMessage: "• Open tickets over $0: 2", - answeredTicketsOverZeroMessage: "• Tickets answered over $0: 2", - avgReplyTimeMessage: "• Average reply time: 0h 45m", - vipAvgReplyTimeMessage: "• VIP average reply time: 0h 30m", - avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m", - }); + openTicketCount: 2, + answeredTicketCount: 2, + openTicketsOverZeroCount: 2, + answeredTicketsOverZeroCount: 2, + avgReplyTimeResult: "0h 45m", + vipAvgReplyTime: "0h 30m", + avgWaitTime: "12h 0m", + }); + + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + const html = await render(call.react); + expectEmailTableRow(html, "Open tickets", 2); + expectEmailTableRow(html, "Tickets answered", 2); + expectEmailTableRow(html, "Open tickets over $0", 2); + expectEmailTableRow(html, "Tickets answered over $0", 2); + expectEmailTableRow(html, "Average reply time", "0h 45m"); + expectEmailTableRow(html, "VIP average reply time", "0h 30m"); + expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m"); }); it("handles scenarios with no platform customers", async () => { - const { mailbox, user } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "test-token", - slackAlertChannel: "test-channel", - }, - }); - - vi.mocked(getMailbox).mockResolvedValue(mailbox); + const { mailbox, user } = await userFactory.createRootUser(); const endTime = new Date(); const midTime = subHours(endTime, 12); @@ -210,29 +217,32 @@ describe("generateMailboxDailyReport", () => { responseToId: userMsg.id, }); - const result = await generateMailboxDailyReport(); + const result = await generateMailboxEmailReport({ mailbox }); expect(result).toEqual({ success: true, - openCountMessage: "• Open tickets: 1", - answeredCountMessage: "• Tickets answered: 1", - openTicketsOverZeroMessage: null, - answeredTicketsOverZeroMessage: null, - avgReplyTimeMessage: "• Average reply time: 1h 0m", - vipAvgReplyTimeMessage: null, - avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m", - }); + openTicketCount: 1, + answeredTicketCount: 1, + openTicketsOverZeroCount: 0, + answeredTicketsOverZeroCount: 0, + avgReplyTimeResult: "1h 0m", + vipAvgReplyTime: null, + avgWaitTime: "12h 0m", + }); + + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + const html = await render(call.react); + expectEmailTableRow(html, "Open tickets", 1); + expectEmailTableRow(html, "Tickets answered", 1); + expectEmailTableRow(html, "Open tickets over $0", 0); + expectEmailTableRow(html, "Tickets answered over $0", 0); + expectEmailTableRow(html, "Average reply time", "1h 0m"); + expectEmailTableRow(html, "VIP average reply time", "—"); + expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m"); }); it("handles zero-value platform customers correctly", async () => { - const { mailbox, user } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "test-token", - slackAlertChannel: "test-channel", - }, - }); - - vi.mocked(getMailbox).mockResolvedValue(mailbox); + const { mailbox, user } = await userFactory.createRootUser(); const endTime = new Date(); const midTime = subHours(endTime, 12); @@ -259,29 +269,32 @@ describe("generateMailboxDailyReport", () => { responseToId: userMsg.id, }); - const result = await generateMailboxDailyReport(); + const result = await generateMailboxEmailReport({ mailbox }); expect(result).toEqual({ success: true, - openCountMessage: "• Open tickets: 1", - answeredCountMessage: "• Tickets answered: 1", - openTicketsOverZeroMessage: null, - answeredTicketsOverZeroMessage: null, - avgReplyTimeMessage: "• Average reply time: 1h 0m", - vipAvgReplyTimeMessage: null, - avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m", - }); + openTicketCount: 1, + answeredTicketCount: 1, + openTicketsOverZeroCount: 0, + answeredTicketsOverZeroCount: 0, + avgReplyTimeResult: "1h 0m", + vipAvgReplyTime: null, + avgWaitTime: "12h 0m", + }); + + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + const html = await render(call.react); + expectEmailTableRow(html, "Open tickets", 1); + expectEmailTableRow(html, "Tickets answered", 1); + expectEmailTableRow(html, "Open tickets over $0", 0); + expectEmailTableRow(html, "Tickets answered over $0", 0); + expectEmailTableRow(html, "Average reply time", "1h 0m"); + expectEmailTableRow(html, "VIP average reply time", "—"); + expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m"); }); it("excludes merged conversations from counts", async () => { - const { mailbox, user } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "test-token", - slackAlertChannel: "test-channel", - }, - }); - - vi.mocked(getMailbox).mockResolvedValue(mailbox); + const { mailbox, user } = await userFactory.createRootUser(); const endTime = new Date(); const midTime = subHours(endTime, 12); @@ -305,29 +318,32 @@ describe("generateMailboxDailyReport", () => { responseToId: userMsg.id, }); - const result = await generateMailboxDailyReport(); + const result = await generateMailboxEmailReport({ mailbox }); expect(result).toEqual({ success: true, - openCountMessage: "• Open tickets: 1", - answeredCountMessage: "• Tickets answered: 1", - openTicketsOverZeroMessage: null, - answeredTicketsOverZeroMessage: null, - avgReplyTimeMessage: "• Average reply time: 1h 0m", - vipAvgReplyTimeMessage: null, - avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m", - }); + openTicketCount: 1, + answeredTicketCount: 1, + openTicketsOverZeroCount: 0, + answeredTicketsOverZeroCount: 0, + avgReplyTimeResult: "1h 0m", + vipAvgReplyTime: null, + avgWaitTime: "12h 0m", + }); + + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + const html = await render(call.react); + expectEmailTableRow(html, "Open tickets", 1); + expectEmailTableRow(html, "Tickets answered", 1); + expectEmailTableRow(html, "Open tickets over $0", 0); + expectEmailTableRow(html, "Tickets answered over $0", 0); + expectEmailTableRow(html, "Average reply time", "1h 0m"); + expectEmailTableRow(html, "VIP average reply time", "—"); + expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m"); }); it("only counts messages within the 24-hour window", async () => { - const { mailbox, user } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "test-token", - slackAlertChannel: "test-channel", - }, - }); - - vi.mocked(getMailbox).mockResolvedValue(mailbox); + const { mailbox, user } = await userFactory.createRootUser(); const endTime = new Date(); const beforeWindow = subHours(endTime, 30); @@ -352,17 +368,58 @@ describe("generateMailboxDailyReport", () => { responseToId: userMsg.id, }); - const result = await generateMailboxDailyReport(); + const result = await generateMailboxEmailReport({ mailbox }); expect(result).toEqual({ success: true, - openCountMessage: "• Open tickets: 1", - answeredCountMessage: "• Tickets answered: 1", - openTicketsOverZeroMessage: null, - answeredTicketsOverZeroMessage: null, - avgReplyTimeMessage: "• Average reply time: 1h 0m", - vipAvgReplyTimeMessage: null, - avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m", + openTicketCount: 1, + answeredTicketCount: 1, + openTicketsOverZeroCount: 0, + answeredTicketsOverZeroCount: 0, + avgReplyTimeResult: "1h 0m", + vipAvgReplyTime: null, + avgWaitTime: "12h 0m", + }); + + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + const html = await render(call.react); + expectEmailTableRow(html, "Open tickets", 1); + expectEmailTableRow(html, "Tickets answered", 1); + expectEmailTableRow(html, "Open tickets over $0", 0); + expectEmailTableRow(html, "Tickets answered over $0", 0); + expectEmailTableRow(html, "Average reply time", "1h 0m"); + expectEmailTableRow(html, "VIP average reply time", "—"); + expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m"); + }); + + it("excludes users with allowDailyEmail=false from recipients", async () => { + const { mailbox, user: u1 } = await userFactory.createRootUser({ + userOverrides: { email: "a@example.com" }, + }); + const { user: u2 } = await userFactory.createRootUser({ + userOverrides: { email: "b@example.com" }, + }); + await userFactory.createRootUser({ + userOverrides: { email: "c@example.com" }, }); + + await db + .update(userProfiles) + .set({ preferences: { allowDailyEmail: false } }) + .where(eq(userProfiles.id, u1.id)); + await db + .update(userProfiles) + .set({ preferences: { allowDailyEmail: false } }) + .where(eq(userProfiles.id, u2.id)); + + // Ensure there is at least one open ticket so the report is sent + await conversationFactory.create({ status: "open", lastUserEmailCreatedAt: subHours(new Date(), 6) }); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(sentEmailViaResend).toHaveBeenCalledTimes(1); + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + expect(call.memberList).toEqual([{ email: "c@example.com" }]); + expect(result).toEqual(expect.objectContaining({ success: true })); }); }); diff --git a/tests/jobs/generateWeeklyReports.test.ts b/tests/jobs/generateWeeklyReports.test.ts index a50fea484..81821e068 100644 --- a/tests/jobs/generateWeeklyReports.test.ts +++ b/tests/jobs/generateWeeklyReports.test.ts @@ -1,19 +1,23 @@ +import { render } from "@react-email/render"; import { userFactory } from "@tests/support/factories/users"; import { mockJobs } from "@tests/support/jobsUtils"; +import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { generateMailboxReport, generateWeeklyReports } from "@/jobs/generateWeeklyReports"; +import { db } from "@/db/client"; +import { userProfiles } from "@/db/schema"; +import { generateMailboxEmailReport, generateWeeklyEmailReports } from "@/jobs/generateWeeklyReports"; import { getMemberStats } from "@/lib/data/stats"; -import { getSlackUsersByEmail, postSlackMessage } from "@/lib/slack/client"; +import { sentEmailViaResend } from "@/lib/resend/client"; // Mock dependencies vi.mock("@/lib/data/stats", () => ({ getMemberStats: vi.fn(), })); -vi.mock("@/lib/slack/client", () => ({ - postSlackMessage: vi.fn(), - getSlackUsersByEmail: vi.fn(), +vi.mock("@/lib/resend/client", () => ({ + sentEmailViaResend: vi.fn(), })); +vi.mocked(sentEmailViaResend).mockResolvedValue([{ success: true }]); vi.mock("@/lib/data/user", async (importOriginal) => ({ ...(await importOriginal()), @@ -31,22 +35,12 @@ describe("generateWeeklyReports", () => { vi.clearAllMocks(); }); - it("sends weekly report events for mailboxes with Slack configured", async () => { - await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "valid-token", - slackAlertChannel: "channel-id", - }, - }); + it("sends weekly email reports for mailboxes", async () => { + await userFactory.createRootUser(); - await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: null, - slackAlertChannel: null, - }, - }); + await userFactory.createRootUser(); - await generateWeeklyReports(); + await generateWeeklyEmailReports(); expect(jobsMock.triggerEvent).toHaveBeenCalledTimes(1); expect(jobsMock.triggerEvent).toHaveBeenCalledWith("reports/weekly", {}); @@ -58,189 +52,126 @@ describe("generateMailboxWeeklyReport", () => { vi.clearAllMocks(); }); - it("generates and posts report to Slack when there are stats", async () => { - const { mailbox } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "valid-token", - slackAlertChannel: "channel-id", - }, + it("sends emails when there are stats", async () => { + const { mailbox, user } = await userFactory.createRootUser({ + userOverrides: { email: "john@example.com" }, }); vi.mocked(getMemberStats).mockResolvedValue([ { id: "user1", email: "john@example.com", displayName: "John Doe", replyCount: 5 }, ]); - vi.mocked(getSlackUsersByEmail).mockResolvedValue(new Map([["john@example.com", "SLACK123"]])); + const result = await generateMailboxEmailReport({ mailbox }); - const result = await generateMailboxReport({ - mailbox, - slackBotToken: mailbox.slackBotToken!, - slackAlertChannel: mailbox.slackAlertChannel!, - }); - - expect(postSlackMessage).toHaveBeenCalledWith( - "valid-token", + expect(sentEmailViaResend).toHaveBeenCalledWith( expect.objectContaining({ - channel: "channel-id", - blocks: [ - { - type: "section", - text: { - type: "plain_text", - text: `Last week in the ${mailbox.name} mailbox:`, - emoji: true, - }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: "*Team members:*", - }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: "• <@SLACK123>: 5", - }, - }, - { - type: "divider", - }, - { - type: "section", - text: { - type: "mrkdwn", - text: "*Total replies:*\n5 from 1 person", - }, - }, - ], - text: expect.stringMatching(/Week of \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2}/), + subject: `Weekly report for ${mailbox.name}`, + memberList: expect.arrayContaining([{ email: user.email! }]), + react: expect.anything(), }), ); - - expect(result).toBe("Report sent"); + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + const html = await render(call.react); + expect(html).toContain("John Doe"); + expect(html).toMatch(/>5<\/td>/); + expect(call.subject).toBe(`Weekly report for ${mailbox.name}`); + + // react-emails may splits text nodes with comment markers to preserve whitespace/structure + const normalized = html + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + expect(normalized).toContain("from 1 person"); + + expect(result).toBe("Email sent"); }); - it("generates and posts report with both core and non-core members", async () => { - const { mailbox } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "valid-token", - slackAlertChannel: "channel-id", - }, - }); + it("sends emails with inactive members and correct totals", async () => { + const { mailbox } = await userFactory.createRootUser({ userOverrides: { email: "john@example.com" } }); - // Create mock data with both core and non-core members, active and inactive + // Active and inactive mix. Totals: 10 + 5 + 8 + 3 = 26 from 4 people vi.mocked(getMemberStats).mockResolvedValue([ - // Active core members { id: "user1", email: "john@example.com", displayName: "John Doe", replyCount: 10 }, { id: "user2", email: "jane@example.com", displayName: "Jane Smith", replyCount: 5 }, - // Inactive core member { id: "user3", email: "alex@example.com", displayName: "Alex Johnson", replyCount: 0 }, - // Active non-core members { id: "user4", email: "sam@example.com", displayName: "Sam Wilson", replyCount: 8 }, { id: "user5", email: "pat@example.com", displayName: "Pat Brown", replyCount: 3 }, - // Inactive non-core member { id: "user6", email: "chris@example.com", displayName: "Chris Lee", replyCount: 0 }, - // AFK member { id: "user7", email: "bob@example.com", displayName: "Bob White", replyCount: 0 }, ]); - vi.mocked(getSlackUsersByEmail).mockResolvedValue( - new Map([ - ["john@example.com", "SLACK1"], - ["jane@example.com", "SLACK2"], - ["alex@example.com", "SLACK3"], - ["sam@example.com", "SLACK4"], - ["pat@example.com", "SLACK5"], - ["chris@example.com", "SLACK6"], - ["bob@example.com", "SLACK7"], - ]), - ); - - const result = await generateMailboxReport({ - mailbox, - slackBotToken: mailbox.slackBotToken!, - slackAlertChannel: mailbox.slackAlertChannel!, - }); + const result = await generateMailboxEmailReport({ mailbox }); - expect(postSlackMessage).toHaveBeenCalledWith( - "valid-token", + expect(sentEmailViaResend).toHaveBeenCalledWith( expect.objectContaining({ - channel: "channel-id", - blocks: expect.arrayContaining([ - // Header - { - type: "section", - text: { - type: "plain_text", - text: `Last week in the ${mailbox.name} mailbox:`, - emoji: true, - }, - }, - // Team members header - { - type: "section", - text: { - type: "mrkdwn", - text: "*Team members:*", - }, - }, - // Team members mention by slack ID - { - type: "section", - text: { - type: "mrkdwn", - text: "• <@SLACK1>: 10\n• <@SLACK4>: 8\n• <@SLACK2>: 5\n• <@SLACK5>: 3", - }, - }, - // Inactive members - { - type: "section", - text: { - type: "mrkdwn", - text: "*No tickets answered:* <@SLACK3>, <@SLACK6>, <@SLACK7>", - }, - }, - // Divider before total - { - type: "divider", - }, - // Total replies - { - type: "section", - text: { - type: "mrkdwn", - text: "*Total replies:*\n26 from 4 people", - }, - }, - // AFK member NOT mentioned - ]), - text: expect.stringMatching(/Week of \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2}/), + subject: `Weekly report for ${mailbox.name}`, + react: expect.anything(), }), ); - expect(result).toBe("Report sent"); + const call = vi.mocked(sentEmailViaResend).mock.calls.at(-1)![0]; + const html = await render(call.react); + const normalized = html + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + + // Inactive members section should list the names + expect(normalized).toContain("No tickets answered:"); + expect(normalized).toContain("Alex Johnson, Chris Lee, Bob White"); + + // Totals block + expect(normalized).toContain("26 replies"); + expect(normalized).toContain("from 4 people"); + + expect(result).toBe("Email sent"); }); - it("skips report generation when there are no stats", async () => { - const { mailbox } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "valid-token", - slackAlertChannel: "channel-id", - }, - }); + it("skips sending emails when there are no stats", async () => { + const { mailbox } = await userFactory.createRootUser(); vi.mocked(getMemberStats).mockResolvedValue([]); - const result = await generateMailboxReport({ + const result = await generateMailboxEmailReport({ mailbox, - slackBotToken: mailbox.slackBotToken!, - slackAlertChannel: mailbox.slackAlertChannel!, }); - expect(postSlackMessage).not.toHaveBeenCalled(); - expect(result).toBe("No stats found"); + expect(sentEmailViaResend).not.toHaveBeenCalled(); + expect(result).toEqual({ + skipped: true, + reason: "No stats found", + }); + }); + + it("excludes users with allowWeeklyEmail=false from recipients", async () => { + const { mailbox, user: u1 } = await userFactory.createRootUser({ + userOverrides: { email: "a@example.com" }, + }); + const { user: u2 } = await userFactory.createRootUser({ + userOverrides: { email: "b@example.com" }, + }); + const { user: u3 } = await userFactory.createRootUser({ + userOverrides: { email: "c@example.com" }, + }); + + await db + .update(userProfiles) + .set({ preferences: { allowWeeklyEmail: false } }) + .where(eq(userProfiles.id, u1.id)); + await db + .update(userProfiles) + .set({ preferences: { allowWeeklyEmail: false } }) + .where(eq(userProfiles.id, u2.id)); + + vi.mocked(getMemberStats).mockResolvedValue([ + { id: u3.id, email: u3.email!, displayName: "User C", replyCount: 3 }, + ]); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(sentEmailViaResend).toHaveBeenCalledTimes(1); + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + expect(call.memberList).toEqual([{ email: "c@example.com" }]); + expect(result).toBe("Email sent"); }); }); diff --git a/tests/jobs/notifyVipMessage.test.ts b/tests/jobs/notifyVipMessage.test.ts new file mode 100644 index 000000000..0d23761bc --- /dev/null +++ b/tests/jobs/notifyVipMessage.test.ts @@ -0,0 +1,183 @@ +import { render } from "@react-email/render"; +import { conversationFactory } from "@tests/support/factories/conversations"; +import { platformCustomerFactory } from "@tests/support/factories/platformCustomers"; +import { userFactory } from "@tests/support/factories/users"; +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, inject, it, vi } from "vitest"; +import { db } from "@/db/client"; +import { mailboxes, userProfiles } from "@/db/schema"; +import { notifyVipMessageEmail } from "@/jobs/notifyVipMessage"; +import { sentEmailViaResend } from "@/lib/resend/client"; + +vi.mock("@/lib/env", () => ({ + env: { + POSTGRES_URL: inject("TEST_DATABASE_URL"), + RESEND_API_KEY: "test-api-key", + RESEND_FROM_ADDRESS: "test@example.com", + AUTH_URL: "https://helperai.dev", + }, +})); + +// Mock email sender +vi.mock("@/lib/resend/client", () => ({ + sentEmailViaResend: vi.fn(), +})); + +vi.mocked(sentEmailViaResend).mockResolvedValue([{ success: true }]); + +describe("notifyVipMessageEmail", () => { + beforeEach(async () => { + vi.clearAllMocks(); + // Ensure any existing mailboxes will qualify and have a threshold + // so VIP determination can be made deterministically in tests. + try { + await db.update(mailboxes).set({ vipThreshold: 500 }); + } catch (_) { + // ignore if no rows yet + } + }); + + it("sends a VIP email for a new user message", async () => { + const { user } = await userFactory.createRootUser({ + userOverrides: { email: "agent@example.com" }, + }); + + // Make sure the selected mailbox has a threshold + await db.update(mailboxes).set({ vipThreshold: 500 }); + + // VIP customer record with high value so they pass threshold + const vipEmail = "vip@example.com"; + await platformCustomerFactory.create({ + email: vipEmail, + name: "Acme VIP", + value: "60000", // 600.00 + links: { Dashboard: "https://example.com/dashboard" }, + }); + + const { conversation } = await conversationFactory.create({ + emailFrom: vipEmail, + status: "open", + }); + const userMsg = await conversationFactory.createUserEmail(conversation.id, { + cleanedUpText: "Hello from the VIP!", + }); + + const result = await notifyVipMessageEmail({ messageId: userMsg.id }); + + expect(result).toBe("Email sent"); + expect(sentEmailViaResend).toHaveBeenCalledTimes(1); + expect(sentEmailViaResend).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "VIP Customer: Acme VIP", + memberList: expect.arrayContaining([{ email: user.email! }]), + react: expect.anything(), + }), + ); + + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + const html = await render(call.react); + const normalized = html + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + + expect(normalized).toContain("VIP Customer"); + expect(normalized).toContain("Original message:"); + expect(normalized).toContain("Hello from the VIP!"); + expect(normalized).toContain("View in Helper"); + }); + + it("sends a VIP email for a staff reply and shows Closed by", async () => { + await userFactory.createRootUser({ userOverrides: { email: "team@example.com" } }); + const { user: staffUser } = await userFactory.createRootUser({ userOverrides: { email: "staff@example.com" } }); + + await db.update(userProfiles).set({ displayName: "Agent Smith" }).where(eq(userProfiles.id, staffUser.id)); + await db.update(mailboxes).set({ vipThreshold: 500 }); + + const vipEmail = "vip2@example.com"; + await platformCustomerFactory.create({ email: vipEmail, name: "VIP 2", value: "99999" }); + + const { conversation } = await conversationFactory.create({ emailFrom: vipEmail, status: "closed" }); + const original = await conversationFactory.createUserEmail(conversation.id, { + cleanedUpText: "Customer said hi", + }); + const staffReply = await conversationFactory.createStaffEmail(conversation.id, staffUser.id, { + responseToId: original.id, + cleanedUpText: "Replying to your message", + status: "sent", + }); + + const result = await notifyVipMessageEmail({ messageId: staffReply.id }); + expect(result).toBe("Email sent"); + + const call = vi.mocked(sentEmailViaResend).mock.calls.at(-1)![0]; + expect(call.subject).toBe("VIP Customer: VIP 2"); + const html = await render(call.react); + const normalized = html + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + expect(normalized).toContain("Original message:"); + expect(normalized).toContain("Customer said hi"); + expect(normalized).toContain("Reply:"); + expect(normalized).toContain("Replying to your message"); + expect(normalized).toContain("Closed by Agent Smith"); + }); + + it("skips when customer is not VIP", async () => { + await userFactory.createRootUser(); + await db.update(mailboxes).set({ vipThreshold: 1000 }); + + // No platform customer record for this email => not VIP + const { conversation } = await conversationFactory.create({ emailFrom: "random@example.com", status: "open" }); + const userMsg = await conversationFactory.createUserEmail(conversation.id); + + const result = await notifyVipMessageEmail({ messageId: userMsg.id }); + expect(result).toEqual({ skipped: true, reason: "Not a VIP customer" }); + expect(sentEmailViaResend).not.toHaveBeenCalled(); + }); + + it("excludes users with allowVipMessageEmail=false from recipients", async () => { + const { user: u1 } = await userFactory.createRootUser({ userOverrides: { email: "a@example.com" } }); + const { user: u2 } = await userFactory.createRootUser({ userOverrides: { email: "b@example.com" } }); + await userFactory.createRootUser({ userOverrides: { email: "c@example.com" } }); + + // u1 and u2 opt out; u3 should receive + await db + .update(userProfiles) + .set({ preferences: { allowVipMessageEmail: false } }) + .where(eq(userProfiles.id, u1.id)); + await db + .update(userProfiles) + .set({ preferences: { allowVipMessageEmail: false } }) + .where(eq(userProfiles.id, u2.id)); + + // Ensure VIP threshold low enough to qualify + await db.update(mailboxes).set({ vipThreshold: 100 }); + + const vipEmail = "vip3@example.com"; + await platformCustomerFactory.create({ email: vipEmail, name: "VIP 3", value: "50000" }); + + const { conversation } = await conversationFactory.create({ emailFrom: vipEmail }); + const msg = await conversationFactory.createUserEmail(conversation.id); + + const result = await notifyVipMessageEmail({ messageId: msg.id }); + + expect(sentEmailViaResend).toHaveBeenCalledTimes(1); + const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; + expect(call.memberList).toEqual([{ email: "c@example.com" }]); + expect(result).toBe("Email sent"); + }); + + it("skips for anonymous conversations", async () => { + await userFactory.createRootUser(); + await db.update(mailboxes).set({ vipThreshold: 100 }); + + const { conversation } = await conversationFactory.create({ emailFrom: null }); + const msg = await conversationFactory.createUserEmail(conversation.id); + + const result = await notifyVipMessageEmail({ messageId: msg.id }); + expect(result).toEqual({ skipped: true, reason: "Anonymous conversation" }); + expect(sentEmailViaResend).not.toHaveBeenCalled(); + }); +}); diff --git a/trpc/router/user.ts b/trpc/router/user.ts index f9d6ff091..02d8e4b35 100644 --- a/trpc/router/user.ts +++ b/trpc/router/user.ts @@ -193,6 +193,9 @@ export const userRouter = { confetti: z.boolean().optional(), disableNextTicketPreview: z.boolean().optional(), autoAssignOnReply: z.boolean().optional(), + allowDailyEmail: z.boolean().optional(), + allowWeeklyEmail: z.boolean().optional(), + allowVipMessageEmail: z.boolean().optional(), }) .optional(), }),