feat: monthly email digest (#10621)

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Pradumn Kumar 2023-09-19 19:49:36 +05:30 committed by GitHub
parent 50970dc249
commit 786c1c2ba3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 684 additions and 7 deletions

View File

@ -0,0 +1,33 @@
name: Cron - monthlyDigestEmail
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs on the 28th, 29th, 30th and 31st of every month (see https://crontab.guru)
- cron: "59 23 28-31 * *"
jobs:
cron-monthlyDigestEmail:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: Check if today is the last day of the month
id: check-last-day
run: |
LAST_DAY=$(date -d tomorrow +%d)
if [ "$LAST_DAY" == "01" ]; then
echo "::set-output name=is_last_day::true"
else
echo "::set-output name=is_last_day::false"
fi
- name: cURL request
if: ${{ env.APP_URL && env.CRON_API_KEY && steps.check-last-day.outputs.is_last_day == 'true' }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/monthlyDigestEmail \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
--fail

View File

@ -0,0 +1,340 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { sendMonthlyDigestEmails } from "@calcom/emails/email-manager";
import { EventsInsights } from "@calcom/features/insights/server/events";
import { getTranslation } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
const querySchema = z.object({
page: z.coerce.number().min(0).optional().default(0),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
let { page: pageNumber } = querySchema.parse(req.query);
const firstDateOfMonth = new Date();
firstDateOfMonth.setDate(1);
while (true) {
const teams = await prisma.team.findMany({
where: {
slug: {
not: null,
},
createdAt: {
// created before or on the first day of this month
lte: firstDateOfMonth,
},
},
select: {
id: true,
createdAt: true,
members: true,
name: true,
},
skip: pageNumber * pageSize,
take: pageSize,
});
if (teams.length === 0) {
break;
}
for (const team of teams) {
const EventData: {
Created: number;
Completed: number;
Rescheduled: number;
Cancelled: number;
mostBookedEvents: {
eventTypeId?: number | null;
eventTypeName?: string | null;
count?: number | null;
}[];
membersWithMostBookings: {
userId: number | null;
user: {
id: number;
name: string | null;
email: string;
avatar: string | null;
username: string | null;
};
count: number;
}[];
admin: { email: string; name: string };
team: {
name: string;
id: number;
};
} = {
Created: 0,
Completed: 0,
Rescheduled: 0,
Cancelled: 0,
mostBookedEvents: [],
membersWithMostBookings: [],
admin: { email: "", name: "" },
team: { name: team.name, id: team.id },
};
const userIdsFromTeams = team.members.map((u) => u.userId);
// Booking Events
const whereConditional: Prisma.BookingTimeStatusWhereInput = {
OR: [
{
teamId: team.id,
},
{
userId: {
in: userIdsFromTeams,
},
teamId: null,
},
],
};
const promisesResult = await Promise.all([
EventsInsights.getCreatedEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
EventsInsights.getCompletedEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
EventsInsights.getRescheduledEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
EventsInsights.getCancelledEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
]);
EventData["Created"] = promisesResult[0];
EventData["Completed"] = promisesResult[1];
EventData["Rescheduled"] = promisesResult[2];
EventData["Cancelled"] = promisesResult[3];
// Most Booked Event Type
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
createdAt: {
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
lte: dayjs(new Date()).endOf("day").toDate(),
},
OR: [
{
teamId: team.id,
},
{
userId: {
in: userIdsFromTeams,
},
teamId: null,
},
],
};
const bookingsFromSelected = await prisma.bookingTimeStatus.groupBy({
by: ["eventTypeId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const eventTypeIds = bookingsFromSelected
.filter((booking) => typeof booking.eventTypeId === "number")
.map((booking) => booking.eventTypeId);
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
id: {
in: eventTypeIds as number[],
},
};
const eventTypesFrom = await prisma.eventType.findMany({
select: {
id: true,
title: true,
teamId: true,
userId: true,
slug: true,
users: {
select: {
username: true,
},
},
team: {
select: {
slug: true,
},
},
},
where: eventTypeWhereConditional,
});
const eventTypeHashMap: Map<
number,
Prisma.EventTypeGetPayload<{
select: {
id: true;
title: true;
teamId: true;
userId: true;
slug: true;
users: {
select: {
username: true;
};
};
team: {
select: {
slug: true;
};
};
};
}>
> = new Map();
eventTypesFrom.forEach((eventType) => {
eventTypeHashMap.set(eventType.id, eventType);
});
EventData["mostBookedEvents"] = bookingsFromSelected.map((booking) => {
const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
if (!eventTypeSelected) {
return {};
}
let eventSlug = "";
if (eventTypeSelected.userId) {
eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`;
}
if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
}
return {
eventTypeId: booking.eventTypeId,
eventTypeName: eventSlug,
count: booking._count.id,
};
});
// Most booked members
const bookingsFromTeam = await prisma.bookingTimeStatus.groupBy({
by: ["userId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const userIds = bookingsFromTeam
.filter((booking) => typeof booking.userId === "number")
.map((booking) => booking.userId);
if (userIds.length === 0) {
EventData["membersWithMostBookings"] = [];
} else {
const teamUsers = await prisma.user.findMany({
where: {
id: {
in: userIds as number[],
},
},
select: { id: true, name: true, email: true, avatar: true, username: true },
});
const userHashMap = new Map();
teamUsers.forEach((user) => {
userHashMap.set(user.id, user);
});
EventData["membersWithMostBookings"] = bookingsFromTeam.map((booking) => {
return {
userId: booking.userId,
user: userHashMap.get(booking.userId),
count: booking._count.id,
};
});
}
// Send mail to all Owners and Admins
const mailReceivers = team?.members?.filter(
(member) => member.role === "OWNER" || member.role === "ADMIN"
);
const mailsToSend = mailReceivers.map(async (receiver) => {
const owner = await prisma.user.findUnique({
where: {
id: receiver?.userId,
},
});
if (owner) {
const t = await getTranslation(owner?.locale ?? "en", "common");
// Only send email if user has allowed to receive monthly digest emails
if (owner.receiveMonthlyDigestEmail) {
await sendMonthlyDigestEmails({
...EventData,
admin: { email: owner?.email ?? "", name: owner?.name ?? "" },
language: t,
});
}
}
});
await Promise.all(mailsToSend);
await delay(100); // Adjust the delay as needed to avoid rate limiting
}
pageNumber++;
}
res.json({ ok: true });
}

View File

@ -13,14 +13,50 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
res.write(
renderEmail("VerifyAccountEmail", {
renderEmail("MonthlyDigestEmail", {
language: t,
user: {
name: "Pro Example",
email: "pro@example.com",
},
verificationEmailLink:
"http://localhost:3000/api/auth/verify-email?token=b91af0eee5a9a24a8d83a3d3d6a58c1606496e94ced589441649273c66100f5b",
Created: 12,
Completed: 13,
Rescheduled: 14,
Cancelled: 16,
mostBookedEvents: [
{
eventTypeId: 3,
eventTypeName: "Test1",
count: 3,
},
{
eventTypeId: 4,
eventTypeName: "Test2",
count: 5,
},
],
membersWithMostBookings: [
{
userId: 4,
user: {
id: 4,
name: "User1 name",
email: "email.com",
avatar: "none",
username: "User1",
},
count: 4,
},
{
userId: 6,
user: {
id: 6,
name: "User2 name",
email: "email2.com",
avatar: "none",
username: "User2",
},
count: 8,
},
],
admin: { email: "admin.com", name: "admin" },
team: { name: "Team1", id: 4 },
})
);
res.end();

View File

@ -107,6 +107,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
},
allowDynamicBooking: user.allowDynamicBooking ?? true,
allowSEOIndexing: user.allowSEOIndexing ?? true,
receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail ?? true,
},
});
const {
@ -235,6 +236,23 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
/>
</div>
<div className="mt-8">
<Controller
name="receiveMonthlyDigestEmail"
control={formMethods.control}
render={() => (
<SettingsToggle
title={t("monthly_digest_email")}
description={t("monthly_digest_email_for_teams")}
checked={formMethods.getValues("receiveMonthlyDigestEmail")}
onCheckedChange={(checked) => {
formMethods.setValue("receiveMonthlyDigestEmail", checked, { shouldDirty: true });
}}
/>
)}
/>
</div>
<Button
loading={mutation.isLoading}
disabled={isDisabled}

View File

@ -2016,7 +2016,13 @@
"attendee_last_name_variable": "Attendee last name",
"attendee_first_name_info": "The person booking's first name",
"attendee_last_name_info": "The person booking's last name",
"your_monthly_digest": "Your Monthly Digest",
"member_name": "Member Name",
"most_popular_events": "Most Popular Events",
"summary_of_events_for_your_team_for_the_last_30_days": "Here's your summary of popular events for your team {{teamName}} for the last 30 days",
"me": "Me",
"monthly_digest_email":"Monthly Digest Email",
"monthly_digest_email_for_teams": "Monthly digest email for teams",
"verify_team_tooltip": "Verify your team to enable sending messages to attendees",
"member_removed": "Member removed",
"my_availability": "My Availability",

View File

@ -7,6 +7,7 @@ import { getEventName } from "@calcom/core/event";
import type BaseEmail from "@calcom/emails/templates/_base-email";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { MonthlyDigestEmailData } from "./src/templates/MonthlyDigestEmail";
import type { EmailVerifyLink } from "./templates/account-verify-email";
import AccountVerifyEmail from "./templates/account-verify-email";
import type { OrganizationNotification } from "./templates/admin-organization-notification";
@ -29,6 +30,7 @@ import type { Feedback } from "./templates/feedback-email";
import FeedbackEmail from "./templates/feedback-email";
import type { PasswordReset } from "./templates/forgot-password-email";
import ForgotPasswordEmail from "./templates/forgot-password-email";
import MonthlyDigestEmail from "./templates/monthly-digest-email";
import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email";
import type { OrgAutoInvite } from "./templates/org-auto-join-invite";
import OrgAutoJoinEmail from "./templates/org-auto-join-invite";
@ -379,6 +381,10 @@ export const sendOrganizationEmailVerification = async (sendOrgInput: Organizati
await sendEmail(() => new OrganizationEmailVerification(sendOrgInput));
};
export const sendMonthlyDigestEmails = async (eventData: MonthlyDigestEmailData) => {
await sendEmail(() => new MonthlyDigestEmail(eventData));
};
export const sendAdminOrganizationNotification = async (input: OrganizationNotification) => {
await sendEmail(() => new AdminOrganizationNotification(input));
};

View File

@ -0,0 +1,204 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, SENDER_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
export type MonthlyDigestEmailData = {
language: TFunction;
Created: number;
Completed: number;
Rescheduled: number;
Cancelled: number;
mostBookedEvents: {
eventTypeId?: number | null;
eventTypeName?: string | null;
count?: number | null;
}[];
membersWithMostBookings: {
userId: number | null;
user: {
id: number;
name: string | null;
email: string;
avatar: string | null;
username: string | null;
};
count: number;
}[];
admin: { email: string; name: string };
team: { name: string; id: number };
};
export const MonthlyDigestEmail = (
props: MonthlyDigestEmailData & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const EventsDetails = () => {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "50px",
marginTop: "30px",
marginBottom: "30px",
}}>
<div>
<p
style={{
fontWeight: 500,
fontSize: "48px",
lineHeight: "48px",
}}>
{props.Created}
</p>
<p style={{ fontSize: "16px", fontWeight: 500, lineHeight: "20px" }}>
{props.language("events_created")}
</p>
</div>
<div>
<p
style={{
fontWeight: 500,
fontSize: "48px",
lineHeight: "48px",
}}>
{props.Completed}
</p>
<p style={{ fontSize: "16px", fontWeight: 500, lineHeight: "20px" }}>
{props.language("completed")}
</p>
</div>
<div>
<p
style={{
fontWeight: 500,
fontSize: "48px",
lineHeight: "48px",
}}>
{props.Rescheduled}
</p>
<p style={{ fontSize: "16px", fontWeight: 500, lineHeight: "20px" }}>
{props.language("rescheduled")}
</p>
</div>
<div>
<p
style={{
fontWeight: 500,
fontSize: "48px",
lineHeight: "48px",
}}>
{props.Cancelled}
</p>
<p style={{ fontSize: "16px", fontWeight: 500, lineHeight: "20px" }}>
{props.language("cancelled")}
</p>
</div>
</div>
);
};
return (
<BaseEmailHtml subject={props.language("verify_email_subject", { appName: APP_NAME })}>
<div>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
width: "100%",
marginBottom: "30px",
}}>
{props.language("your_monthly_digest")}
</p>
<p style={{ fontWeight: "normal", fontSize: "16px", lineHeight: "24px" }}>
{props.language("hi_user_name", { name: props.admin.name })}!
</p>
<p style={{ fontWeight: "normal", fontSize: "16px", lineHeight: "24px" }}>
{props.language("summary_of_events_for_your_team_for_the_last_30_days", {
teamName: props.team.name,
})}
</p>
<EventsDetails />
<div
style={{
width: "100%",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #D1D5DB",
fontSize: "16px",
}}>
<p style={{ fontWeight: 500 }}>{props.language("most_popular_events")}</p>
<p style={{ fontWeight: 500 }}>{props.language("bookings")}</p>
</div>
{props.mostBookedEvents
? props.mostBookedEvents.map((ev, idx) => (
<div
key={ev.eventTypeId}
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: `${idx === props.mostBookedEvents.length - 1 ? "" : "1px solid #D1D5DB"}`,
}}>
<p style={{ fontWeight: "normal" }}>{ev.eventTypeName}</p>
<p style={{ fontWeight: "normal" }}>{ev.count}</p>
</div>
))
: null}
</div>
<div style={{ width: "100%", marginTop: "30px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #D1D5DB",
}}>
<p style={{ fontWeight: 500 }}>{props.language("most_booked_members")}</p>
<p style={{ fontWeight: 500 }}>{props.language("bookings")}</p>
</div>
{props.membersWithMostBookings
? props.membersWithMostBookings.map((it, idx) => (
<div
key={it.userId}
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: `${
idx === props.membersWithMostBookings.length - 1 ? "" : "1px solid #D1D5DB"
}`,
}}>
<p style={{ fontWeight: "normal" }}>{it.user.name}</p>
<p style={{ fontWeight: "normal" }}>{it.count}</p>
</div>
))
: null}
</div>
<div style={{ marginTop: "30px", marginBottom: "30px" }}>
<CallToAction
label="View all stats"
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/insights?teamId=${props.team.id}`}
endIconName="white-arrow-right"
/>
</div>
</div>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("happy_scheduling")}, <br />
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("the_calcom_team", { companyName: SENDER_NAME })}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@ -29,4 +29,5 @@ export * from "@calcom/app-store/routing-forms/emails/components";
export { DailyVideoDownloadRecordingEmail } from "./DailyVideoDownloadRecordingEmail";
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
export { MonthlyDigestEmail } from "./MonthlyDigestEmail";
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";

View File

@ -0,0 +1,24 @@
import { APP_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import type { MonthlyDigestEmailData } from "../src/templates/MonthlyDigestEmail";
import BaseEmail from "./_base-email";
export default class MonthlyDigestEmail extends BaseEmail {
eventData: MonthlyDigestEmailData;
constructor(eventData: MonthlyDigestEmailData) {
super();
this.eventData = eventData;
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
to: this.eventData.admin.email,
subject: `${APP_NAME}: Your monthly digest`,
html: renderEmail("MonthlyDigestEmail", this.eventData),
text: "",
};
}
}

View File

@ -223,6 +223,7 @@ export const buildUser = <T extends Partial<UserPayload>>(user?: T): UserPayload
weekStart: "",
organizationId: null,
allowSEOIndexing: null,
receiveMonthlyDigestEmail: null,
...user,
};
};

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "receiveMonthlyDigestEmail" BOOLEAN DEFAULT true;

View File

@ -223,6 +223,9 @@ model User {
// participate in SEO indexing or not
allowSEOIndexing Boolean? @default(true)
// receive monthly digest email for teams or not
receiveMonthlyDigestEmail Boolean? @default(true)
/// @zod.custom(imports.userMetadata)
metadata Json?
verified Boolean? @default(false)

View File

@ -59,6 +59,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
organizationId: true,
allowDynamicBooking: true,
allowSEOIndexing: true,
receiveMonthlyDigestEmail: true,
organization: {
select: {
id: true,

View File

@ -46,6 +46,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
defaultBookerLayouts: user.defaultBookerLayouts,
allowDynamicBooking: user.allowDynamicBooking,
allowSEOIndexing: user.allowSEOIndexing,
receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail,
organizationId: user.organizationId,
organization: user.organization,
};

View File

@ -19,6 +19,7 @@ export const ZUpdateProfileInputSchema = z.object({
hideBranding: z.boolean().optional(),
allowDynamicBooking: z.boolean().optional(),
allowSEOIndexing: z.boolean().optional(),
receiveMonthlyDigestEmail: z.boolean().optional(),
brandColor: z.string().optional(),
darkBrandColor: z.string().optional(),
theme: z.string().optional().nullable(),