-
Notifications
You must be signed in to change notification settings - Fork 264
Phase 1: Migrate to Email Notification System (with unit tests) #1082
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
stefanbinoj
wants to merge
17
commits into
antiwork:main
Choose a base branch
from
stefanbinoj:remove_slack/phase_1
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,421
−566
Open
Changes from 7 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
f730b21
added resend integration
stefanbinoj 5839a7a
updated design mockup
stefanbinoj 2a0a4ee
added e2e
stefanbinoj 194862b
copied contents
stefanbinoj 83abdeb
rename + delete
stefanbinoj 8d35550
[autofix.ci] apply automated fixes
autofix-ci[bot] 9fae4e7
added fallback as 0 for email templates
stefanbinoj c269846
feat: introduced standard client
stefanbinoj 1c5396e
sentry capture
stefanbinoj 4b2ce24
[autofix.ci] apply automated fixes
autofix-ci[bot] 1514eb5
Merge branch 'remove_slack/phase_1' of https://github.com/stefanbinoj…
stefanbinoj b5754e1
added unit tests
stefanbinoj a7023f4
fix unit test
stefanbinoj 9615ed7
remove unused var
stefanbinoj fd12555
fix lints
stefanbinoj b83cc42
chore: tighten unit tests
stefanbinoj 6801558
[autofix.ci] apply automated fixes
autofix-ci[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
47 changes: 47 additions & 0 deletions
47
app/(dashboard)/settings/preferences/dailyEmailSetting.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
47 changes: 47 additions & 0 deletions
47
app/(dashboard)/settings/preferences/vipMessageEmailSetting.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
47
app/(dashboard)/settings/preferences/weeklyEmailSetting.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'`), | ||
| }); | ||
| 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'`)); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 fromserver-onlyfile which would cause error while usingreact-email/components