From f730b21f373c886bd00986e69ead8613674e8c16 Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Sat, 8 Nov 2025 20:57:54 +0530 Subject: [PATCH 01/16] added resend integration --- jobs/generateDailyEmailReports.ts | 208 +++++++++++++++++++++++++++++ jobs/generateWeeklyEmailReports.ts | 141 +++++++++++++++++++ jobs/index.ts | 10 +- jobs/notifyVipMessageByEmail.ts | 159 ++++++++++++++++++++++ jobs/trigger.ts | 5 +- lib/emails/dailyReports.tsx | 86 ++++++++++++ lib/emails/vipNotification.tsx | 106 +++++++++++++++ lib/emails/weeklyReports.tsx | 108 +++++++++++++++ 8 files changed, 819 insertions(+), 4 deletions(-) create mode 100644 jobs/generateDailyEmailReports.ts create mode 100644 jobs/generateWeeklyEmailReports.ts create mode 100644 jobs/notifyVipMessageByEmail.ts create mode 100644 lib/emails/dailyReports.tsx create mode 100644 lib/emails/vipNotification.tsx create mode 100644 lib/emails/weeklyReports.tsx diff --git a/jobs/generateDailyEmailReports.ts b/jobs/generateDailyEmailReports.ts new file mode 100644 index 000000000..12a182b4a --- /dev/null +++ b/jobs/generateDailyEmailReports.ts @@ -0,0 +1,208 @@ +import { subHours } from "date-fns"; +import { aliasedTable, and, eq, gt, isNotNull, isNull, lt, sql } from "drizzle-orm"; +import React from "react"; +import { Resend } from "resend"; +import { db } from "@/db/client"; +import { conversationMessages, conversations, mailboxes, platformCustomers, userProfiles } from "@/db/schema"; +import { authUsers } from "@/db/supabaseSchema/auth"; +import { triggerEvent } from "@/jobs/trigger"; +import { DailyReportEmail } from "@/lib/emails/dailyReports"; +import { env } from "@/lib/env"; +import { captureExceptionAndLog } from "@/lib/shared/sentry"; + +export const TIME_ZONE = "America/New_York"; + +export async function generateDailyEmailReports() { + const mailboxesList = await db.query.mailboxes.findMany({ + columns: { id: true }, + }); + + if (!mailboxesList.length) return; + + await triggerEvent("reports/daily", {}); +} + +export async function generateMailboxDailyEmailReport() { + // Inline getMailbox() to avoid importing a module that uses `server-only` (which breaks in tsx runtime) + const mailbox = await db.query.mailboxes.findFirst({ + where: isNull(sql`${mailboxes.preferences}->>'disabled'`), + }); + if (!mailbox) return; + + if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) { + return { skipped: true, reason: "Email not configured" }; + } + + const endTime = new Date(); + const startTime = subHours(endTime, 24); + + const openTicketCount = await db.$count( + conversations, + and(eq(conversations.status, "open"), isNull(conversations.mergedIntoId)), + ); + + if (openTicketCount === 0) return { skipped: true, reason: "No open tickets" }; + + const answeredTicketCount = await db + .select({ count: sql`count(DISTINCT ${conversations.id})` }) + .from(conversationMessages) + .innerJoin(conversations, eq(conversationMessages.conversationId, conversations.id)) + .where( + and( + eq(conversationMessages.role, "staff"), + gt(conversationMessages.createdAt, startTime), + lt(conversationMessages.createdAt, endTime), + isNull(conversations.mergedIntoId), + ), + ) + .then((result) => Number(result[0]?.count || 0)); + + const openTicketsOverZeroCount = await db + .select({ count: sql`count(*)` }) + .from(conversations) + .leftJoin(platformCustomers, and(eq(conversations.emailFrom, platformCustomers.email))) + .where( + and( + eq(conversations.status, "open"), + isNull(conversations.mergedIntoId), + gt(sql`CAST(${platformCustomers.value} AS INTEGER)`, 0), + ), + ) + .then((result) => Number(result[0]?.count || 0)); + + const answeredTicketsOverZeroCount = await db + .select({ count: sql`count(DISTINCT ${conversations.id})` }) + .from(conversationMessages) + .innerJoin(conversations, eq(conversationMessages.conversationId, conversations.id)) + .leftJoin(platformCustomers, and(eq(conversations.emailFrom, platformCustomers.email))) + .where( + and( + eq(conversationMessages.role, "staff"), + gt(conversationMessages.createdAt, startTime), + lt(conversationMessages.createdAt, endTime), + isNull(conversations.mergedIntoId), + gt(sql`CAST(${platformCustomers.value} AS INTEGER)`, 0), + ), + ) + .then((result) => Number(result[0]?.count || 0)); + + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + }; + + const userMessages = aliasedTable(conversationMessages, "userMessages"); + const [avgReplyTimeResult] = await db + .select({ + average: sql`ROUND(AVG( + EXTRACT(EPOCH FROM (${conversationMessages.createdAt} - ${userMessages.createdAt})) + ))::integer`, + }) + .from(conversationMessages) + .innerJoin(conversations, eq(conversationMessages.conversationId, conversations.id)) + .innerJoin(userMessages, and(eq(conversationMessages.responseToId, userMessages.id), eq(userMessages.role, "user"))) + .where( + and( + eq(conversationMessages.role, "staff"), + gt(conversationMessages.createdAt, startTime), + lt(conversationMessages.createdAt, endTime), + ), + ); + + let vipAvgReplyTimeMessage = null; + if (mailbox.vipThreshold) { + const [vipReplyTimeResult] = await db + .select({ + average: sql`ROUND(AVG( + EXTRACT(EPOCH FROM (${conversationMessages.createdAt} - ${userMessages.createdAt})) + ))::integer`, + }) + .from(conversationMessages) + .innerJoin(conversations, eq(conversationMessages.conversationId, conversations.id)) + .innerJoin(platformCustomers, eq(conversations.emailFrom, platformCustomers.email)) + .innerJoin( + userMessages, + and(eq(conversationMessages.responseToId, userMessages.id), eq(userMessages.role, "user")), + ) + .where( + and( + eq(conversationMessages.role, "staff"), + gt(conversationMessages.createdAt, startTime), + lt(conversationMessages.createdAt, endTime), + gt(sql`CAST(${platformCustomers.value} AS INTEGER)`, (mailbox.vipThreshold ?? 0) * 100), + ), + ); + vipAvgReplyTimeMessage = vipReplyTimeResult?.average + ? `• VIP average reply time: ${formatTime(vipReplyTimeResult.average)}` + : null; + } + + const [avgWaitTimeResult] = await db + .select({ + average: sql`ROUND(AVG( + EXTRACT(EPOCH FROM (${endTime.toISOString()}::timestamp - ${conversations.lastUserEmailCreatedAt})) + ))::integer`, + }) + .from(conversations) + .where( + and( + eq(conversations.status, "open"), + isNull(conversations.mergedIntoId), + isNotNull(conversations.lastUserEmailCreatedAt), + ), + ); + const avgWaitTimeMessage = 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)) + .limit(100); + + if (teamMembers.length === 0) { + return { skipped: true, reason: "No team members found" }; + } + + const resend = new Resend(env.RESEND_API_KEY); + + const emailPromises = teamMembers.map(async (member) => { + if (!member.email) return { success: false, reason: "No email address" }; + + try { + await resend.emails.send({ + from: env.RESEND_FROM_ADDRESS!, + to: member.email, + subject: `Daily summary for ${mailbox.name}`, + react: React.createElement(DailyReportEmail, { + mailboxName: mailbox.name, + openTickets: openTicketCount, + ticketsAnswered: answeredTicketCount, + openTicketsOverZero: openTicketsOverZeroCount || undefined, + ticketsAnsweredOverZero: answeredTicketsOverZeroCount || undefined, + avgReplyTime: avgReplyTimeResult?.average ? formatTime(avgReplyTimeResult.average) : undefined, + vipAvgReplyTime: vipAvgReplyTimeMessage + ? vipAvgReplyTimeMessage.replace("• VIP average reply time: ", "") + : undefined, + avgWaitTime: avgWaitTimeMessage, + }), + }); + + return { success: true }; + } catch (error) { + captureExceptionAndLog(error); + return { success: false, error }; + } + }); + + const emailResults = await Promise.all(emailPromises); + + return { + success: true, + emailsSent: emailResults.filter((r) => r.success).length, + totalRecipients: teamMembers.length, + }; +} diff --git a/jobs/generateWeeklyEmailReports.ts b/jobs/generateWeeklyEmailReports.ts new file mode 100644 index 000000000..2bfd44b53 --- /dev/null +++ b/jobs/generateWeeklyEmailReports.ts @@ -0,0 +1,141 @@ +import { endOfWeek, startOfWeek, subWeeks } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; +import { eq, isNull, sql } from "drizzle-orm"; +import { Resend } from "resend"; +import { db } from "@/db/client"; +import { mailboxes, userProfiles } from "@/db/schema"; +import { authUsers } from "@/db/supabaseSchema/auth"; +import { TIME_ZONE } from "@/jobs/generateDailyEmailReports"; +import { triggerEvent } from "@/jobs/trigger"; +import { getMemberStats, MemberStats } from "@/lib/data/stats"; +import { WeeklyReportEmail } from "@/lib/emails/weeklyReports"; +import { env } from "@/lib/env"; +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 generateWeeklyEmailReports() { + const mailbox = await db.query.mailboxes.findFirst({ + where: isNull(sql`${mailboxes.preferences}->>'disabled'`), + }); + if (!mailbox) return; + + await triggerEvent("reports/weekly", {}); +} + +export const generateMailboxWeeklyEmailReport = async () => { + const mailbox = await db.query.mailboxes.findFirst({ + where: isNull(sql`${mailboxes.preferences}->>'disabled'`), + }); + if (!mailbox) { + return; + } + + if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) { + return { skipped: true, reason: "Email not configured" }; + } + + const result = await generateMailboxEmailReport({ + mailbox, + }); + + return result; +}; + +export async function generateMailboxEmailReport({ mailbox }: { mailbox: typeof mailboxes.$inferSelect }) { + const now = toZonedTime(new Date(), TIME_ZONE); + const lastWeekStart = subWeeks(startOfWeek(now, { weekStartsOn: 0 }), 1); + const lastWeekEnd = subWeeks(endOfWeek(now, { weekStartsOn: 0 }), 1); + + const stats = await getMemberStats({ + startDate: lastWeekStart, + endDate: lastWeekEnd, + }); + + if (!stats.length) { + return "No stats found"; + } + + const allMembersData = processAllMembers(stats); + + const tableData: { name: string; count: number }[] = []; + + for (const member of stats) { + const name = member.displayName || member.email || `Unnamed user: ${member.id}`; + + tableData.push({ + name, + count: member.replyCount, + }); + } + + const humanUsers = tableData.sort((a, b) => b.count - a.count); + const totalTicketsResolved = tableData.reduce((sum, agent) => sum + agent.count, 0); + const activeUserCount = humanUsers.filter((user) => user.count > 0).length; + + const teamMembers = await db + .select({ + email: authUsers.email, + displayName: userProfiles.displayName, + }) + .from(userProfiles) + .innerJoin(authUsers, eq(userProfiles.id, authUsers.id)) + .limit(100); + + if (teamMembers.length === 0) { + return { skipped: true, reason: "No team members found" }; + } + + const resend = new Resend(env.RESEND_API_KEY); + const dateRange = formatDateRange(lastWeekStart, lastWeekEnd); + + const emailPromises = teamMembers.map(async (member) => { + if (!member.email) return { success: false, reason: "No email address" }; + + try { + await resend.emails.send({ + from: env.RESEND_FROM_ADDRESS!, + to: member.email, + subject: `Weekly report for ${mailbox.name}`, + react: WeeklyReportEmail({ + mailboxName: mailbox.name, + dateRange, + teamMembers: allMembersData.activeMembers, + inactiveMembers: allMembersData.inactiveMembers, + totalReplies: totalTicketsResolved, + activeUserCount, + }), + }); + return { success: true }; + } catch (error) { + captureExceptionAndLog(error); + return { success: false, error }; + } + }); + + const emailResults = await Promise.all(emailPromises); + + return { + success: true, + emailsSent: emailResults.filter((r) => r.success).length, + totalRecipients: teamMembers.length, + }; +} + +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, + })); + + const inactiveMembers = members + .filter((member) => member.replyCount === 0) + .map((member) => member.displayName || member.email || "Unknown"); + + return { activeMembers, inactiveMembers }; +} diff --git a/jobs/index.ts b/jobs/index.ts index 0fbabbd5f..4debbdbee 100644 --- a/jobs/index.ts +++ b/jobs/index.ts @@ -10,8 +10,10 @@ import { crawlWebsite } from "./crawlWebsite"; import { embeddingConversation } from "./embeddingConversation"; import { embeddingFaq } from "./embeddingFaq"; import { generateConversationSummaryEmbeddings } from "./generateConversationSummaryEmbeddings"; +import { generateDailyEmailReports, generateMailboxDailyEmailReport } from "./generateDailyEmailReports"; import { generateDailyReports, generateMailboxDailyReport } from "./generateDailyReports"; import { generateFilePreview } from "./generateFilePreview"; +import { generateMailboxWeeklyEmailReport, generateWeeklyEmailReports } from "./generateWeeklyEmailReports"; import { generateMailboxWeeklyReport, generateWeeklyReports } from "./generateWeeklyReports"; import { handleAutoResponse } from "./handleAutoResponse"; import { handleGmailWebhookEvent } from "./handleGmailWebhookEvent"; @@ -21,6 +23,7 @@ import { importRecentGmailThreads } from "./importRecentGmailThreads"; import { indexConversationMessage } from "./indexConversation"; import { mergeSimilarConversations } from "./mergeSimilarConversations"; import { notifyVipMessage } from "./notifyVipMessage"; +import { notifyVipMessageEmail } from "./notifyVipMessageByEmail"; import { postEmailToGmail } from "./postEmailToGmail"; import { publishNewMessageEvent } from "./publishNewMessageEvent"; import { publishRequestHumanSupport } from "./publishRequestHumanSupport"; @@ -39,6 +42,7 @@ export const eventJobs = { mergeSimilarConversations, publishNewMessageEvent, notifyVipMessage, + notifyVipMessageEmail, postEmailToGmail, handleAutoResponse, bulkUpdateConversations, @@ -48,7 +52,9 @@ export const eventJobs = { importRecentGmailThreads, importGmailThreads, generateMailboxWeeklyReport, + generateMailboxWeeklyEmailReport, generateMailboxDailyReport, + generateMailboxDailyEmailReport, crawlWebsite, suggestKnowledgeBankChanges, closeInactiveConversations, @@ -72,6 +78,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": { generateDailyReports, generateDailyEmailReports }, + "0 16 * * 1": { generateWeeklyReports, generateWeeklyEmailReports }, }; diff --git a/jobs/notifyVipMessageByEmail.ts b/jobs/notifyVipMessageByEmail.ts new file mode 100644 index 000000000..284cf083d --- /dev/null +++ b/jobs/notifyVipMessageByEmail.ts @@ -0,0 +1,159 @@ +import { eq, isNull, sql } from "drizzle-orm"; +import { htmlToText } from "html-to-text"; +import { Resend } from "resend"; +import { db } from "@/db/client"; +import { conversationMessages, conversations, mailboxes, platformCustomers, userProfiles } from "@/db/schema"; +import { authUsers } from "@/db/supabaseSchema/auth"; +import { getBasicProfileById } from "@/lib/data/user"; +import { VipNotificationEmail } from "@/lib/emails/vipNotification"; +import { env } from "@/lib/env"; +import { captureExceptionAndLog } from "@/lib/shared/sentry"; +import { assertDefinedOrRaiseNonRetriableError } from "./utils"; + +type MessageWithConversationAndMailbox = typeof conversationMessages.$inferSelect & { + conversation: typeof conversations.$inferSelect; +}; + +// Local copy of cleaned-up text helpers (avoid importing conversationMessage.ts which pulls in server-only modules) +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 ensureCleanedUpTextLocal = 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({ + where: eq(conversationMessages.id, messageId), + with: { + conversation: {}, + }, + }), + ); + + if (message.conversation.mergedIntoId) { + const mergedConversation = assertDefinedOrRaiseNonRetriableError( + await db.query.conversations.findFirst({ + where: eq(conversations.id, message.conversation.mergedIntoId), + }), + ); + + return { ...message, conversation: mergedConversation }; + } + + return message; +} + +async function handleVipEmailNotification(message: MessageWithConversationAndMailbox) { + const conversation = assertDefinedOrRaiseNonRetriableError(message.conversation); + // Inline mailbox fetch to avoid importing server-only module + const mailbox = await db.query.mailboxes.findFirst({ + where: isNull(sql`${mailboxes.preferences}->>'disabled'`), + }); + assertDefinedOrRaiseNonRetriableError(mailbox); + + if (conversation.isPrompt) return "Not sent, prompt conversation"; + if (!conversation.emailFrom) return "Not sent, anonymous conversation"; + if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) return "Not sent, email not configured"; + + // Inline platform customer fetch logic to avoid indirect server-only mailbox import + const platformCustomerRecord = await db.query.platformCustomers.findFirst({ + where: eq(platformCustomers.email, conversation.emailFrom), + }); + const numericValue = platformCustomerRecord?.value != null ? Number(platformCustomerRecord.value) : null; + const vipThreshold = mailbox?.vipThreshold ?? 0; + const isVip = numericValue != null ? numericValue / 100 >= vipThreshold : false; + if (!isVip) return "Not sent, not a VIP customer"; + + const customerName = platformCustomerRecord?.name ?? conversation.emailFrom ?? "Unknown"; + const conversationLink = `${env.AUTH_URL}/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 (message.role !== "user" && message.responseToId) { + const originalMsg = await db.query.conversationMessages.findFirst({ + where: eq(conversationMessages.id, message.responseToId), + }); + if (!originalMsg) return "Not sent, original message not found"; + + originalMessage = await ensureCleanedUpTextLocal(originalMsg); + replyMessage = await ensureCleanedUpTextLocal(message); + if (message.userId) { + const user = await getBasicProfileById(message.userId); + closedBy = user?.displayName || user?.email || undefined; + } + } else if (message.role === "user") { + originalMessage = await ensureCleanedUpTextLocal(message); + } else { + return "Not sent, not a user message and not a reply to a user message"; + } + + const teamMembers = await db + .select({ + email: authUsers.email, + displayName: userProfiles.displayName, + }) + .from(userProfiles) + .innerJoin(authUsers, eq(userProfiles.id, authUsers.id)) + .limit(100); + + if (teamMembers.length === 0) return "Not sent, no team members found"; + + const resend = new Resend(env.RESEND_API_KEY); + const emailPromises = teamMembers.map(async (member) => { + if (!member.email) return { success: false, reason: "No email address" } as const; + try { + await resend.emails.send({ + from: env.RESEND_FROM_ADDRESS!, + to: member.email, + subject: `VIP Customer: ${customerName}`, + react: VipNotificationEmail({ + customerName, + customerEmail: conversation.emailFrom!, + originalMessage, + replyMessage, + conversationLink, + customerLinks, + closed: conversation.status === "closed", + closedBy, + }), + }); + return { success: true } as const; + } catch (error) { + captureExceptionAndLog(error); + return { success: false, error } as const; + } + }); + + const emailResults = await Promise.all(emailPromises); + return { + sent: true as const, + emailsSent: emailResults.filter((r) => r.success).length, + totalRecipients: teamMembers.length, + }; +} + +export const notifyVipMessageEmail = async ({ messageId }: { messageId: number }) => { + const message = assertDefinedOrRaiseNonRetriableError(await fetchConversationMessage(messageId)); + return await handleVipEmailNotification(message); +}; diff --git a/jobs/trigger.ts b/jobs/trigger.ts index 6675cdd6c..d86ba8b1f 100644 --- a/jobs/trigger.ts +++ b/jobs/trigger.ts @@ -24,6 +24,7 @@ const events = { "mergeSimilarConversations", "publishNewMessageEvent", "notifyVipMessage", + "notifyVipMessageEmail", "categorizeConversationToIssueGroup", ], }, @@ -87,11 +88,11 @@ const events = { }, "reports/weekly": { data: z.object({}), - jobs: ["generateMailboxWeeklyReport"], + jobs: ["generateMailboxWeeklyReport", "generateMailboxWeeklyEmailReport"], }, "reports/daily": { data: z.object({}), - jobs: ["generateMailboxDailyReport"], + jobs: ["generateMailboxDailyReport", "generateMailboxDailyEmailReport"], }, "websites/crawl.create": { data: z.object({ diff --git a/lib/emails/dailyReports.tsx b/lib/emails/dailyReports.tsx new file mode 100644 index 000000000..c6a8f4800 --- /dev/null +++ b/lib/emails/dailyReports.tsx @@ -0,0 +1,86 @@ +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 DailyReportEmail = ({ + mailboxName, + openTickets, + ticketsAnswered, + openTicketsOverZero, + ticketsAnsweredOverZero, + avgReplyTime, + vipAvgReplyTime, + avgWaitTime, +}: Props) => ( + + + Daily summary for {mailboxName} + + + Daily summary for {mailboxName} + + +
+ • Open tickets: {openTickets.toLocaleString()} + • Tickets answered: {ticketsAnswered.toLocaleString()} + {openTicketsOverZero !== undefined && ( + • Open tickets over $0: {openTicketsOverZero.toLocaleString()} + )} + {ticketsAnsweredOverZero !== undefined && ( + + • Tickets answered over $0: {ticketsAnsweredOverZero.toLocaleString()} + + )} + {avgReplyTime && • Average reply time: {avgReplyTime}} + {vipAvgReplyTime && • VIP average reply time: {vipAvgReplyTime}} + {avgWaitTime && ( + • Average time existing open tickets have been open: {avgWaitTime} + )} +
+ +
+ + + Powered by + + Helper Logo + + + + +); diff --git a/lib/emails/vipNotification.tsx b/lib/emails/vipNotification.tsx new file mode 100644 index 000000000..ff6b38910 --- /dev/null +++ b/lib/emails/vipNotification.tsx @@ -0,0 +1,106 @@ +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 VipNotificationEmail = ({ + customerName, + customerEmail, + originalMessage, + replyMessage, + conversationLink, + customerLinks, + closed, + closedBy, +}: Props) => ( + + + VIP Customer: {customerName} + + ⭐ VIP Customer + + + New message from VIP customer {customerName} ({customerEmail}) + + +
+ Original message: + {originalMessage} + + {replyMessage && ( + <> +
+ Reply: + {replyMessage} + + )} +
+ + {customerLinks && customerLinks.length > 0 && ( + + {customerLinks.map((link, index) => ( + + + {link.label} + + + ))} + + )} + + + + View in Helper → + + + + {closed && closedBy && ( + ✓ Closed by {closedBy} + )} + +
+ + + Powered by + + Helper Logo + + + + +); diff --git a/lib/emails/weeklyReports.tsx b/lib/emails/weeklyReports.tsx new file mode 100644 index 000000000..3e67e7d39 --- /dev/null +++ b/lib/emails/weeklyReports.tsx @@ -0,0 +1,108 @@ +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 WeeklyReportEmail = ({ + mailboxName, + dateRange, + teamMembers, + inactiveMembers, + totalReplies, + activeUserCount, +}: Props) => { + const peopleText = activeUserCount === 1 ? "person" : "people"; + + return ( + + + Last week in the {mailboxName} mailbox + + + Last week in the {mailboxName} mailbox + + + {dateRange} + + {teamMembers.length > 0 && ( + <> + Team members: +
+ {teamMembers.map((member, index) => ( + + • {member.name}: {member.count.toLocaleString()} + + ))} +
+ + )} + + {inactiveMembers.length > 0 && ( + + No tickets answered: {inactiveMembers.join(", ")} + + )} + +
+ + {totalReplies > 0 && ( +
+ Total replies: + + {totalReplies.toLocaleString()} from {activeUserCount} {peopleText} + +
+ )} + +
+ + + Powered by + + Helper Logo + + + + + ); +}; From 5839a7a6a1ac9829b1a99679b3ad47abc6327eac Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Mon, 10 Nov 2025 11:20:45 +0530 Subject: [PATCH 02/16] updated design mockup --- db/schema/userProfiles.ts | 3 + jobs/generateDailyEmailReports.ts | 11 +- jobs/generateWeeklyEmailReports.ts | 35 ++--- jobs/notifyVipMessageByEmail.ts | 43 +++--- lib/emails/dailyEmailReportTemplate.tsx | 138 +++++++++++++++++++ lib/emails/dailyReports.tsx | 86 ------------ lib/emails/vipNotification.tsx | 106 -------------- lib/emails/vipNotificationEmailTemplate.tsx | 145 ++++++++++++++++++++ lib/emails/weeklyEmailReportTemplate.tsx | 142 +++++++++++++++++++ lib/emails/weeklyReports.tsx | 108 --------------- 10 files changed, 475 insertions(+), 342 deletions(-) create mode 100644 lib/emails/dailyEmailReportTemplate.tsx delete mode 100644 lib/emails/dailyReports.tsx delete mode 100644 lib/emails/vipNotification.tsx create mode 100644 lib/emails/vipNotificationEmailTemplate.tsx create mode 100644 lib/emails/weeklyEmailReportTemplate.tsx delete mode 100644 lib/emails/weeklyReports.tsx 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/generateDailyEmailReports.ts b/jobs/generateDailyEmailReports.ts index 12a182b4a..6c93d0954 100644 --- a/jobs/generateDailyEmailReports.ts +++ b/jobs/generateDailyEmailReports.ts @@ -1,12 +1,11 @@ import { subHours } from "date-fns"; -import { aliasedTable, and, eq, gt, isNotNull, isNull, lt, sql } from "drizzle-orm"; -import React from "react"; +import { aliasedTable, and, eq, gt, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; import { Resend } from "resend"; import { db } from "@/db/client"; import { conversationMessages, conversations, mailboxes, platformCustomers, userProfiles } from "@/db/schema"; import { authUsers } from "@/db/supabaseSchema/auth"; import { triggerEvent } from "@/jobs/trigger"; -import { DailyReportEmail } from "@/lib/emails/dailyReports"; +import { DailyEmailReportTemplate } from "@/lib/emails/dailyEmailReportTemplate"; import { env } from "@/lib/env"; import { captureExceptionAndLog } from "@/lib/shared/sentry"; @@ -23,7 +22,6 @@ export async function generateDailyEmailReports() { } export async function generateMailboxDailyEmailReport() { - // Inline getMailbox() to avoid importing a module that uses `server-only` (which breaks in tsx runtime) const mailbox = await db.query.mailboxes.findFirst({ where: isNull(sql`${mailboxes.preferences}->>'disabled'`), }); @@ -161,7 +159,7 @@ export async function generateMailboxDailyEmailReport() { }) .from(userProfiles) .innerJoin(authUsers, eq(userProfiles.id, authUsers.id)) - .limit(100); + .where(or(isNull(userProfiles.preferences), sql`${userProfiles.preferences}->>'allowDailyEmail' != 'false'`)); if (teamMembers.length === 0) { return { skipped: true, reason: "No team members found" }; @@ -173,11 +171,12 @@ export async function generateMailboxDailyEmailReport() { if (!member.email) return { success: false, reason: "No email address" }; try { + await resend.emails.send({ from: env.RESEND_FROM_ADDRESS!, to: member.email, subject: `Daily summary for ${mailbox.name}`, - react: React.createElement(DailyReportEmail, { + react: DailyEmailReportTemplate({ mailboxName: mailbox.name, openTickets: openTicketCount, ticketsAnswered: answeredTicketCount, diff --git a/jobs/generateWeeklyEmailReports.ts b/jobs/generateWeeklyEmailReports.ts index 2bfd44b53..92533dc04 100644 --- a/jobs/generateWeeklyEmailReports.ts +++ b/jobs/generateWeeklyEmailReports.ts @@ -1,6 +1,6 @@ import { endOfWeek, startOfWeek, subWeeks } from "date-fns"; import { toZonedTime } from "date-fns-tz"; -import { eq, isNull, sql } from "drizzle-orm"; +import { eq, isNull, or, sql } from "drizzle-orm"; import { Resend } from "resend"; import { db } from "@/db/client"; import { mailboxes, userProfiles } from "@/db/schema"; @@ -8,7 +8,7 @@ import { authUsers } from "@/db/supabaseSchema/auth"; import { TIME_ZONE } from "@/jobs/generateDailyEmailReports"; import { triggerEvent } from "@/jobs/trigger"; import { getMemberStats, MemberStats } from "@/lib/data/stats"; -import { WeeklyReportEmail } from "@/lib/emails/weeklyReports"; +import { WeeklyEmailReportTemplate } from "@/lib/emails/weeklyEmailReportTemplate"; import { env } from "@/lib/env"; import { captureExceptionAndLog } from "@/lib/shared/sentry"; @@ -26,22 +26,23 @@ export async function generateWeeklyEmailReports() { } export const generateMailboxWeeklyEmailReport = async () => { - const mailbox = await db.query.mailboxes.findFirst({ - where: isNull(sql`${mailboxes.preferences}->>'disabled'`), - }); - if (!mailbox) { - return; - } + try { + const mailbox = await db.query.mailboxes.findFirst({ + where: isNull(sql`${mailboxes.preferences}->>'disabled'`), + }); - if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) { - return { skipped: true, reason: "Email not configured" }; - } + if (!mailbox) return { skipped: true, reason: "No mailbox found" }; - const result = await generateMailboxEmailReport({ - mailbox, - }); + 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 generateMailboxEmailReport({ mailbox }: { mailbox: typeof mailboxes.$inferSelect }) { @@ -82,7 +83,7 @@ export async function generateMailboxEmailReport({ mailbox }: { mailbox: typeof }) .from(userProfiles) .innerJoin(authUsers, eq(userProfiles.id, authUsers.id)) - .limit(100); + .where(or(isNull(userProfiles.preferences), sql`${userProfiles.preferences}->>'allowWeeklyEmail' != 'false'`)); if (teamMembers.length === 0) { return { skipped: true, reason: "No team members found" }; @@ -99,7 +100,7 @@ export async function generateMailboxEmailReport({ mailbox }: { mailbox: typeof from: env.RESEND_FROM_ADDRESS!, to: member.email, subject: `Weekly report for ${mailbox.name}`, - react: WeeklyReportEmail({ + react: WeeklyEmailReportTemplate({ mailboxName: mailbox.name, dateRange, teamMembers: allMembersData.activeMembers, diff --git a/jobs/notifyVipMessageByEmail.ts b/jobs/notifyVipMessageByEmail.ts index 284cf083d..c6ef7d0c1 100644 --- a/jobs/notifyVipMessageByEmail.ts +++ b/jobs/notifyVipMessageByEmail.ts @@ -1,11 +1,12 @@ -import { eq, isNull, sql } from "drizzle-orm"; +import { eq, isNull, or, sql } from "drizzle-orm"; import { htmlToText } from "html-to-text"; import { Resend } from "resend"; +import { getBaseUrl } from "@/components/constants"; import { db } from "@/db/client"; import { conversationMessages, conversations, mailboxes, platformCustomers, userProfiles } from "@/db/schema"; import { authUsers } from "@/db/supabaseSchema/auth"; import { getBasicProfileById } from "@/lib/data/user"; -import { VipNotificationEmail } from "@/lib/emails/vipNotification"; +import { VipNotificationEmailTemplate } from "@/lib/emails/vipNotificationEmailTemplate"; import { env } from "@/lib/env"; import { captureExceptionAndLog } from "@/lib/shared/sentry"; import { assertDefinedOrRaiseNonRetriableError } from "./utils"; @@ -14,7 +15,11 @@ type MessageWithConversationAndMailbox = typeof conversationMessages.$inferSelec conversation: typeof conversations.$inferSelect; }; -// Local copy of cleaned-up text helpers (avoid importing conversationMessage.ts which pulls in server-only modules) +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, { @@ -29,7 +34,7 @@ const generateCleanedUpText = (html: string) => { return paragraphs.join("\n\n"); }; -const ensureCleanedUpTextLocal = async (m: typeof conversationMessages.$inferSelect) => { +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)); @@ -61,7 +66,6 @@ async function fetchConversationMessage(messageId: number): Promise>'disabled'`), }); @@ -71,17 +75,15 @@ async function handleVipEmailNotification(message: MessageWithConversationAndMai if (!conversation.emailFrom) return "Not sent, anonymous conversation"; if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) return "Not sent, email not configured"; - // Inline platform customer fetch logic to avoid indirect server-only mailbox import const platformCustomerRecord = await db.query.platformCustomers.findFirst({ where: eq(platformCustomers.email, conversation.emailFrom), }); - const numericValue = platformCustomerRecord?.value != null ? Number(platformCustomerRecord.value) : null; - const vipThreshold = mailbox?.vipThreshold ?? 0; - const isVip = numericValue != null ? numericValue / 100 >= vipThreshold : false; + + const isVip = determineVipStatus(platformCustomerRecord?.value ?? null, mailbox?.vipThreshold ?? null); if (!isVip) return "Not sent, not a VIP customer"; const customerName = platformCustomerRecord?.name ?? conversation.emailFrom ?? "Unknown"; - const conversationLink = `${env.AUTH_URL}/conversations?id=${conversation.slug}`; + const conversationLink = `${getBaseUrl()}/conversations?id=${conversation.slug}`; const customerLinks = platformCustomerRecord?.links ? Object.entries(platformCustomerRecord.links).map(([key, value]) => ({ label: key, url: value })) : undefined; @@ -94,16 +96,19 @@ async function handleVipEmailNotification(message: MessageWithConversationAndMai const originalMsg = await db.query.conversationMessages.findFirst({ where: eq(conversationMessages.id, message.responseToId), }); - if (!originalMsg) return "Not sent, original message not found"; - originalMessage = await ensureCleanedUpTextLocal(originalMsg); - replyMessage = await ensureCleanedUpTextLocal(message); - if (message.userId) { - const user = await getBasicProfileById(message.userId); - closedBy = user?.displayName || user?.email || undefined; + 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 "Not sent, original message not found"; } } else if (message.role === "user") { - originalMessage = await ensureCleanedUpTextLocal(message); + originalMessage = await ensureCleanedUpText(message); } else { return "Not sent, not a user message and not a reply to a user message"; } @@ -115,7 +120,7 @@ async function handleVipEmailNotification(message: MessageWithConversationAndMai }) .from(userProfiles) .innerJoin(authUsers, eq(userProfiles.id, authUsers.id)) - .limit(100); + .where(or(isNull(userProfiles.preferences), sql`${userProfiles.preferences}->>'allowVipMessageEmail' != 'false'`)); if (teamMembers.length === 0) return "Not sent, no team members found"; @@ -127,7 +132,7 @@ async function handleVipEmailNotification(message: MessageWithConversationAndMai from: env.RESEND_FROM_ADDRESS!, to: member.email, subject: `VIP Customer: ${customerName}`, - react: VipNotificationEmail({ + react: VipNotificationEmailTemplate({ customerName, customerEmail: conversation.emailFrom!, originalMessage, diff --git a/lib/emails/dailyEmailReportTemplate.tsx b/lib/emails/dailyEmailReportTemplate.tsx new file mode 100644 index 000000000..9c05cce3d --- /dev/null +++ b/lib/emails/dailyEmailReportTemplate.tsx @@ -0,0 +1,138 @@ +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/dailyReports.tsx b/lib/emails/dailyReports.tsx deleted file mode 100644 index c6a8f4800..000000000 --- a/lib/emails/dailyReports.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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 DailyReportEmail = ({ - mailboxName, - openTickets, - ticketsAnswered, - openTicketsOverZero, - ticketsAnsweredOverZero, - avgReplyTime, - vipAvgReplyTime, - avgWaitTime, -}: Props) => ( - - - Daily summary for {mailboxName} - - - Daily summary for {mailboxName} - - -
- • Open tickets: {openTickets.toLocaleString()} - • Tickets answered: {ticketsAnswered.toLocaleString()} - {openTicketsOverZero !== undefined && ( - • Open tickets over $0: {openTicketsOverZero.toLocaleString()} - )} - {ticketsAnsweredOverZero !== undefined && ( - - • Tickets answered over $0: {ticketsAnsweredOverZero.toLocaleString()} - - )} - {avgReplyTime && • Average reply time: {avgReplyTime}} - {vipAvgReplyTime && • VIP average reply time: {vipAvgReplyTime}} - {avgWaitTime && ( - • Average time existing open tickets have been open: {avgWaitTime} - )} -
- -
- - - Powered by - - Helper Logo - - - - -); diff --git a/lib/emails/vipNotification.tsx b/lib/emails/vipNotification.tsx deleted file mode 100644 index ff6b38910..000000000 --- a/lib/emails/vipNotification.tsx +++ /dev/null @@ -1,106 +0,0 @@ -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 VipNotificationEmail = ({ - customerName, - customerEmail, - originalMessage, - replyMessage, - conversationLink, - customerLinks, - closed, - closedBy, -}: Props) => ( - - - VIP Customer: {customerName} - - ⭐ VIP Customer - - - New message from VIP customer {customerName} ({customerEmail}) - - -
- Original message: - {originalMessage} - - {replyMessage && ( - <> -
- Reply: - {replyMessage} - - )} -
- - {customerLinks && customerLinks.length > 0 && ( - - {customerLinks.map((link, index) => ( - - - {link.label} - - - ))} - - )} - - - - View in Helper → - - - - {closed && closedBy && ( - ✓ Closed by {closedBy} - )} - -
- - - Powered by - - Helper Logo - - - - -); 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/emails/weeklyReports.tsx b/lib/emails/weeklyReports.tsx deleted file mode 100644 index 3e67e7d39..000000000 --- a/lib/emails/weeklyReports.tsx +++ /dev/null @@ -1,108 +0,0 @@ -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 WeeklyReportEmail = ({ - mailboxName, - dateRange, - teamMembers, - inactiveMembers, - totalReplies, - activeUserCount, -}: Props) => { - const peopleText = activeUserCount === 1 ? "person" : "people"; - - return ( - - - Last week in the {mailboxName} mailbox - - - Last week in the {mailboxName} mailbox - - - {dateRange} - - {teamMembers.length > 0 && ( - <> - Team members: -
- {teamMembers.map((member, index) => ( - - • {member.name}: {member.count.toLocaleString()} - - ))} -
- - )} - - {inactiveMembers.length > 0 && ( - - No tickets answered: {inactiveMembers.join(", ")} - - )} - -
- - {totalReplies > 0 && ( -
- Total replies: - - {totalReplies.toLocaleString()} from {activeUserCount} {peopleText} - -
- )} - -
- - - Powered by - - Helper Logo - - - - - ); -}; From 2a0a4eecf9ea02b584e73b0cc93e46fa3f3a59a0 Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Mon, 10 Nov 2025 12:23:31 +0530 Subject: [PATCH 03/16] added e2e --- .../preferences/dailyEmailSetting.tsx | 47 +++++++++++++++++ .../preferences/preferencesSetting.tsx | 6 +++ .../preferences/vipMessageEmailSetting.tsx | 47 +++++++++++++++++ .../preferences/weeklyEmailSetting.tsx | 47 +++++++++++++++++ jobs/generateDailyEmailReports.ts | 1 - tests/e2e/settings/preferences.spec.ts | 51 +++++++++++++++++++ trpc/router/user.ts | 3 ++ 7 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 app/(dashboard)/settings/preferences/dailyEmailSetting.tsx create mode 100644 app/(dashboard)/settings/preferences/vipMessageEmailSetting.tsx create mode 100644 app/(dashboard)/settings/preferences/weeklyEmailSetting.tsx 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/jobs/generateDailyEmailReports.ts b/jobs/generateDailyEmailReports.ts index 6c93d0954..6f008710a 100644 --- a/jobs/generateDailyEmailReports.ts +++ b/jobs/generateDailyEmailReports.ts @@ -171,7 +171,6 @@ export async function generateMailboxDailyEmailReport() { if (!member.email) return { success: false, reason: "No email address" }; try { - await resend.emails.send({ from: env.RESEND_FROM_ADDRESS!, to: member.email, 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/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(), }), From 194862bb55ea3e556230fb880c02675e33dc3653 Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Mon, 10 Nov 2025 14:26:17 +0530 Subject: [PATCH 04/16] copied contents --- jobs/generateDailyReports.ts | 132 ++++++++++----------- jobs/generateWeeklyReports.ts | 213 ++++++++++++++-------------------- jobs/notifyVipMessage.ts | 175 ++++++++++++++++++---------- 3 files changed, 268 insertions(+), 252 deletions(-) diff --git a/jobs/generateDailyReports.ts b/jobs/generateDailyReports.ts index 826ed397e..6f008710a 100644 --- a/jobs/generateDailyReports.ts +++ b/jobs/generateDailyReports.ts @@ -1,18 +1,19 @@ -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 { Resend } from "resend"; 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 { captureExceptionAndLog } from "@/lib/shared/sentry"; 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,20 +21,15 @@ 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() { + const mailbox = await db.query.mailboxes.findFirst({ + where: isNull(sql`${mailboxes.preferences}->>'disabled'`), + }); + if (!mailbox) return; + + if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) { + return { skipped: true, reason: "Email not configured" }; + } const endTime = new Date(); const startTime = subHours(endTime, 24); @@ -45,8 +41,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 +55,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 +68,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 +84,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,9 +107,6 @@ export async function generateMailboxDailyReport() { lt(conversationMessages.createdAt, endTime), ), ); - const avgReplyTimeMessage = avgReplyTimeResult?.average - ? `• Average reply time: ${formatTime(avgReplyTimeResult.average)}` - : null; let vipAvgReplyTimeMessage = null; if (mailbox.vipThreshold) { @@ -169,42 +150,57 @@ 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 avgWaitTimeMessage = 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'`)); - await postSlackMessage(mailbox.slackBotToken, { - channel: mailbox.slackAlertChannel, - text: `Daily summary for ${mailbox.name}`, - blocks, + if (teamMembers.length === 0) { + return { skipped: true, reason: "No team members found" }; + } + + const resend = new Resend(env.RESEND_API_KEY); + + const emailPromises = teamMembers.map(async (member) => { + if (!member.email) return { success: false, reason: "No email address" }; + + try { + await resend.emails.send({ + from: env.RESEND_FROM_ADDRESS!, + to: member.email, + subject: `Daily summary for ${mailbox.name}`, + react: DailyEmailReportTemplate({ + mailboxName: mailbox.name, + openTickets: openTicketCount, + ticketsAnswered: answeredTicketCount, + openTicketsOverZero: openTicketsOverZeroCount || undefined, + ticketsAnsweredOverZero: answeredTicketsOverZeroCount || undefined, + avgReplyTime: avgReplyTimeResult?.average ? formatTime(avgReplyTimeResult.average) : undefined, + vipAvgReplyTime: vipAvgReplyTimeMessage + ? vipAvgReplyTimeMessage.replace("• VIP average reply time: ", "") + : undefined, + avgWaitTime: avgWaitTimeMessage, + }), + }); + + return { success: true }; + } catch (error) { + captureExceptionAndLog(error); + return { success: false, error }; + } }); + const emailResults = await Promise.all(emailPromises); + return { success: true, - openCountMessage, - answeredCountMessage, - openTicketsOverZeroMessage, - answeredTicketsOverZeroMessage, - avgReplyTimeMessage, - vipAvgReplyTimeMessage, - avgWaitTimeMessage, + emailsSent: emailResults.filter((r) => r.success).length, + totalRecipients: teamMembers.length, }; } diff --git a/jobs/generateWeeklyReports.ts b/jobs/generateWeeklyReports.ts index 875af4652..92533dc04 100644 --- a/jobs/generateWeeklyReports.ts +++ b/jobs/generateWeeklyReports.ts @@ -1,54 +1,51 @@ 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 { TIME_ZONE } from "@/jobs/generateDailyReports"; +import { eq, isNull, or, sql } from "drizzle-orm"; +import { Resend } from "resend"; +import { db } from "@/db/client"; +import { mailboxes, userProfiles } from "@/db/schema"; +import { authUsers } from "@/db/supabaseSchema/auth"; +import { TIME_ZONE } from "@/jobs/generateDailyEmailReports"; 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 { 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; - } +export const generateMailboxWeeklyEmailReport = async () => { + 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({ - mailbox, - slackBotToken, - slackAlertChannel, -}: { - mailbox: typeof mailboxes.$inferSelect; - slackBotToken: string; - slackAlertChannel: string; -}) { +export async function generateMailboxEmailReport({ mailbox }: { mailbox: typeof mailboxes.$inferSelect }) { const now = toZonedTime(new Date(), TIME_ZONE); const lastWeekStart = subWeeks(startOfWeek(now, { weekStartsOn: 0 }), 1); const lastWeekEnd = subWeeks(endOfWeek(now, { weekStartsOn: 0 }), 1); @@ -62,20 +59,16 @@ export async function generateMailboxReport({ return "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 +76,67 @@ 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"), - }, - }); - } - - await postSlackMessage(slackBotToken, { - channel: slackAlertChannel, - text: formatDateRange(lastWeekStart, lastWeekEnd), - blocks, + const resend = new Resend(env.RESEND_API_KEY); + const dateRange = formatDateRange(lastWeekStart, lastWeekEnd); + + const emailPromises = teamMembers.map(async (member) => { + if (!member.email) return { success: false, reason: "No email address" }; + + try { + await resend.emails.send({ + from: env.RESEND_FROM_ADDRESS!, + to: member.email, + subject: `Weekly report for ${mailbox.name}`, + react: WeeklyEmailReportTemplate({ + mailboxName: mailbox.name, + dateRange, + teamMembers: allMembersData.activeMembers, + inactiveMembers: allMembersData.inactiveMembers, + totalReplies: totalTicketsResolved, + activeUserCount, + }), + }); + return { success: true }; + } catch (error) { + captureExceptionAndLog(error); + return { success: false, error }; + } }); - return "Report 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"; + const emailResults = await Promise.all(emailPromises); - return `• ${userName}: ${formattedCount}`; - }); + return { + success: true, + emailsSent: emailResults.filter((r) => r.success).length, + totalRecipients: teamMembers.length, + }; +} - 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/notifyVipMessage.ts b/jobs/notifyVipMessage.ts index d68b60393..786c2d0af 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 { Resend } from "resend"; +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 { 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,102 @@ async function fetchConversationMessage(messageId: number): Promise>'disabled'`), + }); + assertDefinedOrRaiseNonRetriableError(mailbox); - if (conversation.isPrompt) { - return "Not posted, prompt conversation"; - } - if (!conversation.emailFrom) { - return "Not posted, anonymous conversation"; - } + if (conversation.isPrompt) return "Not sent, prompt conversation"; + if (!conversation.emailFrom) return "Not sent, anonymous conversation"; + if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) return "Not sent, email not configured"; + + const platformCustomerRecord = await db.query.platformCustomers.findFirst({ + where: eq(platformCustomers.email, conversation.emailFrom), + }); - const platformCustomer = await getPlatformCustomer(conversation.emailFrom); + const isVip = determineVipStatus(platformCustomerRecord?.value ?? null, mailbox?.vipThreshold ?? null); + if (!isVip) return "Not sent, not a VIP customer"; - // 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 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 "Not sent, original message not found"; } + } else if (message.role === "user") { + originalMessage = await ensureCleanedUpText(message); + } else { + return "Not sent, not a user message and not a reply to a user message"; } - 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 slackMessageTs = await postVipMessageToSlack({ - conversation, - mailbox, - message: cleanedUpText, - platformCustomer, - slackBotToken: mailbox.slackBotToken, - slackChannel: mailbox.vipChannelId, + 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 "Not sent, no team members found"; + + const resend = new Resend(env.RESEND_API_KEY); + const emailPromises = teamMembers.map(async (member) => { + if (!member.email) return { success: false, reason: "No email address" } as const; + try { + await resend.emails.send({ + from: env.RESEND_FROM_ADDRESS!, + to: member.email, + subject: `VIP Customer: ${customerName}`, + react: VipNotificationEmailTemplate({ + customerName, + customerEmail: conversation.emailFrom!, + originalMessage, + replyMessage, + conversationLink, + customerLinks, + closed: conversation.status === "closed", + closedBy, + }), + }); + return { success: true } as const; + } catch (error) { + captureExceptionAndLog(error); + return { success: false, error } as const; + } }); - await db - .update(conversationMessages) - .set({ slackMessageTs, slackChannel: mailbox.vipChannelId }) - .where(eq(conversationMessages.id, message.id)); - return "Posted"; + const emailResults = await Promise.all(emailPromises); + return { + sent: true as const, + emailsSent: emailResults.filter((r) => r.success).length, + totalRecipients: teamMembers.length, + }; } -export const notifyVipMessage = async ({ messageId }: { messageId: number }) => { +export const notifyVipMessageEmail = async ({ messageId }: { messageId: number }) => { const message = assertDefinedOrRaiseNonRetriableError(await fetchConversationMessage(messageId)); - return await handleVipSlackMessage(message); + return await handleVipEmailNotification(message); }; + From 83abdebf8584c4923c19aa4f092ed7e652a69a23 Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Mon, 10 Nov 2025 14:50:04 +0530 Subject: [PATCH 05/16] rename + delete --- jobs/generateDailyEmailReports.ts | 206 ------------- jobs/generateWeeklyEmailReports.ts | 142 --------- jobs/generateWeeklyReports.ts | 2 +- jobs/index.ts | 16 +- jobs/notifyVipMessageByEmail.ts | 164 ---------- jobs/trigger.ts | 5 +- tests/jobs/generateDailyReports.test.ts | 368 ----------------------- tests/jobs/generateWeeklyReports.test.ts | 246 --------------- 8 files changed, 8 insertions(+), 1141 deletions(-) delete mode 100644 jobs/generateDailyEmailReports.ts delete mode 100644 jobs/generateWeeklyEmailReports.ts delete mode 100644 jobs/notifyVipMessageByEmail.ts delete mode 100644 tests/jobs/generateDailyReports.test.ts delete mode 100644 tests/jobs/generateWeeklyReports.test.ts diff --git a/jobs/generateDailyEmailReports.ts b/jobs/generateDailyEmailReports.ts deleted file mode 100644 index 6f008710a..000000000 --- a/jobs/generateDailyEmailReports.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { subHours } from "date-fns"; -import { aliasedTable, and, eq, gt, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; -import { Resend } from "resend"; -import { db } from "@/db/client"; -import { conversationMessages, conversations, mailboxes, platformCustomers, userProfiles } from "@/db/schema"; -import { authUsers } from "@/db/supabaseSchema/auth"; -import { triggerEvent } from "@/jobs/trigger"; -import { DailyEmailReportTemplate } from "@/lib/emails/dailyEmailReportTemplate"; -import { env } from "@/lib/env"; -import { captureExceptionAndLog } from "@/lib/shared/sentry"; - -export const TIME_ZONE = "America/New_York"; - -export async function generateDailyEmailReports() { - const mailboxesList = await db.query.mailboxes.findMany({ - columns: { id: true }, - }); - - if (!mailboxesList.length) return; - - await triggerEvent("reports/daily", {}); -} - -export async function generateMailboxDailyEmailReport() { - const mailbox = await db.query.mailboxes.findFirst({ - where: isNull(sql`${mailboxes.preferences}->>'disabled'`), - }); - if (!mailbox) return; - - if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) { - return { skipped: true, reason: "Email not configured" }; - } - - const endTime = new Date(); - const startTime = subHours(endTime, 24); - - const openTicketCount = await db.$count( - conversations, - and(eq(conversations.status, "open"), isNull(conversations.mergedIntoId)), - ); - - if (openTicketCount === 0) return { skipped: true, reason: "No open tickets" }; - - const answeredTicketCount = await db - .select({ count: sql`count(DISTINCT ${conversations.id})` }) - .from(conversationMessages) - .innerJoin(conversations, eq(conversationMessages.conversationId, conversations.id)) - .where( - and( - eq(conversationMessages.role, "staff"), - gt(conversationMessages.createdAt, startTime), - lt(conversationMessages.createdAt, endTime), - isNull(conversations.mergedIntoId), - ), - ) - .then((result) => Number(result[0]?.count || 0)); - - const openTicketsOverZeroCount = await db - .select({ count: sql`count(*)` }) - .from(conversations) - .leftJoin(platformCustomers, and(eq(conversations.emailFrom, platformCustomers.email))) - .where( - and( - eq(conversations.status, "open"), - isNull(conversations.mergedIntoId), - gt(sql`CAST(${platformCustomers.value} AS INTEGER)`, 0), - ), - ) - .then((result) => Number(result[0]?.count || 0)); - - const answeredTicketsOverZeroCount = await db - .select({ count: sql`count(DISTINCT ${conversations.id})` }) - .from(conversationMessages) - .innerJoin(conversations, eq(conversationMessages.conversationId, conversations.id)) - .leftJoin(platformCustomers, and(eq(conversations.emailFrom, platformCustomers.email))) - .where( - and( - eq(conversationMessages.role, "staff"), - gt(conversationMessages.createdAt, startTime), - lt(conversationMessages.createdAt, endTime), - isNull(conversations.mergedIntoId), - gt(sql`CAST(${platformCustomers.value} AS INTEGER)`, 0), - ), - ) - .then((result) => Number(result[0]?.count || 0)); - - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - return `${hours}h ${minutes}m`; - }; - - const userMessages = aliasedTable(conversationMessages, "userMessages"); - const [avgReplyTimeResult] = await db - .select({ - average: sql`ROUND(AVG( - EXTRACT(EPOCH FROM (${conversationMessages.createdAt} - ${userMessages.createdAt})) - ))::integer`, - }) - .from(conversationMessages) - .innerJoin(conversations, eq(conversationMessages.conversationId, conversations.id)) - .innerJoin(userMessages, and(eq(conversationMessages.responseToId, userMessages.id), eq(userMessages.role, "user"))) - .where( - and( - eq(conversationMessages.role, "staff"), - gt(conversationMessages.createdAt, startTime), - lt(conversationMessages.createdAt, endTime), - ), - ); - - let vipAvgReplyTimeMessage = null; - if (mailbox.vipThreshold) { - const [vipReplyTimeResult] = await db - .select({ - average: sql`ROUND(AVG( - EXTRACT(EPOCH FROM (${conversationMessages.createdAt} - ${userMessages.createdAt})) - ))::integer`, - }) - .from(conversationMessages) - .innerJoin(conversations, eq(conversationMessages.conversationId, conversations.id)) - .innerJoin(platformCustomers, eq(conversations.emailFrom, platformCustomers.email)) - .innerJoin( - userMessages, - and(eq(conversationMessages.responseToId, userMessages.id), eq(userMessages.role, "user")), - ) - .where( - and( - eq(conversationMessages.role, "staff"), - gt(conversationMessages.createdAt, startTime), - lt(conversationMessages.createdAt, endTime), - gt(sql`CAST(${platformCustomers.value} AS INTEGER)`, (mailbox.vipThreshold ?? 0) * 100), - ), - ); - vipAvgReplyTimeMessage = vipReplyTimeResult?.average - ? `• VIP average reply time: ${formatTime(vipReplyTimeResult.average)}` - : null; - } - - const [avgWaitTimeResult] = await db - .select({ - average: sql`ROUND(AVG( - EXTRACT(EPOCH FROM (${endTime.toISOString()}::timestamp - ${conversations.lastUserEmailCreatedAt})) - ))::integer`, - }) - .from(conversations) - .where( - and( - eq(conversations.status, "open"), - isNull(conversations.mergedIntoId), - isNotNull(conversations.lastUserEmailCreatedAt), - ), - ); - const avgWaitTimeMessage = 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 resend = new Resend(env.RESEND_API_KEY); - - const emailPromises = teamMembers.map(async (member) => { - if (!member.email) return { success: false, reason: "No email address" }; - - try { - await resend.emails.send({ - from: env.RESEND_FROM_ADDRESS!, - to: member.email, - subject: `Daily summary for ${mailbox.name}`, - react: DailyEmailReportTemplate({ - mailboxName: mailbox.name, - openTickets: openTicketCount, - ticketsAnswered: answeredTicketCount, - openTicketsOverZero: openTicketsOverZeroCount || undefined, - ticketsAnsweredOverZero: answeredTicketsOverZeroCount || undefined, - avgReplyTime: avgReplyTimeResult?.average ? formatTime(avgReplyTimeResult.average) : undefined, - vipAvgReplyTime: vipAvgReplyTimeMessage - ? vipAvgReplyTimeMessage.replace("• VIP average reply time: ", "") - : undefined, - avgWaitTime: avgWaitTimeMessage, - }), - }); - - return { success: true }; - } catch (error) { - captureExceptionAndLog(error); - return { success: false, error }; - } - }); - - const emailResults = await Promise.all(emailPromises); - - return { - success: true, - emailsSent: emailResults.filter((r) => r.success).length, - totalRecipients: teamMembers.length, - }; -} diff --git a/jobs/generateWeeklyEmailReports.ts b/jobs/generateWeeklyEmailReports.ts deleted file mode 100644 index 92533dc04..000000000 --- a/jobs/generateWeeklyEmailReports.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { endOfWeek, startOfWeek, subWeeks } from "date-fns"; -import { toZonedTime } from "date-fns-tz"; -import { eq, isNull, or, sql } from "drizzle-orm"; -import { Resend } from "resend"; -import { db } from "@/db/client"; -import { mailboxes, userProfiles } from "@/db/schema"; -import { authUsers } from "@/db/supabaseSchema/auth"; -import { TIME_ZONE } from "@/jobs/generateDailyEmailReports"; -import { triggerEvent } from "@/jobs/trigger"; -import { getMemberStats, MemberStats } from "@/lib/data/stats"; -import { WeeklyEmailReportTemplate } from "@/lib/emails/weeklyEmailReportTemplate"; -import { env } from "@/lib/env"; -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 generateWeeklyEmailReports() { - const mailbox = await db.query.mailboxes.findFirst({ - where: isNull(sql`${mailboxes.preferences}->>'disabled'`), - }); - if (!mailbox) return; - - await triggerEvent("reports/weekly", {}); -} - -export const generateMailboxWeeklyEmailReport = async () => { - 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 }) { - const now = toZonedTime(new Date(), TIME_ZONE); - const lastWeekStart = subWeeks(startOfWeek(now, { weekStartsOn: 0 }), 1); - const lastWeekEnd = subWeeks(endOfWeek(now, { weekStartsOn: 0 }), 1); - - const stats = await getMemberStats({ - startDate: lastWeekStart, - endDate: lastWeekEnd, - }); - - if (!stats.length) { - return "No stats found"; - } - - const allMembersData = processAllMembers(stats); - - const tableData: { name: string; count: number }[] = []; - - for (const member of stats) { - const name = member.displayName || member.email || `Unnamed user: ${member.id}`; - - tableData.push({ - name, - count: member.replyCount, - }); - } - - const humanUsers = tableData.sort((a, b) => b.count - a.count); - const totalTicketsResolved = tableData.reduce((sum, agent) => sum + agent.count, 0); - const activeUserCount = humanUsers.filter((user) => user.count > 0).length; - - 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" }; - } - - const resend = new Resend(env.RESEND_API_KEY); - const dateRange = formatDateRange(lastWeekStart, lastWeekEnd); - - const emailPromises = teamMembers.map(async (member) => { - if (!member.email) return { success: false, reason: "No email address" }; - - try { - await resend.emails.send({ - from: env.RESEND_FROM_ADDRESS!, - to: member.email, - subject: `Weekly report for ${mailbox.name}`, - react: WeeklyEmailReportTemplate({ - mailboxName: mailbox.name, - dateRange, - teamMembers: allMembersData.activeMembers, - inactiveMembers: allMembersData.inactiveMembers, - totalReplies: totalTicketsResolved, - activeUserCount, - }), - }); - return { success: true }; - } catch (error) { - captureExceptionAndLog(error); - return { success: false, error }; - } - }); - - const emailResults = await Promise.all(emailPromises); - - return { - success: true, - emailsSent: emailResults.filter((r) => r.success).length, - totalRecipients: teamMembers.length, - }; -} - -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, - })); - - const inactiveMembers = members - .filter((member) => member.replyCount === 0) - .map((member) => member.displayName || member.email || "Unknown"); - - return { activeMembers, inactiveMembers }; -} diff --git a/jobs/generateWeeklyReports.ts b/jobs/generateWeeklyReports.ts index 92533dc04..cc4fc1cc8 100644 --- a/jobs/generateWeeklyReports.ts +++ b/jobs/generateWeeklyReports.ts @@ -5,7 +5,7 @@ import { Resend } from "resend"; import { db } from "@/db/client"; import { mailboxes, userProfiles } from "@/db/schema"; import { authUsers } from "@/db/supabaseSchema/auth"; -import { TIME_ZONE } from "@/jobs/generateDailyEmailReports"; +import { TIME_ZONE } from "@/jobs/generateDailyReports"; import { triggerEvent } from "@/jobs/trigger"; import { getMemberStats, MemberStats } from "@/lib/data/stats"; import { WeeklyEmailReportTemplate } from "@/lib/emails/weeklyEmailReportTemplate"; diff --git a/jobs/index.ts b/jobs/index.ts index 4debbdbee..30b0e4378 100644 --- a/jobs/index.ts +++ b/jobs/index.ts @@ -10,11 +10,9 @@ import { crawlWebsite } from "./crawlWebsite"; import { embeddingConversation } from "./embeddingConversation"; import { embeddingFaq } from "./embeddingFaq"; import { generateConversationSummaryEmbeddings } from "./generateConversationSummaryEmbeddings"; -import { generateDailyEmailReports, generateMailboxDailyEmailReport } from "./generateDailyEmailReports"; -import { generateDailyReports, generateMailboxDailyReport } from "./generateDailyReports"; +import { generateDailyEmailReports, generateMailboxDailyEmailReport } from "./generateDailyReports"; import { generateFilePreview } from "./generateFilePreview"; -import { generateMailboxWeeklyEmailReport, generateWeeklyEmailReports } from "./generateWeeklyEmailReports"; -import { generateMailboxWeeklyReport, generateWeeklyReports } from "./generateWeeklyReports"; +import { generateMailboxWeeklyEmailReport, generateWeeklyEmailReports } from "./generateWeeklyReports"; import { handleAutoResponse } from "./handleAutoResponse"; import { handleGmailWebhookEvent } from "./handleGmailWebhookEvent"; import { handleSlackAgentMessage } from "./handleSlackAgentMessage"; @@ -22,8 +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 "./notifyVipMessageByEmail"; +import { notifyVipMessageEmail } from "./notifyVipMessage"; import { postEmailToGmail } from "./postEmailToGmail"; import { publishNewMessageEvent } from "./publishNewMessageEvent"; import { publishRequestHumanSupport } from "./publishRequestHumanSupport"; @@ -41,7 +38,6 @@ export const eventJobs = { generateConversationSummaryEmbeddings, mergeSimilarConversations, publishNewMessageEvent, - notifyVipMessage, notifyVipMessageEmail, postEmailToGmail, handleAutoResponse, @@ -51,9 +47,7 @@ export const eventJobs = { embeddingFaq, importRecentGmailThreads, importGmailThreads, - generateMailboxWeeklyReport, generateMailboxWeeklyEmailReport, - generateMailboxDailyReport, generateMailboxDailyEmailReport, crawlWebsite, suggestKnowledgeBankChanges, @@ -78,6 +72,6 @@ export const cronJobs = { }, "0 0 * * *": { renewMailboxWatches }, "0 0 * * 0": { scheduledWebsiteCrawl }, - "0 16 * * 0,2-6": { generateDailyReports, generateDailyEmailReports }, - "0 16 * * 1": { generateWeeklyReports, generateWeeklyEmailReports }, + "0 16 * * 0,2-6": { generateDailyEmailReports }, + "0 16 * * 1": { generateWeeklyEmailReports }, }; diff --git a/jobs/notifyVipMessageByEmail.ts b/jobs/notifyVipMessageByEmail.ts deleted file mode 100644 index c6ef7d0c1..000000000 --- a/jobs/notifyVipMessageByEmail.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { eq, isNull, or, sql } from "drizzle-orm"; -import { htmlToText } from "html-to-text"; -import { Resend } from "resend"; -import { getBaseUrl } from "@/components/constants"; -import { db } from "@/db/client"; -import { conversationMessages, conversations, mailboxes, platformCustomers, userProfiles } from "@/db/schema"; -import { authUsers } from "@/db/supabaseSchema/auth"; -import { getBasicProfileById } from "@/lib/data/user"; -import { VipNotificationEmailTemplate } from "@/lib/emails/vipNotificationEmailTemplate"; -import { env } from "@/lib/env"; -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({ - where: eq(conversationMessages.id, messageId), - with: { - conversation: {}, - }, - }), - ); - - if (message.conversation.mergedIntoId) { - const mergedConversation = assertDefinedOrRaiseNonRetriableError( - await db.query.conversations.findFirst({ - where: eq(conversations.id, message.conversation.mergedIntoId), - }), - ); - - return { ...message, conversation: mergedConversation }; - } - - return message; -} - -async function handleVipEmailNotification(message: MessageWithConversationAndMailbox) { - const conversation = assertDefinedOrRaiseNonRetriableError(message.conversation); - const mailbox = await db.query.mailboxes.findFirst({ - where: isNull(sql`${mailboxes.preferences}->>'disabled'`), - }); - assertDefinedOrRaiseNonRetriableError(mailbox); - - if (conversation.isPrompt) return "Not sent, prompt conversation"; - if (!conversation.emailFrom) return "Not sent, anonymous conversation"; - if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) return "Not sent, email not configured"; - - const platformCustomerRecord = await db.query.platformCustomers.findFirst({ - where: eq(platformCustomers.email, conversation.emailFrom), - }); - - const isVip = determineVipStatus(platformCustomerRecord?.value ?? null, mailbox?.vipThreshold ?? null); - if (!isVip) return "Not sent, 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 (message.role !== "user" && message.responseToId) { - const originalMsg = await db.query.conversationMessages.findFirst({ - where: eq(conversationMessages.id, message.responseToId), - }); - - 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 "Not sent, original message not found"; - } - } else if (message.role === "user") { - originalMessage = await ensureCleanedUpText(message); - } else { - return "Not sent, not a user message and not a reply to a user 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 "Not sent, no team members found"; - - const resend = new Resend(env.RESEND_API_KEY); - const emailPromises = teamMembers.map(async (member) => { - if (!member.email) return { success: false, reason: "No email address" } as const; - try { - await resend.emails.send({ - from: env.RESEND_FROM_ADDRESS!, - to: member.email, - subject: `VIP Customer: ${customerName}`, - react: VipNotificationEmailTemplate({ - customerName, - customerEmail: conversation.emailFrom!, - originalMessage, - replyMessage, - conversationLink, - customerLinks, - closed: conversation.status === "closed", - closedBy, - }), - }); - return { success: true } as const; - } catch (error) { - captureExceptionAndLog(error); - return { success: false, error } as const; - } - }); - - const emailResults = await Promise.all(emailPromises); - return { - sent: true as const, - emailsSent: emailResults.filter((r) => r.success).length, - totalRecipients: teamMembers.length, - }; -} - -export const notifyVipMessageEmail = async ({ messageId }: { messageId: number }) => { - const message = assertDefinedOrRaiseNonRetriableError(await fetchConversationMessage(messageId)); - return await handleVipEmailNotification(message); -}; diff --git a/jobs/trigger.ts b/jobs/trigger.ts index d86ba8b1f..491254607 100644 --- a/jobs/trigger.ts +++ b/jobs/trigger.ts @@ -23,7 +23,6 @@ const events = { "generateConversationSummaryEmbeddings", "mergeSimilarConversations", "publishNewMessageEvent", - "notifyVipMessage", "notifyVipMessageEmail", "categorizeConversationToIssueGroup", ], @@ -88,11 +87,11 @@ const events = { }, "reports/weekly": { data: z.object({}), - jobs: ["generateMailboxWeeklyReport", "generateMailboxWeeklyEmailReport"], + jobs: ["generateMailboxWeeklyEmailReport"], }, "reports/daily": { data: z.object({}), - jobs: ["generateMailboxDailyReport", "generateMailboxDailyEmailReport"], + jobs: ["generateMailboxDailyEmailReport"], }, "websites/crawl.create": { data: z.object({ diff --git a/tests/jobs/generateDailyReports.test.ts b/tests/jobs/generateDailyReports.test.ts deleted file mode 100644 index f429284ac..000000000 --- a/tests/jobs/generateDailyReports.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { conversationFactory } from "@tests/support/factories/conversations"; -import { platformCustomerFactory } from "@tests/support/factories/platformCustomers"; -import { userFactory } from "@tests/support/factories/users"; -import { subHours } from "date-fns"; -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"; - -vi.mock("@/lib/data/mailbox", () => ({ - getMailbox: vi.fn(), -})); - -vi.mock("@/lib/slack/client", () => ({ - postSlackMessage: vi.fn(), -})); - -describe("generateMailboxDailyReport", () => { - 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); - - const result = await generateMailboxDailyReport(); - - expect(result).toBeUndefined(); - expect(postSlackMessage).not.toHaveBeenCalled(); - }); - - it("skips when there are no open tickets", async () => { - const { mailbox } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "test-token", - slackAlertChannel: "test-channel", - }, - }); - - vi.mocked(getMailbox).mockResolvedValue(mailbox); - - const result = await generateMailboxDailyReport(); - - expect(result).toEqual({ - skipped: true, - reason: "No open tickets", - }); - expect(postSlackMessage).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 endTime = new Date(); - const midTime = subHours(endTime, 12); - - const { conversation: openConv1 } = await conversationFactory.create({ - status: "open", - lastUserEmailCreatedAt: midTime, - }); - const { conversation: openConv2 } = await conversationFactory.create({ - status: "open", - lastUserEmailCreatedAt: midTime, - }); - const { conversation: _closedConv } = await conversationFactory.create({ - status: "closed", - }); - - const userMsg1 = await conversationFactory.createUserEmail(openConv1.id, { - createdAt: midTime, - }); - const userMsg2 = await conversationFactory.createUserEmail(openConv2.id, { - createdAt: midTime, - }); - - await conversationFactory.createStaffEmail(openConv1.id, user.id, { - createdAt: new Date(midTime.getTime() + 3600000), - responseToId: userMsg1.id, - }); - await conversationFactory.createStaffEmail(openConv2.id, user.id, { - createdAt: new Date(midTime.getTime() + 7200000), - responseToId: userMsg2.id, - }); - - const result = await generateMailboxDailyReport(); - - 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), - }); - }); - - 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 endTime = new Date(); - const midTime = subHours(endTime, 12); - - const customerEmail = faker.internet.email(); - const vipCustomerEmail = faker.internet.email(); - - await platformCustomerFactory.create({ - email: customerEmail, - value: "50.00", - }); - await platformCustomerFactory.create({ - email: vipCustomerEmail, - value: "25000.00", - }); - - const { conversation: normalConv } = await conversationFactory.create({ - status: "open", - emailFrom: customerEmail, - lastUserEmailCreatedAt: midTime, - }); - const { conversation: vipConv } = await conversationFactory.create({ - status: "open", - emailFrom: vipCustomerEmail, - lastUserEmailCreatedAt: midTime, - }); - - const normalUserMsg = await conversationFactory.createUserEmail(normalConv.id, { - createdAt: midTime, - }); - const vipUserMsg = await conversationFactory.createUserEmail(vipConv.id, { - createdAt: midTime, - }); - - await conversationFactory.createStaffEmail(normalConv.id, user.id, { - createdAt: new Date(midTime.getTime() + 3600000), - responseToId: normalUserMsg.id, - }); - await conversationFactory.createStaffEmail(vipConv.id, user.id, { - createdAt: new Date(midTime.getTime() + 1800000), - responseToId: vipUserMsg.id, - }); - - const result = await generateMailboxDailyReport(); - - 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", - }); - }); - - 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 endTime = new Date(); - const midTime = subHours(endTime, 12); - - const { conversation: openConv } = await conversationFactory.create({ - status: "open", - lastUserEmailCreatedAt: midTime, - }); - - const userMsg = await conversationFactory.createUserEmail(openConv.id, { - createdAt: midTime, - }); - - await conversationFactory.createStaffEmail(openConv.id, user.id, { - createdAt: new Date(midTime.getTime() + 3600000), - responseToId: userMsg.id, - }); - - const result = await generateMailboxDailyReport(); - - 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", - }); - }); - - 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 endTime = new Date(); - const midTime = subHours(endTime, 12); - - const customerEmail = faker.internet.email(); - - await platformCustomerFactory.create({ - email: customerEmail, - value: "0.00", - }); - - const { conversation: openConv } = await conversationFactory.create({ - status: "open", - emailFrom: customerEmail, - lastUserEmailCreatedAt: midTime, - }); - - const userMsg = await conversationFactory.createUserEmail(openConv.id, { - createdAt: midTime, - }); - - await conversationFactory.createStaffEmail(openConv.id, user.id, { - createdAt: new Date(midTime.getTime() + 3600000), - responseToId: userMsg.id, - }); - - const result = await generateMailboxDailyReport(); - - 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", - }); - }); - - 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 endTime = new Date(); - const midTime = subHours(endTime, 12); - - const { conversation: openConv } = await conversationFactory.create({ - status: "open", - lastUserEmailCreatedAt: midTime, - }); - const { conversation: _mergedConv } = await conversationFactory.create({ - status: "open", - mergedIntoId: openConv.id, - lastUserEmailCreatedAt: midTime, - }); - - const userMsg = await conversationFactory.createUserEmail(openConv.id, { - createdAt: midTime, - }); - - await conversationFactory.createStaffEmail(openConv.id, user.id, { - createdAt: new Date(midTime.getTime() + 3600000), - responseToId: userMsg.id, - }); - - const result = await generateMailboxDailyReport(); - - 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", - }); - }); - - 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 endTime = new Date(); - const beforeWindow = subHours(endTime, 30); - const withinWindow = subHours(endTime, 12); - - const { conversation: openConv } = await conversationFactory.create({ - status: "open", - lastUserEmailCreatedAt: withinWindow, - }); - - const userMsg = await conversationFactory.createUserEmail(openConv.id, { - createdAt: withinWindow, - }); - - await conversationFactory.createStaffEmail(openConv.id, user.id, { - createdAt: beforeWindow, - responseToId: userMsg.id, - }); - - await conversationFactory.createStaffEmail(openConv.id, user.id, { - createdAt: new Date(withinWindow.getTime() + 3600000), - responseToId: userMsg.id, - }); - - const result = await generateMailboxDailyReport(); - - 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", - }); - }); -}); diff --git a/tests/jobs/generateWeeklyReports.test.ts b/tests/jobs/generateWeeklyReports.test.ts deleted file mode 100644 index a50fea484..000000000 --- a/tests/jobs/generateWeeklyReports.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { userFactory } from "@tests/support/factories/users"; -import { mockJobs } from "@tests/support/jobsUtils"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { generateMailboxReport, generateWeeklyReports } from "@/jobs/generateWeeklyReports"; -import { getMemberStats } from "@/lib/data/stats"; -import { getSlackUsersByEmail, postSlackMessage } from "@/lib/slack/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/data/user", async (importOriginal) => ({ - ...(await importOriginal()), - UserRoles: { - CORE: "core", - NON_CORE: "nonCore", - AFK: "afk", - }, -})); - -const jobsMock = mockJobs(); - -describe("generateWeeklyReports", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("sends weekly report events for mailboxes with Slack configured", async () => { - await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "valid-token", - slackAlertChannel: "channel-id", - }, - }); - - await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: null, - slackAlertChannel: null, - }, - }); - - await generateWeeklyReports(); - - expect(jobsMock.triggerEvent).toHaveBeenCalledTimes(1); - expect(jobsMock.triggerEvent).toHaveBeenCalledWith("reports/weekly", {}); - }); -}); - -describe("generateMailboxWeeklyReport", () => { - beforeEach(() => { - 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", - }, - }); - - 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 generateMailboxReport({ - mailbox, - slackBotToken: mailbox.slackBotToken!, - slackAlertChannel: mailbox.slackAlertChannel!, - }); - - expect(postSlackMessage).toHaveBeenCalledWith( - "valid-token", - 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}/), - }), - ); - - expect(result).toBe("Report 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", - }, - }); - - // Create mock data with both core and non-core members, active and inactive - 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!, - }); - - expect(postSlackMessage).toHaveBeenCalledWith( - "valid-token", - 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}/), - }), - ); - - expect(result).toBe("Report sent"); - }); - - it("skips report generation when there are no stats", async () => { - const { mailbox } = await userFactory.createRootUser({ - mailboxOverrides: { - slackBotToken: "valid-token", - slackAlertChannel: "channel-id", - }, - }); - - vi.mocked(getMemberStats).mockResolvedValue([]); - - const result = await generateMailboxReport({ - mailbox, - slackBotToken: mailbox.slackBotToken!, - slackAlertChannel: mailbox.slackAlertChannel!, - }); - - expect(postSlackMessage).not.toHaveBeenCalled(); - expect(result).toBe("No stats found"); - }); -}); From 8d3555098b8a215b93723a9c151ae697f3f386cd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 09:32:45 +0000 Subject: [PATCH 06/16] [autofix.ci] apply automated fixes --- jobs/notifyVipMessage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/jobs/notifyVipMessage.ts b/jobs/notifyVipMessage.ts index 786c2d0af..c6ef7d0c1 100644 --- a/jobs/notifyVipMessage.ts +++ b/jobs/notifyVipMessage.ts @@ -162,4 +162,3 @@ export const notifyVipMessageEmail = async ({ messageId }: { messageId: number } const message = assertDefinedOrRaiseNonRetriableError(await fetchConversationMessage(messageId)); return await handleVipEmailNotification(message); }; - From 9fae4e79b36fabe6111b211aae911efa1a192ab5 Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Wed, 12 Nov 2025 16:13:11 +0530 Subject: [PATCH 07/16] added fallback as 0 for email templates --- lib/emails/dailyEmailReportTemplate.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/emails/dailyEmailReportTemplate.tsx b/lib/emails/dailyEmailReportTemplate.tsx index 9c05cce3d..f49526af8 100644 --- a/lib/emails/dailyEmailReportTemplate.tsx +++ b/lib/emails/dailyEmailReportTemplate.tsx @@ -63,11 +63,11 @@ export const DailyEmailReportTemplate = ({ From c269846ee8de53ee3c7c5c5954a75c60619f623d Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Thu, 13 Nov 2025 09:23:18 +0530 Subject: [PATCH 08/16] feat: introduced standard client --- jobs/generateDailyReports.ts | 65 ++++++++++++--------------- jobs/generateWeeklyReports.ts | 63 +++++++++++++-------------- jobs/notifyVipMessage.ts | 82 +++++++++++++++++------------------ lib/resend/client.ts | 33 ++++++++++++++ 4 files changed, 128 insertions(+), 115 deletions(-) create mode 100644 lib/resend/client.ts diff --git a/jobs/generateDailyReports.ts b/jobs/generateDailyReports.ts index 6f008710a..f01520780 100644 --- a/jobs/generateDailyReports.ts +++ b/jobs/generateDailyReports.ts @@ -1,12 +1,12 @@ import { subHours } from "date-fns"; import { aliasedTable, and, eq, gt, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; -import { Resend } from "resend"; import { db } from "@/db/client"; import { conversationMessages, conversations, mailboxes, platformCustomers, userProfiles } from "@/db/schema"; import { authUsers } from "@/db/supabaseSchema/auth"; import { triggerEvent } from "@/jobs/trigger"; import { DailyEmailReportTemplate } from "@/lib/emails/dailyEmailReportTemplate"; import { env } from "@/lib/env"; +import { sentEmailViaResend } from "@/lib/resend/client"; import { captureExceptionAndLog } from "@/lib/shared/sentry"; export const TIME_ZONE = "America/New_York"; @@ -21,11 +21,11 @@ export async function generateDailyEmailReports() { await triggerEvent("reports/daily", {}); } -export async function generateMailboxDailyEmailReport() { +export async function generateMailboxDailyEmailReport(): Promise<{ skipped: true; reason: string } | "Email sent"> { const mailbox = await db.query.mailboxes.findFirst({ where: isNull(sql`${mailboxes.preferences}->>'disabled'`), }); - if (!mailbox) return; + 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" }; @@ -165,42 +165,31 @@ export async function generateMailboxDailyEmailReport() { return { skipped: true, reason: "No team members found" }; } - const resend = new Resend(env.RESEND_API_KEY); - - const emailPromises = teamMembers.map(async (member) => { - if (!member.email) return { success: false, reason: "No email address" }; - - try { - await resend.emails.send({ - from: env.RESEND_FROM_ADDRESS!, - to: member.email, - subject: `Daily summary for ${mailbox.name}`, - react: DailyEmailReportTemplate({ - mailboxName: mailbox.name, - openTickets: openTicketCount, - ticketsAnswered: answeredTicketCount, - openTicketsOverZero: openTicketsOverZeroCount || undefined, - ticketsAnsweredOverZero: answeredTicketsOverZeroCount || undefined, - avgReplyTime: avgReplyTimeResult?.average ? formatTime(avgReplyTimeResult.average) : undefined, - vipAvgReplyTime: vipAvgReplyTimeMessage - ? vipAvgReplyTimeMessage.replace("• VIP average reply time: ", "") - : undefined, - avgWaitTime: avgWaitTimeMessage, - }), - }); - - return { success: true }; - } catch (error) { - captureExceptionAndLog(error); - return { success: false, error }; - } + const reactTemplate = DailyEmailReportTemplate({ + mailboxName: mailbox.name, + openTickets: openTicketCount, + ticketsAnswered: answeredTicketCount, + openTicketsOverZero: openTicketsOverZeroCount || undefined, + ticketsAnsweredOverZero: answeredTicketsOverZeroCount || undefined, + avgReplyTime: avgReplyTimeResult?.average ? formatTime(avgReplyTimeResult.average) : undefined, + vipAvgReplyTime: vipAvgReplyTimeMessage + ? vipAvgReplyTimeMessage.replace("• VIP average reply time: ", "") + : undefined, + avgWaitTime: avgWaitTimeMessage, }); - const emailResults = await Promise.all(emailPromises); + 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({ + error: `Daily report : failed to send ${failures.length}/${emailResults.length} daily emails`, + hint: failures, + }); + } - return { - success: true, - emailsSent: emailResults.filter((r) => r.success).length, - totalRecipients: teamMembers.length, - }; + return "Email sent"; } diff --git a/jobs/generateWeeklyReports.ts b/jobs/generateWeeklyReports.ts index cc4fc1cc8..af9230b4a 100644 --- a/jobs/generateWeeklyReports.ts +++ b/jobs/generateWeeklyReports.ts @@ -1,7 +1,6 @@ import { endOfWeek, startOfWeek, subWeeks } from "date-fns"; import { toZonedTime } from "date-fns-tz"; import { eq, isNull, or, sql } from "drizzle-orm"; -import { Resend } from "resend"; import { db } from "@/db/client"; import { mailboxes, userProfiles } from "@/db/schema"; import { authUsers } from "@/db/supabaseSchema/auth"; @@ -10,6 +9,7 @@ import { triggerEvent } from "@/jobs/trigger"; import { getMemberStats, MemberStats } from "@/lib/data/stats"; 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) => { @@ -25,7 +25,8 @@ export async function generateWeeklyEmailReports() { await triggerEvent("reports/weekly", {}); } -export const generateMailboxWeeklyEmailReport = async () => { +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'`), @@ -45,7 +46,11 @@ export const generateMailboxWeeklyEmailReport = async () => { } }; -export async function generateMailboxEmailReport({ mailbox }: { mailbox: typeof mailboxes.$inferSelect }) { +export async function generateMailboxEmailReport({ + mailbox, +}: { + mailbox: typeof mailboxes.$inferSelect; +}): Promise { const now = toZonedTime(new Date(), TIME_ZONE); const lastWeekStart = subWeeks(startOfWeek(now, { weekStartsOn: 0 }), 1); const lastWeekEnd = subWeeks(endOfWeek(now, { weekStartsOn: 0 }), 1); @@ -56,7 +61,7 @@ export async function generateMailboxEmailReport({ mailbox }: { mailbox: typeof }); if (!stats.length) { - return "No stats found"; + return { skipped: true, reason: "No stats found" }; } const allMembersData = processAllMembers(stats); @@ -89,40 +94,30 @@ export async function generateMailboxEmailReport({ mailbox }: { mailbox: typeof return { skipped: true, reason: "No team members found" }; } - const resend = new Resend(env.RESEND_API_KEY); const dateRange = formatDateRange(lastWeekStart, lastWeekEnd); - - const emailPromises = teamMembers.map(async (member) => { - if (!member.email) return { success: false, reason: "No email address" }; - - try { - await resend.emails.send({ - from: env.RESEND_FROM_ADDRESS!, - to: member.email, - subject: `Weekly report for ${mailbox.name}`, - react: WeeklyEmailReportTemplate({ - mailboxName: mailbox.name, - dateRange, - teamMembers: allMembersData.activeMembers, - inactiveMembers: allMembersData.inactiveMembers, - totalReplies: totalTicketsResolved, - activeUserCount, - }), - }); - return { success: true }; - } catch (error) { - captureExceptionAndLog(error); - return { success: false, error }; - } + const reactTemplate = WeeklyEmailReportTemplate({ + mailboxName: mailbox.name, + dateRange, + teamMembers: allMembersData.activeMembers, + inactiveMembers: allMembersData.inactiveMembers, + totalReplies: totalTicketsResolved, + activeUserCount, }); - const emailResults = await Promise.all(emailPromises); + 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({ + error: `Weekly report : failed to send ${failures.length}/${emailResults.length} emails`, + hint: failures, + }); + } - return { - success: true, - emailsSent: emailResults.filter((r) => r.success).length, - totalRecipients: teamMembers.length, - }; + return "Email sent"; } function processAllMembers(members: MemberStats) { diff --git a/jobs/notifyVipMessage.ts b/jobs/notifyVipMessage.ts index c6ef7d0c1..3aa62e2de 100644 --- a/jobs/notifyVipMessage.ts +++ b/jobs/notifyVipMessage.ts @@ -1,6 +1,5 @@ import { eq, isNull, or, sql } from "drizzle-orm"; import { htmlToText } from "html-to-text"; -import { Resend } from "resend"; import { getBaseUrl } from "@/components/constants"; import { db } from "@/db/client"; import { conversationMessages, conversations, mailboxes, platformCustomers, userProfiles } from "@/db/schema"; @@ -8,6 +7,7 @@ import { authUsers } from "@/db/supabaseSchema/auth"; import { getBasicProfileById } from "@/lib/data/user"; 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"; @@ -64,23 +64,25 @@ async function fetchConversationMessage(messageId: number): Promise { const conversation = assertDefinedOrRaiseNonRetriableError(message.conversation); const mailbox = await db.query.mailboxes.findFirst({ where: isNull(sql`${mailboxes.preferences}->>'disabled'`), }); - assertDefinedOrRaiseNonRetriableError(mailbox); + const mailboxRecord = assertDefinedOrRaiseNonRetriableError(mailbox); - if (conversation.isPrompt) return "Not sent, prompt conversation"; - if (!conversation.emailFrom) return "Not sent, anonymous conversation"; - if (!env.RESEND_API_KEY || !env.RESEND_FROM_ADDRESS) return "Not sent, email not configured"; + 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 platformCustomerRecord = await db.query.platformCustomers.findFirst({ where: eq(platformCustomers.email, conversation.emailFrom), }); - const isVip = determineVipStatus(platformCustomerRecord?.value ?? null, mailbox?.vipThreshold ?? null); - if (!isVip) return "Not sent, not a VIP customer"; + 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}`; @@ -105,12 +107,12 @@ async function handleVipEmailNotification(message: MessageWithConversationAndMai closedBy = user?.displayName || user?.email || undefined; } } else { - return "Not sent, original message not found"; + return { skipped: true, reason: "Original message not found" }; } } else if (message.role === "user") { originalMessage = await ensureCleanedUpText(message); } else { - return "Not sent, not a user message and not a reply to a user message"; + return { skipped: true, reason: "Not a user message or reply to user" }; } const teamMembers = await db @@ -122,43 +124,37 @@ async function handleVipEmailNotification(message: MessageWithConversationAndMai .innerJoin(authUsers, eq(userProfiles.id, authUsers.id)) .where(or(isNull(userProfiles.preferences), sql`${userProfiles.preferences}->>'allowVipMessageEmail' != 'false'`)); - if (teamMembers.length === 0) return "Not sent, no team members found"; - - const resend = new Resend(env.RESEND_API_KEY); - const emailPromises = teamMembers.map(async (member) => { - if (!member.email) return { success: false, reason: "No email address" } as const; - try { - await resend.emails.send({ - from: env.RESEND_FROM_ADDRESS!, - to: member.email, - subject: `VIP Customer: ${customerName}`, - react: VipNotificationEmailTemplate({ - customerName, - customerEmail: conversation.emailFrom!, - originalMessage, - replyMessage, - conversationLink, - customerLinks, - closed: conversation.status === "closed", - closedBy, - }), - }); - return { success: true } as const; - } catch (error) { - captureExceptionAndLog(error); - return { success: false, error } as const; - } + 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 vipResults = await sentEmailViaResend({ + memberList: teamMembers.filter((m) => !!m.email).map((m) => ({ email: m.email! })), + subject: `VIP Customer: ${customerName}`, + react: reactTemplate, }); - const emailResults = await Promise.all(emailPromises); - return { - sent: true as const, - emailsSent: emailResults.filter((r) => r.success).length, - totalRecipients: teamMembers.length, - }; + const failures = vipResults.filter((r) => !r.success); + if (failures.length > 0) { + captureExceptionAndLog({ + error: `Daily report : failed to send ${failures.length}/${vipResults.length} daily emails`, + hint: failures, + }); + } + + return "Email sent"; } -export const notifyVipMessageEmail = async ({ messageId }: { messageId: number }) => { +export const notifyVipMessageEmail = async ({ messageId }: { messageId: number }): Promise => { const message = assertDefinedOrRaiseNonRetriableError(await fetchConversationMessage(messageId)); return await handleVipEmailNotification(message); }; diff --git a/lib/resend/client.ts b/lib/resend/client.ts new file mode 100644 index 000000000..f70a32db4 --- /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: subject, + react: react, + }); + return { success: true }; + } catch (error) { + return { success: false, email: member.email, error } as const; + } + }); + const emailResults = await Promise.all(emailPromises); + return emailResults; +}; From 1c5396eb43eeb0c2d610b7727b2a52d0960fc7e5 Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Thu, 13 Nov 2025 09:34:42 +0530 Subject: [PATCH 09/16] sentry capture --- jobs/generateDailyReports.ts | 8 ++++---- jobs/generateWeeklyReports.ts | 8 ++++---- jobs/notifyVipMessage.ts | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/jobs/generateDailyReports.ts b/jobs/generateDailyReports.ts index f01520780..d64ad3347 100644 --- a/jobs/generateDailyReports.ts +++ b/jobs/generateDailyReports.ts @@ -185,10 +185,10 @@ export async function generateMailboxDailyEmailReport(): Promise<{ skipped: true }); const failures = emailResults.filter((r) => !r.success); if (failures.length > 0) { - captureExceptionAndLog({ - error: `Daily report : failed to send ${failures.length}/${emailResults.length} daily emails`, - hint: failures, - }); + captureExceptionAndLog( + new Error(`Daily report: failed to send ${failures.length}/${emailResults.length} daily emails`), + { extra: { failures } }, + ); } return "Email sent"; diff --git a/jobs/generateWeeklyReports.ts b/jobs/generateWeeklyReports.ts index af9230b4a..728505091 100644 --- a/jobs/generateWeeklyReports.ts +++ b/jobs/generateWeeklyReports.ts @@ -111,10 +111,10 @@ export async function generateMailboxEmailReport({ }); const failures = emailResults.filter((r) => !r.success); if (failures.length > 0) { - captureExceptionAndLog({ - error: `Weekly report : failed to send ${failures.length}/${emailResults.length} emails`, - hint: failures, - }); + captureExceptionAndLog( + new Error(`Weekly report: failed to send ${failures.length}/${emailResults.length} emails`), + { extra: { failures } }, + ); } return "Email sent"; diff --git a/jobs/notifyVipMessage.ts b/jobs/notifyVipMessage.ts index 3aa62e2de..0ccfed418 100644 --- a/jobs/notifyVipMessage.ts +++ b/jobs/notifyVipMessage.ts @@ -145,10 +145,10 @@ async function handleVipEmailNotification(message: MessageWithConversationAndMai const failures = vipResults.filter((r) => !r.success); if (failures.length > 0) { - captureExceptionAndLog({ - error: `Daily report : failed to send ${failures.length}/${vipResults.length} daily emails`, - hint: failures, - }); + captureExceptionAndLog( + new Error(`VIP notification: failed to send ${failures.length}/${vipResults.length} emails`), + { extra: { failures } }, + ); } return "Email sent"; From 4b2ce2489b889b9ae21bf3595f31ca544b6258e4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 04:05:04 +0000 Subject: [PATCH 10/16] [autofix.ci] apply automated fixes --- jobs/notifyVipMessage.ts | 2 +- lib/resend/client.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/jobs/notifyVipMessage.ts b/jobs/notifyVipMessage.ts index 3aa62e2de..5a420fe38 100644 --- a/jobs/notifyVipMessage.ts +++ b/jobs/notifyVipMessage.ts @@ -128,7 +128,7 @@ async function handleVipEmailNotification(message: MessageWithConversationAndMai const reactTemplate = VipNotificationEmailTemplate({ customerName, - customerEmail: conversation.emailFrom!, + customerEmail: conversation.emailFrom, originalMessage, replyMessage, conversationLink, diff --git a/lib/resend/client.ts b/lib/resend/client.ts index f70a32db4..049cec68f 100644 --- a/lib/resend/client.ts +++ b/lib/resend/client.ts @@ -20,8 +20,8 @@ export const sentEmailViaResend = async ({ await resend.emails.send({ from: env.RESEND_FROM_ADDRESS!, to: member.email, - subject: subject, - react: react, + subject, + react, }); return { success: true }; } catch (error) { From b5754e1bf1b1651498e483a2fadee5cc68e6397e Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Thu, 13 Nov 2025 13:45:54 +0530 Subject: [PATCH 11/16] added unit tests --- jobs/generateDailyReports.ts | 79 +++-- lib/emails/dailyEmailReportTemplate.tsx | 14 +- tests/jobs/generateDailyReports.test.ts | 430 +++++++++++++++++++++++ tests/jobs/generateWeeklyReports.test.ts | 177 ++++++++++ tests/jobs/notifyVipMessage.test.ts | 174 +++++++++ 5 files changed, 841 insertions(+), 33 deletions(-) create mode 100644 tests/jobs/generateDailyReports.test.ts create mode 100644 tests/jobs/generateWeeklyReports.test.ts create mode 100644 tests/jobs/notifyVipMessage.test.ts diff --git a/jobs/generateDailyReports.ts b/jobs/generateDailyReports.ts index d64ad3347..b03398eca 100644 --- a/jobs/generateDailyReports.ts +++ b/jobs/generateDailyReports.ts @@ -9,6 +9,20 @@ 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 generateDailyEmailReports() { @@ -21,16 +35,29 @@ export async function generateDailyEmailReports() { await triggerEvent("reports/daily", {}); } -export async function generateMailboxDailyEmailReport(): Promise<{ skipped: true; reason: string } | "Email sent"> { - 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" }; +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); @@ -107,8 +134,9 @@ export async function generateMailboxDailyEmailReport(): Promise<{ skipped: true lt(conversationMessages.createdAt, endTime), ), ); + const avgReplyTime = avgReplyTimeResult?.average ? formatTime(avgReplyTimeResult.average) : undefined; - let vipAvgReplyTimeMessage = null; + let vipAvgReplyTime = null; if (mailbox.vipThreshold) { const [vipReplyTimeResult] = await db .select({ @@ -131,9 +159,7 @@ export async function generateMailboxDailyEmailReport(): Promise<{ skipped: true 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 @@ -150,7 +176,7 @@ export async function generateMailboxDailyEmailReport(): Promise<{ skipped: true isNotNull(conversations.lastUserEmailCreatedAt), ), ); - const avgWaitTimeMessage = avgWaitTimeResult?.average ? formatTime(avgWaitTimeResult.average) : undefined; + const avgWaitTime = avgWaitTimeResult?.average ? formatTime(avgWaitTimeResult.average) : undefined; const teamMembers = await db .select({ @@ -167,15 +193,13 @@ export async function generateMailboxDailyEmailReport(): Promise<{ skipped: true const reactTemplate = DailyEmailReportTemplate({ mailboxName: mailbox.name, - openTickets: openTicketCount, - ticketsAnswered: answeredTicketCount, - openTicketsOverZero: openTicketsOverZeroCount || undefined, - ticketsAnsweredOverZero: answeredTicketsOverZeroCount || undefined, - avgReplyTime: avgReplyTimeResult?.average ? formatTime(avgReplyTimeResult.average) : undefined, - vipAvgReplyTime: vipAvgReplyTimeMessage - ? vipAvgReplyTimeMessage.replace("• VIP average reply time: ", "") - : undefined, - avgWaitTime: avgWaitTimeMessage, + openTickets: openTicketCount || 0, + ticketsAnswered: answeredTicketCount || 0, + openTicketsOverZero: openTicketsOverZeroCount || 0, + ticketsAnsweredOverZero: answeredTicketsOverZeroCount || 0, + avgReplyTime: avgReplyTime || undefined, + vipAvgReplyTime: vipAvgReplyTime || undefined, + avgWaitTime: avgWaitTime || undefined, }); const emailResults = await sentEmailViaResend({ @@ -191,5 +215,14 @@ export async function generateMailboxDailyEmailReport(): Promise<{ skipped: true ); } - return "Email sent"; + return { + success: true, + openTicketCount, + answeredTicketCount, + openTicketsOverZeroCount, + answeredTicketsOverZeroCount, + avgReplyTimeResult: avgReplyTimeResult?.average ? formatTime(avgReplyTimeResult.average) : undefined, + vipAvgReplyTime, + avgWaitTime, + }; } diff --git a/lib/emails/dailyEmailReportTemplate.tsx b/lib/emails/dailyEmailReportTemplate.tsx index f49526af8..9f479b693 100644 --- a/lib/emails/dailyEmailReportTemplate.tsx +++ b/lib/emails/dailyEmailReportTemplate.tsx @@ -6,8 +6,8 @@ type Props = { mailboxName: string; openTickets: number; ticketsAnswered: number; - openTicketsOverZero?: number; - ticketsAnsweredOverZero?: number; + openTicketsOverZero: number; + ticketsAnsweredOverZero: number; avgReplyTime?: string; vipAvgReplyTime?: string; avgWaitTime?: string; @@ -61,14 +61,8 @@ export const DailyEmailReportTemplate = ({ - - + + diff --git a/tests/jobs/generateDailyReports.test.ts b/tests/jobs/generateDailyReports.test.ts new file mode 100644 index 000000000..030471cf3 --- /dev/null +++ b/tests/jobs/generateDailyReports.test.ts @@ -0,0 +1,430 @@ +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 { subHours } from "date-fns"; +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { db } from "@/db/client"; +import { userProfiles } from "@/db/schema"; +import { generateMailboxEmailReport } from "@/jobs/generateDailyReports"; +import { sentEmailViaResend } from "@/lib/resend/client"; + +vi.mock("@/lib/resend/client", () => ({ + sentEmailViaResend: vi.fn(), +})); +vi.mocked(sentEmailViaResend).mockResolvedValue([{ success: true }]); + +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("generateMailboxEmailReport", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips when there are no open tickets", async () => { + const { mailbox } = await userFactory.createRootUser(); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(result).toEqual({ + skipped: true, + reason: "No open tickets", + }); + expect(sentEmailViaResend).not.toHaveBeenCalled(); + }); + + it("calculates correct metrics for basic scenarios", async () => { + const { mailbox, user } = await userFactory.createRootUser(); + + const endTime = new Date(); + const midTime = subHours(endTime, 12); + + const { conversation: openConv1 } = await conversationFactory.create({ + status: "open", + lastUserEmailCreatedAt: midTime, + }); + const { conversation: openConv2 } = await conversationFactory.create({ + status: "open", + lastUserEmailCreatedAt: midTime, + }); + const { conversation: _closedConv } = await conversationFactory.create({ + status: "closed", + }); + + const userMsg1 = await conversationFactory.createUserEmail(openConv1.id, { + createdAt: midTime, + }); + const userMsg2 = await conversationFactory.createUserEmail(openConv2.id, { + createdAt: midTime, + }); + + await conversationFactory.createStaffEmail(openConv1.id, user.id, { + createdAt: new Date(midTime.getTime() + 3600000), + responseToId: userMsg1.id, + }); + await conversationFactory.createStaffEmail(openConv2.id, user.id, { + createdAt: new Date(midTime.getTime() + 7200000), + responseToId: userMsg2.id, + }); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(result).toEqual({ + success: true, + 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); + const normalized = html + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + 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: { vipThreshold: 100 } }); + + const endTime = new Date(); + const midTime = subHours(endTime, 12); + + const customerEmail = faker.internet.email(); + const vipCustomerEmail = faker.internet.email(); + + await platformCustomerFactory.create({ + email: customerEmail, + value: "50.00", + }); + await platformCustomerFactory.create({ + email: vipCustomerEmail, + value: "25000.00", + }); + + const { conversation: normalConv } = await conversationFactory.create({ + status: "open", + emailFrom: customerEmail, + lastUserEmailCreatedAt: midTime, + }); + const { conversation: vipConv } = await conversationFactory.create({ + status: "open", + emailFrom: vipCustomerEmail, + lastUserEmailCreatedAt: midTime, + }); + + const normalUserMsg = await conversationFactory.createUserEmail(normalConv.id, { + createdAt: midTime, + }); + const vipUserMsg = await conversationFactory.createUserEmail(vipConv.id, { + createdAt: midTime, + }); + + await conversationFactory.createStaffEmail(normalConv.id, user.id, { + createdAt: new Date(midTime.getTime() + 3600000), + responseToId: normalUserMsg.id, + }); + await conversationFactory.createStaffEmail(vipConv.id, user.id, { + createdAt: new Date(midTime.getTime() + 1800000), + responseToId: vipUserMsg.id, + }); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(result).toEqual({ + success: true, + 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); + const normalized = html + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + 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(); + + const endTime = new Date(); + const midTime = subHours(endTime, 12); + + const { conversation: openConv } = await conversationFactory.create({ + status: "open", + lastUserEmailCreatedAt: midTime, + }); + + const userMsg = await conversationFactory.createUserEmail(openConv.id, { + createdAt: midTime, + }); + + await conversationFactory.createStaffEmail(openConv.id, user.id, { + createdAt: new Date(midTime.getTime() + 3600000), + responseToId: userMsg.id, + }); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(result).toEqual({ + success: true, + 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); + const normalized = html + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + 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(); + + const endTime = new Date(); + const midTime = subHours(endTime, 12); + + const customerEmail = faker.internet.email(); + + await platformCustomerFactory.create({ + email: customerEmail, + value: "0.00", + }); + + const { conversation: openConv } = await conversationFactory.create({ + status: "open", + emailFrom: customerEmail, + lastUserEmailCreatedAt: midTime, + }); + + const userMsg = await conversationFactory.createUserEmail(openConv.id, { + createdAt: midTime, + }); + + await conversationFactory.createStaffEmail(openConv.id, user.id, { + createdAt: new Date(midTime.getTime() + 3600000), + responseToId: userMsg.id, + }); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(result).toEqual({ + success: true, + 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); + const normalized = html + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + 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(); + + const endTime = new Date(); + const midTime = subHours(endTime, 12); + + const { conversation: openConv } = await conversationFactory.create({ + status: "open", + lastUserEmailCreatedAt: midTime, + }); + const { conversation: _mergedConv } = await conversationFactory.create({ + status: "open", + mergedIntoId: openConv.id, + lastUserEmailCreatedAt: midTime, + }); + + const userMsg = await conversationFactory.createUserEmail(openConv.id, { + createdAt: midTime, + }); + + await conversationFactory.createStaffEmail(openConv.id, user.id, { + createdAt: new Date(midTime.getTime() + 3600000), + responseToId: userMsg.id, + }); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(result).toEqual({ + success: true, + 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); + const normalized = html + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + 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(); + + const endTime = new Date(); + const beforeWindow = subHours(endTime, 30); + const withinWindow = subHours(endTime, 12); + + const { conversation: openConv } = await conversationFactory.create({ + status: "open", + lastUserEmailCreatedAt: withinWindow, + }); + + const userMsg = await conversationFactory.createUserEmail(openConv.id, { + createdAt: withinWindow, + }); + + await conversationFactory.createStaffEmail(openConv.id, user.id, { + createdAt: beforeWindow, + responseToId: userMsg.id, + }); + + await conversationFactory.createStaffEmail(openConv.id, user.id, { + createdAt: new Date(withinWindow.getTime() + 3600000), + responseToId: userMsg.id, + }); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(result).toEqual({ + success: true, + 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); + const normalized = html + .replace(//g, "") + .replace(/\s+/g, " ") + .trim(); + 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" }, + }); + const { user: u3 } = 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 new file mode 100644 index 000000000..81821e068 --- /dev/null +++ b/tests/jobs/generateWeeklyReports.test.ts @@ -0,0 +1,177 @@ +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 { db } from "@/db/client"; +import { userProfiles } from "@/db/schema"; +import { generateMailboxEmailReport, generateWeeklyEmailReports } from "@/jobs/generateWeeklyReports"; +import { getMemberStats } from "@/lib/data/stats"; +import { sentEmailViaResend } from "@/lib/resend/client"; + +// Mock dependencies +vi.mock("@/lib/data/stats", () => ({ + getMemberStats: 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()), + UserRoles: { + CORE: "core", + NON_CORE: "nonCore", + AFK: "afk", + }, +})); + +const jobsMock = mockJobs(); + +describe("generateWeeklyReports", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends weekly email reports for mailboxes", async () => { + await userFactory.createRootUser(); + + await userFactory.createRootUser(); + + await generateWeeklyEmailReports(); + + expect(jobsMock.triggerEvent).toHaveBeenCalledTimes(1); + expect(jobsMock.triggerEvent).toHaveBeenCalledWith("reports/weekly", {}); + }); +}); + +describe("generateMailboxWeeklyReport", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + 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 }, + ]); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(sentEmailViaResend).toHaveBeenCalledWith( + expect.objectContaining({ + subject: `Weekly report 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); + 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("sends emails with inactive members and correct totals", async () => { + const { mailbox } = await userFactory.createRootUser({ userOverrides: { email: "john@example.com" } }); + + // Active and inactive mix. Totals: 10 + 5 + 8 + 3 = 26 from 4 people + vi.mocked(getMemberStats).mockResolvedValue([ + { id: "user1", email: "john@example.com", displayName: "John Doe", replyCount: 10 }, + { id: "user2", email: "jane@example.com", displayName: "Jane Smith", replyCount: 5 }, + { id: "user3", email: "alex@example.com", displayName: "Alex Johnson", replyCount: 0 }, + { id: "user4", email: "sam@example.com", displayName: "Sam Wilson", replyCount: 8 }, + { id: "user5", email: "pat@example.com", displayName: "Pat Brown", replyCount: 3 }, + { id: "user6", email: "chris@example.com", displayName: "Chris Lee", replyCount: 0 }, + { id: "user7", email: "bob@example.com", displayName: "Bob White", replyCount: 0 }, + ]); + + const result = await generateMailboxEmailReport({ mailbox }); + + expect(sentEmailViaResend).toHaveBeenCalledWith( + expect.objectContaining({ + subject: `Weekly report for ${mailbox.name}`, + react: expect.anything(), + }), + ); + + 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 sending emails when there are no stats", async () => { + const { mailbox } = await userFactory.createRootUser(); + + vi.mocked(getMemberStats).mockResolvedValue([]); + + const result = await generateMailboxEmailReport({ + mailbox, + }); + + 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..b26c61f79 --- /dev/null +++ b/tests/jobs/notifyVipMessage.test.ts @@ -0,0 +1,174 @@ +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, 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"; + +// 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 () => { + const { user: teamUser } = 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" } }); + const { user: u3 } = 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(); + }); +}); From a7023f441bdbad72e54169639fe95a11dc277679 Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Thu, 13 Nov 2025 14:01:49 +0530 Subject: [PATCH 12/16] fix unit test --- jobs/notifyVipMessage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/jobs/notifyVipMessage.ts b/jobs/notifyVipMessage.ts index 07413d78f..891f57ce7 100644 --- a/jobs/notifyVipMessage.ts +++ b/jobs/notifyVipMessage.ts @@ -75,7 +75,6 @@ async function handleVipEmailNotification(message: MessageWithConversationAndMai 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 platformCustomerRecord = await db.query.platformCustomers.findFirst({ where: eq(platformCustomers.email, conversation.emailFrom), From 9615ed7f76ae49e95dcbce8fc400dbbcc0487d4f Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Thu, 13 Nov 2025 14:17:06 +0530 Subject: [PATCH 13/16] remove unused var --- tests/jobs/generateDailyReports.test.ts | 26 +------------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/tests/jobs/generateDailyReports.test.ts b/tests/jobs/generateDailyReports.test.ts index 030471cf3..9c66ed4a1 100644 --- a/tests/jobs/generateDailyReports.test.ts +++ b/tests/jobs/generateDailyReports.test.ts @@ -100,10 +100,6 @@ describe("generateMailboxEmailReport", () => { ); const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; const html = await render(call.react); - const normalized = html - .replace(//g, "") - .replace(/\s+/g, " ") - .trim(); expectEmailTableRow(html, "Open tickets", 2); expectEmailTableRow(html, "Tickets answered", 2); expectEmailTableRow(html, "Open tickets over $0", 0); @@ -173,10 +169,6 @@ describe("generateMailboxEmailReport", () => { const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; const html = await render(call.react); - const normalized = html - .replace(//g, "") - .replace(/\s+/g, " ") - .trim(); expectEmailTableRow(html, "Open tickets", 2); expectEmailTableRow(html, "Tickets answered", 2); expectEmailTableRow(html, "Open tickets over $0", 2); @@ -221,10 +213,6 @@ describe("generateMailboxEmailReport", () => { const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; const html = await render(call.react); - const normalized = html - .replace(//g, "") - .replace(/\s+/g, " ") - .trim(); expectEmailTableRow(html, "Open tickets", 1); expectEmailTableRow(html, "Tickets answered", 1); expectEmailTableRow(html, "Open tickets over $0", 0); @@ -277,10 +265,6 @@ describe("generateMailboxEmailReport", () => { const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; const html = await render(call.react); - const normalized = html - .replace(//g, "") - .replace(/\s+/g, " ") - .trim(); expectEmailTableRow(html, "Open tickets", 1); expectEmailTableRow(html, "Tickets answered", 1); expectEmailTableRow(html, "Open tickets over $0", 0); @@ -330,10 +314,6 @@ describe("generateMailboxEmailReport", () => { const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; const html = await render(call.react); - const normalized = html - .replace(//g, "") - .replace(/\s+/g, " ") - .trim(); expectEmailTableRow(html, "Open tickets", 1); expectEmailTableRow(html, "Tickets answered", 1); expectEmailTableRow(html, "Open tickets over $0", 0); @@ -384,10 +364,6 @@ describe("generateMailboxEmailReport", () => { const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0]; const html = await render(call.react); - const normalized = html - .replace(//g, "") - .replace(/\s+/g, " ") - .trim(); expectEmailTableRow(html, "Open tickets", 1); expectEmailTableRow(html, "Tickets answered", 1); expectEmailTableRow(html, "Open tickets over $0", 0); @@ -404,7 +380,7 @@ describe("generateMailboxEmailReport", () => { const { user: u2 } = await userFactory.createRootUser({ userOverrides: { email: "b@example.com" }, }); - const { user: u3 } = await userFactory.createRootUser({ + await userFactory.createRootUser({ userOverrides: { email: "c@example.com" }, }); From fd1255564cec1ef83f9ea1d8f297ecad16c11fac Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Thu, 13 Nov 2025 14:25:17 +0530 Subject: [PATCH 14/16] fix lints --- jobs/notifyVipMessage.ts | 1 - tests/jobs/notifyVipMessage.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/jobs/notifyVipMessage.ts b/jobs/notifyVipMessage.ts index 891f57ce7..f64c999e2 100644 --- a/jobs/notifyVipMessage.ts +++ b/jobs/notifyVipMessage.ts @@ -6,7 +6,6 @@ import { conversationMessages, conversations, mailboxes, platformCustomers, user import { authUsers } from "@/db/supabaseSchema/auth"; import { getBasicProfileById } from "@/lib/data/user"; 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"; diff --git a/tests/jobs/notifyVipMessage.test.ts b/tests/jobs/notifyVipMessage.test.ts index b26c61f79..24f2ddc0e 100644 --- a/tests/jobs/notifyVipMessage.test.ts +++ b/tests/jobs/notifyVipMessage.test.ts @@ -79,7 +79,7 @@ describe("notifyVipMessageEmail", () => { }); it("sends a VIP email for a staff reply and shows Closed by", async () => { - const { user: teamUser } = await userFactory.createRootUser({ userOverrides: { email: "team@example.com" } }); + 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)); @@ -131,7 +131,7 @@ describe("notifyVipMessageEmail", () => { 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" } }); - const { user: u3 } = await userFactory.createRootUser({ userOverrides: { email: "c@example.com" } }); + await userFactory.createRootUser({ userOverrides: { email: "c@example.com" } }); // u1 and u2 opt out; u3 should receive await db From b83cc42338286cb44d10d6d3dff6cb9bf7f46b44 Mon Sep 17 00:00:00 2001 From: stefan binoj Date: Sat, 15 Nov 2025 19:33:52 +0530 Subject: [PATCH 15/16] chore: tighten unit tests --- jobs/notifyVipMessage.ts | 2 ++ tests/jobs/generateDailyReports.test.ts | 21 ++++++++++++++++++++- tests/jobs/notifyVipMessage.test.ts | 11 ++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/jobs/notifyVipMessage.ts b/jobs/notifyVipMessage.ts index f64c999e2..8bf09c4a4 100644 --- a/jobs/notifyVipMessage.ts +++ b/jobs/notifyVipMessage.ts @@ -9,6 +9,7 @@ import { VipNotificationEmailTemplate } from "@/lib/emails/vipNotificationEmailT import { sentEmailViaResend } from "@/lib/resend/client"; import { captureExceptionAndLog } from "@/lib/shared/sentry"; import { assertDefinedOrRaiseNonRetriableError } from "./utils"; +import { env } from "@/lib/env"; type MessageWithConversationAndMailbox = typeof conversationMessages.$inferSelect & { conversation: typeof conversations.$inferSelect; @@ -74,6 +75,7 @@ async function handleVipEmailNotification(message: MessageWithConversationAndMai 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 platformCustomerRecord = await db.query.platformCustomers.findFirst({ where: eq(platformCustomers.email, conversation.emailFrom), diff --git a/tests/jobs/generateDailyReports.test.ts b/tests/jobs/generateDailyReports.test.ts index 9c66ed4a1..eaad7b402 100644 --- a/tests/jobs/generateDailyReports.test.ts +++ b/tests/jobs/generateDailyReports.test.ts @@ -2,19 +2,21 @@ 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 { mockJobs } from "@tests/support/jobsUtils"; import { userFactory } from "@tests/support/factories/users"; import { subHours } from "date-fns"; import { eq } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { db } from "@/db/client"; import { userProfiles } from "@/db/schema"; -import { generateMailboxEmailReport } from "@/jobs/generateDailyReports"; +import { generateDailyEmailReports, generateMailboxEmailReport } from "@/jobs/generateDailyReports"; import { sentEmailViaResend } from "@/lib/resend/client"; vi.mock("@/lib/resend/client", () => ({ sentEmailViaResend: vi.fn(), })); vi.mocked(sentEmailViaResend).mockResolvedValue([{ success: true }]); +const jobsMock = mockJobs(); const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -27,6 +29,23 @@ const expectEmailTableRow = (html: string, label: string, value: string | number expect(rx.test(html)).toBe(true); }; +describe("generateDailyReports", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends daily email reports for mailboxes", async () => { + await userFactory.createRootUser(); + + await userFactory.createRootUser(); + + await generateDailyEmailReports(); + + expect(jobsMock.triggerEvent).toHaveBeenCalledTimes(1); + expect(jobsMock.triggerEvent).toHaveBeenCalledWith("reports/daily", {}); + }); +}); + describe("generateMailboxEmailReport", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/tests/jobs/notifyVipMessage.test.ts b/tests/jobs/notifyVipMessage.test.ts index 24f2ddc0e..0d23761bc 100644 --- a/tests/jobs/notifyVipMessage.test.ts +++ b/tests/jobs/notifyVipMessage.test.ts @@ -3,12 +3,21 @@ 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, it, vi } from "vitest"; +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(), From 680155873ad2cb593bb3f28c54dea2ddd7517bb0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 15 Nov 2025 14:16:07 +0000 Subject: [PATCH 16/16] [autofix.ci] apply automated fixes --- jobs/notifyVipMessage.ts | 2 +- tests/jobs/generateDailyReports.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jobs/notifyVipMessage.ts b/jobs/notifyVipMessage.ts index 8bf09c4a4..07413d78f 100644 --- a/jobs/notifyVipMessage.ts +++ b/jobs/notifyVipMessage.ts @@ -6,10 +6,10 @@ import { conversationMessages, conversations, mailboxes, platformCustomers, user import { authUsers } from "@/db/supabaseSchema/auth"; import { getBasicProfileById } from "@/lib/data/user"; 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"; -import { env } from "@/lib/env"; type MessageWithConversationAndMailbox = typeof conversationMessages.$inferSelect & { conversation: typeof conversations.$inferSelect; diff --git a/tests/jobs/generateDailyReports.test.ts b/tests/jobs/generateDailyReports.test.ts index eaad7b402..11e7583b5 100644 --- a/tests/jobs/generateDailyReports.test.ts +++ b/tests/jobs/generateDailyReports.test.ts @@ -2,8 +2,8 @@ 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 { mockJobs } from "@tests/support/jobsUtils"; 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";