]*>\\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";
|