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>pull/11429/head^2
parent
50970dc249
commit
786c1c2ba3
|
@ -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
|
|
@ -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 });
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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";
|
||||
|
|
|
@ -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: "",
|
||||
};
|
||||
}
|
||||
}
|
|
@ -223,6 +223,7 @@ export const buildUser = <T extends Partial<UserPayload>>(user?: T): UserPayload
|
|||
weekStart: "",
|
||||
organizationId: null,
|
||||
allowSEOIndexing: null,
|
||||
receiveMonthlyDigestEmail: null,
|
||||
...user,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "receiveMonthlyDigestEmail" BOOLEAN DEFAULT true;
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Reference in New Issue