Skip to content

Commit 786c1c2

Browse files
Pradumn27CarinaWolliPeerRich
authored
feat: monthly email digest (#10621)
Co-authored-by: Carina Wollendorfer <[email protected]> Co-authored-by: Peer Richelsen <[email protected]> Co-authored-by: Peer Richelsen <[email protected]>
1 parent 50970dc commit 786c1c2

File tree

15 files changed

+684
-7
lines changed

15 files changed

+684
-7
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Cron - monthlyDigestEmail
2+
3+
on:
4+
# "Scheduled workflows run on the latest commit on the default or base branch."
5+
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
6+
schedule:
7+
# Runs on the 28th, 29th, 30th and 31st of every month (see https://crontab.guru)
8+
- cron: "59 23 28-31 * *"
9+
jobs:
10+
cron-monthlyDigestEmail:
11+
env:
12+
APP_URL: ${{ secrets.APP_URL }}
13+
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Check if today is the last day of the month
17+
id: check-last-day
18+
run: |
19+
LAST_DAY=$(date -d tomorrow +%d)
20+
if [ "$LAST_DAY" == "01" ]; then
21+
echo "::set-output name=is_last_day::true"
22+
else
23+
echo "::set-output name=is_last_day::false"
24+
fi
25+
26+
- name: cURL request
27+
if: ${{ env.APP_URL && env.CRON_API_KEY && steps.check-last-day.outputs.is_last_day == 'true' }}
28+
run: |
29+
curl ${{ secrets.APP_URL }}/api/cron/monthlyDigestEmail \
30+
-X POST \
31+
-H 'content-type: application/json' \
32+
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
33+
--fail
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import type { Prisma } from "@prisma/client";
2+
import type { NextApiRequest, NextApiResponse } from "next";
3+
import { z } from "zod";
4+
5+
import dayjs from "@calcom/dayjs";
6+
import { sendMonthlyDigestEmails } from "@calcom/emails/email-manager";
7+
import { EventsInsights } from "@calcom/features/insights/server/events";
8+
import { getTranslation } from "@calcom/lib/server";
9+
import prisma from "@calcom/prisma";
10+
11+
const querySchema = z.object({
12+
page: z.coerce.number().min(0).optional().default(0),
13+
});
14+
15+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
16+
const apiKey = req.headers.authorization || req.query.apiKey;
17+
18+
if (process.env.CRON_API_KEY !== apiKey) {
19+
res.status(401).json({ message: "Not authenticated" });
20+
return;
21+
}
22+
23+
if (req.method !== "POST") {
24+
res.status(405).json({ message: "Invalid method" });
25+
return;
26+
}
27+
28+
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
29+
const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
30+
let { page: pageNumber } = querySchema.parse(req.query);
31+
32+
const firstDateOfMonth = new Date();
33+
firstDateOfMonth.setDate(1);
34+
35+
while (true) {
36+
const teams = await prisma.team.findMany({
37+
where: {
38+
slug: {
39+
not: null,
40+
},
41+
createdAt: {
42+
// created before or on the first day of this month
43+
lte: firstDateOfMonth,
44+
},
45+
},
46+
select: {
47+
id: true,
48+
createdAt: true,
49+
members: true,
50+
name: true,
51+
},
52+
skip: pageNumber * pageSize,
53+
take: pageSize,
54+
});
55+
56+
if (teams.length === 0) {
57+
break;
58+
}
59+
60+
for (const team of teams) {
61+
const EventData: {
62+
Created: number;
63+
Completed: number;
64+
Rescheduled: number;
65+
Cancelled: number;
66+
mostBookedEvents: {
67+
eventTypeId?: number | null;
68+
eventTypeName?: string | null;
69+
count?: number | null;
70+
}[];
71+
membersWithMostBookings: {
72+
userId: number | null;
73+
user: {
74+
id: number;
75+
name: string | null;
76+
email: string;
77+
avatar: string | null;
78+
username: string | null;
79+
};
80+
count: number;
81+
}[];
82+
admin: { email: string; name: string };
83+
team: {
84+
name: string;
85+
id: number;
86+
};
87+
} = {
88+
Created: 0,
89+
Completed: 0,
90+
Rescheduled: 0,
91+
Cancelled: 0,
92+
mostBookedEvents: [],
93+
membersWithMostBookings: [],
94+
admin: { email: "", name: "" },
95+
team: { name: team.name, id: team.id },
96+
};
97+
98+
const userIdsFromTeams = team.members.map((u) => u.userId);
99+
100+
// Booking Events
101+
const whereConditional: Prisma.BookingTimeStatusWhereInput = {
102+
OR: [
103+
{
104+
teamId: team.id,
105+
},
106+
{
107+
userId: {
108+
in: userIdsFromTeams,
109+
},
110+
teamId: null,
111+
},
112+
],
113+
};
114+
115+
const promisesResult = await Promise.all([
116+
EventsInsights.getCreatedEventsInTimeRange(
117+
{
118+
start: dayjs(firstDateOfMonth),
119+
end: dayjs(new Date()),
120+
},
121+
whereConditional
122+
),
123+
EventsInsights.getCompletedEventsInTimeRange(
124+
{
125+
start: dayjs(firstDateOfMonth),
126+
end: dayjs(new Date()),
127+
},
128+
whereConditional
129+
),
130+
EventsInsights.getRescheduledEventsInTimeRange(
131+
{
132+
start: dayjs(firstDateOfMonth),
133+
end: dayjs(new Date()),
134+
},
135+
whereConditional
136+
),
137+
EventsInsights.getCancelledEventsInTimeRange(
138+
{
139+
start: dayjs(firstDateOfMonth),
140+
end: dayjs(new Date()),
141+
},
142+
whereConditional
143+
),
144+
]);
145+
146+
EventData["Created"] = promisesResult[0];
147+
EventData["Completed"] = promisesResult[1];
148+
EventData["Rescheduled"] = promisesResult[2];
149+
EventData["Cancelled"] = promisesResult[3];
150+
151+
// Most Booked Event Type
152+
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
153+
createdAt: {
154+
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
155+
lte: dayjs(new Date()).endOf("day").toDate(),
156+
},
157+
OR: [
158+
{
159+
teamId: team.id,
160+
},
161+
{
162+
userId: {
163+
in: userIdsFromTeams,
164+
},
165+
teamId: null,
166+
},
167+
],
168+
};
169+
170+
const bookingsFromSelected = await prisma.bookingTimeStatus.groupBy({
171+
by: ["eventTypeId"],
172+
where: bookingWhere,
173+
_count: {
174+
id: true,
175+
},
176+
orderBy: {
177+
_count: {
178+
id: "desc",
179+
},
180+
},
181+
take: 10,
182+
});
183+
184+
const eventTypeIds = bookingsFromSelected
185+
.filter((booking) => typeof booking.eventTypeId === "number")
186+
.map((booking) => booking.eventTypeId);
187+
188+
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
189+
id: {
190+
in: eventTypeIds as number[],
191+
},
192+
};
193+
194+
const eventTypesFrom = await prisma.eventType.findMany({
195+
select: {
196+
id: true,
197+
title: true,
198+
teamId: true,
199+
userId: true,
200+
slug: true,
201+
users: {
202+
select: {
203+
username: true,
204+
},
205+
},
206+
team: {
207+
select: {
208+
slug: true,
209+
},
210+
},
211+
},
212+
where: eventTypeWhereConditional,
213+
});
214+
215+
const eventTypeHashMap: Map<
216+
number,
217+
Prisma.EventTypeGetPayload<{
218+
select: {
219+
id: true;
220+
title: true;
221+
teamId: true;
222+
userId: true;
223+
slug: true;
224+
users: {
225+
select: {
226+
username: true;
227+
};
228+
};
229+
team: {
230+
select: {
231+
slug: true;
232+
};
233+
};
234+
};
235+
}>
236+
> = new Map();
237+
eventTypesFrom.forEach((eventType) => {
238+
eventTypeHashMap.set(eventType.id, eventType);
239+
});
240+
241+
EventData["mostBookedEvents"] = bookingsFromSelected.map((booking) => {
242+
const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
243+
if (!eventTypeSelected) {
244+
return {};
245+
}
246+
247+
let eventSlug = "";
248+
if (eventTypeSelected.userId) {
249+
eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`;
250+
}
251+
if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
252+
eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
253+
}
254+
return {
255+
eventTypeId: booking.eventTypeId,
256+
eventTypeName: eventSlug,
257+
count: booking._count.id,
258+
};
259+
});
260+
261+
// Most booked members
262+
const bookingsFromTeam = await prisma.bookingTimeStatus.groupBy({
263+
by: ["userId"],
264+
where: bookingWhere,
265+
_count: {
266+
id: true,
267+
},
268+
orderBy: {
269+
_count: {
270+
id: "desc",
271+
},
272+
},
273+
take: 10,
274+
});
275+
276+
const userIds = bookingsFromTeam
277+
.filter((booking) => typeof booking.userId === "number")
278+
.map((booking) => booking.userId);
279+
280+
if (userIds.length === 0) {
281+
EventData["membersWithMostBookings"] = [];
282+
} else {
283+
const teamUsers = await prisma.user.findMany({
284+
where: {
285+
id: {
286+
in: userIds as number[],
287+
},
288+
},
289+
select: { id: true, name: true, email: true, avatar: true, username: true },
290+
});
291+
292+
const userHashMap = new Map();
293+
teamUsers.forEach((user) => {
294+
userHashMap.set(user.id, user);
295+
});
296+
297+
EventData["membersWithMostBookings"] = bookingsFromTeam.map((booking) => {
298+
return {
299+
userId: booking.userId,
300+
user: userHashMap.get(booking.userId),
301+
count: booking._count.id,
302+
};
303+
});
304+
}
305+
306+
// Send mail to all Owners and Admins
307+
const mailReceivers = team?.members?.filter(
308+
(member) => member.role === "OWNER" || member.role === "ADMIN"
309+
);
310+
311+
const mailsToSend = mailReceivers.map(async (receiver) => {
312+
const owner = await prisma.user.findUnique({
313+
where: {
314+
id: receiver?.userId,
315+
},
316+
});
317+
318+
if (owner) {
319+
const t = await getTranslation(owner?.locale ?? "en", "common");
320+
321+
// Only send email if user has allowed to receive monthly digest emails
322+
if (owner.receiveMonthlyDigestEmail) {
323+
await sendMonthlyDigestEmails({
324+
...EventData,
325+
admin: { email: owner?.email ?? "", name: owner?.name ?? "" },
326+
language: t,
327+
});
328+
}
329+
}
330+
});
331+
332+
await Promise.all(mailsToSend);
333+
334+
await delay(100); // Adjust the delay as needed to avoid rate limiting
335+
}
336+
337+
pageNumber++;
338+
}
339+
res.json({ ok: true });
340+
}

0 commit comments

Comments
 (0)