From e641bb3d5728951c2ced0c2e3e0e1436c5cbf39c Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:39:01 +0300 Subject: [PATCH] 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 --- apps/api/v2/src/app.ts | 4 +- .../api/v2/src/ee/bookings/bookings.module.ts | 3 +- .../controllers/bookings.controller.ts | 99 ++++++++--- .../calendars/services/calendars.service.ts | 6 +- .../credentials/credentials.repository.ts | 2 +- apps/api/v2/swagger/documentation.json | 53 +++++- .../googlecalendar/lib/CalendarService.ts | 94 ++++++----- packages/emails/email-manager.ts | 159 +++++++++++------- .../emails/src/components/BaseEmailHtml.tsx | 3 +- .../src/templates/BaseScheduledEmail.tsx | 1 + .../bookings/lib/handleCancelBooking.ts | 17 +- .../features/bookings/lib/handleNewBooking.ts | 14 +- packages/lib/CalEventParser.ts | 75 ++++++++- packages/lib/formatCalendarEvent.ts | 27 +++ .../platform/atoms/hooks/useOAuthClient.ts | 16 +- packages/platform/atoms/lib/http.ts | 6 + packages/platform/constants/api.ts | 1 + packages/types/Calendar.d.ts | 4 + 18 files changed, 442 insertions(+), 142 deletions(-) create mode 100644 packages/lib/formatCalendarEvent.ts diff --git a/apps/api/v2/src/app.ts b/apps/api/v2/src/app.ts index 45c7adcf6b..874b08b61d 100644 --- a/apps/api/v2/src/app.ts +++ b/apps/api/v2/src/app.ts @@ -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, }); diff --git a/apps/api/v2/src/ee/bookings/bookings.module.ts b/apps/api/v2/src/ee/bookings/bookings.module.ts index 8b52876bfa..9725cac786 100644 --- a/apps/api/v2/src/ee/bookings/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/bookings.module.ts @@ -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 {} diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts index 88e805618f..2962055563 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -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> { - 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 { + 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> { - 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>>> { + 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 { + 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 { + 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 { diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts index 5ad1297bdd..6fee7cb40e 100644 --- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -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 ); } diff --git a/apps/api/v2/src/modules/credentials/credentials.repository.ts b/apps/api/v2/src/modules/credentials/credentials.repository.ts index 329d75fba6..5a496cc85f 100644 --- a/apps/api/v2/src/modules/credentials/credentials.repository.ts +++ b/apps/api/v2/src/modules/credentials/credentials.repository.ts @@ -27,7 +27,7 @@ export class CredentialsRepository { invalid: false, }, where: { - id: credential?.id, + id: credential?.id ?? 0, }, }); } diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 5bb6252432..2b63fe2502 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -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" } } }, diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 1a28f1e518..8b739ae6e2 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -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 { + 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 { + 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; } diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index aca5ad30b5..705155496d 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -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[] = []; 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[] = []; 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[] = []; 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[] = []; 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[] = []; - 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[] = [ 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[] = []; 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[] = []; - 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[] = []; 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 ) => { + const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - 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[] = []; - 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[] = []; + 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[] = []; - 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[] = []; - 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); diff --git a/packages/emails/src/components/BaseEmailHtml.tsx b/packages/emails/src/components/BaseEmailHtml.tsx index 2f14915782..3297dd2231 100644 --- a/packages/emails/src/components/BaseEmailHtml.tsx +++ b/packages/emails/src/components/BaseEmailHtml.tsx @@ -23,6 +23,7 @@ export const BaseEmailHtml = (props: { title?: string; subtitle?: React.ReactNode | string; headerType?: BodyHeadType; + hideLogo?: boolean; }) => { return ( @@ -196,7 +197,7 @@ export const BaseEmailHtml = (props: { - + {!Boolean(props.hideLogo) && } diff --git a/packages/emails/src/templates/BaseScheduledEmail.tsx b/packages/emails/src/templates/BaseScheduledEmail.tsx index e75fdb6883..50f3d024e6 100644 --- a/packages/emails/src/templates/BaseScheduledEmail.tsx +++ b/packages/emails/src/templates/BaseScheduledEmail.tsx @@ -50,6 +50,7 @@ export const BaseScheduledEmail = ( return ( >; + 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 }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index a87834910e..f97a46cf0e 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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) { diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index ec1dbe7cc3..5b64e0f670 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -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}`; }; diff --git a/packages/lib/formatCalendarEvent.ts b/packages/lib/formatCalendarEvent.ts new file mode 100644 index 0000000000..8c177dc74b --- /dev/null +++ b/packages/lib/formatCalendarEvent.ts @@ -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; +}; diff --git a/packages/platform/atoms/hooks/useOAuthClient.ts b/packages/platform/atoms/hooks/useOAuthClient.ts index a6b26c8c32..786a388933 100644 --- a/packages/platform/atoms/hooks/useOAuthClient.ts +++ b/packages/platform/atoms/hooks/useOAuthClient.ts @@ -30,13 +30,17 @@ export const useOAuthClient = ({ clientId, apiUrl, refreshUrl, onError, onSucces useEffect(() => { if (clientId && http.getUrl() && prevClientId !== clientId) { try { - http.get(`/ee/provider/${clientId}`).catch((err: AxiosError) => { - if (err.response?.status === 401) { - onError("Invalid oAuth Client."); - } else { + http + .get(`/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); } diff --git a/packages/platform/atoms/lib/http.ts b/packages/platform/atoms/lib/http.ts index bb089d9803..952a9d703b 100644 --- a/packages/platform/atoms/lib/http.ts +++ b/packages/platform/atoms/lib/http.ts @@ -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 => { const response = await fetch(`${refreshUrl}`, { method: "GET", diff --git a/packages/platform/constants/api.ts b/packages/platform/constants/api.ts index b36d1715df..747edf523d 100644 --- a/packages/platform/constants/api.ts +++ b/packages/platform/constants/api.ts @@ -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; diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index adcae7ee94..b3ffec8d63 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -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 {