chore: enable platform emails (#14471)

* feat: enable platform emails

* fixup! feat: enable platform emails

* fixup! fixup! feat: enable platform emails

* fixup! Merge branch 'main' into chore-platform-emails

* fixup! fixup! Merge branch 'main' into chore-platform-emails

* fixup! fixup! fixup! Merge branch 'main' into chore-platform-emails

* chore: add oauth client repo

* chore: enable platform emails using oauth client

* fix: call getConnectedCalendar with dbWrite since it creates destinationCalendar for new credentials

* chore: update doc

* fixup! chore: update doc

* fixup! fixup! chore: update doc
This commit is contained in:
Morgan
2024-04-12 22:39:01 +03:00
committed by GitHub
parent 19d938ed60
commit e641bb3d57
18 changed files with 442 additions and 142 deletions
+3 -1
View File
@@ -11,6 +11,8 @@ import * as Sentry from "@sentry/node";
import * as cookieParser from "cookie-parser";
import helmet from "helmet";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
@@ -26,7 +28,7 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
app.enableCors({
origin: "*",
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
allowedHeaders: ["Accept", "Authorization", "Content-Type", "Origin"],
allowedHeaders: [X_CAL_CLIENT_ID, X_CAL_SECRET_KEY, "Accept", "Authorization", "Content-Type", "Origin"],
maxAge: 86_400,
});
@@ -1,4 +1,5 @@
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
@@ -7,7 +8,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, TokensModule],
providers: [TokensRepository, OAuthFlowService],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
controllers: [BookingsController],
})
export class BookingsModule {}
@@ -6,6 +6,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import {
@@ -15,6 +16,7 @@ import {
Req,
InternalServerErrorException,
Body,
Headers,
HttpException,
Param,
Get,
@@ -27,6 +29,7 @@ import { User } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
@@ -45,6 +48,25 @@ import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-t
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
type BookingRequest = Request & {
userId?: number;
};
type OAuthRequestParams = {
platformClientId: string;
platformRescheduleUrl: string;
platformCancelUrl: string;
platformBookingUrl: string;
};
const DEFAULT_PLATFORM_PARAMS = {
platformClientId: "",
platformCancelUrl: "",
platformRescheduleUrl: "",
platformBookingUrl: "",
areEmailsEnabled: true,
};
@Controller({
path: "ee/bookings",
version: "2",
@@ -56,7 +78,8 @@ export class BookingsController {
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService
private readonly prismaReadService: PrismaReadService,
private readonly oAuthClientRepository: OAuthClientRepository
) {}
@Get("/")
@@ -117,13 +140,13 @@ export class BookingsController {
@Post("/")
async createBooking(
@Req() req: Request & { userId?: number },
@Body() _: CreateBookingInput
@Req() req: BookingRequest,
@Body() _: CreateBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<unknown>> {
req.userId = (await this.getOwnerId(req)) ?? -1;
req.body = { ...req.body, noEmail: true };
const oAuthClientId = clientId?.toString();
try {
const booking = await handleNewBooking(req as unknown as NextApiRequest & { userId?: number });
const booking = await handleNewBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
return {
status: SUCCESS_STATUS,
data: booking,
@@ -136,15 +159,15 @@ export class BookingsController {
@Post("/:bookingId/cancel")
async cancelBooking(
@Req() req: Request & { userId?: number },
@Req() req: BookingRequest,
@Param("bookingId") bookingId: string,
@Body() body: CancelBookingInput
@Body() _: CancelBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse> {
const oAuthClientId = clientId?.toString();
if (bookingId) {
req.userId = (await this.getOwnerId(req)) ?? -1;
req.body = { ...body, id: parseInt(bookingId) };
try {
await handleCancelBooking(req as unknown as NextApiRequest & { userId?: number });
await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
return {
status: SUCCESS_STATUS,
};
@@ -159,14 +182,14 @@ export class BookingsController {
@Post("/reccuring")
async createReccuringBooking(
@Req() req: Request & { userId?: number },
@Body() _: CreateReccuringBookingInput[]
@Req() req: BookingRequest,
@Body() _: CreateReccuringBookingInput[],
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<BookingResponse[]>> {
req.userId = (await this.getOwnerId(req)) ?? -1;
req.body = { ...req.body, noEmail: true };
const oAuthClientId = clientId?.toString();
try {
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
req as unknown as NextApiRequest & { userId?: number }
await this.createNextApiBookingRequest(req, oAuthClientId)
);
return {
status: SUCCESS_STATUS,
@@ -180,14 +203,15 @@ export class BookingsController {
@Post("/instant")
async createInstantBooking(
@Req() req: Request & { userId?: number },
@Body() _: CreateBookingInput
@Req() req: BookingRequest,
@Body() _: CreateBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
const oAuthClientId = clientId?.toString();
req.userId = (await this.getOwnerId(req)) ?? -1;
req.body = { ...req.body, noEmail: true };
try {
const instantMeeting = await handleInstantMeeting(
req as unknown as NextApiRequest & { userId?: number }
await this.createNextApiBookingRequest(req, oAuthClientId)
);
return {
status: SUCCESS_STATUS,
@@ -209,6 +233,41 @@ export class BookingsController {
this.logger.error(err);
}
}
async getOAuthClientsParams(
req: BookingRequest,
clientId: string
): Promise<OAuthRequestParams & { areEmailsEnabled: boolean }> {
const res = DEFAULT_PLATFORM_PARAMS;
try {
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
// fetch oAuthClient from db and use data stored in db to set these values
if (client) {
res.platformClientId = clientId;
res.platformCancelUrl = client.bookingCancelRedirectUri ?? "";
res.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
res.platformBookingUrl = client.bookingRedirectUri ?? "";
res.areEmailsEnabled = client.areEmailsEnabled;
}
return res;
} catch (err) {
this.logger.error(err);
return res;
}
}
async createNextApiBookingRequest(
req: BookingRequest,
oAuthClientId?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(req, oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(req, { userId, ...oAuthParams });
req.body = { ...req.body, areEmailsEnabled: oAuthParams.areEmailsEnabled };
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
}
function handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
@@ -3,6 +3,7 @@ import {
CredentialsWithUserEmail,
} from "@/modules/credentials/credentials.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersRepository } from "@/modules/users/users.repository";
import {
Injectable,
@@ -23,7 +24,8 @@ export class CalendarsService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly dbRead: PrismaReadService
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService
) {}
async getCalendars(userId: number) {
@@ -35,7 +37,7 @@ export class CalendarsService {
return getConnectedDestinationCalendars(
userWithCalendars,
false,
this.dbRead.prisma as unknown as PrismaClient
this.dbWrite.prisma as unknown as PrismaClient
);
}
@@ -27,7 +27,7 @@ export class CredentialsRepository {
invalid: false,
},
where: {
id: credential?.id,
id: credential?.id ?? 0,
},
});
}
+50 -3
View File
@@ -1235,7 +1235,16 @@
},
"post": {
"operationId": "BookingsController_createBooking",
"parameters": [],
"parameters": [
{
"name": "x-cal-client-id",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
@@ -1334,6 +1343,14 @@
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -1366,7 +1383,16 @@
"/api/v2/ee/bookings/reccuring": {
"post": {
"operationId": "BookingsController_createReccuringBooking",
"parameters": [],
"parameters": [
{
"name": "x-cal-client-id",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
@@ -1400,7 +1426,16 @@
"/api/v2/ee/bookings/instant": {
"post": {
"operationId": "BookingsController_createInstantBooking",
"parameters": [],
"parameters": [
{
"name": "x-cal-client-id",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
@@ -1912,6 +1947,18 @@
"items": {
"type": "string"
}
},
"bookingRedirectUri": {
"type": "string"
},
"bookingCancelRedirectUri": {
"type": "string"
},
"bookingRescheduleRedirectUri": {
"type": "string"
},
"areEmailsEnabled": {
"type": "boolean"
}
}
},
@@ -9,6 +9,7 @@ import dayjs from "@calcom/dayjs";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import type CalendarService from "@calcom/lib/CalendarService";
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
@@ -187,61 +188,65 @@ export default class GoogleCalendarService implements Calendar {
};
async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
const formattedCalEvent = formatCalEvent(calEventRaw);
const payload: calendar_v3.Schema$Event = {
summary: calEventRaw.title,
description: getRichDescription(calEventRaw),
summary: formattedCalEvent.title,
description: getRichDescription(formattedCalEvent),
start: {
dateTime: calEventRaw.startTime,
timeZone: calEventRaw.organizer.timeZone,
dateTime: formattedCalEvent.startTime,
timeZone: formattedCalEvent.organizer.timeZone,
},
end: {
dateTime: calEventRaw.endTime,
timeZone: calEventRaw.organizer.timeZone,
dateTime: formattedCalEvent.endTime,
timeZone: formattedCalEvent.organizer.timeZone,
},
attendees: this.getAttendees(calEventRaw),
attendees: this.getAttendees(formattedCalEvent),
reminders: {
useDefault: true,
},
guestsCanSeeOtherGuests: !!calEventRaw.seatsPerTimeSlot ? calEventRaw.seatsShowAttendees : true,
iCalUID: calEventRaw.iCalUID,
guestsCanSeeOtherGuests: !!formattedCalEvent.seatsPerTimeSlot
? formattedCalEvent.seatsShowAttendees
: true,
iCalUID: formattedCalEvent.iCalUID,
};
if (calEventRaw.location) {
payload["location"] = getLocation(calEventRaw);
if (formattedCalEvent.location) {
payload["location"] = getLocation(formattedCalEvent);
}
if (calEventRaw.recurringEvent) {
if (formattedCalEvent.recurringEvent) {
const rule = new RRule({
freq: calEventRaw.recurringEvent.freq,
interval: calEventRaw.recurringEvent.interval,
count: calEventRaw.recurringEvent.count,
freq: formattedCalEvent.recurringEvent.freq,
interval: formattedCalEvent.recurringEvent.interval,
count: formattedCalEvent.recurringEvent.count,
});
payload["recurrence"] = [rule.toString()];
}
if (calEventRaw.conferenceData && calEventRaw.location === MeetLocationType) {
payload["conferenceData"] = calEventRaw.conferenceData;
if (formattedCalEvent.conferenceData && formattedCalEvent.location === MeetLocationType) {
payload["conferenceData"] = formattedCalEvent.conferenceData;
}
const calendar = await this.authedCalendar();
// Find in calEventRaw.destinationCalendar the one with the same credentialId
// Find in formattedCalEvent.destinationCalendar the one with the same credentialId
const selectedCalendar =
calEventRaw.destinationCalendar?.find((cal) => cal.credentialId === credentialId)?.externalId ||
formattedCalEvent.destinationCalendar?.find((cal) => cal.credentialId === credentialId)?.externalId ||
"primary";
try {
let event;
let recurringEventId = null;
if (calEventRaw.existingRecurringEvent) {
recurringEventId = calEventRaw.existingRecurringEvent.recurringEventId;
if (formattedCalEvent.existingRecurringEvent) {
recurringEventId = formattedCalEvent.existingRecurringEvent.recurringEventId;
const recurringEventInstances = await calendar.events.instances({
calendarId: selectedCalendar,
eventId: calEventRaw.existingRecurringEvent.recurringEventId,
eventId: formattedCalEvent.existingRecurringEvent.recurringEventId,
});
if (recurringEventInstances.data.items) {
const calComEventStartTime = dayjs(calEventRaw.startTime)
.tz(calEventRaw.organizer.timeZone)
const calComEventStartTime = dayjs(formattedCalEvent.startTime)
.tz(formattedCalEvent.organizer.timeZone)
.format();
for (let i = 0; i < recurringEventInstances.data.items.length; i++) {
const instance = recurringEventInstances.data.items[i];
@@ -266,9 +271,9 @@ export default class GoogleCalendarService implements Calendar {
calendarId: selectedCalendar,
eventId: event.id || "",
requestBody: {
location: getLocation(calEventRaw),
location: getLocation(formattedCalEvent),
description: getRichDescription({
...calEventRaw,
...formattedCalEvent,
}),
},
});
@@ -296,7 +301,7 @@ export default class GoogleCalendarService implements Calendar {
eventId: event.id || "",
requestBody: {
description: getRichDescription({
...calEventRaw,
...formattedCalEvent,
additionalInformation: { hangoutLink: event.hangoutLink },
}),
},
@@ -342,37 +347,42 @@ export default class GoogleCalendarService implements Calendar {
}
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise<any> {
const formattedCalEvent = formatCalEvent(event);
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: getRichDescription(event),
summary: formattedCalEvent.title,
description: getRichDescription(formattedCalEvent),
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
dateTime: formattedCalEvent.startTime,
timeZone: formattedCalEvent.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
dateTime: formattedCalEvent.endTime,
timeZone: formattedCalEvent.organizer.timeZone,
},
attendees: this.getAttendees(event),
attendees: this.getAttendees(formattedCalEvent),
reminders: {
useDefault: true,
},
guestsCanSeeOtherGuests: !!event.seatsPerTimeSlot ? event.seatsShowAttendees : true,
guestsCanSeeOtherGuests: !!formattedCalEvent.seatsPerTimeSlot
? formattedCalEvent.seatsShowAttendees
: true,
};
if (event.location) {
payload["location"] = getLocation(event);
if (formattedCalEvent.location) {
payload["location"] = getLocation(formattedCalEvent);
}
if (event.conferenceData && event.location === MeetLocationType) {
payload["conferenceData"] = event.conferenceData;
if (formattedCalEvent.conferenceData && formattedCalEvent.location === MeetLocationType) {
payload["conferenceData"] = formattedCalEvent.conferenceData;
}
const calendar = await this.authedCalendar();
const selectedCalendar =
(externalCalendarId
? event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId
? formattedCalEvent.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)
?.externalId
: undefined) || "primary";
try {
@@ -397,7 +407,7 @@ export default class GoogleCalendarService implements Calendar {
eventId: evt.data.id || "",
requestBody: {
description: getRichDescription({
...event,
...formattedCalEvent,
additionalInformation: { hangoutLink: evt.data.hangoutLink },
}),
},
@@ -419,7 +429,7 @@ export default class GoogleCalendarService implements Calendar {
} catch (error) {
this.log.error(
"There was an error updating event in google calendar: ",
safeStringify({ error, event, uid })
safeStringify({ error, event: formattedCalEvent, uid })
);
throw error;
}
+100 -59
View File
@@ -5,6 +5,7 @@ import type { TFunction } from "next-i18next";
import type { EventNameObjectType } from "@calcom/core/event";
import { getEventName } from "@calcom/core/event";
import type BaseEmail from "@calcom/emails/templates/_base-email";
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { MonthlyDigestEmailData } from "./src/templates/MonthlyDigestEmail";
@@ -73,27 +74,30 @@ export const sendScheduledEmails = async (
hostEmailDisabled?: boolean,
attendeeEmailDisabled?: boolean
) => {
const formattedCalEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
if (!hostEmailDisabled) {
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent })));
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent })));
if (calEvent.team) {
for (const teamMember of calEvent.team.members) {
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent, teamMember })));
if (formattedCalEvent.team) {
for (const teamMember of formattedCalEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent, teamMember }))
);
}
}
}
if (!attendeeEmailDisabled) {
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
...formattedCalEvent.attendees.map((attendee) => {
return sendEmail(
() =>
new AttendeeScheduledEmail(
{
...calEvent,
...(calEvent.hideCalendarNotes && { additionalNotes: undefined }),
...formattedCalEvent,
...(formattedCalEvent.hideCalendarNotes && { additionalNotes: undefined }),
...(eventNameObject && {
title: getEventName({ ...eventNameObject, t: attendee.language.translate }),
}),
@@ -110,49 +114,59 @@ export const sendScheduledEmails = async (
// for rescheduled round robin booking that assigned new members
export const sendRoundRobinScheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => {
const formattedCalEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
for (const teamMember of members) {
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent, teamMember })));
emailsToSend.push(
sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent, teamMember }))
);
}
await Promise.all(emailsToSend);
};
export const sendRoundRobinRescheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
for (const teamMember of members) {
emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent, teamMember })));
emailsToSend.push(
sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember }))
);
}
await Promise.all(emailsToSend);
};
export const sendRoundRobinCancelledEmails = async (calEvent: CalendarEvent, members: Person[]) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
for (const teamMember of members) {
emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent, teamMember })));
emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember })));
}
await Promise.all(emailsToSend);
};
export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent })));
emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })));
if (calEvent.team) {
for (const teamMember of calEvent.team.members) {
emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent, teamMember })));
if (calendarEvent.team) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember }))
);
}
}
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeRescheduledEmail(calEvent, attendee));
...calendarEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, attendee));
})
);
@@ -160,10 +174,12 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
};
export const sendRescheduledSeatEmail = async (calEvent: CalendarEvent, attendee: Person) => {
const clonedCalEvent = cloneDeep(calEvent);
const calendarEvent = formatCalEvent(calEvent);
const clonedCalEvent = cloneDeep(calendarEvent);
const emailsToSend: Promise<unknown>[] = [
sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee)),
sendEmail(() => new OrganizerRescheduledEmail({ calEvent })),
sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })),
];
await Promise.all(emailsToSend);
@@ -177,14 +193,18 @@ export const sendScheduledSeatsEmails = async (
hostEmailDisabled?: boolean,
attendeeEmailDisabled?: boolean
) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
if (!hostEmailDisabled) {
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent, newSeat })));
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent: calendarEvent, newSeat })));
if (calEvent.team) {
for (const teamMember of calEvent.team.members) {
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent, newSeat, teamMember })));
if (calendarEvent.team) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerScheduledEmail({ calEvent: calendarEvent, newSeat, teamMember }))
);
}
}
}
@@ -195,8 +215,8 @@ export const sendScheduledSeatsEmails = async (
() =>
new AttendeeScheduledEmail(
{
...calEvent,
...(calEvent.hideCalendarNotes && { additionalNotes: undefined }),
...calendarEvent,
...(calendarEvent.hideCalendarNotes && { additionalNotes: undefined }),
},
invitee,
showAttendees
@@ -208,21 +228,24 @@ export const sendScheduledSeatsEmails = async (
};
export const sendCancelledSeatEmails = async (calEvent: CalendarEvent, cancelledAttendee: Person) => {
const clonedCalEvent = cloneDeep(calEvent);
const formattedCalEvent = formatCalEvent(calEvent);
const clonedCalEvent = cloneDeep(formattedCalEvent);
await Promise.all([
sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee)),
sendEmail(() => new OrganizerAttendeeCancelledSeatEmail({ calEvent })),
sendEmail(() => new OrganizerAttendeeCancelledSeatEmail({ calEvent: formattedCalEvent })),
]);
};
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerRequestEmail({ calEvent })));
emailsToSend.push(sendEmail(() => new OrganizerRequestEmail({ calEvent: calendarEvent })));
if (calEvent.team?.members) {
for (const teamMember of calEvent.team.members) {
emailsToSend.push(sendEmail(() => new OrganizerRequestEmail({ calEvent, teamMember })));
if (calendarEvent.team?.members) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(sendEmail(() => new OrganizerRequestEmail({ calEvent: calendarEvent, teamMember })));
}
}
@@ -230,15 +253,17 @@ export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
};
export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee: Person) => {
await sendEmail(() => new AttendeeRequestEmail(calEvent, attendee));
const calendarEvent = formatCalEvent(calEvent);
await sendEmail(() => new AttendeeRequestEmail(calendarEvent, attendee));
};
export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeDeclinedEmail(calEvent, attendee));
...calendarEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeDeclinedEmail(calendarEvent, attendee));
})
);
@@ -249,31 +274,34 @@ export const sendCancelledEmails = async (
calEvent: CalendarEvent,
eventNameObject: Pick<EventNameObjectType, "eventName">
) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent })));
emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent })));
if (calEvent.team?.members) {
for (const teamMember of calEvent.team.members) {
emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent, teamMember })));
if (calendarEvent.team?.members) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember }))
);
}
}
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
...calendarEvent.attendees.map((attendee) => {
return sendEmail(
() =>
new AttendeeCancelledEmail(
{
...calEvent,
...calendarEvent,
title: getEventName({
...eventNameObject,
t: attendee.language.translate,
attendeeName: attendee.name,
host: calEvent.organizer.name,
eventType: calEvent.type,
...(calEvent.responses && { bookingFields: calEvent.responses }),
...(calEvent.location && { location: calEvent.location }),
host: calendarEvent.organizer.name,
eventType: calendarEvent.type,
...(calendarEvent.responses && { bookingFields: calendarEvent.responses }),
...(calendarEvent.location && { location: calendarEvent.location }),
}),
},
attendee
@@ -286,13 +314,17 @@ export const sendCancelledEmails = async (
};
export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerRequestReminderEmail({ calEvent })));
emailsToSend.push(sendEmail(() => new OrganizerRequestReminderEmail({ calEvent: calendarEvent })));
if (calEvent.team?.members) {
for (const teamMember of calEvent.team.members) {
emailsToSend.push(sendEmail(() => new OrganizerRequestReminderEmail({ calEvent, teamMember })));
if (calendarEvent.team?.members) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerRequestReminderEmail({ calEvent: calendarEvent, teamMember }))
);
}
}
};
@@ -354,28 +386,33 @@ export const sendRequestRescheduleEmail = async (
metadata: { rescheduleLink: string }
) => {
const emailsToSend: Promise<unknown>[] = [];
const calendarEvent = formatCalEvent(calEvent);
emailsToSend.push(sendEmail(() => new OrganizerRequestedToRescheduleEmail(calEvent, metadata)));
emailsToSend.push(sendEmail(() => new OrganizerRequestedToRescheduleEmail(calendarEvent, metadata)));
emailsToSend.push(sendEmail(() => new AttendeeWasRequestedToRescheduleEmail(calEvent, metadata)));
emailsToSend.push(sendEmail(() => new AttendeeWasRequestedToRescheduleEmail(calendarEvent, metadata)));
await Promise.all(emailsToSend);
};
export const sendLocationChangeEmails = async (calEvent: CalendarEvent) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerLocationChangeEmail({ calEvent })));
emailsToSend.push(sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent })));
if (calEvent.team?.members) {
for (const teamMember of calEvent.team.members) {
emailsToSend.push(sendEmail(() => new OrganizerLocationChangeEmail({ calEvent, teamMember })));
if (calendarEvent.team?.members) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent, teamMember }))
);
}
}
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeLocationChangeEmail(calEvent, attendee));
...calendarEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeLocationChangeEmail(calendarEvent, attendee));
})
);
@@ -386,7 +423,8 @@ export const sendFeedbackEmail = async (feedback: Feedback) => {
};
export const sendBrokenIntegrationEmail = async (evt: CalendarEvent, type: "video" | "calendar") => {
await sendEmail(() => new BrokenIntegrationEmail(evt, type));
const calendarEvent = formatCalEvent(evt);
await sendEmail(() => new BrokenIntegrationEmail(calendarEvent, type));
};
export const sendDisabledAppEmail = async ({
@@ -428,13 +466,16 @@ export const sendNoShowFeeChargedEmail = async (attendee: Person, evt: CalendarE
};
export const sendDailyVideoRecordingEmails = async (calEvent: CalendarEvent, downloadLink: string) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerDailyVideoDownloadRecordingEmail(calEvent, downloadLink)));
emailsToSend.push(
sendEmail(() => new OrganizerDailyVideoDownloadRecordingEmail(calendarEvent, downloadLink))
);
for (const attendee of calEvent.attendees) {
for (const attendee of calendarEvent.attendees) {
emailsToSend.push(
sendEmail(() => new AttendeeDailyVideoDownloadRecordingEmail(calEvent, attendee, downloadLink))
sendEmail(() => new AttendeeDailyVideoDownloadRecordingEmail(calendarEvent, attendee, downloadLink))
);
}
await Promise.all(emailsToSend);
@@ -23,6 +23,7 @@ export const BaseEmailHtml = (props: {
title?: string;
subtitle?: React.ReactNode | string;
headerType?: BodyHeadType;
hideLogo?: boolean;
}) => {
return (
<Html>
@@ -196,7 +197,7 @@ export const BaseEmailHtml = (props: {
</Row>
</div>
</div>
<EmailBodyLogo />
{!Boolean(props.hideLogo) && <EmailBodyLogo />}
<RawHtml html="<!--[if mso | IE]></td></tr></table><![endif]-->" />
</div>
</body>
@@ -50,6 +50,7 @@ export const BaseScheduledEmail = (
return (
<BaseEmailHtml
hideLogo={Boolean(props.calEvent.platformClientId)}
headerType={props.headerType || "checkCircle"}
subject={props.subject || subject}
title={t(
@@ -125,13 +125,24 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
export type CustomRequest = NextApiRequest & {
userId?: number;
bookingToDelete?: Awaited<ReturnType<typeof getBookingToDelete>>;
platformClientId?: string;
platformRescheduleUrl?: string;
platformCancelUrl?: string;
platformBookingUrl?: string;
};
async function handler(req: CustomRequest) {
const { id, uid, allRemainingBookings, cancellationReason, seatReferenceUid } =
schemaBookingCancelParams.parse(req.body);
req.bookingToDelete = await getBookingToDelete(id, uid);
const { bookingToDelete, userId } = req;
const {
bookingToDelete,
userId,
platformBookingUrl,
platformCancelUrl,
platformClientId,
platformRescheduleUrl,
} = req;
if (!bookingToDelete || !bookingToDelete.user) {
throw new HttpError({ statusCode: 400, message: "Booking not found" });
@@ -269,6 +280,10 @@ async function handler(req: CustomRequest) {
seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees,
iCalUID: bookingToDelete.iCalUID,
iCalSequence: bookingToDelete.iCalSequence + 1,
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
};
const dataForWebhooks = { evt, webhooks, eventTypeInfo };
@@ -902,10 +902,16 @@ type BookingDataSchemaGetter =
| typeof import("@calcom/features/bookings/lib/getBookingDataSchemaForApi").default;
async function handler(
req: NextApiRequest & { userId?: number | undefined },
req: NextApiRequest & {
userId?: number | undefined;
platformClientId?: string;
platformRescheduleUrl?: string;
platformCancelUrl?: string;
platformBookingUrl?: string;
},
bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema
) {
const { userId } = req;
const { userId, platformClientId, platformCancelUrl, platformBookingUrl, platformRescheduleUrl } = req;
// handle dynamic user
let eventType =
@@ -1503,6 +1509,10 @@ async function handler(
schedulingType: eventType.schedulingType,
iCalUID,
iCalSequence,
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
};
if (req.body.thirdPartyRecurringEventId) {
+72 -3
View File
@@ -154,25 +154,94 @@ const getSeatReferenceId = (calEvent: CalendarEvent): string => {
return calEvent.attendeeSeatId ? calEvent.attendeeSeatId : "";
};
export const getPlatformManageLink = (calEvent: CalendarEvent, t: TFunction) => {
if (calEvent.platformBookingUrl) {
return `${t("need_to_reschedule_or_cancel")} ${calEvent.platformBookingUrl}/${getUid(calEvent)}?slug=${
calEvent.type
}&username=${calEvent.organizer.username}&changes=true`;
}
let res = "";
if (calEvent.platformCancelUrl) {
res += `${t("cancel")}: ${getCancelLink(calEvent)}`;
}
if (calEvent.platformRescheduleUrl) {
res += `${calEvent.platformCancelUrl ? ` ${t("or_lowercase")} ` : ""}${t(
"reschedule"
)}: ${getRescheduleLink(calEvent)}`;
}
return res;
};
export const getManageLink = (calEvent: CalendarEvent, t: TFunction) => {
return `${t("need_to_reschedule_or_cancel")}${calEvent.bookerUrl ?? WEBAPP_URL}/booking/${getUid(
if (calEvent.platformClientId) {
return getPlatformManageLink(calEvent, t);
}
return `${t("need_to_reschedule_or_cancel")} ${calEvent.bookerUrl ?? WEBAPP_URL}/booking/${getUid(
calEvent
)}?changes=true`;
};
export const getPlatformCancelLink = (
calEvent: CalendarEvent,
bookingUid: string,
seatUid?: string
): string => {
if (calEvent.platformCancelUrl) {
const platformCancelLink = new URL(`${calEvent.platformCancelUrl}/${bookingUid}`);
platformCancelLink.searchParams.append("slug", calEvent.type);
calEvent.organizer.username &&
platformCancelLink.searchParams.append("username", calEvent.organizer.username);
platformCancelLink.searchParams.append("cancel", "true");
platformCancelLink.searchParams.append("allRemainingBookings", String(!!calEvent.recurringEvent));
if (seatUid) platformCancelLink.searchParams.append("seatReferenceUid", seatUid);
return platformCancelLink.toString();
}
return "";
};
export const getCancelLink = (calEvent: CalendarEvent): string => {
const cancelLink = new URL(`${calEvent.bookerUrl ?? WEBAPP_URL}/booking/${getUid(calEvent)}`);
const Uid = getUid(calEvent);
const seatReferenceUid = getSeatReferenceId(calEvent);
if (calEvent.platformClientId) {
return getPlatformCancelLink(calEvent, Uid, seatReferenceUid);
}
const cancelLink = new URL(`${calEvent.bookerUrl ?? WEBAPP_URL}/booking/${Uid}`);
cancelLink.searchParams.append("cancel", "true");
cancelLink.searchParams.append("allRemainingBookings", String(!!calEvent.recurringEvent));
const seatReferenceUid = getSeatReferenceId(calEvent);
if (seatReferenceUid) cancelLink.searchParams.append("seatReferenceUid", seatReferenceUid);
return cancelLink.toString();
};
export const getPlatformRescheduleLink = (
calEvent: CalendarEvent,
bookingUid: string,
seatUid?: string
): string => {
if (calEvent.platformRescheduleUrl) {
const platformRescheduleLink = new URL(
`${calEvent.platformRescheduleUrl}/${seatUid ? seatUid : bookingUid}`
);
platformRescheduleLink.searchParams.append("slug", calEvent.type);
calEvent.organizer.username &&
platformRescheduleLink.searchParams.append("username", calEvent.organizer.username);
platformRescheduleLink.searchParams.append("reschedule", "true");
return platformRescheduleLink.toString();
}
return "";
};
export const getRescheduleLink = (calEvent: CalendarEvent): string => {
const Uid = getUid(calEvent);
const seatUid = getSeatReferenceId(calEvent);
if (calEvent.platformClientId) {
return getPlatformRescheduleLink(calEvent, Uid, seatUid);
}
return `${calEvent.bookerUrl ?? WEBAPP_URL}/reschedule/${seatUid ? seatUid : Uid}`;
};
+27
View File
@@ -0,0 +1,27 @@
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import type { CalendarEvent } from "@calcom/types/Calendar";
// format CalEvent to remove platformClientId from email addresses
const formatClientIdFromEmails = (calEvent: CalendarEvent, clientId: string) => {
const attendees = calEvent.attendees.map((attendee) => ({
...attendee,
email: attendee.email.replace(`+${clientId}`, ""),
}));
const organizer = {
...calEvent.organizer,
email: calEvent.organizer.email.replace(`+${clientId}`, ""),
};
return [attendees, organizer];
};
export const formatCalEvent = (calEvent: CalendarEvent) => {
const clonedEvent = cloneDeep(calEvent);
if (clonedEvent.platformClientId) {
const [attendees, organizer] = formatClientIdFromEmails(clonedEvent, clonedEvent.platformClientId);
Object.assign(clonedEvent, { attendees, organizer });
}
return clonedEvent;
};
@@ -30,13 +30,17 @@ export const useOAuthClient = ({ clientId, apiUrl, refreshUrl, onError, onSucces
useEffect(() => {
if (clientId && http.getUrl() && prevClientId !== clientId) {
try {
http.get<ApiResponse>(`/ee/provider/${clientId}`).catch((err: AxiosError) => {
if (err.response?.status === 401) {
onError("Invalid oAuth Client.");
} else {
http
.get<ApiResponse>(`/ee/provider/${clientId}`)
.then(() => {
onSuccess();
}
});
http.setClientIdHeader(clientId);
})
.catch((err: AxiosError) => {
if (err.response?.status === 401) {
onError("Invalid oAuth Client.");
}
});
} catch (err) {
console.error(err);
}
+6
View File
@@ -34,6 +34,12 @@ const http = (function () {
getAuthorizationHeader: () => {
return instance.defaults.headers.common?.["Authorization"]?.toString() ?? "";
},
setClientIdHeader: (clientId: string) => {
instance.defaults.headers.common["x-cal-client-id"] = clientId;
},
getClientIdHeader: () => {
return instance.defaults.headers.common?.["x-cal-client-id"]?.toString() ?? "";
},
refreshTokens: async (refreshUrl: string): Promise<string> => {
const response = await fetch(`${refreshUrl}`, {
method: "GET",
+1
View File
@@ -46,6 +46,7 @@ export const API_ERROR_CODES = [
// Request headers
export const X_CAL_SECRET_KEY = "x-cal-secret-key";
export const X_CAL_CLIENT_ID = "x-cal-client-id";
// HTTP status codes
export const HTTP_CODE_TOKEN_EXPIRED = 498;
+4
View File
@@ -200,6 +200,10 @@ export interface CalendarEvent {
// It just has responses to only the user fields. It allows to easily iterate over to show only user fields
userFieldsResponses?: CalEventResponses | null;
platformClientId?: string | null;
platformRescheduleUrl?: string | null;
platformCancelUrl?: string | null;
platformBookingUrl?: string | null;
}
export interface EntryPoint {