Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions app/(dashboard)/settings/preferences/dailyEmailSetting.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative">
<div className="absolute top-2 right-4 z-10">
<SavingIndicator state={savingIndicator.state} />
</div>
<SwitchSectionWrapper
title="Daily Email Reports"
description="Receive a daily summary email with key ticket and performance metrics"
initialSwitchChecked={user?.preferences?.allowDailyEmail !== false}
onSwitchChange={handleSwitchChange}
>
<></>
</SwitchSectionWrapper>
</div>
);
};

export default DailyEmailSetting;
6 changes: 6 additions & 0 deletions app/(dashboard)/settings/preferences/preferencesSetting.tsx
Original file line number Diff line number Diff line change
@@ -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() ?? {};
Expand All @@ -13,6 +16,9 @@ const PreferencesSetting = () => {
<AutoAssignSetting />
<ConfettiSetting />
<NextTicketPreviewSetting />
<DailyEmailSetting />
<WeeklyEmailSetting />
<VipMessageEmailSetting />
</div>
);
};
Expand Down
47 changes: 47 additions & 0 deletions app/(dashboard)/settings/preferences/vipMessageEmailSetting.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative">
<div className="absolute top-2 right-4 z-10">
<SavingIndicator state={savingIndicator.state} />
</div>
<SwitchSectionWrapper
title="VIP Message Email Alerts"
description="Receive immediate email alerts when a VIP message arrives"
initialSwitchChecked={user?.preferences?.allowVipMessageEmail !== false}
onSwitchChange={handleSwitchChange}
>
<></>
</SwitchSectionWrapper>
</div>
);
};

export default VipMessageEmailSetting;
47 changes: 47 additions & 0 deletions app/(dashboard)/settings/preferences/weeklyEmailSetting.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative">
<div className="absolute top-2 right-4 z-10">
<SavingIndicator state={savingIndicator.state} />
</div>
<SwitchSectionWrapper
title="Weekly Email Reports"
description="Receive a weekly summary email with trends and aggregate metrics"
initialSwitchChecked={user?.preferences?.allowWeeklyEmail !== false}
onSwitchChange={handleSwitchChange}
>
<></>
</SwitchSectionWrapper>
</div>
);
};

export default WeeklyEmailSetting;
3 changes: 3 additions & 0 deletions db/schema/userProfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export const userProfiles = pgTable("user_profiles", {
confetti?: boolean;
disableNextTicketPreview?: boolean;
autoAssignOnReply?: boolean;
allowDailyEmail?: boolean;
allowWeeklyEmail?: boolean;
allowVipMessageEmail?: boolean;
}>(),
}).enableRLS();

Expand Down
132 changes: 64 additions & 68 deletions jobs/generateDailyReports.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,35 @@
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;

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'`),
});
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inlined getMailbox() because it was imported from server-only file which would cause error while using react-email/components

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);
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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'`));
Copy link
Contributor Author

@stefanbinoj stefanbinoj Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where(or(isNull(userProfiles.preferences), sql`${userProfiles.preferences}->>'allowDailyEmail' != 'false'`))

This condition checks for user preferences and doesn't sends email only if user have manually changed preference via UI.


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,
};
}
Loading