diff --git a/.github/workflows/cron-monthlyDigestEmail.yml b/.github/workflows/cron-monthlyDigestEmail.yml new file mode 100644 index 0000000000..ed2ec64825 --- /dev/null +++ b/.github/workflows/cron-monthlyDigestEmail.yml @@ -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 diff --git a/apps/web/pages/api/cron/monthlyDigestEmail.ts b/apps/web/pages/api/cron/monthlyDigestEmail.ts new file mode 100644 index 0000000000..ed08276cf1 --- /dev/null +++ b/apps/web/pages/api/cron/monthlyDigestEmail.ts @@ -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 }); +} diff --git a/apps/web/pages/api/email.ts b/apps/web/pages/api/email.ts index cfbbff4899..b855eccee2 100644 --- a/apps/web/pages/api/email.ts +++ b/apps/web/pages/api/email.ts @@ -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(); diff --git a/apps/web/pages/settings/my-account/general.tsx b/apps/web/pages/settings/my-account/general.tsx index 3d92bac93e..688a79ff6a 100644 --- a/apps/web/pages/settings/my-account/general.tsx +++ b/apps/web/pages/settings/my-account/general.tsx @@ -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) => { /> +
+ ( + { + formMethods.setValue("receiveMonthlyDigestEmail", checked, { shouldDirty: true }); + }} + /> + )} + /> +
+