feat: add daily email usage (#97)
* add daily email usage * remove console
This commit is contained in:
@@ -68,108 +68,85 @@ export const emailRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { team } = ctx;
|
||||
const days = input.days !== 7 ? 30 : 7;
|
||||
const daysInMs = days * 24 * 60 * 60 * 1000;
|
||||
|
||||
const rawEmailStatusCounts = await db.email.findMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
createdAt: {
|
||||
gt: new Date(Date.now() - daysInMs),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
latestStatus: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
const isoStartDate = startDate.toISOString().split("T")[0];
|
||||
|
||||
const totalCount = rawEmailStatusCounts.length;
|
||||
type DailyEmailUsage = {
|
||||
date: string;
|
||||
sent: number;
|
||||
delivered: number;
|
||||
opened: number;
|
||||
clicked: number;
|
||||
bounced: number;
|
||||
complained: number;
|
||||
};
|
||||
|
||||
const emailStatusCounts = rawEmailStatusCounts.reduce(
|
||||
(acc, cur) => {
|
||||
acc[cur.latestStatus] = {
|
||||
count: (acc[cur.latestStatus]?.count || 0) + 1,
|
||||
percentage: Number(
|
||||
(
|
||||
(((acc[cur.latestStatus]?.count || 0) + 1) / totalCount) *
|
||||
100
|
||||
).toFixed(0)
|
||||
),
|
||||
};
|
||||
const result = await db.$queryRaw<Array<DailyEmailUsage>>`
|
||||
SELECT
|
||||
date,
|
||||
sent,
|
||||
delivered,
|
||||
opened,
|
||||
clicked,
|
||||
bounced,
|
||||
complained
|
||||
FROM "DailyEmailUsage"
|
||||
WHERE "teamId" = ${team.id}
|
||||
AND "date" >= ${isoStartDate}
|
||||
ORDER BY "date" ASC
|
||||
`;
|
||||
|
||||
// Fill in any missing dates with 0 values
|
||||
const filledResult: DailyEmailUsage[] = [];
|
||||
const endDateObj = new Date();
|
||||
|
||||
for (let i = days; i > -1; i--) {
|
||||
const dateStr = subDays(endDateObj, i)
|
||||
.toISOString()
|
||||
.split("T")[0] as string;
|
||||
const existingData = result.find((r) => r.date === dateStr);
|
||||
|
||||
if (existingData) {
|
||||
filledResult.push({
|
||||
...existingData,
|
||||
date: format(dateStr, "MMM dd"),
|
||||
});
|
||||
} else {
|
||||
filledResult.push({
|
||||
date: format(dateStr, "MMM dd"),
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
complained: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalCounts = result.reduce(
|
||||
(acc, curr) => {
|
||||
acc.sent += curr.sent;
|
||||
acc.delivered += curr.delivered;
|
||||
acc.opened += curr.opened;
|
||||
acc.clicked += curr.clicked;
|
||||
acc.bounced += curr.bounced;
|
||||
acc.complained += curr.complained;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
DELIVERED: { count: 0, percentage: 0 },
|
||||
COMPLAINED: { count: 0, percentage: 0 },
|
||||
OPENED: { count: 0, percentage: 0 },
|
||||
CLICKED: { count: 0, percentage: 0 },
|
||||
BOUNCED: { count: 0, percentage: 0 },
|
||||
} as Record<EmailStatus, { count: number; percentage: number }>
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
complained: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const dateRecord: Record<
|
||||
string,
|
||||
Record<
|
||||
"DELIVERED" | "COMPLAINED" | "OPENED" | "CLICKED" | "BOUNCED",
|
||||
number
|
||||
>
|
||||
> = {};
|
||||
|
||||
const currentDate = new Date();
|
||||
|
||||
for (let i = 0; i < (input.days || 7); i++) {
|
||||
const actualDate = subDays(currentDate, i);
|
||||
dateRecord[format(actualDate, "MMM dd")] = {
|
||||
DELIVERED: 0,
|
||||
COMPLAINED: 0,
|
||||
OPENED: 0,
|
||||
CLICKED: 0,
|
||||
BOUNCED: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const _emailDailyStatusCounts = rawEmailStatusCounts.reduce(
|
||||
(acc, { latestStatus, createdAt }) => {
|
||||
const day = format(createdAt, "MMM dd");
|
||||
|
||||
if (
|
||||
!day ||
|
||||
![
|
||||
"DELIVERED",
|
||||
"COMPLAINED",
|
||||
"OPENED",
|
||||
"CLICKED",
|
||||
"BOUNCED",
|
||||
].includes(latestStatus)
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (!acc[day]) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[day]![
|
||||
latestStatus as
|
||||
| "DELIVERED"
|
||||
| "COMPLAINED"
|
||||
| "OPENED"
|
||||
| "CLICKED"
|
||||
| "BOUNCED"
|
||||
]++;
|
||||
return acc;
|
||||
},
|
||||
dateRecord
|
||||
);
|
||||
|
||||
const emailDailyStatusCounts = Object.entries(_emailDailyStatusCounts)
|
||||
.reverse()
|
||||
.map(([date, counts]) => ({
|
||||
name: date,
|
||||
...counts,
|
||||
}));
|
||||
|
||||
return { emailStatusCounts, totalCount, emailDailyStatusCounts };
|
||||
return { result: filledResult, totalCounts };
|
||||
}),
|
||||
|
||||
getEmail: emailProcedure.query(async ({ input }) => {
|
||||
|
||||
@@ -162,15 +162,14 @@ export async function sendEmailThroughSes({
|
||||
...(unsubUrl
|
||||
? [
|
||||
{ Name: "List-Unsubscribe", Value: `<${unsubUrl}>` },
|
||||
{ Name: "List-Unsubscribe-Post", Value: "List-Unsubscribe=One-Click" },
|
||||
{
|
||||
Name: "List-Unsubscribe-Post",
|
||||
Value: "List-Unsubscribe=One-Click",
|
||||
},
|
||||
]
|
||||
: []
|
||||
),
|
||||
: []),
|
||||
// Spread in the precedence header if present
|
||||
...(isBulk
|
||||
? [{ Name: "Precedence", Value: "bulk" }]
|
||||
: []
|
||||
),
|
||||
...(isBulk ? [{ Name: "Precedence", Value: "bulk" }] : []),
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -216,7 +215,8 @@ export async function sendEmailWithAttachments({
|
||||
rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`;
|
||||
rawEmail += cc && cc.length ? `Cc: ${cc.join(", ")}\n` : "";
|
||||
rawEmail += bcc && bcc.length ? `Bcc: ${bcc.join(", ")}\n` : "";
|
||||
rawEmail += replyTo && replyTo.length ? `Reply-To: ${replyTo.join(", ")}\n` : "";
|
||||
rawEmail +=
|
||||
replyTo && replyTo.length ? `Reply-To: ${replyTo.join(", ")}\n` : "";
|
||||
rawEmail += `Subject: ${subject}\n`;
|
||||
rawEmail += `MIME-Version: 1.0\n`;
|
||||
rawEmail += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`;
|
||||
@@ -266,7 +266,7 @@ export async function addWebhookConfiguration(
|
||||
configName: string,
|
||||
topicArn: string,
|
||||
eventTypes: EventType[],
|
||||
region: string,
|
||||
region: string
|
||||
) {
|
||||
const sesClient = getSesClient(region);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types";
|
||||
import { db } from "../db";
|
||||
import { updateCampaignAnalytics } from "./campaign-service";
|
||||
import { env } from "~/env";
|
||||
import { getRedis } from "../redis";
|
||||
import { Queue, Worker } from "bullmq";
|
||||
|
||||
export async function parseSesHook(data: SesEvent) {
|
||||
const mailStatus = getEmailStatus(data);
|
||||
@@ -45,6 +47,49 @@ export async function parseSesHook(data: SesEvent) {
|
||||
WHERE id = ${email.id}
|
||||
`;
|
||||
|
||||
// Update daily email usage statistics
|
||||
const today = new Date().toISOString().split("T")[0] as string; // Format: YYYY-MM-DD
|
||||
|
||||
if (
|
||||
[
|
||||
"DELIVERED",
|
||||
"OPENED",
|
||||
"CLICKED",
|
||||
"BOUNCED",
|
||||
"COMPLAINED",
|
||||
"SENT",
|
||||
].includes(mailStatus)
|
||||
) {
|
||||
const updateField = mailStatus.toLowerCase();
|
||||
|
||||
await db.dailyEmailUsage.upsert({
|
||||
where: {
|
||||
teamId_domainId_date_type: {
|
||||
teamId: email.teamId,
|
||||
domainId: email.domainId ?? 0,
|
||||
date: today,
|
||||
type: email.campaignId ? "MARKETING" : "TRANSACTIONAL",
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId: email.teamId,
|
||||
domainId: email.domainId ?? 0,
|
||||
date: today,
|
||||
type: email.campaignId ? "MARKETING" : "TRANSACTIONAL",
|
||||
delivered: updateField === "delivered" ? 1 : 0,
|
||||
opened: updateField === "opened" ? 1 : 0,
|
||||
clicked: updateField === "clicked" ? 1 : 0,
|
||||
bounced: updateField === "bounced" ? 1 : 0,
|
||||
complained: updateField === "complained" ? 1 : 0,
|
||||
},
|
||||
update: {
|
||||
[updateField]: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (email.campaignId) {
|
||||
if (
|
||||
mailStatus !== "CLICKED" ||
|
||||
@@ -109,3 +154,28 @@ function getEmailData(data: SesEvent) {
|
||||
return data[eventType.toLowerCase() as SesEventDataKey];
|
||||
}
|
||||
}
|
||||
|
||||
export class SesHookParser {
|
||||
private static sesHookQueue = new Queue("ses-web-hook", {
|
||||
connection: getRedis(),
|
||||
});
|
||||
|
||||
private static worker = new Worker(
|
||||
"ses-web-hook",
|
||||
async (job) => {
|
||||
await this.execute(job.data);
|
||||
},
|
||||
{
|
||||
connection: getRedis(),
|
||||
concurrency: 200,
|
||||
}
|
||||
);
|
||||
|
||||
private static async execute(event: SesEvent) {
|
||||
await parseSesHook(event);
|
||||
}
|
||||
|
||||
static async queue(data: { event: SesEvent; messageId: string }) {
|
||||
return await this.sesHookQueue.add(data.messageId, data.event);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user