]*>\\s*| ]*>\\s*${escapeRegExp(label)}\\s*<\\/td>\\s* | ]*>\\s*${escapeRegExp(v)}\\s*<\\/td>\\s*<\\/tr>`,
+ "i",
+ );
+ expect(rx.test(html)).toBe(true);
+};
-describe("generateMailboxDailyReport", () => {
+describe("generateDailyReports", () => {
beforeEach(() => {
vi.clearAllMocks();
});
- it("skips when mailbox has no slack configuration", async () => {
- vi.mocked(getMailbox).mockResolvedValue({
- id: 1,
- name: "Test Mailbox",
- slackBotToken: null,
- slackAlertChannel: null,
- vipThreshold: null,
- } as any);
+ it("sends daily email reports for mailboxes", async () => {
+ await userFactory.createRootUser();
+
+ await userFactory.createRootUser();
- const result = await generateMailboxDailyReport();
+ await generateDailyEmailReports();
- expect(result).toBeUndefined();
- expect(postSlackMessage).not.toHaveBeenCalled();
+ expect(jobsMock.triggerEvent).toHaveBeenCalledTimes(1);
+ expect(jobsMock.triggerEvent).toHaveBeenCalledWith("reports/daily", {});
});
+});
- it("skips when there are no open tickets", async () => {
- const { mailbox } = await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "test-token",
- slackAlertChannel: "test-channel",
- },
- });
+describe("generateMailboxEmailReport", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
- vi.mocked(getMailbox).mockResolvedValue(mailbox);
+ it("skips when there are no open tickets", async () => {
+ const { mailbox } = await userFactory.createRootUser();
- const result = await generateMailboxDailyReport();
+ const result = await generateMailboxEmailReport({ mailbox });
expect(result).toEqual({
skipped: true,
reason: "No open tickets",
});
- expect(postSlackMessage).not.toHaveBeenCalled();
+ expect(sentEmailViaResend).not.toHaveBeenCalled();
});
it("calculates correct metrics for basic scenarios", async () => {
- const { mailbox, user } = await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "test-token",
- slackAlertChannel: "test-channel",
- },
- });
-
- vi.mocked(getMailbox).mockResolvedValue(mailbox);
+ const { mailbox, user } = await userFactory.createRootUser();
const endTime = new Date();
const midTime = subHours(endTime, 12);
@@ -96,36 +97,39 @@ describe("generateMailboxDailyReport", () => {
responseToId: userMsg2.id,
});
- const result = await generateMailboxDailyReport();
+ const result = await generateMailboxEmailReport({ mailbox });
expect(result).toEqual({
success: true,
- openCountMessage: "• Open tickets: 2",
- answeredCountMessage: "• Tickets answered: 2",
- openTicketsOverZeroMessage: null,
- answeredTicketsOverZeroMessage: null,
- avgReplyTimeMessage: "• Average reply time: 1h 30m",
- vipAvgReplyTimeMessage: null,
- avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m",
- });
-
- expect(postSlackMessage).toHaveBeenCalledWith("test-token", {
- channel: "test-channel",
- text: `Daily summary for ${mailbox.name}`,
- blocks: expect.any(Array),
- });
+ openTicketCount: 2,
+ answeredTicketCount: 2,
+ openTicketsOverZeroCount: 0,
+ answeredTicketsOverZeroCount: 0,
+ avgReplyTimeResult: "1h 30m",
+ vipAvgReplyTime: null,
+ avgWaitTime: "12h 0m",
+ });
+
+ expect(sentEmailViaResend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ subject: `Daily summary for ${mailbox.name}`,
+ memberList: expect.arrayContaining([{ email: user.email! }]),
+ react: expect.anything(),
+ }),
+ );
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ const html = await render(call.react);
+ expectEmailTableRow(html, "Open tickets", 2);
+ expectEmailTableRow(html, "Tickets answered", 2);
+ expectEmailTableRow(html, "Open tickets over $0", 0);
+ expectEmailTableRow(html, "Tickets answered over $0", 0);
+ expectEmailTableRow(html, "Average reply time", "1h 30m");
+ expectEmailTableRow(html, "VIP average reply time", "—");
+ expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m");
});
it("calculates correct metrics with VIP customers", async () => {
- const { mailbox, user } = await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "test-token",
- slackAlertChannel: "test-channel",
- vipThreshold: 100,
- },
- });
-
- vi.mocked(getMailbox).mockResolvedValue(mailbox);
+ const { mailbox, user } = await userFactory.createRootUser({ mailboxOverrides: { vipThreshold: 100 } });
const endTime = new Date();
const midTime = subHours(endTime, 12);
@@ -169,29 +173,32 @@ describe("generateMailboxDailyReport", () => {
responseToId: vipUserMsg.id,
});
- const result = await generateMailboxDailyReport();
+ const result = await generateMailboxEmailReport({ mailbox });
expect(result).toEqual({
success: true,
- openCountMessage: "• Open tickets: 2",
- answeredCountMessage: "• Tickets answered: 2",
- openTicketsOverZeroMessage: "• Open tickets over $0: 2",
- answeredTicketsOverZeroMessage: "• Tickets answered over $0: 2",
- avgReplyTimeMessage: "• Average reply time: 0h 45m",
- vipAvgReplyTimeMessage: "• VIP average reply time: 0h 30m",
- avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m",
- });
+ openTicketCount: 2,
+ answeredTicketCount: 2,
+ openTicketsOverZeroCount: 2,
+ answeredTicketsOverZeroCount: 2,
+ avgReplyTimeResult: "0h 45m",
+ vipAvgReplyTime: "0h 30m",
+ avgWaitTime: "12h 0m",
+ });
+
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ const html = await render(call.react);
+ expectEmailTableRow(html, "Open tickets", 2);
+ expectEmailTableRow(html, "Tickets answered", 2);
+ expectEmailTableRow(html, "Open tickets over $0", 2);
+ expectEmailTableRow(html, "Tickets answered over $0", 2);
+ expectEmailTableRow(html, "Average reply time", "0h 45m");
+ expectEmailTableRow(html, "VIP average reply time", "0h 30m");
+ expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m");
});
it("handles scenarios with no platform customers", async () => {
- const { mailbox, user } = await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "test-token",
- slackAlertChannel: "test-channel",
- },
- });
-
- vi.mocked(getMailbox).mockResolvedValue(mailbox);
+ const { mailbox, user } = await userFactory.createRootUser();
const endTime = new Date();
const midTime = subHours(endTime, 12);
@@ -210,29 +217,32 @@ describe("generateMailboxDailyReport", () => {
responseToId: userMsg.id,
});
- const result = await generateMailboxDailyReport();
+ const result = await generateMailboxEmailReport({ mailbox });
expect(result).toEqual({
success: true,
- openCountMessage: "• Open tickets: 1",
- answeredCountMessage: "• Tickets answered: 1",
- openTicketsOverZeroMessage: null,
- answeredTicketsOverZeroMessage: null,
- avgReplyTimeMessage: "• Average reply time: 1h 0m",
- vipAvgReplyTimeMessage: null,
- avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m",
- });
+ openTicketCount: 1,
+ answeredTicketCount: 1,
+ openTicketsOverZeroCount: 0,
+ answeredTicketsOverZeroCount: 0,
+ avgReplyTimeResult: "1h 0m",
+ vipAvgReplyTime: null,
+ avgWaitTime: "12h 0m",
+ });
+
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ const html = await render(call.react);
+ expectEmailTableRow(html, "Open tickets", 1);
+ expectEmailTableRow(html, "Tickets answered", 1);
+ expectEmailTableRow(html, "Open tickets over $0", 0);
+ expectEmailTableRow(html, "Tickets answered over $0", 0);
+ expectEmailTableRow(html, "Average reply time", "1h 0m");
+ expectEmailTableRow(html, "VIP average reply time", "—");
+ expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m");
});
it("handles zero-value platform customers correctly", async () => {
- const { mailbox, user } = await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "test-token",
- slackAlertChannel: "test-channel",
- },
- });
-
- vi.mocked(getMailbox).mockResolvedValue(mailbox);
+ const { mailbox, user } = await userFactory.createRootUser();
const endTime = new Date();
const midTime = subHours(endTime, 12);
@@ -259,29 +269,32 @@ describe("generateMailboxDailyReport", () => {
responseToId: userMsg.id,
});
- const result = await generateMailboxDailyReport();
+ const result = await generateMailboxEmailReport({ mailbox });
expect(result).toEqual({
success: true,
- openCountMessage: "• Open tickets: 1",
- answeredCountMessage: "• Tickets answered: 1",
- openTicketsOverZeroMessage: null,
- answeredTicketsOverZeroMessage: null,
- avgReplyTimeMessage: "• Average reply time: 1h 0m",
- vipAvgReplyTimeMessage: null,
- avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m",
- });
+ openTicketCount: 1,
+ answeredTicketCount: 1,
+ openTicketsOverZeroCount: 0,
+ answeredTicketsOverZeroCount: 0,
+ avgReplyTimeResult: "1h 0m",
+ vipAvgReplyTime: null,
+ avgWaitTime: "12h 0m",
+ });
+
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ const html = await render(call.react);
+ expectEmailTableRow(html, "Open tickets", 1);
+ expectEmailTableRow(html, "Tickets answered", 1);
+ expectEmailTableRow(html, "Open tickets over $0", 0);
+ expectEmailTableRow(html, "Tickets answered over $0", 0);
+ expectEmailTableRow(html, "Average reply time", "1h 0m");
+ expectEmailTableRow(html, "VIP average reply time", "—");
+ expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m");
});
it("excludes merged conversations from counts", async () => {
- const { mailbox, user } = await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "test-token",
- slackAlertChannel: "test-channel",
- },
- });
-
- vi.mocked(getMailbox).mockResolvedValue(mailbox);
+ const { mailbox, user } = await userFactory.createRootUser();
const endTime = new Date();
const midTime = subHours(endTime, 12);
@@ -305,29 +318,32 @@ describe("generateMailboxDailyReport", () => {
responseToId: userMsg.id,
});
- const result = await generateMailboxDailyReport();
+ const result = await generateMailboxEmailReport({ mailbox });
expect(result).toEqual({
success: true,
- openCountMessage: "• Open tickets: 1",
- answeredCountMessage: "• Tickets answered: 1",
- openTicketsOverZeroMessage: null,
- answeredTicketsOverZeroMessage: null,
- avgReplyTimeMessage: "• Average reply time: 1h 0m",
- vipAvgReplyTimeMessage: null,
- avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m",
- });
+ openTicketCount: 1,
+ answeredTicketCount: 1,
+ openTicketsOverZeroCount: 0,
+ answeredTicketsOverZeroCount: 0,
+ avgReplyTimeResult: "1h 0m",
+ vipAvgReplyTime: null,
+ avgWaitTime: "12h 0m",
+ });
+
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ const html = await render(call.react);
+ expectEmailTableRow(html, "Open tickets", 1);
+ expectEmailTableRow(html, "Tickets answered", 1);
+ expectEmailTableRow(html, "Open tickets over $0", 0);
+ expectEmailTableRow(html, "Tickets answered over $0", 0);
+ expectEmailTableRow(html, "Average reply time", "1h 0m");
+ expectEmailTableRow(html, "VIP average reply time", "—");
+ expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m");
});
it("only counts messages within the 24-hour window", async () => {
- const { mailbox, user } = await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "test-token",
- slackAlertChannel: "test-channel",
- },
- });
-
- vi.mocked(getMailbox).mockResolvedValue(mailbox);
+ const { mailbox, user } = await userFactory.createRootUser();
const endTime = new Date();
const beforeWindow = subHours(endTime, 30);
@@ -352,17 +368,58 @@ describe("generateMailboxDailyReport", () => {
responseToId: userMsg.id,
});
- const result = await generateMailboxDailyReport();
+ const result = await generateMailboxEmailReport({ mailbox });
expect(result).toEqual({
success: true,
- openCountMessage: "• Open tickets: 1",
- answeredCountMessage: "• Tickets answered: 1",
- openTicketsOverZeroMessage: null,
- answeredTicketsOverZeroMessage: null,
- avgReplyTimeMessage: "• Average reply time: 1h 0m",
- vipAvgReplyTimeMessage: null,
- avgWaitTimeMessage: "• Average time existing open tickets have been open: 12h 0m",
+ openTicketCount: 1,
+ answeredTicketCount: 1,
+ openTicketsOverZeroCount: 0,
+ answeredTicketsOverZeroCount: 0,
+ avgReplyTimeResult: "1h 0m",
+ vipAvgReplyTime: null,
+ avgWaitTime: "12h 0m",
+ });
+
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ const html = await render(call.react);
+ expectEmailTableRow(html, "Open tickets", 1);
+ expectEmailTableRow(html, "Tickets answered", 1);
+ expectEmailTableRow(html, "Open tickets over $0", 0);
+ expectEmailTableRow(html, "Tickets answered over $0", 0);
+ expectEmailTableRow(html, "Average reply time", "1h 0m");
+ expectEmailTableRow(html, "VIP average reply time", "—");
+ expectEmailTableRow(html, "Average time existing open tickets have been open", "12h 0m");
+ });
+
+ it("excludes users with allowDailyEmail=false from recipients", async () => {
+ const { mailbox, user: u1 } = await userFactory.createRootUser({
+ userOverrides: { email: "a@example.com" },
+ });
+ const { user: u2 } = await userFactory.createRootUser({
+ userOverrides: { email: "b@example.com" },
+ });
+ await userFactory.createRootUser({
+ userOverrides: { email: "c@example.com" },
});
+
+ await db
+ .update(userProfiles)
+ .set({ preferences: { allowDailyEmail: false } })
+ .where(eq(userProfiles.id, u1.id));
+ await db
+ .update(userProfiles)
+ .set({ preferences: { allowDailyEmail: false } })
+ .where(eq(userProfiles.id, u2.id));
+
+ // Ensure there is at least one open ticket so the report is sent
+ await conversationFactory.create({ status: "open", lastUserEmailCreatedAt: subHours(new Date(), 6) });
+
+ const result = await generateMailboxEmailReport({ mailbox });
+
+ expect(sentEmailViaResend).toHaveBeenCalledTimes(1);
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ expect(call.memberList).toEqual([{ email: "c@example.com" }]);
+ expect(result).toEqual(expect.objectContaining({ success: true }));
});
});
diff --git a/tests/jobs/generateWeeklyReports.test.ts b/tests/jobs/generateWeeklyReports.test.ts
index a50fea484..81821e068 100644
--- a/tests/jobs/generateWeeklyReports.test.ts
+++ b/tests/jobs/generateWeeklyReports.test.ts
@@ -1,19 +1,23 @@
+import { render } from "@react-email/render";
import { userFactory } from "@tests/support/factories/users";
import { mockJobs } from "@tests/support/jobsUtils";
+import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { generateMailboxReport, generateWeeklyReports } from "@/jobs/generateWeeklyReports";
+import { db } from "@/db/client";
+import { userProfiles } from "@/db/schema";
+import { generateMailboxEmailReport, generateWeeklyEmailReports } from "@/jobs/generateWeeklyReports";
import { getMemberStats } from "@/lib/data/stats";
-import { getSlackUsersByEmail, postSlackMessage } from "@/lib/slack/client";
+import { sentEmailViaResend } from "@/lib/resend/client";
// Mock dependencies
vi.mock("@/lib/data/stats", () => ({
getMemberStats: vi.fn(),
}));
-vi.mock("@/lib/slack/client", () => ({
- postSlackMessage: vi.fn(),
- getSlackUsersByEmail: vi.fn(),
+vi.mock("@/lib/resend/client", () => ({
+ sentEmailViaResend: vi.fn(),
}));
+vi.mocked(sentEmailViaResend).mockResolvedValue([{ success: true }]);
vi.mock("@/lib/data/user", async (importOriginal) => ({
...(await importOriginal()),
@@ -31,22 +35,12 @@ describe("generateWeeklyReports", () => {
vi.clearAllMocks();
});
- it("sends weekly report events for mailboxes with Slack configured", async () => {
- await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "valid-token",
- slackAlertChannel: "channel-id",
- },
- });
+ it("sends weekly email reports for mailboxes", async () => {
+ await userFactory.createRootUser();
- await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: null,
- slackAlertChannel: null,
- },
- });
+ await userFactory.createRootUser();
- await generateWeeklyReports();
+ await generateWeeklyEmailReports();
expect(jobsMock.triggerEvent).toHaveBeenCalledTimes(1);
expect(jobsMock.triggerEvent).toHaveBeenCalledWith("reports/weekly", {});
@@ -58,189 +52,126 @@ describe("generateMailboxWeeklyReport", () => {
vi.clearAllMocks();
});
- it("generates and posts report to Slack when there are stats", async () => {
- const { mailbox } = await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "valid-token",
- slackAlertChannel: "channel-id",
- },
+ it("sends emails when there are stats", async () => {
+ const { mailbox, user } = await userFactory.createRootUser({
+ userOverrides: { email: "john@example.com" },
});
vi.mocked(getMemberStats).mockResolvedValue([
{ id: "user1", email: "john@example.com", displayName: "John Doe", replyCount: 5 },
]);
- vi.mocked(getSlackUsersByEmail).mockResolvedValue(new Map([["john@example.com", "SLACK123"]]));
+ const result = await generateMailboxEmailReport({ mailbox });
- const result = await generateMailboxReport({
- mailbox,
- slackBotToken: mailbox.slackBotToken!,
- slackAlertChannel: mailbox.slackAlertChannel!,
- });
-
- expect(postSlackMessage).toHaveBeenCalledWith(
- "valid-token",
+ expect(sentEmailViaResend).toHaveBeenCalledWith(
expect.objectContaining({
- channel: "channel-id",
- blocks: [
- {
- type: "section",
- text: {
- type: "plain_text",
- text: `Last week in the ${mailbox.name} mailbox:`,
- emoji: true,
- },
- },
- {
- type: "section",
- text: {
- type: "mrkdwn",
- text: "*Team members:*",
- },
- },
- {
- type: "section",
- text: {
- type: "mrkdwn",
- text: "• <@SLACK123>: 5",
- },
- },
- {
- type: "divider",
- },
- {
- type: "section",
- text: {
- type: "mrkdwn",
- text: "*Total replies:*\n5 from 1 person",
- },
- },
- ],
- text: expect.stringMatching(/Week of \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2}/),
+ subject: `Weekly report for ${mailbox.name}`,
+ memberList: expect.arrayContaining([{ email: user.email! }]),
+ react: expect.anything(),
}),
);
-
- expect(result).toBe("Report sent");
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ const html = await render(call.react);
+ expect(html).toContain("John Doe");
+ expect(html).toMatch(/>5<\/td>/);
+ expect(call.subject).toBe(`Weekly report for ${mailbox.name}`);
+
+ // react-emails may splits text nodes with comment markers to preserve whitespace/structure
+ const normalized = html
+ .replace(//g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+ expect(normalized).toContain("from 1 person");
+
+ expect(result).toBe("Email sent");
});
- it("generates and posts report with both core and non-core members", async () => {
- const { mailbox } = await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "valid-token",
- slackAlertChannel: "channel-id",
- },
- });
+ it("sends emails with inactive members and correct totals", async () => {
+ const { mailbox } = await userFactory.createRootUser({ userOverrides: { email: "john@example.com" } });
- // Create mock data with both core and non-core members, active and inactive
+ // Active and inactive mix. Totals: 10 + 5 + 8 + 3 = 26 from 4 people
vi.mocked(getMemberStats).mockResolvedValue([
- // Active core members
{ id: "user1", email: "john@example.com", displayName: "John Doe", replyCount: 10 },
{ id: "user2", email: "jane@example.com", displayName: "Jane Smith", replyCount: 5 },
- // Inactive core member
{ id: "user3", email: "alex@example.com", displayName: "Alex Johnson", replyCount: 0 },
- // Active non-core members
{ id: "user4", email: "sam@example.com", displayName: "Sam Wilson", replyCount: 8 },
{ id: "user5", email: "pat@example.com", displayName: "Pat Brown", replyCount: 3 },
- // Inactive non-core member
{ id: "user6", email: "chris@example.com", displayName: "Chris Lee", replyCount: 0 },
- // AFK member
{ id: "user7", email: "bob@example.com", displayName: "Bob White", replyCount: 0 },
]);
- vi.mocked(getSlackUsersByEmail).mockResolvedValue(
- new Map([
- ["john@example.com", "SLACK1"],
- ["jane@example.com", "SLACK2"],
- ["alex@example.com", "SLACK3"],
- ["sam@example.com", "SLACK4"],
- ["pat@example.com", "SLACK5"],
- ["chris@example.com", "SLACK6"],
- ["bob@example.com", "SLACK7"],
- ]),
- );
-
- const result = await generateMailboxReport({
- mailbox,
- slackBotToken: mailbox.slackBotToken!,
- slackAlertChannel: mailbox.slackAlertChannel!,
- });
+ const result = await generateMailboxEmailReport({ mailbox });
- expect(postSlackMessage).toHaveBeenCalledWith(
- "valid-token",
+ expect(sentEmailViaResend).toHaveBeenCalledWith(
expect.objectContaining({
- channel: "channel-id",
- blocks: expect.arrayContaining([
- // Header
- {
- type: "section",
- text: {
- type: "plain_text",
- text: `Last week in the ${mailbox.name} mailbox:`,
- emoji: true,
- },
- },
- // Team members header
- {
- type: "section",
- text: {
- type: "mrkdwn",
- text: "*Team members:*",
- },
- },
- // Team members mention by slack ID
- {
- type: "section",
- text: {
- type: "mrkdwn",
- text: "• <@SLACK1>: 10\n• <@SLACK4>: 8\n• <@SLACK2>: 5\n• <@SLACK5>: 3",
- },
- },
- // Inactive members
- {
- type: "section",
- text: {
- type: "mrkdwn",
- text: "*No tickets answered:* <@SLACK3>, <@SLACK6>, <@SLACK7>",
- },
- },
- // Divider before total
- {
- type: "divider",
- },
- // Total replies
- {
- type: "section",
- text: {
- type: "mrkdwn",
- text: "*Total replies:*\n26 from 4 people",
- },
- },
- // AFK member NOT mentioned
- ]),
- text: expect.stringMatching(/Week of \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2}/),
+ subject: `Weekly report for ${mailbox.name}`,
+ react: expect.anything(),
}),
);
- expect(result).toBe("Report sent");
+ const call = vi.mocked(sentEmailViaResend).mock.calls.at(-1)![0];
+ const html = await render(call.react);
+ const normalized = html
+ .replace(//g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ // Inactive members section should list the names
+ expect(normalized).toContain("No tickets answered:");
+ expect(normalized).toContain("Alex Johnson, Chris Lee, Bob White");
+
+ // Totals block
+ expect(normalized).toContain("26 replies");
+ expect(normalized).toContain("from 4 people");
+
+ expect(result).toBe("Email sent");
});
- it("skips report generation when there are no stats", async () => {
- const { mailbox } = await userFactory.createRootUser({
- mailboxOverrides: {
- slackBotToken: "valid-token",
- slackAlertChannel: "channel-id",
- },
- });
+ it("skips sending emails when there are no stats", async () => {
+ const { mailbox } = await userFactory.createRootUser();
vi.mocked(getMemberStats).mockResolvedValue([]);
- const result = await generateMailboxReport({
+ const result = await generateMailboxEmailReport({
mailbox,
- slackBotToken: mailbox.slackBotToken!,
- slackAlertChannel: mailbox.slackAlertChannel!,
});
- expect(postSlackMessage).not.toHaveBeenCalled();
- expect(result).toBe("No stats found");
+ expect(sentEmailViaResend).not.toHaveBeenCalled();
+ expect(result).toEqual({
+ skipped: true,
+ reason: "No stats found",
+ });
+ });
+
+ it("excludes users with allowWeeklyEmail=false from recipients", async () => {
+ const { mailbox, user: u1 } = await userFactory.createRootUser({
+ userOverrides: { email: "a@example.com" },
+ });
+ const { user: u2 } = await userFactory.createRootUser({
+ userOverrides: { email: "b@example.com" },
+ });
+ const { user: u3 } = await userFactory.createRootUser({
+ userOverrides: { email: "c@example.com" },
+ });
+
+ await db
+ .update(userProfiles)
+ .set({ preferences: { allowWeeklyEmail: false } })
+ .where(eq(userProfiles.id, u1.id));
+ await db
+ .update(userProfiles)
+ .set({ preferences: { allowWeeklyEmail: false } })
+ .where(eq(userProfiles.id, u2.id));
+
+ vi.mocked(getMemberStats).mockResolvedValue([
+ { id: u3.id, email: u3.email!, displayName: "User C", replyCount: 3 },
+ ]);
+
+ const result = await generateMailboxEmailReport({ mailbox });
+
+ expect(sentEmailViaResend).toHaveBeenCalledTimes(1);
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ expect(call.memberList).toEqual([{ email: "c@example.com" }]);
+ expect(result).toBe("Email sent");
});
});
diff --git a/tests/jobs/notifyVipMessage.test.ts b/tests/jobs/notifyVipMessage.test.ts
new file mode 100644
index 000000000..0d23761bc
--- /dev/null
+++ b/tests/jobs/notifyVipMessage.test.ts
@@ -0,0 +1,183 @@
+import { render } from "@react-email/render";
+import { conversationFactory } from "@tests/support/factories/conversations";
+import { platformCustomerFactory } from "@tests/support/factories/platformCustomers";
+import { userFactory } from "@tests/support/factories/users";
+import { eq } from "drizzle-orm";
+import { beforeEach, describe, expect, inject, it, vi } from "vitest";
+import { db } from "@/db/client";
+import { mailboxes, userProfiles } from "@/db/schema";
+import { notifyVipMessageEmail } from "@/jobs/notifyVipMessage";
+import { sentEmailViaResend } from "@/lib/resend/client";
+
+vi.mock("@/lib/env", () => ({
+ env: {
+ POSTGRES_URL: inject("TEST_DATABASE_URL"),
+ RESEND_API_KEY: "test-api-key",
+ RESEND_FROM_ADDRESS: "test@example.com",
+ AUTH_URL: "https://helperai.dev",
+ },
+}));
+
+// Mock email sender
+vi.mock("@/lib/resend/client", () => ({
+ sentEmailViaResend: vi.fn(),
+}));
+
+vi.mocked(sentEmailViaResend).mockResolvedValue([{ success: true }]);
+
+describe("notifyVipMessageEmail", () => {
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ // Ensure any existing mailboxes will qualify and have a threshold
+ // so VIP determination can be made deterministically in tests.
+ try {
+ await db.update(mailboxes).set({ vipThreshold: 500 });
+ } catch (_) {
+ // ignore if no rows yet
+ }
+ });
+
+ it("sends a VIP email for a new user message", async () => {
+ const { user } = await userFactory.createRootUser({
+ userOverrides: { email: "agent@example.com" },
+ });
+
+ // Make sure the selected mailbox has a threshold
+ await db.update(mailboxes).set({ vipThreshold: 500 });
+
+ // VIP customer record with high value so they pass threshold
+ const vipEmail = "vip@example.com";
+ await platformCustomerFactory.create({
+ email: vipEmail,
+ name: "Acme VIP",
+ value: "60000", // 600.00
+ links: { Dashboard: "https://example.com/dashboard" },
+ });
+
+ const { conversation } = await conversationFactory.create({
+ emailFrom: vipEmail,
+ status: "open",
+ });
+ const userMsg = await conversationFactory.createUserEmail(conversation.id, {
+ cleanedUpText: "Hello from the VIP!",
+ });
+
+ const result = await notifyVipMessageEmail({ messageId: userMsg.id });
+
+ expect(result).toBe("Email sent");
+ expect(sentEmailViaResend).toHaveBeenCalledTimes(1);
+ expect(sentEmailViaResend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ subject: "VIP Customer: Acme VIP",
+ memberList: expect.arrayContaining([{ email: user.email! }]),
+ react: expect.anything(),
+ }),
+ );
+
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ const html = await render(call.react);
+ const normalized = html
+ .replace(//g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ expect(normalized).toContain("VIP Customer");
+ expect(normalized).toContain("Original message:");
+ expect(normalized).toContain("Hello from the VIP!");
+ expect(normalized).toContain("View in Helper");
+ });
+
+ it("sends a VIP email for a staff reply and shows Closed by", async () => {
+ await userFactory.createRootUser({ userOverrides: { email: "team@example.com" } });
+ const { user: staffUser } = await userFactory.createRootUser({ userOverrides: { email: "staff@example.com" } });
+
+ await db.update(userProfiles).set({ displayName: "Agent Smith" }).where(eq(userProfiles.id, staffUser.id));
+ await db.update(mailboxes).set({ vipThreshold: 500 });
+
+ const vipEmail = "vip2@example.com";
+ await platformCustomerFactory.create({ email: vipEmail, name: "VIP 2", value: "99999" });
+
+ const { conversation } = await conversationFactory.create({ emailFrom: vipEmail, status: "closed" });
+ const original = await conversationFactory.createUserEmail(conversation.id, {
+ cleanedUpText: "Customer said hi",
+ });
+ const staffReply = await conversationFactory.createStaffEmail(conversation.id, staffUser.id, {
+ responseToId: original.id,
+ cleanedUpText: "Replying to your message",
+ status: "sent",
+ });
+
+ const result = await notifyVipMessageEmail({ messageId: staffReply.id });
+ expect(result).toBe("Email sent");
+
+ const call = vi.mocked(sentEmailViaResend).mock.calls.at(-1)![0];
+ expect(call.subject).toBe("VIP Customer: VIP 2");
+ const html = await render(call.react);
+ const normalized = html
+ .replace(//g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+ expect(normalized).toContain("Original message:");
+ expect(normalized).toContain("Customer said hi");
+ expect(normalized).toContain("Reply:");
+ expect(normalized).toContain("Replying to your message");
+ expect(normalized).toContain("Closed by Agent Smith");
+ });
+
+ it("skips when customer is not VIP", async () => {
+ await userFactory.createRootUser();
+ await db.update(mailboxes).set({ vipThreshold: 1000 });
+
+ // No platform customer record for this email => not VIP
+ const { conversation } = await conversationFactory.create({ emailFrom: "random@example.com", status: "open" });
+ const userMsg = await conversationFactory.createUserEmail(conversation.id);
+
+ const result = await notifyVipMessageEmail({ messageId: userMsg.id });
+ expect(result).toEqual({ skipped: true, reason: "Not a VIP customer" });
+ expect(sentEmailViaResend).not.toHaveBeenCalled();
+ });
+
+ it("excludes users with allowVipMessageEmail=false from recipients", async () => {
+ const { user: u1 } = await userFactory.createRootUser({ userOverrides: { email: "a@example.com" } });
+ const { user: u2 } = await userFactory.createRootUser({ userOverrides: { email: "b@example.com" } });
+ await userFactory.createRootUser({ userOverrides: { email: "c@example.com" } });
+
+ // u1 and u2 opt out; u3 should receive
+ await db
+ .update(userProfiles)
+ .set({ preferences: { allowVipMessageEmail: false } })
+ .where(eq(userProfiles.id, u1.id));
+ await db
+ .update(userProfiles)
+ .set({ preferences: { allowVipMessageEmail: false } })
+ .where(eq(userProfiles.id, u2.id));
+
+ // Ensure VIP threshold low enough to qualify
+ await db.update(mailboxes).set({ vipThreshold: 100 });
+
+ const vipEmail = "vip3@example.com";
+ await platformCustomerFactory.create({ email: vipEmail, name: "VIP 3", value: "50000" });
+
+ const { conversation } = await conversationFactory.create({ emailFrom: vipEmail });
+ const msg = await conversationFactory.createUserEmail(conversation.id);
+
+ const result = await notifyVipMessageEmail({ messageId: msg.id });
+
+ expect(sentEmailViaResend).toHaveBeenCalledTimes(1);
+ const call = vi.mocked(sentEmailViaResend).mock.calls[0]![0];
+ expect(call.memberList).toEqual([{ email: "c@example.com" }]);
+ expect(result).toBe("Email sent");
+ });
+
+ it("skips for anonymous conversations", async () => {
+ await userFactory.createRootUser();
+ await db.update(mailboxes).set({ vipThreshold: 100 });
+
+ const { conversation } = await conversationFactory.create({ emailFrom: null });
+ const msg = await conversationFactory.createUserEmail(conversation.id);
+
+ const result = await notifyVipMessageEmail({ messageId: msg.id });
+ expect(result).toEqual({ skipped: true, reason: "Anonymous conversation" });
+ expect(sentEmailViaResend).not.toHaveBeenCalled();
+ });
+});
diff --git a/trpc/router/user.ts b/trpc/router/user.ts
index f9d6ff091..02d8e4b35 100644
--- a/trpc/router/user.ts
+++ b/trpc/router/user.ts
@@ -193,6 +193,9 @@ export const userRouter = {
confetti: z.boolean().optional(),
disableNextTicketPreview: z.boolean().optional(),
autoAssignOnReply: z.boolean().optional(),
+ allowDailyEmail: z.boolean().optional(),
+ allowWeeklyEmail: z.boolean().optional(),
+ allowVipMessageEmail: z.boolean().optional(),
})
.optional(),
}),
|