From 61f436be2a451c664fbdece2a79e534f157ab4ea Mon Sep 17 00:00:00 2001 From: Kartik Saini <41051387+kart1ka@users.noreply.github.com> Date: Wed, 11 Sep 2024 10:17:52 +0530 Subject: [PATCH 01/40] fix: wrong reschedule link for an org member for a booking on event outside the org (#15260) * fix: wrong reschedule link * test * fix * test * test * refactor: get org id of booking * fix: redirect URL to handle movedToProfile username based on domain context * Revert not needed changes * Revert unnecessary changes now --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Hariom --- apps/web/components/booking/BookingListItem.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 9f2a98a55018c7..0e8fb2a1a117af 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -15,7 +15,6 @@ import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog import classNames from "@calcom/lib/classNames"; import { formatTime } from "@calcom/lib/date-fns"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; -import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useGetTheme } from "@calcom/lib/hooks/useTheme"; @@ -70,7 +69,6 @@ type BookingItemProps = BookingItem & { }; function BookingListItem(booking: BookingItemProps) { - const bookerUrl = useBookerUrl(); const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser; const { @@ -183,7 +181,7 @@ function BookingListItem(booking: BookingItemProps) { id: "reschedule", icon: "clock" as const, label: t("reschedule_booking"), - href: `${bookerUrl}/reschedule/${booking.uid}${ + href: `/reschedule/${booking.uid}${ booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : "" }`, }, From 4d53f327d9c1afb99189b0970ed10a731220d6b2 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:41:54 +0530 Subject: [PATCH 02/40] feat: V2 API endpoint create phone call (#16528) * refactor: V2 API endpoint create phone call * fix: type err * fix: type and build err * chore: change default value * chore: move it to another route * test: for create phone call * chore: undo constant * chore: remove test * fix: make begin_message optional * chore: improvements * chore: begin message * chore: remove unused import * chore: bump platform libraries with handleCreatePhoneCall * chore: improvement --------- Co-authored-by: Keith Williams Co-authored-by: Benny Joo Co-authored-by: Morgan Vernay Co-authored-by: Peer Richelsen --- apps/api/v2/package.json | 2 +- .../inputs/create-phone-call.input.ts | 71 ++++++++ .../outputs/create-phone-call.output.ts | 28 +++ .../organizations-event-types.controller.ts | 29 ++- apps/api/v2/swagger/documentation.json | 167 +++++++++++++++++- .../ee/cal-ai-phone/retellAIService.ts | 2 +- .../features/ee/cal-ai-phone/zod-utils.ts | 2 +- .../components/tabs/ai/AIEventController.tsx | 3 +- packages/features/handleCreatePhoneCall.ts | 126 +++++++++++++ packages/lib/constants.ts | 1 + packages/platform/libraries/index.ts | 2 + .../server/routers/viewer/eventTypes/util.ts | 17 +- .../routers/viewer/organizations/_router.tsx | 3 +- .../organizations/createPhoneCall.handler.ts | 114 +----------- 14 files changed, 443 insertions(+), 124 deletions(-) create mode 100644 apps/api/v2/src/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input.ts create mode 100644 apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output.ts create mode 100644 packages/features/handleCreatePhoneCall.ts diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 10f4c71ecca3ec..5eda351e7920cc 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -28,7 +28,7 @@ "dependencies": { "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.33", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.34", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input.ts new file mode 100644 index 00000000000000..8fa63319d22a01 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input.ts @@ -0,0 +1,71 @@ +import { ApiProperty as DocsProperty } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsString, IsBoolean, IsOptional, IsEnum, Matches } from "class-validator"; + +export enum TemplateType { + CHECK_IN_APPOINTMENT = "CHECK_IN_APPOINTMENT", + CUSTOM_TEMPLATE = "CUSTOM_TEMPLATE", +} + +export class CreatePhoneCallInput { + @IsString() + @Matches(/^\+[1-9]\d{1,14}$/, { + message: + "Invalid phone number format. Expected format: + with no spaces or separators.", + }) + @DocsProperty({ description: "Your phone number" }) + yourPhoneNumber!: string; + + @IsString() + @Matches(/^\+[1-9]\d{1,14}$/, { + message: + "Invalid phone number format. Expected format: + with no spaces or separators.", + }) + @DocsProperty({ description: "Number to call" }) + numberToCall!: string; + + @IsString() + @DocsProperty({ description: "CAL API Key" }) + calApiKey!: string; + + @IsBoolean() + @DocsProperty({ description: "Enabled status", default: true }) + enabled = true; + + @IsEnum(TemplateType) + @DocsProperty({ description: "Template type", enum: TemplateType }) + templateType: TemplateType = TemplateType.CUSTOM_TEMPLATE; + + @IsOptional() + @IsString() + @DocsProperty({ description: "Scheduler name" }) + schedulerName?: string; + + @IsOptional() + @IsString() + @Transform(({ value }) => (value ? value : undefined)) + @DocsProperty({ description: "Guest name" }) + guestName?: string; + + @IsOptional() + @IsString() + @Transform(({ value }) => (value ? value : undefined)) + @DocsProperty({ description: "Guest email" }) + guestEmail?: string; + + @IsOptional() + @IsString() + @Transform(({ value }) => (value ? value : undefined)) + @DocsProperty({ description: "Guest company" }) + guestCompany?: string; + + @IsOptional() + @IsString() + @DocsProperty({ description: "Begin message" }) + beginMessage?: string; + + @IsOptional() + @IsString() + @DocsProperty({ description: "General prompt" }) + generalPrompt?: string; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output.ts new file mode 100644 index 00000000000000..ac4c2dd965176f --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Data { + @IsString() + @ApiProperty() + callId!: string; + + @IsString() + @ApiProperty() + agentId!: string; +} + +export class CreatePhoneCallOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: Data, + }) + @ValidateNested() + @Type(() => Data) + data!: Data; +} diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts index fa1c41df66508b..6ba6caf3d204f6 100644 --- a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts @@ -1,3 +1,5 @@ +import { CreatePhoneCallInput } from "@/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input"; +import { CreatePhoneCallOutput } from "@/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; @@ -33,6 +35,7 @@ import { import { ApiTags as DocsTags } from "@nestjs/swagger"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { handleCreatePhoneCall } from "@calcom/platform-libraries"; import { CreateTeamEventTypeInput_2024_06_14, GetTeamEventTypesQuery_2024_06_14, @@ -91,6 +94,30 @@ export class OrganizationsEventTypesController { }; } + @Roles("TEAM_ADMIN") + @Post("/teams/:teamId/event-types/:eventTypeId/create-phone-call") + @UseGuards(ApiAuthGuard, IsOrgGuard, IsTeamInOrg, RolesGuard) + async createPhoneCall( + @Param("eventTypeId") eventTypeId: number, + @Param("orgId", ParseIntPipe) orgId: number, + @Body() body: CreatePhoneCallInput, + @GetUser() user: UserWithProfile + ): Promise { + const data = await handleCreatePhoneCall({ + user: { + id: user.id, + timeZone: user.timeZone, + profile: { organization: { id: orgId } }, + }, + input: { ...body, eventTypeId }, + }); + + return { + status: SUCCESS_STATUS, + data, + }; + } + @UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard) @Get("/teams/:teamId/event-types") async getTeamEventTypes( @@ -138,7 +165,7 @@ export class OrganizationsEventTypesController { @Patch("/teams/:teamId/event-types/:eventTypeId") async updateTeamEventType( @Param("teamId", ParseIntPipe) teamId: number, - @Param("eventTypeId") eventTypeId: number, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, @GetUser() user: UserWithProfile, @Body() bodyEventType: UpdateTeamEventTypeInput_2024_06_14 ): Promise { diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 219408b271a8da..f9f0083a6096a7 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -1982,6 +1982,54 @@ ] } }, + "/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/create-phone-call": { + "post": { + "operationId": "OrganizationsEventTypesController_createPhoneCall", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePhoneCallInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePhoneCallOutput" + } + } + } + } + }, + "tags": [ + "Organizations Event Types" + ] + } + }, "/v2/organizations/{orgId}/teams/event-types": { "get": { "operationId": "OrganizationsEventTypesController_getTeamsEventTypes", @@ -3324,7 +3372,31 @@ "/v2/calendars/busy-times": { "get": { "operationId": "CalendarsController_getBusyTimes", - "parameters": [], + "parameters": [ + { + "name": "loggedInUsersTz", + "required": true, + "in": "query", + "description": "The timezone of the logged in user represented as a string", + "example": "America/New_York", + "schema": { + "type": "string" + } + }, + { + "name": "calendarsToLoad", + "required": true, + "in": "query", + "description": "An array of Calendar objects representing the calendars to be loaded", + "example": "[{ credentialId: \"1\", externalId: \"AQgtJE7RnHEeyisVq2ENs2gAAAgEGAAAACgtJE7RnHEeyisVq2ENs2gAAAhSDAAAA\" }, { credentialId: \"2\", externalId: \"AQM7RnHEeyisVq2ENs2gAAAhFDBBBBB\" }]", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], "responses": { "200": { "description": "", @@ -7794,6 +7866,90 @@ "data" ] }, + "CreatePhoneCallInput": { + "type": "object", + "properties": { + "yourPhoneNumber": { + "type": "string", + "pattern": "/^\\+[1-9]\\d{1,14}$/", + "description": "Your phone number" + }, + "numberToCall": { + "type": "string", + "pattern": "/^\\+[1-9]\\d{1,14}$/", + "description": "Number to call" + }, + "calApiKey": { + "type": "string", + "description": "CAL API Key" + }, + "enabled": { + "type": "object", + "default": true, + "description": "Enabled status" + }, + "templateType": { + "default": "CUSTOM_TEMPLATE", + "enum": [ + "CHECK_IN_APPOINTMENT", + "CUSTOM_TEMPLATE" + ], + "type": "string", + "description": "Template type" + }, + "schedulerName": { + "type": "string", + "description": "Scheduler name" + }, + "guestName": { + "type": "string", + "description": "Guest name" + }, + "guestEmail": { + "type": "string", + "description": "Guest email" + }, + "guestCompany": { + "type": "string", + "description": "Guest company" + }, + "beginMessage": { + "type": "string", + "description": "Begin message" + }, + "generalPrompt": { + "type": "string", + "description": "General prompt" + } + }, + "required": [ + "yourPhoneNumber", + "numberToCall", + "calApiKey", + "enabled", + "templateType" + ] + }, + "CreatePhoneCallOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/Data" + } + }, + "required": [ + "status", + "data" + ] + }, "GetTeamEventTypesOutput": { "type": "object", "properties": { @@ -8636,7 +8792,8 @@ "MEETING_STARTED", "RECORDING_READY", "INSTANT_MEETING", - "RECORDING_TRANSCRIPTION_GENERATED" + "RECORDING_TRANSCRIPTION_GENERATED", + "OOO_CREATED" ] }, "active": { @@ -8709,7 +8866,8 @@ "MEETING_STARTED", "RECORDING_READY", "INSTANT_MEETING", - "RECORDING_TRANSCRIPTION_GENERATED" + "RECORDING_TRANSCRIPTION_GENERATED", + "OOO_CREATED" ] }, "active": { @@ -10304,9 +10462,6 @@ "rescheduleUid": { "type": "string" }, - "recurringEventId": { - "type": "string" - }, "timeZone": { "type": "string" }, diff --git a/packages/features/ee/cal-ai-phone/retellAIService.ts b/packages/features/ee/cal-ai-phone/retellAIService.ts index ed911c6949e9f0..b903c8883f49d4 100644 --- a/packages/features/ee/cal-ai-phone/retellAIService.ts +++ b/packages/features/ee/cal-ai-phone/retellAIService.ts @@ -31,7 +31,7 @@ type initProps = { eventTypeId: number; calApiKey: string; loggedInUserTimeZone: string; - beginMessage?: string; + beginMessage?: string | null; dynamicVariables: DynamicVariables; generalPrompt: string; }; diff --git a/packages/features/ee/cal-ai-phone/zod-utils.ts b/packages/features/ee/cal-ai-phone/zod-utils.ts index 994484f75273ac..16452a296e1b41 100644 --- a/packages/features/ee/cal-ai-phone/zod-utils.ts +++ b/packages/features/ee/cal-ai-phone/zod-utils.ts @@ -130,7 +130,7 @@ export type TCreateRetellLLMSchema = z.infer; export const ZGetRetellLLMSchema = z .object({ general_prompt: z.string(), - begin_message: z.string().nullable(), + begin_message: z.string().nullable().optional(), llm_id: z.string(), llm_websocket_url: z.string(), general_tools: z.array( diff --git a/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx b/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx index b7cf8df83e299f..632e6f28ee9e9c 100644 --- a/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx +++ b/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx @@ -150,7 +150,7 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => { const createCallMutation = trpc.viewer.organizations.createPhoneCall.useMutation({ onSuccess: (data) => { - if (!!data?.call_id) { + if (!!data?.callId) { showToast("Phone Call Created successfully", "success"); } }, @@ -175,6 +175,7 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => { guestName: values.guestName && values.guestName.trim().length ? values.guestName : undefined, eventTypeId: eventType.id, calApiKey, + id: eventType.id, }); createCallMutation.mutate(data); diff --git a/packages/features/handleCreatePhoneCall.ts b/packages/features/handleCreatePhoneCall.ts new file mode 100644 index 00000000000000..c9b084fd610be5 --- /dev/null +++ b/packages/features/handleCreatePhoneCall.ts @@ -0,0 +1,126 @@ +import { PROMPT_TEMPLATES } from "@calcom/features/ee/cal-ai-phone/promptTemplates"; +import { RetellAIService, validatePhoneNumber } from "@calcom/features/ee/cal-ai-phone/retellAIService"; +import { templateTypeEnum } from "@calcom/features/ee/cal-ai-phone/zod-utils"; +import type { TCreatePhoneCallSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +export const handleCreatePhoneCall = async ({ + user, + input, +}: { + user: { timeZone: string; id: number; profile?: { organization?: { id?: number } } }; + input: TCreatePhoneCallSchema; +}) => { + if (!user?.profile?.organization) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not part of an organization" }); + } + + await checkRateLimitAndThrowError({ + rateLimitingType: "core", + identifier: `createPhoneCall:${user.id}`, + }); + + await validatePhoneNumber(input.yourPhoneNumber); + + const { + yourPhoneNumber, + numberToCall, + guestName, + guestEmail, + guestCompany, + eventTypeId, + beginMessage, + calApiKey, + templateType, + schedulerName, + generalPrompt: userCustomPrompt, + } = input; + + const generalPrompt = + templateType === templateTypeEnum.enum.CUSTOM_TEMPLATE + ? userCustomPrompt + : PROMPT_TEMPLATES[templateType]?.generalPrompt; + + const retellAI = new RetellAIService({ + templateType, + generalPrompt: generalPrompt ?? "", + beginMessage: beginMessage ?? null, + yourPhoneNumber, + loggedInUserTimeZone: user.timeZone, + eventTypeId, + calApiKey, + dynamicVariables: { + guestName, + guestEmail, + guestCompany, + schedulerName, + }, + }); + + const aiPhoneCallConfig = await prisma.aIPhoneCallConfiguration.upsert({ + where: { + eventTypeId, + }, + update: { + beginMessage, + enabled: true, + guestName, + guestEmail, + guestCompany, + numberToCall, + yourPhoneNumber, + schedulerName, + templateType, + generalPrompt, + }, + create: { + eventTypeId, + beginMessage, + enabled: true, + guestName, + guestEmail, + guestCompany, + numberToCall, + yourPhoneNumber, + schedulerName, + templateType, + generalPrompt, + }, + }); + + // If no retell LLM is associated with the event type, create one + if (!aiPhoneCallConfig.llmId) { + const createdRetellLLM = await retellAI.createRetellLLMAndUpdateWebsocketUrl(); + + await prisma.aIPhoneCallConfiguration.update({ + where: { + eventTypeId, + }, + data: { + llmId: createdRetellLLM.llm_id, + }, + }); + } else { + // aiPhoneCallConfig.llmId would be set here in the else block + const retellLLM = await retellAI.getRetellLLM(aiPhoneCallConfig.llmId as string); + + const shouldUpdateLLM = + retellLLM.general_prompt !== generalPrompt || retellLLM.begin_message !== beginMessage; + + if (shouldUpdateLLM) { + const updatedRetellLLM = await retellAI.updatedRetellLLMAndUpdateWebsocketUrl( + aiPhoneCallConfig.llmId as string + ); + logger.debug("updated Retell LLM", updatedRetellLLM); + } + } + + const createPhoneCallRes = await retellAI.createRetellPhoneCall(numberToCall); + logger.debug("Create Call Response", createPhoneCallRes); + + return { callId: createPhoneCallRes.call_id, agentId: createPhoneCallRes.agent_id }; +}; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 1ce361a791193a..35802b7ddbbc14 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -94,6 +94,7 @@ export const IS_STRIPE_ENABLED = !!( ); /** This has correct value only server side. When you want to use client side, go for IS_TEAM_BILLING_ENABLED_CLIENT. I think we should use the _CLIENT one only everywhere so that it works reliably everywhere on client as well as server */ export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES; + export const IS_TEAM_BILLING_ENABLED_CLIENT = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES; diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 77f74c1001674c..c3c7f802e9eb14 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -5,6 +5,7 @@ import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo"; import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking"; import * as newBookingMethods from "@calcom/features/bookings/lib/handleNewBooking"; import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; +import { handleCreatePhoneCall } from "@calcom/features/handleCreatePhoneCall"; import handleMarkNoShow from "@calcom/features/handleMarkNoShow"; import * as instantMeetingMethods from "@calcom/features/instant-meeting/handleInstantMeeting"; import getAllUserBookings from "@calcom/lib/bookings/getAllUserBookings"; @@ -48,6 +49,7 @@ const handleInstantMeeting = instantMeetingMethods.default; export { handleInstantMeeting }; export { handleMarkNoShow }; +export { handleCreatePhoneCall }; export { getAvailableSlots }; export type AvailableSlotsType = Awaited>; diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts index a4891c05a75aee..b464229f517078 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/util.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts @@ -18,15 +18,22 @@ type EventType = Awaited>[n export const eventOwnerProcedure = authedProcedure .input( - z.object({ - id: z.number(), - users: z.array(z.number()).optional().default([]), - }) + z + .object({ + id: z.number().optional(), + eventTypeId: z.number().optional(), + users: z.array(z.number()).optional().default([]), + }) + .refine((data) => data.id !== undefined || data.eventTypeId !== undefined, { + message: "At least one of 'id' or 'eventTypeId' must be present", + path: ["id", "eventTypeId"], + }) ) .use(async ({ ctx, input, next }) => { + const id = input.eventTypeId ?? input.id; // Prevent non-owners to update/delete a team event const event = await ctx.prisma.eventType.findUnique({ - where: { id: input.id }, + where: { id }, include: { users: { select: { diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 4392508ff39b23..4761600aede8b9 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -6,6 +6,7 @@ import authedProcedure, { authedOrgAdminProcedure, } from "../../../procedures/authedProcedure"; import { importHandler, router } from "../../../trpc"; +import { eventOwnerProcedure } from "../eventTypes/util"; import { ZAddMembersToEventTypes } from "./addMembersToEventTypes.schema"; import { ZAddMembersToTeams } from "./addMembersToTeams.schema"; import { ZAdminDeleteInput } from "./adminDelete.schema"; @@ -161,7 +162,7 @@ export const viewerOrganizationsRouter = router({ const handler = await importHandler(namespaced("adminDelete"), () => import("./adminDelete.handler")); return handler(opts); }), - createPhoneCall: authedProcedure.input(createPhoneCallSchema).mutation(async (opts) => { + createPhoneCall: eventOwnerProcedure.input(createPhoneCallSchema).mutation(async (opts) => { const handler = await importHandler( namespaced("createPhoneCall"), () => import("./createPhoneCall.handler") diff --git a/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts b/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts index 9bec06ad2caf93..5d73b0b43c9c11 100644 --- a/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts @@ -1,13 +1,8 @@ import type { z } from "zod"; -import { PROMPT_TEMPLATES } from "@calcom/features/ee/cal-ai-phone/promptTemplates"; -import { RetellAIService, validatePhoneNumber } from "@calcom/features/ee/cal-ai-phone/retellAIService"; import type { createPhoneCallSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils"; -import { templateTypeEnum } from "@calcom/features/ee/cal-ai-phone/zod-utils"; -import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; -import logger from "@calcom/lib/logger"; +import { handleCreatePhoneCall } from "@calcom/features/handleCreatePhoneCall"; import type { PrismaClient } from "@calcom/prisma"; -import { TRPCError } from "@calcom/trpc/server"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; type CreatePhoneCallProps = { @@ -19,109 +14,14 @@ type CreatePhoneCallProps = { }; const createPhoneCallHandler = async ({ input, ctx }: CreatePhoneCallProps) => { - if (!!!ctx.user.profile.organization) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - await checkRateLimitAndThrowError({ - rateLimitingType: "core", - identifier: `createPhoneCall:${ctx.user.id}`, - }); - - await validatePhoneNumber(input.yourPhoneNumber); - - const { - yourPhoneNumber, - numberToCall, - guestName, - guestEmail, - guestCompany, - eventTypeId, - beginMessage, - calApiKey, - templateType, - schedulerName, - generalPrompt: userCustomPrompt, - } = input; - - const generalPrompt = - templateType === templateTypeEnum.enum.CUSTOM_TEMPLATE - ? userCustomPrompt - : PROMPT_TEMPLATES[templateType]?.generalPrompt; - - const retellAI = new RetellAIService({ - templateType, - generalPrompt: generalPrompt ?? "", - yourPhoneNumber, - loggedInUserTimeZone: ctx.user.timeZone, - eventTypeId, - calApiKey, - dynamicVariables: { - guestName, - guestEmail, - guestCompany, - schedulerName, - }, - }); - - const aiPhoneCallConfig = await ctx.prisma.aIPhoneCallConfiguration.upsert({ - where: { - eventTypeId, - }, - update: { - beginMessage, - enabled: true, - guestName, - guestEmail, - guestCompany, - numberToCall, - yourPhoneNumber, - schedulerName, - templateType, - generalPrompt, - }, - create: { - eventTypeId, - beginMessage, - enabled: true, - guestName, - guestEmail, - guestCompany, - numberToCall, - yourPhoneNumber, - schedulerName, - templateType, - generalPrompt, + return await handleCreatePhoneCall({ + user: { + id: ctx.user.id, + timeZone: ctx.user.timeZone, + profile: { organization: { id: ctx.user.profile.organization?.id } }, }, + input, }); - - // If no retell LLM is associated with the event type, create one - if (!aiPhoneCallConfig.llmId) { - const createdRetellLLM = await retellAI.createRetellLLMAndUpdateWebsocketUrl(); - - await ctx.prisma.aIPhoneCallConfiguration.update({ - where: { - eventTypeId, - }, - data: { - llmId: createdRetellLLM.llm_id, - }, - }); - } else { - const retellLLM = await retellAI.getRetellLLM(aiPhoneCallConfig.llmId); - - const doWeNeedToUpdateLLM = - retellLLM.general_prompt !== generalPrompt || retellLLM.begin_message !== beginMessage; - if (doWeNeedToUpdateLLM) { - const updatedRetellLLM = await retellAI.updatedRetellLLMAndUpdateWebsocketUrl(aiPhoneCallConfig.llmId); - logger.debug("updated Retell LLM", updatedRetellLLM); - } - } - - const createPhoneCallRes = await retellAI.createRetellPhoneCall(numberToCall); - logger.debug("Create Call Response", createPhoneCallRes); - - return createPhoneCallRes; }; export default createPhoneCallHandler; From e362c37ff37a1704d532410c0aaa3716f8d1759a Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 11 Sep 2024 18:01:53 +0530 Subject: [PATCH 03/40] fix: Location Change to Organizer Default Conferencing App (#16379) * Fix * Dont include test files * Fix location change * Self review fixes * Add unit test * Add more test * Add a bookingScenario as well --- apps/web/components/apps/AppList.tsx | 4 +- .../components/booking/BookingListItem.tsx | 46 ++- .../components/dialog/EditLocationDialog.tsx | 182 ++++----- .../__tests__/EditLocationDialog.test.tsx | 225 +++++++++++ apps/web/public/static/locales/en/common.json | 5 +- .../web/test/utils/bookingScenario/expects.ts | 27 ++ packages/app-store/locations.ts | 38 +- packages/core/EventManager.ts | 4 +- .../features/bookings/lib/handleNewBooking.ts | 14 +- .../buildCalEventFromBooking.test.ts | 208 ++++++++++ packages/lib/buildCalEventFromBooking.ts | 106 ++++++ packages/lib/server/repository/booking.ts | 25 ++ packages/lib/server/repository/credential.ts | 18 + packages/lib/server/repository/user.ts | 12 + .../__tests__/editLocation.handler.test.ts | 264 +++++++++++++ .../viewer/bookings/editLocation.handler.ts | 358 ++++++++++++------ .../viewer/bookings/editLocation.schema.ts | 2 +- .../server/routers/viewer/bookings/util.ts | 72 ++-- packages/ui/components/test-setup.ts | 4 + vitest.workspace.ts | 9 + 20 files changed, 1335 insertions(+), 288 deletions(-) create mode 100644 apps/web/components/dialog/__tests__/EditLocationDialog.test.tsx create mode 100644 packages/lib/__tests__/buildCalEventFromBooking.test.ts create mode 100644 packages/lib/buildCalEventFromBooking.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/__tests__/editLocation.handler.test.ts diff --git a/apps/web/components/apps/AppList.tsx b/apps/web/components/apps/AppList.tsx index 2b73559cf3f7a7..0c514d460995b7 100644 --- a/apps/web/components/apps/AppList.tsx +++ b/apps/web/components/apps/AppList.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import { AppSettings } from "@calcom/app-store/_components/AppSettings"; import { InstallAppButton } from "@calcom/app-store/components"; -import { getEventLocationTypeFromApp, type EventLocationType } from "@calcom/app-store/locations"; +import { getLocationFromApp, type EventLocationType } from "@calcom/app-store/locations"; import type { CredentialOwner } from "@calcom/app-store/types"; import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog"; import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal"; @@ -92,7 +92,7 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL color="secondary" StartIcon="video" onClick={() => { - const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? ""); + const locationType = getLocationFromApp(item?.locationOption?.value ?? ""); if (locationType?.linkType === "static") { setLocationType({ ...locationType, slug: appSlug }); } else { diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 0e8fb2a1a117af..69c61e2cbd6539 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -2,12 +2,8 @@ import Link from "next/link"; import { useState } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; -import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations"; -import { - getEventLocationType, - getSuccessPageLocationMessage, - guessEventLocationType, -} from "@calcom/app-store/locations"; +import type { getEventLocationValue } from "@calcom/app-store/locations"; +import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; // TODO: Use browser locale, implement Intl in Dayjs maybe? import "@calcom/dayjs/locales"; @@ -284,20 +280,36 @@ function BookingListItem(booking: BookingItemProps) { setIsOpenLocationDialog(false); utils.viewer.bookings.invalidate(); }, + onError: (e) => { + const errorMessages: Record = { + UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"), + BAD_REQUEST: e.message, + }; + + const message = errorMessages[e.data?.code as string] || t("location_update_failed"); + showToast(message, "error"); + }, }); - const saveLocation = ( - newLocationType: EventLocationType["type"], - details: { - [key: string]: string; - } - ) => { - let newLocation = newLocationType as string; - const eventLocationType = getEventLocationType(newLocationType); - if (eventLocationType?.organizerInputType) { - newLocation = details[Object.keys(details)[0]]; + const saveLocation = async ({ + newLocation, + credentialId, + }: { + newLocation: string; + /** + * It could be set for conferencing locations that support team level installations. + */ + credentialId: number | null; + }) => { + try { + await setLocationMutation.mutateAsync({ + bookingId: booking.id, + newLocation, + credentialId, + }); + } catch { + // Errors are shown through the mutation onError handler } - setLocationMutation.mutate({ bookingId: booking.id, newLocation, details }); }; // Getting accepted recurring dates to show diff --git a/apps/web/components/dialog/EditLocationDialog.tsx b/apps/web/components/dialog/EditLocationDialog.tsx index c07bd7154f8adf..279dc5202a4ee5 100644 --- a/apps/web/components/dialog/EditLocationDialog.tsx +++ b/apps/web/components/dialog/EditLocationDialog.tsx @@ -1,9 +1,7 @@ import { ErrorMessage } from "@hookform/error-message"; import { zodResolver } from "@hookform/resolvers/zod"; import { isValidPhoneNumber } from "libphonenumber-js"; -import { Trans } from "next-i18next"; -import Link from "next/link"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Controller, useForm, useWatch, useFormContext } from "react-hook-form"; import { z } from "zod"; @@ -12,25 +10,30 @@ import { getEventLocationType, getHumanReadableLocationValue, getMessageForOrganizer, + isAttendeeInputRequired, LocationType, OrganizerDefaultConferencingAppType, } from "@calcom/app-store/locations"; -import CheckboxField from "@calcom/features/form/components/CheckboxField"; import type { LocationOption } from "@calcom/features/form/components/LocationSelect"; import LocationSelect from "@calcom/features/form/components/LocationSelect"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import { Button, Icon, Input, Dialog, DialogContent, DialogFooter, Form, PhoneInput } from "@calcom/ui"; -import { QueryCell } from "@lib/QueryCell"; - -type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number]; +import { QueryCell } from "../../lib/QueryCell"; interface ISetLocationDialog { - saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void; + saveLocation: ({ + newLocation, + credentialId, + }: { + newLocation: string; + credentialId: number | null; + }) => Promise; selection?: LocationOption; - booking?: BookingItem; + booking: { + location: string | null; + }; defaultValues?: LocationObject[]; setShowLocationModal: React.Dispatch>; isOpenDialog: boolean; @@ -100,8 +103,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { locationType: z.string(), phone: z.string().optional().nullable(), locationAddress: z.string().optional(), - credentialId: z.number().optional(), - teamName: z.string().optional(), + credentialId: z.number().nullable().optional(), locationLink: z .string() .optional() @@ -134,7 +136,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { } return; }), - displayLocationPublicly: z.boolean().optional(), locationPhoneNumber: z .string() .nullable() @@ -145,6 +146,8 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { .optional(), }); + const [isLocationUpdating, setIsLocationUpdating] = useState(false); + const locationFormMethods = useForm({ mode: "onSubmit", resolver: zodResolver(locationFormSchema), @@ -172,7 +175,10 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { } ); - const LocationOptions = (() => { + /** + * Depending on the location type that is selected, we show different input types or no input at all. + */ + const SelectedLocationInput = (() => { if (eventLocationType && eventLocationType.organizerInputType && LocationInput) { if (!eventLocationType.variable) { console.error("eventLocationType.variable can't be undefined"); @@ -203,25 +209,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { as="p" /> - {!booking && ( -
- ( - - locationFormMethods.setValue("displayLocationPublicly", e.target.checked) - } - informationIconText={t("display_location_info_badge")} - /> - )} - /> -
- )} ); } else { @@ -241,91 +228,51 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { - {!booking && ( -

- - Can't find the right conferencing app? Visit our - - App Store - - . - -

- )}
- {booking && ( - <> -

{t("current_location")}:

-

- {getHumanReadableLocationValue(booking.location, t)} -

- - )} +

{t("current_location")}:

+

+ {getHumanReadableLocationValue(booking.location, t)} +

{ - const { locationType: newLocation, displayLocationPublicly } = values; - - let details = {}; - if (newLocation === LocationType.InPerson) { - details = { - address: values.locationAddress, - }; - } - const eventLocationType = getEventLocationType(newLocation); - - // TODO: There can be a property that tells if it is to be saved in `link` - if ( - newLocation === LocationType.Link || - (!eventLocationType?.default && eventLocationType?.linkType === "static") - ) { - details = { link: values.locationLink }; - } - - if (newLocation === LocationType.UserPhone) { - details = { hostPhoneNumber: values.locationPhoneNumber }; - } - + const { locationType: newLocationType } = values; + let newLocation; + // For the locations that require organizer to type-in some values, we need the value if (eventLocationType?.organizerInputType) { - details = { - ...details, - displayLocationPublicly, - }; - } - - if (values.credentialId) { - details = { - ...details, - credentialId: values.credentialId, - }; + newLocation = values[eventLocationType.variable]; + } else { + // locationType itself can be used here e.g. For zoom we use the type itself which is "integrations:zoom". For Organizer's Default Conferencing App, it is OrganizerDefaultConferencingAppType constant + newLocation = newLocationType; } - if (values.teamName) { - details = { - ...details, - teamName: values.teamName, - }; + setIsLocationUpdating(true); + try { + await saveLocation({ + newLocation, + credentialId: values.credentialId ?? null, + }); + setIsLocationUpdating(false); + setShowLocationModal(false); + setSelectedLocation?.(undefined); + locationFormMethods.unregister([ + "locationType", + "locationLink", + "locationAddress", + "locationPhoneNumber", + ]); + } catch (error) { + // Let the user retry + setIsLocationUpdating(false); } - - saveLocation(newLocation, details); - setShowLocationModal(false); - setSelectedLocation?.(undefined); - locationFormMethods.unregister([ - "locationType", - "locationLink", - "locationAddress", - "locationPhoneNumber", - ]); }}> { if (!data.length) return null; - const locationOptions = [...data].map((option) => { + let locationOptions = [...data].map((option) => { if (teamId) { // Let host's Default conferencing App option show for Team Event return option; @@ -335,13 +282,11 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { options: option.options.filter((o) => o.value !== OrganizerDefaultConferencingAppType), }; }); - if (booking) { - locationOptions.map((location) => - location.options.filter( - (l) => !["phone", "attendeeInPerson", "somewhereElse"].includes(l.value) - ) - ); - } + + locationOptions = locationOptions.map((locationOption) => + filterLocationOptionsForBooking(locationOption) + ); + return ( { onChange={(val) => { if (val) { locationFormMethods.setValue("locationType", val.value); - if (typeof val.credentialId === "number" && val.credentialId >= 0) { - locationFormMethods.setValue("credentialId", val.credentialId); - locationFormMethods.setValue("teamName", val.teamName ?? ""); - } - + locationFormMethods.setValue("credentialId", val.credentialId); locationFormMethods.unregister([ "locationLink", "locationAddress", @@ -382,7 +323,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { ); }} /> - {selectedLocation && LocationOptions} + {selectedLocation && SelectedLocationInput} - @@ -407,3 +348,10 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { ); }; + +function filterLocationOptionsForBooking(locationOption: T) { + return { + ...locationOption, + options: locationOption.options.filter((o) => !isAttendeeInputRequired(o.value)), + }; +} diff --git a/apps/web/components/dialog/__tests__/EditLocationDialog.test.tsx b/apps/web/components/dialog/__tests__/EditLocationDialog.test.tsx new file mode 100644 index 00000000000000..41578855364f60 --- /dev/null +++ b/apps/web/components/dialog/__tests__/EditLocationDialog.test.tsx @@ -0,0 +1,225 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import * as React from "react"; +import { vi } from "vitest"; + +import LocationSelect from "@calcom/features/form/components/LocationSelect"; + +import { QueryCell } from "../../../lib/QueryCell"; +import { EditLocationDialog } from "../EditLocationDialog"; + +// // Mock the trpc hook +vi.mock("@calcom/trpc/react", () => ({ + trpc: { + viewer: { + locationOptions: { + useQuery: vi.fn(), + }, + }, + }, +})); + +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key }), +})); + +vi.mock("../../../lib/QueryCell", () => ({ + QueryCell: vi.fn(), +})); + +vi.mock("@calcom/features/form/components/LocationSelect", () => { + return { + default: vi.fn(), + }; +}); + +const AttendeePhoneNumberLabel = "Attendee Phone Number"; +const OrganizerPhoneLabel = "Organizer Phone Number"; +const CampfireLabel = "Campfire"; +const ZoomVideoLabel = "Zoom Video"; +const OrganizerDefaultConferencingAppLabel = "Organizer's default app"; + +describe("EditLocationDialog", () => { + const mockProps = { + saveLocation: vi.fn(), + setShowLocationModal: vi.fn(), + isOpenDialog: true, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + QueryCell.mockImplementation(({ success }) => { + return success({ + data: [ + { + label: "Conferencing", + options: [ + { + value: "integrations:campfire_video", + label: CampfireLabel, + disabled: false, + icon: "/app-store/campfire/icon.svg", + slug: "campfire", + credentialId: 2, + teamName: null, + }, + { + value: "integrations:daily", + label: "Cal Video (Global)", + disabled: false, + icon: "/app-store/dailyvideo/icon.svg", + slug: "daily-video", + credentialId: 0, + teamName: "Global", + }, + { + value: "integrations:zoom", + label: ZoomVideoLabel, + disabled: false, + icon: "/app-store/zoomvideo/icon.svg", + slug: "zoom", + credentialId: 1, + teamName: null, + }, + { + label: "Organizer's default app", + value: "conferencing", + icon: "/link.svg", + }, + ], + }, + { + label: "in person", + options: [ + { + label: "In Person (Attendee Address)", + value: "attendeeInPerson", + icon: "/map-pin-dark.svg", + }, + { + label: "In Person (Organizer Address)", + value: "inPerson", + icon: "/map-pin-dark.svg", + }, + ], + }, + { + label: "Other", + options: [ + { + label: "Custom attendee location", + value: "somewhereElse", + icon: "/message-pin.svg", + }, + { + label: "Link meeting", + value: "link", + icon: "/link.svg", + }, + ], + }, + { + label: "phone", + options: [ + { + label: AttendeePhoneNumberLabel, + value: "phone", + icon: "/phone.svg", + }, + { + label: OrganizerPhoneLabel, + value: "userPhone", + icon: "/phone.svg", + }, + ], + }, + ], + }); + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + LocationSelect.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ({ options, defaultValue, onChange }: { options: any; defaultValue: any; onChange: any }) => { + return ( + + ); + } + ); + }); + + it("renders the dialog when open", () => { + // It shows whatever the location even if it isn't in the options list + render(); + expect(screen.getByText("edit_location")).toBeInTheDocument(); + expect(screen.getByText("current_location:")).toBeInTheDocument(); + expect(screen.getByText("Office")).toBeInTheDocument(); + }); + + it("closes the dialog when cancel is clicked", async () => { + render(); + fireEvent.click(screen.getByText("cancel")); + expect(mockProps.setShowLocationModal).toHaveBeenCalledWith(false); + }); + + describe("Team Booking Case", () => { + it("should not show Attendee Phone Number but show Organizer Phone Number and dynamic link Conferencing apps", async () => { + render(); + + expect(screen.queryByText(AttendeePhoneNumberLabel)).not.toBeInTheDocument(); + expect(screen.queryByText(OrganizerPhoneLabel)).toBeInTheDocument(); + expect(screen.queryByText(CampfireLabel)).toBeInTheDocument(); + expect(screen.queryByText(ZoomVideoLabel)).toBeInTheDocument(); + }); + + it("should update location to Organizer Default App", async () => { + render(); + + const select = screen.getByRole("combobox"); + fireEvent.change(select, { target: { value: "conferencing" } }); + // Submit the form + fireEvent.click(screen.getByText("update")); + + await waitFor(() => { + expect(mockProps.saveLocation).toHaveBeenCalledWith({ + newLocation: "conferencing", + credentialId: null, + }); + expect(mockProps.setShowLocationModal).toHaveBeenCalledWith(false); + }); + }); + }); + + describe("Non Team Booking Case", () => { + it("should not show Organizer's default app", async () => { + render(); + + expect(screen.queryByText(OrganizerDefaultConferencingAppLabel)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 57177a75218c54..0834ae22939b5b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1147,6 +1147,7 @@ "set_location": "Set Location", "update_location": "Update Location", "location_updated": "Location updated", + "location_update_failed": "Location update failed", "guests_added": "Guests added", "unable_to_add_guests": "Unable to add guests", "email_validation_error": "That doesn't look like an email address", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Set a default app link", "default_app_link_description": "Setting a default app link allows all newly created event types to use the app link you set.", "organizer_default_conferencing_app": "Organizer's default app", + "organizer_default_conferencing_app_not_found": "{{organizer}} has no default conferencing app", "under_maintenance": "Down for maintenance", "under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.", "event_type_seats": "{{numberOfSeats}} seats", @@ -2596,6 +2598,7 @@ "number_of_options":"{{count}} options", "reschedule_with_same_round_robin_host_title": "Reschedule with same Round-Robin host", "reschedule_with_same_round_robin_host_description": "Rescheduled events will be assigned to the same host as initially scheduled", - "disable_input_if_prefilled": "Disable input if the URL identifier is prefilled", + "disable_input_if_prefilled": "Disable input if the URL identifier is prefilled", + "you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 61568a76fe7f01..48f6f818e09eca 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -763,6 +763,33 @@ export function expectSuccessfulBookingRescheduledEmails({ ); } +export function expectSuccesfulLocationChangeEmails({ + emails, + organizer, + location, +}: { + emails: Fixtures["emails"]; + organizer: { email: string; name: string }; + location: { + href: string; + linkText: string; + }; +}) { + expect(emails).toHaveEmail( + { + titleTag: "location_changed_event_type_subject", + links: [ + { + href: location.href, + text: location.linkText, + }, + ], + to: `${organizer.email}`, + }, + `${organizer.email}` + ); +} + export function expectAwaitingPaymentEmails({ emails, booker, diff --git a/packages/app-store/locations.ts b/packages/app-store/locations.ts index fd40c1240a187b..52255bb2735793 100644 --- a/packages/app-store/locations.ts +++ b/packages/app-store/locations.ts @@ -14,6 +14,7 @@ export type DefaultEventLocationType = { label: string; messageForOrganizer: string; category: "in person" | "conferencing" | "other" | "phone"; + linkType: "static"; iconUrl: string; urlRegExp?: string; @@ -101,6 +102,7 @@ export const defaultLocations: DefaultEventLocationType[] = [ defaultValueVariable: "attendeeAddress", iconUrl: "/map-pin-dark.svg", category: "in person", + linkType: "static", }, { default: true, @@ -114,6 +116,7 @@ export const defaultLocations: DefaultEventLocationType[] = [ defaultValueVariable: "somewhereElse", iconUrl: "/message-pin.svg", category: "other", + linkType: "static", }, { default: true, @@ -126,6 +129,7 @@ export const defaultLocations: DefaultEventLocationType[] = [ defaultValueVariable: "address", iconUrl: "/map-pin-dark.svg", category: "in person", + linkType: "static", }, { default: true, @@ -137,6 +141,7 @@ export const defaultLocations: DefaultEventLocationType[] = [ defaultValueVariable: "hostDefault", category: "conferencing", messageForOrganizer: "", + linkType: "static", }, { default: true, @@ -148,6 +153,7 @@ export const defaultLocations: DefaultEventLocationType[] = [ defaultValueVariable: "link", iconUrl: "/link.svg", category: "other", + linkType: "static", }, { default: true, @@ -163,6 +169,7 @@ export const defaultLocations: DefaultEventLocationType[] = [ // inputType: "phone" iconUrl: "/phone.svg", category: "phone", + linkType: "static", }, { default: true, @@ -174,6 +181,7 @@ export const defaultLocations: DefaultEventLocationType[] = [ defaultValueVariable: "hostPhoneNumber", iconUrl: "/phone.svg", category: "phone", + linkType: "static", }, ]; @@ -247,21 +255,20 @@ for (const [appName, meta] of Object.entries(appStoreMetadata)) { } } -const locationsTypes = [...defaultLocations, ...locationsFromApps]; -export const getStaticLinkBasedLocation = (locationType: string) => - locationsFromApps.find((l) => l.linkType === "static" && l.type === locationType); +const locations = [...defaultLocations, ...locationsFromApps]; -export const getEventLocationTypeFromApp = (locationType: string) => +export const getLocationFromApp = (locationType: string) => locationsFromApps.find((l) => l.type === locationType); +// TODO: Rename this to getLocationByType() export const getEventLocationType = (locationType: string | undefined | null) => - locationsTypes.find((l) => l.type === locationType); + locations.find((l) => l.type === locationType); -export const getEventLocationTypeFromValue = (value: string | undefined | null) => { +const getStaticLinkLocationByValue = (value: string | undefined | null) => { if (!value) { return null; } - return locationsTypes.find((l) => { + return locations.find((l) => { if (l.default || l.linkType == "dynamic" || !l.urlRegExp) { return; } @@ -270,7 +277,7 @@ export const getEventLocationTypeFromValue = (value: string | undefined | null) }; export const guessEventLocationType = (locationTypeOrValue: string | undefined | null) => - getEventLocationType(locationTypeOrValue) || getEventLocationTypeFromValue(locationTypeOrValue); + getEventLocationType(locationTypeOrValue) || getStaticLinkLocationByValue(locationTypeOrValue); export const LocationType = { ...DefaultEventLocationTypeEnum, ...AppStoreLocationType }; @@ -303,7 +310,7 @@ export const privacyFilteredLocations = (locations: LocationObject[]): PrivacyFi * @returns string */ export const getMessageForOrganizer = (location: string, t: TFunction) => { - const videoLocation = getEventLocationTypeFromApp(location); + const videoLocation = getLocationFromApp(location); const defaultLocation = defaultLocations.find((l) => l.type === location); if (defaultLocation) { return t(defaultLocation.messageForOrganizer); @@ -464,8 +471,17 @@ export const getTranslatedLocation = ( export const getOrganizerInputLocationTypes = () => { const result: DefaultEventLocationType["type"] | EventLocationTypeFromApp["type"][] = []; - const locations = locationsTypes.filter((location) => !!location.organizerInputType); - locations?.forEach((l) => result.push(l.type)); + const organizerInputTypeLocations = locations.filter((location) => !!location.organizerInputType); + organizerInputTypeLocations?.forEach((l) => result.push(l.type)); return result; }; + +export const isAttendeeInputRequired = (locationType: string) => { + const location = locations.find((l) => l.type === locationType); + if (!location) { + // Consider throwing an error here. This shouldn't happen normally. + return false; + } + return location.attendeeInputType; +}; diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 700b1c52d2b943..40799a2c308247 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -7,7 +7,7 @@ import type { z } from "zod"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvideo/zod"; -import { getEventLocationTypeFromApp, MeetLocationType } from "@calcom/app-store/locations"; +import { getLocationFromApp, MeetLocationType } from "@calcom/app-store/locations"; import getApps from "@calcom/app-store/utils"; import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; @@ -57,7 +57,7 @@ const latestCredentialFirst = (a: T, b: T) => { }; export const getLocationRequestFromIntegration = (location: string) => { - const eventLocationType = getEventLocationTypeFromApp(location); + const eventLocationType = getLocationFromApp(location); if (eventLocationType) { const requestId = uuidv5(location, uuidv5.URL); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index dc99444a7064eb..a7ab6b65bd1d14 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1396,7 +1396,7 @@ async function handler( safeStringify({ error, results }) ); } else { - const metadata: AdditionalInformation = {}; + const additionalInformation: AdditionalInformation = {}; if (results.length) { // Handle Google Meet results @@ -1451,12 +1451,14 @@ async function handler( } } // TODO: Handle created event metadata more elegantly - metadata.hangoutLink = results[0].createdEvent?.hangoutLink; - metadata.conferenceData = results[0].createdEvent?.conferenceData; - metadata.entryPoints = results[0].createdEvent?.entryPoints; + additionalInformation.hangoutLink = results[0].createdEvent?.hangoutLink; + additionalInformation.conferenceData = results[0].createdEvent?.conferenceData; + additionalInformation.entryPoints = results[0].createdEvent?.entryPoints; evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); videoCallUrl = - metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl; + additionalInformation.hangoutLink || + organizerOrFirstDynamicGroupMemberDefaultLocationUrl || + videoCallUrl; if (evt.iCalUID !== booking.iCalUID) { // The eventManager could change the iCalUID. At this point we can update the DB record @@ -1497,7 +1499,7 @@ async function handler( await sendScheduledEmails( { ...evt, - additionalInformation: metadata, + additionalInformation, additionalNotes, customInputs, }, diff --git a/packages/lib/__tests__/buildCalEventFromBooking.test.ts b/packages/lib/__tests__/buildCalEventFromBooking.test.ts new file mode 100644 index 00000000000000..d88a07c6df99e5 --- /dev/null +++ b/packages/lib/__tests__/buildCalEventFromBooking.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import dayjs from "@calcom/dayjs"; + +import { buildCalEventFromBooking } from "../buildCalEventFromBooking"; +import { parseRecurringEvent } from "../isRecurringEvent"; +import { getTranslation } from "../server"; + +// Mock dependencies +vi.mock("../isRecurringEvent", () => ({ + parseRecurringEvent: vi.fn(), +})); + +vi.mock("../server", () => ({ + getTranslation: vi.fn(), +})); + +// Helper functions +const createOrganizer = (overrides = {}) => ({ + email: "organizer@example.com", + name: "Organizer", + timeZone: "UTC", + locale: "en", + ...overrides, +}); + +const createAttendee = (overrides = {}) => ({ + name: "Attendee 1", + email: "attendee1@example.com", + timeZone: "UTC", + locale: "en", + ...overrides, +}); + +const createBooking = (overrides = {}) => ({ + title: "Test Booking", + description: "Test Description", + startTime: new Date("2023-04-01T10:00:00Z"), + endTime: new Date("2023-04-01T11:00:00Z"), + userPrimaryEmail: "user@example.com", + uid: "test-uid", + attendees: [createAttendee()], + eventType: { + title: "Test Event Type", + seatsPerTimeSlot: 5, + seatsShowAttendees: true, + recurringEvent: { + frequency: "daily", + interval: 1, + endDate: new Date("2023-04-01T11:00:00Z"), + }, + }, + destinationCalendar: null, + user: null, + ...overrides, +}); + +describe("buildCalEventFromBooking", () => { + beforeEach(() => { + // vi.resetAllMocks(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + getTranslation.mockImplementation((locale: string, namespace: string) => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const translate = () => {}; + translate.locale = locale; + translate.namespace = namespace; + return translate; + }); + + parseRecurringEvent.mockImplementation((recurringEvent) => { + if (!recurringEvent) { + return { parsed: true }; + } + return { ...recurringEvent, parsed: true }; + }); + }); + + it("should build a calendar event from a booking", async () => { + const booking = createBooking({ + title: "Booking Title", + }); + const organizer = createOrganizer(); + const location = "Test Location"; + const conferenceCredentialId = 123; + + const result = await buildCalEventFromBooking({ + booking, + organizer, + location, + conferenceCredentialId, + }); + + expect(result).toEqual({ + title: booking.title, + type: booking.eventType.title, + description: booking.description, + startTime: dayjs(booking.startTime).format(), + endTime: dayjs(booking.endTime).format(), + organizer: { + email: booking.userPrimaryEmail, + name: organizer.name, + timeZone: organizer.timeZone, + language: { translate: expect.any(Function), locale: "en" }, + }, + attendees: [ + { + name: booking.attendees[0].name, + email: booking.attendees[0].email, + timeZone: booking.attendees[0].timeZone, + language: { translate: expect.any(Function), locale: "en" }, + }, + ], + uid: booking.uid, + recurringEvent: { + ...booking.eventType?.recurringEvent, + parsed: true, + }, + location, + conferenceCredentialId: conferenceCredentialId, + destinationCalendar: [], + seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, + seatsShowAttendees: true, + }); + + expect(parseRecurringEvent).toHaveBeenCalledWith(booking.eventType?.recurringEvent); + }); + + it("should handle missing optional fields", async () => { + const booking = createBooking({ + title: "", + description: null, + startTime: null, + endTime: null, + userPrimaryEmail: null, + attendees: [], + eventType: null, + }); + + const organizer = createOrganizer({ name: null, locale: null }); + const location = ""; + const conferenceCredentialId = null; + + const result = await buildCalEventFromBooking({ + booking, + organizer, + location, + conferenceCredentialId, + }); + + expect(result).toEqual({ + title: booking.title, + type: "", + description: "", + startTime: "", + endTime: "", + organizer: { + email: organizer.email, + name: "Nameless", + timeZone: organizer.timeZone, + language: { translate: expect.any(Function), locale: "en" }, + }, + attendees: [], + uid: "test-uid", + recurringEvent: { + parsed: true, + }, + location: "", + conferenceCredentialId: undefined, + destinationCalendar: [], + seatsPerTimeSlot: undefined, + seatsShowAttendees: undefined, + }); + + // @ts-expect-error - locale is set in mock + expect(result.organizer.language.translate.locale).toBe("en"); + // @ts-expect-error - namespace is set in mock + expect(result.organizer.language.translate.namespace).toBe("common"); + }); + + it("should use user destination calendar when booking destination calendar is null", async () => { + const booking = createBooking({ + destinationCalendar: null, + user: { + destinationCalendar: { + id: 1, + integration: "test-integration", + externalId: "external-id", + primaryEmail: "user@example.com", + userId: 1, + eventTypeId: 1, + credentialId: 1, + }, + }, + }); + + const organizer = createOrganizer(); + + const result = await buildCalEventFromBooking({ + booking, + organizer, + location: "", + conferenceCredentialId: null, + }); + + expect(result.destinationCalendar).toEqual([booking.user.destinationCalendar]); + }); +}); diff --git a/packages/lib/buildCalEventFromBooking.ts b/packages/lib/buildCalEventFromBooking.ts new file mode 100644 index 00000000000000..ede1d436f18561 --- /dev/null +++ b/packages/lib/buildCalEventFromBooking.ts @@ -0,0 +1,106 @@ +import type { Prisma } from "@prisma/client"; + +import dayjs from "@calcom/dayjs"; + +import { parseRecurringEvent } from "./isRecurringEvent"; +import { getTranslation } from "./server"; + +type DestinationCalendar = { + id: number; + integration: string; + externalId: string; + primaryEmail: string | null; + userId: number | null; + eventTypeId: number | null; + credentialId: number | null; +} | null; + +type Attendee = { + email: string; + name: string; + timeZone: string; + locale: string | null; +}; + +type Organizer = { + email: string; + name: string | null; + timeZone: string; + locale: string | null; +}; + +type EventType = { + title: string; + recurringEvent: Prisma.JsonValue | null; + seatsPerTimeSlot: number | null; + seatsShowAttendees: boolean | null; +}; + +type Booking = { + title: string; + description: string | null; + startTime: Date | null; + endTime: Date | null; + userPrimaryEmail: string | null; + uid: string; + destinationCalendar: DestinationCalendar; + user: { + destinationCalendar: DestinationCalendar; + } | null; + attendees: Attendee[]; + eventType: EventType | null; +}; + +export const buildCalEventFromBooking = async ({ + booking, + organizer, + location, + conferenceCredentialId, +}: { + booking: Booking; + organizer: Organizer; + location: string; + conferenceCredentialId: number | null; +}) => { + const attendeesList = await Promise.all( + booking.attendees.map(async (attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + }) + ); + + const tOrganizer = await getTranslation(organizer.locale ?? "en", "common"); + + return { + title: booking.title || "", + type: (booking.eventType?.title as string) || booking.title || "", + description: booking.description || "", + startTime: booking.startTime ? dayjs(booking.startTime).format() : "", + endTime: booking.endTime ? dayjs(booking.endTime).format() : "", + organizer: { + email: booking.userPrimaryEmail ?? organizer.email, + name: organizer.name ?? "Nameless", + timeZone: organizer.timeZone, + language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, + }, + attendees: attendeesList, + uid: booking.uid, + recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), + location, + conferenceCredentialId: conferenceCredentialId ?? undefined, + destinationCalendar: booking.destinationCalendar + ? [booking.destinationCalendar] + : booking.user?.destinationCalendar + ? [booking.user?.destinationCalendar] + : [], + seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, + seatsShowAttendees: booking.eventType?.seatsShowAttendees, + }; +}; diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index b5d85a31f092e4..87581b3856d07e 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -171,4 +171,29 @@ export class BookingRepository { }, }); } + + static async updateLocationById({ + where: { id }, + data: { location, metadata, referencesToCreate }, + }: { + where: { id: number }; + data: { + location: string; + metadata: Record; + referencesToCreate: Prisma.BookingReferenceCreateInput[]; + }; + }) { + await prisma.booking.update({ + where: { + id, + }, + data: { + location, + metadata, + references: { + create: referencesToCreate, + }, + }, + }); + } } diff --git a/packages/lib/server/repository/credential.ts b/packages/lib/server/repository/credential.ts index b5a81f36a3d4f9..8f2d50a2874006 100644 --- a/packages/lib/server/repository/credential.ts +++ b/packages/lib/server/repository/credential.ts @@ -1,9 +1,27 @@ import type { Prisma } from "@prisma/client"; import { prisma } from "@calcom/prisma"; +import { safeCredentialSelect } from "@calcom/prisma/selects/credential"; export class CredentialRepository { static async create(data: Prisma.CredentialCreateInput) { return await prisma.credential.create({ data }); } + + /** + * Doesn't retrieve key field as that has credentials + */ + static async findFirstByIdWithUser({ id }: { id: number }) { + return await prisma.credential.findFirst({ where: { id }, select: safeCredentialSelect }); + } + + /** + * Includes 'key' field which is sensitive data. + */ + static async findFirstByIdWithKeyAndUser({ id }: { id: number }) { + return await prisma.credential.findFirst({ + where: { id }, + select: { ...safeCredentialSelect, key: true }, + }); + } } diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 91c7a29f6c14b7..e67f3415995b93 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -9,6 +9,7 @@ import prisma from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; import type { User as UserType } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; +import { userMetadata } from "@calcom/prisma/zod-utils"; import type { UpId, UserProfile } from "@calcom/types/UserProfile"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "../../availability"; @@ -249,6 +250,17 @@ export class UserRepository { if (!user) { return null; } + return { + ...user, + metadata: userMetadata.parse(user.metadata), + }; + } + + static async findByIdOrThrow({ id }: { id: number }) { + const user = await UserRepository.findById({ id }); + if (!user) { + throw new Error(`User with id ${id} not found`); + } return user; } diff --git a/packages/trpc/server/routers/viewer/bookings/__tests__/editLocation.handler.test.ts b/packages/trpc/server/routers/viewer/bookings/__tests__/editLocation.handler.test.ts new file mode 100644 index 00000000000000..0b6c1e0425dfaa --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/__tests__/editLocation.handler.test.ts @@ -0,0 +1,264 @@ +import { + createBookingScenario, + getOrganizer, + TestData, + getScenarioData, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { getZoomAppCredential } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { expectSuccesfulLocationChangeEmails } from "@calcom/web/test/utils/bookingScenario/expects"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import { describe, expect, vi, beforeEach } from "vitest"; + +import { prisma } from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; + +import { + editLocationHandler, + getLocationForOrganizerDefaultConferencingAppInEvtFormat, + SystemError, + UserError, +} from "../editLocation.handler"; + +describe("getLocationForOrganizerDefaultConferencingAppInEvtFormat", () => { + const mockTranslate = vi.fn((key: string) => key); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("Dynamic link apps", () => { + test("should return the app type for Zoom", () => { + const organizer = { + name: "Test Organizer", + metadata: { + defaultConferencingApp: { + appSlug: "zoom", + }, + }, + }; + + const result = getLocationForOrganizerDefaultConferencingAppInEvtFormat({ + organizer, + loggedInUserTranslate: mockTranslate, + }); + + expect(result).toBe("integrations:zoom"); + }); + + test("should return the app type for Google Meet", () => { + const organizer = { + name: "Test Organizer", + metadata: { + defaultConferencingApp: { + appSlug: "google-meet", + }, + }, + }; + + const result = getLocationForOrganizerDefaultConferencingAppInEvtFormat({ + organizer, + loggedInUserTranslate: mockTranslate, + }); + + expect(result).toBe("integrations:google:meet"); + }); + }); + + describe("Static link apps", () => { + test("should return the app type for Campfire", () => { + const organizer = { + name: "Test Organizer", + metadata: { + defaultConferencingApp: { + appSlug: "campfire", + appLink: "https://campfire.com", + }, + }, + }; + const result = getLocationForOrganizerDefaultConferencingAppInEvtFormat({ + organizer, + loggedInUserTranslate: mockTranslate, + }); + expect(result).toBe("https://campfire.com"); + }); + }); + + describe("Error handling", () => { + test("should throw a UserError if defaultConferencingApp is not set", () => { + const organizer = { + name: "Test Organizer", + metadata: null, + }; + + expect(() => + getLocationForOrganizerDefaultConferencingAppInEvtFormat({ + organizer, + loggedInUserTranslate: mockTranslate, + }) + ).toThrow(UserError); + expect(mockTranslate).toHaveBeenCalledWith("organizer_default_conferencing_app_not_found", { + organizer: "Test Organizer", + }); + }); + + test("should throw a SystemError if the app is not found", () => { + const organizer = { + name: "Test Organizer", + metadata: { + defaultConferencingApp: { + appSlug: "invalid-app", + }, + }, + }; + + expect(() => + getLocationForOrganizerDefaultConferencingAppInEvtFormat({ + organizer, + loggedInUserTranslate: mockTranslate, + }) + ).toThrow(SystemError); + }); + + test("should throw a SystemError for static link apps if appLink is missing", () => { + const organizer = { + name: "Test Organizer", + metadata: { + defaultConferencingApp: { + appSlug: "no-link-app", + }, + }, + }; + + expect(() => + getLocationForOrganizerDefaultConferencingAppInEvtFormat({ + organizer, + loggedInUserTranslate: mockTranslate, + }) + ).toThrow(SystemError); + }); + }); +}); + +describe("editLocation.handler", () => { + setupAndTeardown(); + + describe("Changing organizer default conferencing app", () => { + test("should update the booking location when organizer's default conferencing app changes", async ({ + emails, + }) => { + const scenarioData = { + organizer: getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getZoomAppCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + metadata: { + defaultConferencingApp: { + appSlug: "campfire", + appLink: "https://campfire.com", + }, + }, + }), + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [{ id: 101 }], + }, + ], + bookings: [ + { + id: 1, + uid: "booking-1", + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + endTime: new Date(Date.now() + 25 * 60 * 60 * 1000).toISOString(), + userId: 101, + attendees: [ + { + id: 102, + name: "Attendee 1", + email: "attendee1@example.com", + timeZone: "Asia/Kolkata", + }, + ], + }, + ], + apps: [TestData.apps["zoom"], TestData.apps["google-meet"]], + }; + + await createBookingScenario(getScenarioData(scenarioData)); + + const booking = await prisma.booking.findFirst({ + where: { + uid: scenarioData.bookings[0].uid, + }, + include: { + // eslint-disable-next-line @calcom/eslint/no-prisma-include-true + user: true, + // eslint-disable-next-line @calcom/eslint/no-prisma-include-true + attendees: true, + // eslint-disable-next-line @calcom/eslint/no-prisma-include-true + references: true, + }, + }); + + const organizerUser = await prisma.user.findFirst({ + where: { + id: scenarioData.organizer.id, + }, + }); + + expect(booking).not.toBeNull(); + + // Simulate changing the organizer's default conferencing app to Google Meet + const updatedOrganizer = { + ...booking.user, + metadata: { + ...booking.user.metadata, + defaultConferencingApp: { + appSlug: "google-meet", + }, + }, + }; + + await editLocationHandler({ + ctx: { + booking, + user: organizerUser, + }, + input: { + newLocation: "conferencing", + }, + currentUserId: updatedOrganizer.id, + }); + + const updatedBooking = await prisma.booking.findFirstOrThrow({ + where: { + uid: scenarioData.bookings[0].uid, + }, + }); + + expect(updatedBooking.location).toBe("https://campfire.com"); + + expectSuccesfulLocationChangeEmails({ + emails, + organizer: organizerUser, + location: { + href: "https://campfire.com", + linkText: "Link", + }, + }); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index f8fcd4bd540ad8..cb6ae567387774 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -1,15 +1,25 @@ +import type { z } from "zod"; + +import { getEventLocationType, OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations"; +import { getAppFromSlug } from "@calcom/app-store/utils"; import EventManager from "@calcom/core/EventManager"; -import dayjs from "@calcom/dayjs"; import { sendLocationChangeEmails } from "@calcom/emails"; -import { parseRecurringEvent } from "@calcom/lib"; +import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; +import { buildCalEventFromBooking } from "@calcom/lib/buildCalEventFromBooking"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server"; import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; +import { CredentialRepository } from "@calcom/lib/server/repository/credential"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import type { Prisma, Booking, BookingReference } from "@calcom/prisma/client"; +import type { userMetadata } from "@calcom/prisma/zod-utils"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; +import type { Ensure } from "@calcom/types/utils"; import { TRPCError } from "@trpc/server"; @@ -17,6 +27,7 @@ import type { TrpcSessionUser } from "../../../trpc"; import type { TEditLocationInputSchema } from "./editLocation.schema"; import type { BookingsProcedureContext } from "./util"; +// #region EditLocation Types and Helpers type EditLocationOptions = { ctx: { user: NonNullable; @@ -24,124 +35,255 @@ type EditLocationOptions = { input: TEditLocationInputSchema; }; -export const editLocationHandler = async ({ ctx, input }: EditLocationOptions) => { - const { bookingId, newLocation: location, details } = input; - const { booking } = ctx; +type UserMetadata = z.infer; - try { - const organizer = await prisma.user.findFirstOrThrow({ - where: { - id: booking.userId || 0, +async function updateLocationInConnectedAppForBooking({ + evt, + eventManager, + booking, +}: { + evt: CalendarEvent; + eventManager: EventManager; + booking: Booking & { + references: BookingReference[]; + }; +}) { + const updatedResult = await eventManager.updateLocation(evt, booking); + const results = updatedResult.results; + if (results.length > 0 && results.every((res) => !res.success)) { + const error = { + errorCode: "BookingUpdateLocationFailed", + message: "Updating location failed", + }; + logger.error(`Updating location failed`, safeStringify(error), safeStringify(results)); + throw new SystemError("Updating location failed"); + } + logger.info(`Got results from updateLocationInConnectedApp`, safeStringify(updatedResult.results)); + return updatedResult; +} + +function extractAdditionalInformation(result: { + updatedEvent: AdditionalInformation; +}): AdditionalInformation { + const additionalInformation: AdditionalInformation = {}; + if (result) { + additionalInformation.hangoutLink = result.updatedEvent?.hangoutLink; + additionalInformation.conferenceData = result.updatedEvent?.conferenceData; + additionalInformation.entryPoints = result.updatedEvent?.entryPoints; + } + return additionalInformation; +} + +async function updateBookingLocationInDb({ + booking, + evt, + referencesToCreate, +}: { + booking: { + id: number; + metadata: Booking["metadata"]; + }; + evt: Ensure; + referencesToCreate: Prisma.BookingReferenceCreateInput[]; +}) { + const bookingMetadataUpdate = { + videoCallUrl: getVideoCallUrlFromCalEvent(evt), + }; + + await BookingRepository.updateLocationById({ + data: { + location: evt.location, + metadata: { + ...(typeof booking.metadata === "object" && booking.metadata), + ...bookingMetadataUpdate, }, - select: { - name: true, - email: true, - timeZone: true, - locale: true, + referencesToCreate, + }, + where: { + id: booking.id, + }, + }); + + await prisma.booking.update({ + where: { + id: booking.id, + }, + data: { + location: evt.location, + metadata: { + ...(typeof booking.metadata === "object" && booking.metadata), + ...bookingMetadataUpdate, }, - }); + references: { + create: referencesToCreate, + }, + }, + }); +} - let conferenceCredential: CredentialPayload | null = null; +async function getAllCredentials({ + user, + conferenceCredentialId, +}: { + user: { id: number }; + conferenceCredentialId: number | null; +}) { + const credentials = await getUsersCredentials(user); - if (details?.credentialId) { - conferenceCredential = await prisma.credential.findFirst({ - where: { - id: details.credentialId, - }, - select: credentialForCalendarServiceSelect, - }); - } + let conferenceCredential: CredentialPayload | null = null; - const tOrganizer = await getTranslation(organizer.locale ?? "en", "common"); - - const attendeesListPromises = booking.attendees.map(async (attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { - translate: await getTranslation(attendee.locale ?? "en", "common"), - locale: attendee.locale ?? "en", - }, - }; + if (conferenceCredentialId) { + conferenceCredential = await CredentialRepository.findFirstByIdWithKeyAndUser({ + id: conferenceCredentialId, }); + } + return [...(credentials ? credentials : []), ...(conferenceCredential ? [conferenceCredential] : [])]; +} - const attendeesList = await Promise.all(attendeesListPromises); +async function getLocationInEvtFormatOrThrow({ + location, + organizer, + loggedInUserTranslate, +}: { + location: string; + organizer: { + name: string | null; + metadata: UserMetadata; + }; + loggedInUserTranslate: Awaited>; +}) { + if (location !== OrganizerDefaultConferencingAppType) { + return location; + } - const evt: CalendarEvent = { - title: booking.title || "", - type: (booking.eventType?.title as string) || booking?.title || "", - description: booking.description || "", - startTime: booking.startTime ? dayjs(booking.startTime).format() : "", - endTime: booking.endTime ? dayjs(booking.endTime).format() : "", + try { + return getLocationForOrganizerDefaultConferencingAppInEvtFormat({ organizer: { - email: booking?.userPrimaryEmail ?? organizer.email, - name: organizer.name ?? "Nameless", - timeZone: organizer.timeZone, - language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, + name: organizer.name ?? "Organizer", + metadata: organizer.metadata, }, - attendees: attendeesList, - uid: booking.uid, - recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), - location, - conferenceCredentialId: details?.credentialId, - destinationCalendar: booking?.destinationCalendar - ? [booking?.destinationCalendar] - : booking?.user?.destinationCalendar - ? [booking?.user?.destinationCalendar] - : [], - seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, - seatsShowAttendees: booking.eventType?.seatsShowAttendees, - }; + loggedInUserTranslate, + }); + } catch (e) { + if (e instanceof UserError) { + throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); + } + logger.error(safeStringify(e)); + throw e; + } +} +// #endregion - const credentials = await getUsersCredentials(ctx.user); +/** + * An error that should be shown to the user + */ +export class UserError extends Error { + constructor(message: string) { + super(message); + this.name = "LocationError"; + } +} - const eventManager = new EventManager({ - ...ctx.user, - credentials: [ - ...(credentials ? credentials : []), - ...(conferenceCredential ? [conferenceCredential] : []), - ], - }); +/** + * An error that should not be shown to the user + */ +export class SystemError extends Error { + constructor(message: string) { + super(message); + this.name = "SystemError"; + } +} - const updatedResult = await eventManager.updateLocation(evt, booking); - const results = updatedResult.results; - if (results.length > 0 && results.every((res) => !res.success)) { - const error = { - errorCode: "BookingUpdateLocationFailed", - message: "Updating location failed", - }; - logger.error(`Booking ${ctx.user.username} failed`, error, results); - } else { - await prisma.booking.update({ - where: { - id: bookingId, - }, - data: { - location, - references: { - create: updatedResult.referencesToCreate, - }, - }, - }); - - const metadata: AdditionalInformation = {}; - if (results.length) { - metadata.hangoutLink = results[0].updatedEvent?.hangoutLink; - metadata.conferenceData = results[0].updatedEvent?.conferenceData; - metadata.entryPoints = results[0].updatedEvent?.entryPoints; - } - try { - await sendLocationChangeEmails( - { ...evt, additionalInformation: metadata }, - booking?.eventType?.metadata as EventTypeMetadata - ); - } catch (error) { - console.log("Error sending LocationChangeEmails"); - } - } - } catch { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); +export function getLocationForOrganizerDefaultConferencingAppInEvtFormat({ + organizer, + loggedInUserTranslate: translate, +}: { + organizer: { + name: string; + metadata: { + defaultConferencingApp?: NonNullable["defaultConferencingApp"]; + } | null; + }; + /** + * translate is used to translate if any error is thrown + */ + loggedInUserTranslate: Awaited>; +}) { + const organizerMetadata = organizer.metadata; + const defaultConferencingApp = organizerMetadata?.defaultConferencingApp; + if (!defaultConferencingApp) { + throw new UserError( + translate("organizer_default_conferencing_app_not_found", { organizer: organizer.name }) + ); + } + const defaultConferencingAppSlug = defaultConferencingApp.appSlug; + const app = getAppFromSlug(defaultConferencingAppSlug); + if (!app) { + throw new SystemError(`Default conferencing app ${defaultConferencingAppSlug} not found`); + } + const defaultConferencingAppLocationType = app.appData?.location?.type; + if (!defaultConferencingAppLocationType) { + throw new SystemError("Default conferencing app has no location type"); } + + const location = defaultConferencingAppLocationType; + const locationType = getEventLocationType(location); + if (!locationType) { + throw new SystemError(`Location type not found: ${location}`); + } + + if (locationType.linkType === "dynamic") { + // Dynamic location type need to return the location as it is e.g. integrations:zoom_video + return location; + } + + const appLink = defaultConferencingApp.appLink; + if (!appLink) { + throw new SystemError(`Default conferencing app ${defaultConferencingAppSlug} has no app link`); + } + return appLink; +} + +export async function editLocationHandler({ ctx, input }: EditLocationOptions) { + const { newLocation, credentialId: conferenceCredentialId } = input; + const { booking, user: loggedInUser } = ctx; + + const organizer = await UserRepository.findByIdOrThrow({ id: booking.userId || 0 }); + + const newLocationInEvtFormat = await getLocationInEvtFormatOrThrow({ + location: newLocation, + organizer, + loggedInUserTranslate: await getTranslation(loggedInUser.locale ?? "en", "common"), + }); + + const evt = await buildCalEventFromBooking({ + booking, + organizer, + location: newLocationInEvtFormat, + conferenceCredentialId, + }); + + const eventManager = new EventManager({ + ...ctx.user, + credentials: await getAllCredentials({ user: ctx.user, conferenceCredentialId }), + }); + + const updatedResult = await updateLocationInConnectedAppForBooking({ + booking, + eventManager, + evt, + }); + + await updateBookingLocationInDb({ booking, evt, referencesToCreate: updatedResult.referencesToCreate }); + + try { + await sendLocationChangeEmails( + { ...evt, additionalInformation: extractAdditionalInformation(updatedResult.results[0]) }, + booking?.eventType?.metadata as EventTypeMetadata + ); + } catch (error) { + console.log("Error sending LocationChangeEmails", safeStringify(error)); + } + return { message: "Location updated" }; -}; +} diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts index fb6e67b90fea8b..2fbf0e8a4e5d00 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts @@ -6,7 +6,7 @@ import { commonBookingSchema } from "./types"; export const ZEditLocationInputSchema = commonBookingSchema.extend({ newLocation: z.string().transform((val) => val || DailyLocationType), - details: z.object({ credentialId: z.number().optional() }).optional(), + credentialId: z.number().nullable(), }); export type TEditLocationInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/util.ts b/packages/trpc/server/routers/viewer/bookings/util.ts index ae8668987c46d4..fdf5ad7b887ca1 100644 --- a/packages/trpc/server/routers/viewer/bookings/util.ts +++ b/packages/trpc/server/routers/viewer/bookings/util.ts @@ -1,11 +1,12 @@ -import type { - Attendee, - Booking, - BookingReference, - Credential, - DestinationCalendar, - EventType, - User, +import { + MembershipRole, + type Attendee, + type Booking, + type BookingReference, + type Credential, + type DestinationCalendar, + type EventType, + type User, } from "@prisma/client"; import { prisma } from "@calcom/prisma"; @@ -21,8 +22,44 @@ export const bookingsProcedure = authedProcedure .use(async ({ ctx, input, next }) => { // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input const { bookingId } = input; + const loggedInUser = ctx.user; + const bookingInclude = { + attendees: true, + eventType: true, + destinationCalendar: true, + references: true, + user: { + include: { + destinationCalendar: true, + credentials: true, + }, + }, + }; - const booking = await prisma.booking.findFirst({ + const bookingByBeingAdmin = await prisma.booking.findFirst({ + where: { + id: bookingId, + eventType: { + team: { + members: { + some: { + userId: loggedInUser.id, + role: { + in: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }, + }, + }, + }, + include: bookingInclude, + }); + + if (!!bookingByBeingAdmin) { + return next({ ctx: { booking: bookingByBeingAdmin } }); + } + + const bookingByBeingOrganizerOrCollectiveEventMember = await prisma.booking.findFirst({ where: { id: bookingId, AND: [ @@ -45,23 +82,12 @@ export const bookingsProcedure = authedProcedure }, ], }, - include: { - attendees: true, - eventType: true, - destinationCalendar: true, - references: true, - user: { - include: { - destinationCalendar: true, - credentials: true, - }, - }, - }, + include: bookingInclude, }); - if (!booking) throw new TRPCError({ code: "UNAUTHORIZED" }); + if (!bookingByBeingOrganizerOrCollectiveEventMember) throw new TRPCError({ code: "UNAUTHORIZED" }); - return next({ ctx: { booking } }); + return next({ ctx: { booking: bookingByBeingOrganizerOrCollectiveEventMember } }); }); export type BookingsProcedureContext = { diff --git a/packages/ui/components/test-setup.ts b/packages/ui/components/test-setup.ts index 36cbce484274b8..0650038897f602 100644 --- a/packages/ui/components/test-setup.ts +++ b/packages/ui/components/test-setup.ts @@ -1,7 +1,11 @@ import matchers from "@testing-library/jest-dom/matchers"; import { cleanup } from "@testing-library/react"; +import React from "react"; import { afterEach, expect, vi } from "vitest"; +// For next.js webapp compponent that use "preserve" for jsx in tsconfig.json +global.React = React; + vi.mock("next-auth/react", () => ({ useSession() { return {}; diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 3e2c5791552608..befcacc264a4b2 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -168,6 +168,15 @@ const workspaces = packagedEmbedTestsOnly setupFiles: ["packages/ui/components/test-setup.ts"], }, }, + { + test: { + globals: true, + name: "@calcom/web/components", + include: ["apps/web/components/**/*.{test,spec}.[jt]sx"], + environment: "jsdom", + setupFiles: ["packages/ui/components/test-setup.ts"], + }, + }, { test: { globals: true, From 5bc0e1265ddb5c3e485234f9f0f90a43f51407a1 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:35:56 +0530 Subject: [PATCH 04/40] fix: use meeting id to get transcripts (#16320) * fix: use meeting id to get transcripts * chore --------- Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> --- apps/web/pages/api/recorded-daily-video.ts | 10 ++++---- .../dailyvideo/lib/VideoApiAdapter.ts | 18 ++++++++++++++ packages/core/videoClient.ts | 24 +++++++++++++++++++ packages/types/VideoApiAdapter.d.ts | 2 ++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/web/pages/api/recorded-daily-video.ts b/apps/web/pages/api/recorded-daily-video.ts index ceab1c5ffcbe55..54e8d5adf65d36 100644 --- a/apps/web/pages/api/recorded-daily-video.ts +++ b/apps/web/pages/api/recorded-daily-video.ts @@ -6,7 +6,7 @@ import { getDownloadLinkOfCalVideoByRecordingId, submitBatchProcessorTranscriptionJob, } from "@calcom/core/videoClient"; -import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient"; +import { getAllTranscriptsAccessLinkFromMeetingId } from "@calcom/core/videoClient"; import { sendDailyVideoRecordingEmails } from "@calcom/emails"; import { sendDailyVideoTranscriptEmails } from "@calcom/emails"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; @@ -153,15 +153,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }); } - const { room } = meetingEndedResponse.data.payload; + const { room, meeting_id } = meetingEndedResponse.data.payload; const bookingReference = await getBookingReference(room); const booking = await getBooking(bookingReference.bookingId as number); - const transcripts = await getAllTranscriptsAccessLinkFromRoomName(room); + const transcripts = await getAllTranscriptsAccessLinkFromMeetingId(meeting_id); if (!transcripts || !transcripts.length) - return res.status(200).json({ message: `No Transcripts found for room name ${room}` }); + return res + .status(200) + .json({ message: `No Transcripts found for room name ${room} and meeting id ${meeting_id}` }); const evt = await getCalendarEvent(booking); await sendDailyVideoTranscriptEmails(evt, transcripts); diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index f9b3cef1df6c9a..05ea6422479c93 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -314,6 +314,24 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { throw new Error("Something went wrong! Unable to get transcription access link"); } }, + getAllTranscriptsAccessLinkFromMeetingId: async (meetingId: string): Promise> => { + try { + const allTranscripts = await fetcher(`/transcript?mtgSessionId=${meetingId}`).then( + getTranscripts.parse + ); + + if (!allTranscripts.data.length) return []; + + const allTranscriptsIds = allTranscripts.data.map((transcript) => transcript.transcriptId); + const allTranscriptsAccessLink = await processTranscriptsInBatches(allTranscriptsIds); + const accessLinks = await Promise.all(allTranscriptsAccessLink); + + return Promise.resolve(accessLinks); + } catch (err) { + console.log("err", err); + throw new Error("Something went wrong! Unable to get transcription access link"); + } + }, submitBatchProcessorJob: async (body: batchProcessorBody): Promise => { try { const batchProcessorJob = await postToDailyAPI("/batch-processor", body).then( diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 8cfba66aee717f..acb7aeecfce3ea 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -301,6 +301,29 @@ const getAllTranscriptsAccessLinkFromRoomName = async (roomName: string) => { return videoAdapter?.getAllTranscriptsAccessLinkFromRoomName?.(roomName); }; +const getAllTranscriptsAccessLinkFromMeetingId = async (meetingId: string) => { + let dailyAppKeys: Awaited>; + try { + dailyAppKeys = await getDailyAppKeys(); + } catch (e) { + console.error("Error: Cal video provider is not installed."); + return; + } + const [videoAdapter] = await getVideoAdapters([ + { + id: 0, + appId: "daily-video", + type: "daily_video", + userId: null, + user: { email: "" }, + teamId: null, + key: dailyAppKeys, + invalid: false, + }, + ]); + return videoAdapter?.getAllTranscriptsAccessLinkFromMeetingId?.(meetingId); +}; + const submitBatchProcessorTranscriptionJob = async (recordingId: string) => { let dailyAppKeys: Awaited>; try { @@ -392,6 +415,7 @@ export { getRecordingsOfCalVideoByRoomName, getDownloadLinkOfCalVideoByRecordingId, getAllTranscriptsAccessLinkFromRoomName, + getAllTranscriptsAccessLinkFromMeetingId, submitBatchProcessorTranscriptionJob, getTranscriptsAccessLinkFromRecordingId, checkIfRoomNameMatchesInRecording, diff --git a/packages/types/VideoApiAdapter.d.ts b/packages/types/VideoApiAdapter.d.ts index 136a4fdf9d3be7..96331d2ccfdf8d 100644 --- a/packages/types/VideoApiAdapter.d.ts +++ b/packages/types/VideoApiAdapter.d.ts @@ -34,6 +34,8 @@ export type VideoApiAdapter = getAllTranscriptsAccessLinkFromRoomName?(roomName: string): Promise>; + getAllTranscriptsAccessLinkFromMeetingId?(meetingId: string): Promise>; + submitBatchProcessorJob?(body: batchProcessorBody): Promise; getTranscriptsAccessLinkFromRecordingId?( From 860d173f0ad45d342b5dffced540ffac4db50bde Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 11 Sep 2024 19:32:14 +0530 Subject: [PATCH 05/40] feat: instant meeting browser notifcations (#16480) * feat: instant meeting browser notifcations * chore: support multiple notifications * chore: save progress * fix: add test notification and move file * fix: type err * chore: feedback improvements * chore: undo DENY --- .../bookings/views/bookings-listing-view.tsx | 25 +----- apps/web/public/service-worker.js | 68 ++++++++++++++-- .../instant-meeting/handleInstantMeeting.ts | 77 +++++++++++++++++++ packages/features/instant-meeting/schema.ts | 10 +++ .../notifications/sendNotification.ts | 11 +++ packages/features/shell/Shell.tsx | 22 +++++- packages/lib/hooks/useNotifications.tsx | 1 + .../addNotificationsSubscription.handler.ts | 39 +++++++++- ...removeNotificationsSubscription.handler.ts | 4 +- 9 files changed, 220 insertions(+), 37 deletions(-) create mode 100644 packages/features/instant-meeting/schema.ts diff --git a/apps/web/modules/bookings/views/bookings-listing-view.tsx b/apps/web/modules/bookings/views/bookings-listing-view.tsx index 5ecef7c6de77ca..952592f69b4052 100644 --- a/apps/web/modules/bookings/views/bookings-listing-view.tsx +++ b/apps/web/modules/bookings/views/bookings-listing-view.tsx @@ -12,7 +12,6 @@ import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQ import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery"; import Shell from "@calcom/features/shell/Shell"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { useNotifications, ButtonState } from "@calcom/lib/hooks/useNotifications"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; @@ -145,27 +144,6 @@ export default function Bookings() { )[0] || []; const [animationParentRef] = useAutoAnimate(); - const { buttonToShow, isLoading, enableNotifications, disableNotifications } = useNotifications(); - - const actions = ( -
- {buttonToShow && ( - - )} -
- ); return ( + description="Create events to share for people to book on your calendar.">
diff --git a/apps/web/public/service-worker.js b/apps/web/public/service-worker.js index 266ac7ea3b46c0..2403d15ff7d6b3 100644 --- a/apps/web/public/service-worker.js +++ b/apps/web/public/service-worker.js @@ -1,16 +1,68 @@ -self.addEventListener("push", (event) => { +self.addEventListener("push", async (event) => { let notificationData = event.data.json(); - const title = notificationData.title || "You have new notification from Cal.com"; - const image ="/cal-com-icon.svg"; - const options = { - ...notificationData.options, + const allClients = await clients.matchAll({ + type: 'window', + includeUncontrolled: true + }); + + if(!allClients.length) { + console.log("No open tabs, skipping the push notification."); + return; + } + + + const title = notificationData.title || "You have a new notification from Cal.com"; + const image = "https://cal.com/api/logo?type=icon"; + const newNotificationOptions = { + requireInteraction: true, + ...notificationData, icon: image, + badge: image, + data: { + url: notificationData.data?.url || "https://app.cal.com", + }, + silent: false, + vibrate: [300, 100, 400], + tag: `notification-${Date.now()}-${Math.random()}`, }; - self.registration.showNotification(title, options); + + + const existingNotifications = await self.registration.getNotifications(); + + // Display each existing notification again to make sure old ones can still be clicked + existingNotifications.forEach((notification) => { + const options = { + body: notification.body, + icon: notification.icon, + badge: notification.badge, + data: notification.data, + silent: notification.silent, + vibrate: notification.vibrate, + requireInteraction: notification.requireInteraction, + tag: notification.tag, + }; + self.registration.showNotification(notification.title, options); + }); + + + // Show the new notification + self.registration.showNotification(title, newNotificationOptions); }); self.addEventListener("notificationclick", (event) => { - event.notification.close(); - event.waitUntil(self.clients.openWindow(event.notification.data.targetURL || "https://app.cal.com")); + if (!event.action) { + // Normal Notification Click + event.notification.close(); + const url = event.notification.data.url; + event.waitUntil(self.clients.openWindow(url)); + } + + switch (event.action) { + case 'connect-action': + event.notification.close(); + const url = event.notification.data.url; + event.waitUntil(self.clients.openWindow(url)); + break; + } }); diff --git a/packages/features/instant-meeting/handleInstantMeeting.ts b/packages/features/instant-meeting/handleInstantMeeting.ts index 9c3d73bfcba61e..bc3c4076c4430f 100644 --- a/packages/features/instant-meeting/handleInstantMeeting.ts +++ b/packages/features/instant-meeting/handleInstantMeeting.ts @@ -12,6 +12,7 @@ import { getCustomInputsResponses } from "@calcom/features/bookings/lib/handleNe import { getBookingData } from "@calcom/features/bookings/lib/handleNewBooking/getBookingData"; import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB"; import { getFullName } from "@calcom/features/form-builder/utils"; +import { sendNotification } from "@calcom/features/notifications/sendNotification"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; import { isPrismaObjOrUndefined } from "@calcom/lib"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -21,6 +22,8 @@ import { getTranslation } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { subscriptionSchema } from "./schema"; + const handleInstantMeetingWebhookTrigger = async (args: { eventTypeId: number; webhookData: Record; @@ -86,6 +89,74 @@ const handleInstantMeetingWebhookTrigger = async (args: { } }; +const triggerBrowserNotifications = async (args: { + title: string; + connectAndJoinUrl: string; + teamId?: number | null; +}) => { + const { title, connectAndJoinUrl, teamId } = args; + + if (!teamId) { + logger.warn("No teamId provided, skipping browser notification trigger"); + return; + } + + const subscribers = await prisma.membership.findMany({ + where: { + teamId, + accepted: true, + }, + select: { + user: { + select: { + id: true, + NotificationsSubscriptions: { + select: { + id: true, + subscription: true, + }, + }, + }, + }, + }, + }); + + const promises = subscribers.map((sub) => { + const subscription = sub.user?.NotificationsSubscriptions?.[0]?.subscription; + if (!subscription) return Promise.resolve(); + + const parsedSubscription = subscriptionSchema.safeParse(JSON.parse(subscription)); + + if (!parsedSubscription.success) { + logger.error("Invalid subscription", parsedSubscription.error, JSON.stringify(sub.user)); + return Promise.resolve(); + } + + return sendNotification({ + subscription: { + endpoint: parsedSubscription.data.endpoint, + keys: { + auth: parsedSubscription.data.keys.auth, + p256dh: parsedSubscription.data.keys.p256dh, + }, + }, + title: title, + body: "User is waiting for you to join. Click to Connect", + url: connectAndJoinUrl, + actions: [ + { + action: "connect-action", + title: "Connect and join", + type: "button", + image: "https://cal.com/api/logo?type=icon", + }, + ], + }); + }); + + await Promise.allSettled(promises); +}; + export type HandleInstantMeetingResponse = { message: string; meetingTokenId: number; @@ -252,6 +323,12 @@ async function handler(req: NextApiRequest) { teamId: eventType.team?.id, }); + await triggerBrowserNotifications({ + title: newBooking.title, + connectAndJoinUrl: webhookData.connectAndJoinUrl, + teamId: eventType.team?.id, + }); + return { message: "Success", meetingTokenId: instantMeetingToken.id, diff --git a/packages/features/instant-meeting/schema.ts b/packages/features/instant-meeting/schema.ts new file mode 100644 index 00000000000000..0cad150e256fc7 --- /dev/null +++ b/packages/features/instant-meeting/schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const subscriptionSchema = z.object({ + endpoint: z.string(), + expirationTime: z.any().optional(), + keys: z.object({ + auth: z.string(), + p256dh: z.string(), + }), +}); diff --git a/packages/features/notifications/sendNotification.ts b/packages/features/notifications/sendNotification.ts index 6e436cba0592cf..3a603f09d5af0d 100644 --- a/packages/features/notifications/sendNotification.ts +++ b/packages/features/notifications/sendNotification.ts @@ -21,17 +21,28 @@ export const sendNotification = async ({ title, body, icon, + url, + actions, + requireInteraction, }: { subscription: Subscription; title: string; body: string; icon?: string; + url?: string; + actions?: { action: string; title: string; type: string; image: string | null }[]; + requireInteraction?: boolean; }) => { try { const payload = JSON.stringify({ title, body, icon, + data: { + url, + }, + actions, + requireInteraction, }); await webpush.sendNotification(subscription, payload); } catch (error) { diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 7a8a7fead3b6af..a365da4fe5cf42 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -53,6 +53,7 @@ import { useFormbricks } from "@calcom/lib/formbricks-client"; import getBrandColours from "@calcom/lib/getBrandColours"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useNotifications, ButtonState } from "@calcom/lib/hooks/useNotifications"; import useTheme from "@calcom/lib/hooks/useTheme"; import { isKeyInObject } from "@calcom/lib/isKeyInObject"; import { localStorage } from "@calcom/lib/webstorage"; @@ -1084,7 +1085,9 @@ function SideBar({ bannersHeight, user }: SideBarProps) { export function ShellMain(props: LayoutProps) { const router = useRouter(); - const { isLocaleReady } = useLocale(); + const { isLocaleReady, t } = useLocale(); + + const { buttonToShow, isLoading, enableNotifications, disableNotifications } = useNotifications(); return ( <> @@ -1144,6 +1147,23 @@ export function ShellMain(props: LayoutProps) {
)} {props.actions && props.actions} + {props.heading === "Bookings" && buttonToShow && ( + + )} )}
diff --git a/packages/lib/hooks/useNotifications.tsx b/packages/lib/hooks/useNotifications.tsx index e8d5b3e44abe72..bc1f18ce6dbd6b 100644 --- a/packages/lib/hooks/useNotifications.tsx +++ b/packages/lib/hooks/useNotifications.tsx @@ -92,6 +92,7 @@ export const useNotifications = () => { } const registration = await navigator.serviceWorker?.getRegistration(); + if (!registration) { // This will not happen ideally as the button will not be shown if the service worker is not registered return; diff --git a/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts b/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts index 19529ab2ddbefd..1a197a1eddb426 100644 --- a/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts @@ -1,6 +1,11 @@ +import { subscriptionSchema } from "@calcom/features/instant-meeting/schema"; +import { sendNotification } from "@calcom/features/notifications/sendNotification"; +import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; +import { TRPCError } from "@trpc/server"; + import type { TAddNotificationsSubscriptionInputSchema } from "./addNotificationsSubscription.schema"; type AddSecondaryEmailOptions = { @@ -10,20 +15,52 @@ type AddSecondaryEmailOptions = { input: TAddNotificationsSubscriptionInputSchema; }; +const log = logger.getSubLogger({ prefix: ["[addNotificationsSubscriptionHandler]"] }); + export const addNotificationsSubscriptionHandler = async ({ ctx, input }: AddSecondaryEmailOptions) => { const { user } = ctx; const { subscription } = input; + const parsedSubscription = subscriptionSchema.safeParse(JSON.parse(subscription)); + + if (!parsedSubscription.success) { + log.error("Invalid subscription", parsedSubscription.error, JSON.stringify(subscription)); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid subscription", + }); + } + const existingSubscription = await prisma.notificationsSubscriptions.findFirst({ - where: { userId: user.id, subscription }, + where: { userId: user.id }, }); if (!existingSubscription) { await prisma.notificationsSubscriptions.create({ data: { userId: user.id, subscription }, }); + } else { + await prisma.notificationsSubscriptions.update({ + where: { id: existingSubscription.id }, + data: { userId: user.id, subscription }, + }); } + // send test notification + sendNotification({ + subscription: { + endpoint: parsedSubscription.data.endpoint, + keys: { + auth: parsedSubscription.data.keys.auth, + p256dh: parsedSubscription.data.keys.p256dh, + }, + }, + title: "Test Notification", + body: "Push Notifications activated successfully", + url: "https://app.cal.com/", + requireInteraction: false, + }); + return { message: "Subscription added successfully", }; diff --git a/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts b/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts index 55e576939c5509..dd53d1284b3ba9 100644 --- a/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts @@ -10,15 +10,13 @@ type AddSecondaryEmailOptions = { input: TRemoveNotificationsSubscriptionInputSchema; }; -export const removeNotificationsSubscriptionHandler = async ({ ctx, input }: AddSecondaryEmailOptions) => { +export const removeNotificationsSubscriptionHandler = async ({ ctx }: AddSecondaryEmailOptions) => { const { user } = ctx; - const { subscription } = input; // We just use findFirst because there will only be single unique subscription for a user const subscriptionToDelete = await prisma.notificationsSubscriptions.findFirst({ where: { userId: user.id, - subscription, }, }); From 82053e0c7c4bea8921cc186bf57599cd4849d568 Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Wed, 11 Sep 2024 11:16:46 -0300 Subject: [PATCH 06/40] perf: Include no shows in main status query (#16594) * perf: Include no shows in main status query * Removed the Promise.all call --- packages/features/insights/server/events.ts | 18 +++++++--------- .../features/insights/server/trpc-router.ts | 21 +++++++------------ 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts index 42ba2a077bf31c..bdd0e2044c8dd1 100644 --- a/packages/features/insights/server/events.ts +++ b/packages/features/insights/server/events.ts @@ -11,7 +11,7 @@ class EventsInsights { static countGroupedByStatus = async (where: Prisma.BookingTimeStatusWhereInput) => { const data = await prisma.bookingTimeStatus.groupBy({ where, - by: ["timeStatus"], + by: ["timeStatus", "noShowHost"], _count: { _all: true, }, @@ -22,6 +22,10 @@ class EventsInsights { if (typeof item.timeStatus === "string" && item) { aggregate[item.timeStatus] += item?._count?._all ?? 0; aggregate["_all"] += item?._count?._all ?? 0; + + if (item.noShowHost) { + aggregate["noShowHost"] += item?._count?._all ?? 0; + } } return aggregate; }, @@ -29,6 +33,7 @@ class EventsInsights { completed: 0, rescheduled: 0, cancelled: 0, + noShowHost: 0, _all: 0, } ); @@ -48,15 +53,6 @@ class EventsInsights { }); }; - static getTotalNoShows = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => { - return await prisma.bookingTimeStatus.count({ - where: { - ...whereConditional, - noShowHost: true, - }, - }); - }; - static getTotalCSAT = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => { const result = await prisma.bookingTimeStatus.findMany({ where: { @@ -173,9 +169,11 @@ class EventsInsights { return 0; } const result = (differenceActualVsPrevious * 100) / previousMetric; + if (isNaN(result) || !isFinite(result)) { return 0; } + return result; }; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index e2953eae0d05e6..f821318ab0cf42 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -278,20 +278,16 @@ export const insightsRouter = router({ const [ countGroupedByStatus, totalRatingsAggregate, - totalNoShow, totalCSAT, lastPeriodCountGroupedByStatus, lastPeriodTotalRatingsAggregate, - lastPeriodTotalNoShow, lastPeriodTotalCSAT, ] = await Promise.all([ EventsInsights.countGroupedByStatus(baseWhereCondition), EventsInsights.getAverageRating(baseWhereCondition), - EventsInsights.getTotalNoShows(baseWhereCondition), EventsInsights.getTotalCSAT(baseWhereCondition), EventsInsights.countGroupedByStatus(lastPeriodBaseCondition), EventsInsights.getAverageRating(lastPeriodBaseCondition), - EventsInsights.getTotalNoShows(lastPeriodBaseCondition), EventsInsights.getTotalCSAT(lastPeriodBaseCondition), ]); @@ -299,6 +295,7 @@ export const insightsRouter = router({ const totalCompleted = countGroupedByStatus["completed"]; const totalRescheduled = countGroupedByStatus["rescheduled"]; const totalCancelled = countGroupedByStatus["cancelled"]; + const totalNoShow = countGroupedByStatus["noShowHost"]; const averageRating = totalRatingsAggregate._avg.rating ? parseFloat(totalRatingsAggregate._avg.rating.toFixed(1)) @@ -307,6 +304,7 @@ export const insightsRouter = router({ const lastPeriodBaseBookingsCount = lastPeriodCountGroupedByStatus["_all"]; const lastPeriodTotalRescheduled = lastPeriodCountGroupedByStatus["rescheduled"]; const lastPeriodTotalCancelled = lastPeriodCountGroupedByStatus["cancelled"]; + const lastPeriodTotalNoShow = lastPeriodCountGroupedByStatus["noShowHost"]; const lastPeriodAverageRating = lastPeriodTotalRatingsAggregate._avg.rating ? parseFloat(lastPeriodTotalRatingsAggregate._avg.rating.toFixed(1)) @@ -542,16 +540,13 @@ export const insightsRouter = router({ }, }; - const promisesResult = await Promise.all([ - EventsInsights.countGroupedByStatus(whereConditional), - EventsInsights.getTotalNoShows(whereConditional), - ]); + const countsByStatus = await EventsInsights.countGroupedByStatus(whereConditional); - EventData["Created"] = promisesResult[0]["_all"]; - EventData["Completed"] = promisesResult[0]["completed"]; - EventData["Rescheduled"] = promisesResult[0]["rescheduled"]; - EventData["Cancelled"] = promisesResult[0]["cancelled"]; - EventData["No-Show (Host)"] = promisesResult[1]; + EventData["Created"] = countsByStatus["_all"]; + EventData["Completed"] = countsByStatus["completed"]; + EventData["Rescheduled"] = countsByStatus["rescheduled"]; + EventData["Cancelled"] = countsByStatus["cancelled"]; + EventData["No-Show (Host)"] = countsByStatus["noShowHost"]; result.push(EventData); } From b292cafe7ba3d74fb44c78bbbc004e5170d2d3ba Mon Sep 17 00:00:00 2001 From: Calcom Bot <109866826+calcom-bot@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:56:28 +0200 Subject: [PATCH 07/40] feat: update translations (#16592) Co-authored-by: Replexica --- apps/web/public/static/locales/ar/common.json | 3 +++ apps/web/public/static/locales/az/common.json | 3 +++ apps/web/public/static/locales/cs/common.json | 3 +++ apps/web/public/static/locales/da/common.json | 3 +++ apps/web/public/static/locales/de/common.json | 3 +++ apps/web/public/static/locales/es-419/common.json | 3 +++ apps/web/public/static/locales/es/common.json | 3 +++ apps/web/public/static/locales/et/common.json | 3 +++ apps/web/public/static/locales/fr/common.json | 3 +++ apps/web/public/static/locales/he/common.json | 3 +++ apps/web/public/static/locales/hu/common.json | 3 +++ apps/web/public/static/locales/it/common.json | 3 +++ apps/web/public/static/locales/ja/common.json | 3 +++ apps/web/public/static/locales/km/common.json | 3 +++ apps/web/public/static/locales/ko/common.json | 3 +++ apps/web/public/static/locales/nl/common.json | 3 +++ apps/web/public/static/locales/no/common.json | 3 +++ apps/web/public/static/locales/pl/common.json | 3 +++ apps/web/public/static/locales/pt-BR/common.json | 3 +++ apps/web/public/static/locales/pt/common.json | 3 +++ apps/web/public/static/locales/ro/common.json | 3 +++ apps/web/public/static/locales/sr/common.json | 3 +++ apps/web/public/static/locales/sv/common.json | 3 +++ apps/web/public/static/locales/tr/common.json | 3 +++ apps/web/public/static/locales/uk/common.json | 3 +++ apps/web/public/static/locales/vi/common.json | 3 +++ apps/web/public/static/locales/zh-CN/common.json | 3 +++ apps/web/public/static/locales/zh-TW/common.json | 3 +++ i18n.lock | 3 +++ 29 files changed, 87 insertions(+) diff --git a/apps/web/public/static/locales/ar/common.json b/apps/web/public/static/locales/ar/common.json index 1556310e34317b..2e2b1f9b943100 100644 --- a/apps/web/public/static/locales/ar/common.json +++ b/apps/web/public/static/locales/ar/common.json @@ -1147,6 +1147,7 @@ "set_location": "تعيين الموقع", "update_location": "تحديث الموقع", "location_updated": "تم تحديث الموقع", + "location_update_failed": "فشل تحديث الموقع", "guests_added": "تمت إضافة الضيوف", "unable_to_add_guests": "غير قادر على إضافة الضيوف", "email_validation_error": "لا يبدو هذا كعنوان بريد إلكتروني", @@ -1887,6 +1888,7 @@ "default_app_link_title": "تعيين رابط تطبيق افتراضي", "default_app_link_description": "يسمح تعيين رابط التطبيق الافتراضي لجميع أنواع الأحداث التي تم إنشاؤها حديثًا باستخدام رابط التطبيق الذي عينته.", "organizer_default_conferencing_app": "تطبيق المنظمة الافتراضي", + "organizer_default_conferencing_app_not_found": "لا يوجد تطبيق مؤتمرات افتراضي لـ {{organizer}}", "under_maintenance": "معطل للصيانة", "under_maintenance_description": "يجري فريق {{appName}} صيانة مجدولة. إذا كان لديك أي أسئلة، يرجى الاتصال بالدعم.", "event_type_seats": "{{numberOfSeats}} من المقاعد", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "إعادة الجدولة مع نفس مضيف round-robin", "reschedule_with_same_round_robin_host_description": "سيتم تعيين الأحداث المعاد جدولتها لنفس المضيف كما تم جدولتها في البداية", "disable_input_if_prefilled": "تعطيل الإدخال إذا كان معرف URL مملوء مسبقًا", + "you_are_unauthorized_to_make_this_change_to_the_booking": "أنت غير مخول لإجراء هذا التغيير على الحجز", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/az/common.json b/apps/web/public/static/locales/az/common.json index d67233c41dd857..2f0deea233498d 100644 --- a/apps/web/public/static/locales/az/common.json +++ b/apps/web/public/static/locales/az/common.json @@ -1147,6 +1147,7 @@ "set_location": "Yeri Təyin Et", "update_location": "Yeri Yenilə", "location_updated": "Yer yeniləndi", + "location_update_failed": "Məkan yeniləməsi uğursuz oldu", "guests_added": "Qonaqlar əlavə edildi", "unable_to_add_guests": "Qonaqları əlavə etmək mümkün deyil", "email_validation_error": "Bu, e-poçt ünvanına bənzəmir", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Varsayılan tətbiq bağlantısını təyin et", "default_app_link_description": "Varsayılan tətbiq bağlantısını təyin etmək, yeni yaradılan bütün tədbir növlərinin təyin etdiyiniz tətbiq bağlantısını istifadə etməsinə imkan verir.", "organizer_default_conferencing_app": "Təşkilatçının varsayılan tətbiqi", + "organizer_default_conferencing_app_not_found": "{{organizer}} üçün standart konfrans tətbiqi tapılmadı", "under_maintenance": "Baxım üçün bağlıdır", "under_maintenance_description": "{{appName}} komandası planlı baxım işləri aparır. Hər hansı bir sualınız varsa, dəstək ilə əlaqə saxlayın.", "event_type_seats": "{{numberOfSeats}} oturacaq", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Eyni Round-Robin ev sahibi ilə yenidən planlaşdır", "reschedule_with_same_round_robin_host_description": "Yenidən planlaşdırılan tədbirlər əvvəlcə təyin olunan ev sahibinə təyin ediləcək", "disable_input_if_prefilled": "URL identifikatoru əvvəlcədən doldurulubsa girişi deaktiv et", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Bu dəyişikliyi etmək üçün səlahiyyətiniz yoxdur", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/cs/common.json b/apps/web/public/static/locales/cs/common.json index b92d3909eb2905..c68b138a0c84ed 100644 --- a/apps/web/public/static/locales/cs/common.json +++ b/apps/web/public/static/locales/cs/common.json @@ -1147,6 +1147,7 @@ "set_location": "Nastavit místo", "update_location": "Aktualizovat místo", "location_updated": "Místo aktualizováno", + "location_update_failed": "Aktualizace polohy se nezdařila", "guests_added": "Hosté přidáni", "unable_to_add_guests": "Nelze přidat hosty", "email_validation_error": "Tohle nevypadá jako e-mailová adresa", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Nastavte výchozí odkaz na aplikaci", "default_app_link_description": "Nastavení výchozího odkazu na aplikaci umožní, aby všechny nově vytvořené typy událostí používaly vámi nastavený odkaz na aplikaci.", "organizer_default_conferencing_app": "Výchozí aplikace organizátora", + "organizer_default_conferencing_app_not_found": "{{organizer}} nemá výchozí aplikaci pro konferenční hovory", "under_maintenance": "Odstávka z důvodu údržby", "under_maintenance_description": "Tým {{appName}} provádí plánovanou údržbu. V případě dotazů se obraťte na podporu.", "event_type_seats": "Počet míst: {{numberOfSeats}}", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Přeplánovat se stejným hostitelem Round-Robin", "reschedule_with_same_round_robin_host_description": "Přeplánované události budou přiřazeny stejnému hostiteli jako původně", "disable_input_if_prefilled": "Zakázat vstup, pokud je identifikátor URL předvyplněn", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Nemáte oprávnění provést tuto změnu v rezervaci", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/da/common.json b/apps/web/public/static/locales/da/common.json index 4e47066cbdfd28..4929668eb03eb5 100644 --- a/apps/web/public/static/locales/da/common.json +++ b/apps/web/public/static/locales/da/common.json @@ -1147,6 +1147,7 @@ "set_location": "Angiv Placering", "update_location": "Opdatér Placering", "location_updated": "Placering opdateret", + "location_update_failed": "Opdatering af placering mislykkedes", "guests_added": "Gæster tilføjet", "unable_to_add_guests": "Kan ikke tilføje gæster", "email_validation_error": "Det ligner ikke en e-mailadresse", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Angiv et standard app-link", "default_app_link_description": "Indstilling af et standard app-link gør det muligt for alle nyoprettede begivenhedstyper at bruge det app-link, du har angivet.", "organizer_default_conferencing_app": "Organisatorens standardapp", + "organizer_default_conferencing_app_not_found": "{{organizer}} har ingen standardkonferenceapp", "under_maintenance": "Nede for vedligeholdelse", "under_maintenance_description": "{{appName}} teamet udfører planlagt vedligeholdelse. Hvis du har spørgsmål, bedes du kontakte support.", "event_type_seats": "{{numberOfSeats}} pladser", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Omlæg med samme Round-Robin vært", "reschedule_with_same_round_robin_host_description": "Omlagte begivenheder vil blive tildelt den samme vært som oprindeligt planlagt", "disable_input_if_prefilled": "Deaktiver input, hvis URL-identifikatoren er udfyldt på forhånd", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Du har ikke tilladelse til at foretage denne ændring i bookingen", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Tilføj dine nye strenge ovenfor her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index 60573a78434c25..8d98a4f3075e39 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -1147,6 +1147,7 @@ "set_location": "Ort festlegen", "update_location": "Ort aktualisieren", "location_updated": "Ort aktualisiert", + "location_update_failed": "Aktualisierung des Standorts fehlgeschlagen", "guests_added": "Gäste hinzugefügt", "unable_to_add_guests": "Gäste konnten nicht hinzugefügt werden", "email_validation_error": "Das sieht nicht wie eine E-Mail-Adresse aus", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Standard-App-Link festlegen", "default_app_link_description": "Mit dem Festlegen eines Standard-App-Links können alle neu erstellten Ereignistypen den App-Link verwenden, den Sie gesetzt haben.", "organizer_default_conferencing_app": "Standard-App des Organisators", + "organizer_default_conferencing_app_not_found": "{{organizer}} hat keine Standard-Konferenz-App", "under_maintenance": "Wartungsarbeiten", "under_maintenance_description": "Das {{appName}} Team führt geplante Wartungsarbeiten durch. Wenn Sie Fragen haben, wenden Sie sich bitte an den Support.", "event_type_seats": "{{numberOfSeats}} Sitze", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Mit demselben Round-Robin-Gastgeber neu planen", "reschedule_with_same_round_robin_host_description": "Neu geplante Ereignisse werden demselben Gastgeber zugewiesen wie ursprünglich geplant", "disable_input_if_prefilled": "Eingabe deaktivieren, wenn die URL-Kennung vorausgefüllt ist", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Sie sind nicht berechtigt, diese Änderung an der Buchung vorzunehmen", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/es-419/common.json b/apps/web/public/static/locales/es-419/common.json index 32ea27b6efac9e..9e67af114dca9b 100644 --- a/apps/web/public/static/locales/es-419/common.json +++ b/apps/web/public/static/locales/es-419/common.json @@ -1147,6 +1147,7 @@ "set_location": "Establecer ubicación", "update_location": "Actualizar ubicación", "location_updated": "Ubicación actualizada", + "location_update_failed": "Error al actualizar la ubicación", "guests_added": "Invitados añadidos", "unable_to_add_guests": "No se pueden añadir invitados", "email_validation_error": "Eso no parece una dirección de correo electrónico", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Establecer un enlace de aplicación predeterminado", "default_app_link_description": "Establecer un enlace de aplicación predeterminado permite que todos los tipos de eventos recién creados usen el enlace de aplicación que configuraste.", "organizer_default_conferencing_app": "Aplicación predeterminada del organizador", + "organizer_default_conferencing_app_not_found": "{{organizer}} no tiene una aplicación de conferencias predeterminada", "under_maintenance": "En mantenimiento", "under_maintenance_description": "El equipo de {{appName}} está realizando un mantenimiento programado. Si tienes alguna pregunta, por favor contacta al soporte.", "event_type_seats": "{{numberOfSeats}} asientos", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Reprogramar con el mismo anfitrión de Round-Robin", "reschedule_with_same_round_robin_host_description": "Los eventos reprogramados se asignarán al mismo anfitrión que se programó inicialmente", "disable_input_if_prefilled": "Desactivar entrada si el identificador de URL está prellenado", + "you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agrega tus nuevas cadenas arriba de esta línea ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/es/common.json b/apps/web/public/static/locales/es/common.json index c7339bd9ba27de..6b3a1774347e0e 100644 --- a/apps/web/public/static/locales/es/common.json +++ b/apps/web/public/static/locales/es/common.json @@ -1147,6 +1147,7 @@ "set_location": "Establecer ubicación", "update_location": "Actualizar ubicación", "location_updated": "Ubicación actualizada", + "location_update_failed": "La actualización de la ubicación ha fallado", "guests_added": "Invitados añadidos", "unable_to_add_guests": "No se pueden añadir invitados", "email_validation_error": "Eso no parece una dirección de correo electrónico", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Establecer un enlace de aplicación predeterminado", "default_app_link_description": "Establecer un enlace de aplicación predeterminado permite que todos los tipos de eventos recién creados utilicen el enlace de aplicación que establezca.", "organizer_default_conferencing_app": "Aplicación por defecto del organizador", + "organizer_default_conferencing_app_not_found": "{{organizer}} no tiene una aplicación de conferencias predeterminada", "under_maintenance": "Fuera de servicio por mantenimiento", "under_maintenance_description": "El equipo de {{appName}} está realizando un mantenimiento programado. Si tiene alguna pregunta, comuníquese con soporte.", "event_type_seats": "{{numberOfSeats}} plazas", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Reprogramar con el mismo anfitrión de Round-Robin", "reschedule_with_same_round_robin_host_description": "Los eventos reprogramados se asignarán al mismo anfitrión que inicialmente", "disable_input_if_prefilled": "Desactivar entrada si el identificador de URL está prellenado", + "you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/et/common.json b/apps/web/public/static/locales/et/common.json index 2443853d8f6530..8dedd976a87629 100644 --- a/apps/web/public/static/locales/et/common.json +++ b/apps/web/public/static/locales/et/common.json @@ -1147,6 +1147,7 @@ "set_location": "Määra asukoht", "update_location": "Uuenda asukohta", "location_updated": "Asukoht värskendatud", + "location_update_failed": "Asukoha uuendamine ebaõnnestus", "guests_added": "Külalised lisatud", "unable_to_add_guests": "Külalisi ei saa lisada", "email_validation_error": "See ei näe välja nagu meiliaadress", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Rakenduse vaikelingi määramine", "default_app_link_description": "Rakenduse vaikelingi määramine võimaldab kõigil äsja loodud sündmuste tüüpidel kasutada teie määratud rakenduse linki.", "organizer_default_conferencing_app": "Korraldaja vaikerakendus", + "organizer_default_conferencing_app_not_found": "{{organizer}} ei ole määranud vaikimisi videokonverentsi rakendust", "under_maintenance": "Hoolduseks maha", "under_maintenance_description": "Tiim {{appName}} teeb plaanipärast hooldust. Kui teil on küsimusi, võtke ühendust toega.", "event_type_seats": "{{numberOfSeats}} kohta", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Planeeri ümber sama Round-Robin hostiga", "reschedule_with_same_round_robin_host_description": "Ümberplaneeritud sündmused määratakse samale hostile, kellele need algselt määrati", "disable_input_if_prefilled": "Keela sisend, kui URL-i identifikaator on eeltäidetud", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Teil puuduvad õigused selle broneeringu muutmiseks", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index 44aa2514b3b4f0..7348b8796bb29c 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -1147,6 +1147,7 @@ "set_location": "Définir le lieu", "update_location": "Mettre à jour le lieu", "location_updated": "Lieu mis à jour", + "location_update_failed": "La mise à jour du lieu a échoué", "guests_added": "Invités ajoutés", "unable_to_add_guests": "Impossible d'ajouter des invités", "email_validation_error": "Ceci ne ressemble pas à une adresse e-mail", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Définir un lien d'application par défaut", "default_app_link_description": "Définir un lien d'application par défaut permet à tous les nouveaux types d'événements d'utiliser le lien d'application que vous avez défini.", "organizer_default_conferencing_app": "Application par défaut de l'organisateur", + "organizer_default_conferencing_app_not_found": "{{organizer}} n'a pas d'application de conférence par défaut", "under_maintenance": "En cours de maintenance", "under_maintenance_description": "L'équipe {{appName}} effectue une maintenance planifiée. Si vous avez des questions, veuillez contacter l'assistance.", "event_type_seats": "{{numberOfSeats}} places", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Reprogrammer avec le même hôte round-robin", "reschedule_with_same_round_robin_host_description": "Les événements reprogrammés seront attribués au même hôte que celui initialement prévu", "disable_input_if_prefilled": "Désactiver la saisie si l'identifiant URL est prérempli", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Vous n'êtes pas autorisé à apporter cette modification à la réservation", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/he/common.json b/apps/web/public/static/locales/he/common.json index 6a3e0248aa94d2..859c3b92c34a85 100644 --- a/apps/web/public/static/locales/he/common.json +++ b/apps/web/public/static/locales/he/common.json @@ -1147,6 +1147,7 @@ "set_location": "הגדרת מיקום", "update_location": "עדכון מיקום", "location_updated": "המיקום עודכן", + "location_update_failed": "עדכון המיקום נכשל", "guests_added": "אורחים נוספו", "unable_to_add_guests": "לא ניתן להוסיף אורחים", "email_validation_error": "המידע שהזנת לא נראה כמו כתובת דוא\"ל", @@ -1887,6 +1888,7 @@ "default_app_link_title": "צור קישור אפליקציה ברירת מחדל", "default_app_link_description": "הגדרת קישור אפליקציה ברירת מחדל מאפשר לכל הארועים החדשים להשתמש בקישור שהגדרת.", "organizer_default_conferencing_app": "אפליקציית ברירת המחדל של המארגן/ת", + "organizer_default_conferencing_app_not_found": "ל{{organizer}} אין אפליקציית ועידה ברירת מחדל", "under_maintenance": "אינו זמין עקב תחזוקה", "under_maintenance_description": "צוות {{appName}} מבצע עבודות תחזוקה שתוכננו מראש. אם יש לך שאלות, נא צור קשר עם התמיכה.", "event_type_seats": "{{numberOfSeats}} מושבים", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "תזמן מחדש עם אותו מארח Round-Robin", "reschedule_with_same_round_robin_host_description": "אירועים שתוזמנו מחדש יוקצו לאותו מארח כפי שתוזמנו במקור", "disable_input_if_prefilled": "השבת קלט אם מזהה ה-URL מולא מראש", + "you_are_unauthorized_to_make_this_change_to_the_booking": "אין לך הרשאה לבצע שינוי זה בהזמנה", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/hu/common.json b/apps/web/public/static/locales/hu/common.json index 4ee915311fd20a..9ca5b520fbcf8b 100644 --- a/apps/web/public/static/locales/hu/common.json +++ b/apps/web/public/static/locales/hu/common.json @@ -1147,6 +1147,7 @@ "set_location": "Hely beállítása", "update_location": "Hely frissítése", "location_updated": "Hely frissítve", + "location_update_failed": "A hely frissítése nem sikerült", "guests_added": "Vendégek hozzáadva", "unable_to_add_guests": "Nem sikerült vendégeket hozzáadni", "email_validation_error": "Ez nem úgy néz ki, mint egy e-mail cím", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Állítson be alapértelmezett alkalmazáshivatkozást", "default_app_link_description": "Az alapértelmezett alkalmazáshivatkozás beállítása lehetővé teszi, hogy minden újonnan létrehozott eseménytípus használja a beállított alkalmazáshivatkozást.", "organizer_default_conferencing_app": "A szervező alapértelmezett alkalmazása", + "organizer_default_conferencing_app_not_found": "{{organizer}} nem rendelkezik alapértelmezett konferenciaalkalmazással", "under_maintenance": "Karbantartás miatt nem elérhető", "under_maintenance_description": "A {{appName}} csapata ütemezett karbantartást végez. Ha bármilyen kérdése van, forduljon az ügyfélszolgálathoz.", "event_type_seats": "{{numberOfSeats}} ülőhely", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Újraütemezés ugyanazzal a Round-Robin házigazdával", "reschedule_with_same_round_robin_host_description": "Az újraütemezett események ugyanahhoz a házigazdához lesznek rendelve, mint eredetileg", "disable_input_if_prefilled": "Bemenet letiltása, ha az URL azonosító előre kitöltött", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Nincs jogosultsága a foglalás módosításához", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adja hozzá az új karakterláncokat fent ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/it/common.json b/apps/web/public/static/locales/it/common.json index c01d2847da33f5..49e2832a6f52d9 100644 --- a/apps/web/public/static/locales/it/common.json +++ b/apps/web/public/static/locales/it/common.json @@ -1147,6 +1147,7 @@ "set_location": "Imposta luogo", "update_location": "Aggiorna luogo", "location_updated": "Luogo aggiornato", + "location_update_failed": "Aggiornamento della posizione fallito", "guests_added": "Ospiti aggiunti", "unable_to_add_guests": "Impossibile aggiungere ospiti", "email_validation_error": "Questo non sembra un indirizzo email valido", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Imposta il link dell'app predefinita", "default_app_link_description": "L'impostazione di un link dell'app predefinita permette a tutti i nuovi tipi di eventi di usare il link dell'app impostato.", "organizer_default_conferencing_app": "Applicazione predefinita dell'organizzatore", + "organizer_default_conferencing_app_not_found": "{{organizer}} non ha un'app di conferenza predefinita", "under_maintenance": "Inattivo per manutenzione", "under_maintenance_description": "Il team di {{appName}} sta eseguendo la manutenzione programmata. Per qualsiasi domanda, contattare il supporto.", "event_type_seats": "{{numberOfSeats}} posti", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Ripianifica con lo stesso host Round-Robin", "reschedule_with_same_round_robin_host_description": "Gli eventi ripianificati saranno assegnati allo stesso host inizialmente programmato", "disable_input_if_prefilled": "Disabilita input se l'identificatore URL è precompilato", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Non sei autorizzato a effettuare questa modifica alla prenotazione", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Aggiungi le tue nuove stringhe qui sopra ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/ja/common.json b/apps/web/public/static/locales/ja/common.json index eadd1cbcc51dc4..554557c68474a2 100644 --- a/apps/web/public/static/locales/ja/common.json +++ b/apps/web/public/static/locales/ja/common.json @@ -1147,6 +1147,7 @@ "set_location": "場所を設定", "update_location": "場所を更新", "location_updated": "場所を更新しました", + "location_update_failed": "位置情報の更新に失敗しました", "guests_added": "ゲストが追加されました", "unable_to_add_guests": "ゲストを追加できません", "email_validation_error": "メールアドレスのようには見えません", @@ -1887,6 +1888,7 @@ "default_app_link_title": "デフォルトのアプリリンクを設定", "default_app_link_description": "デフォルトのアプリリンクを設定することで、新たに作成するすべてのイベントの種類が設定したアプリリンクを使用できるようになります。", "organizer_default_conferencing_app": "主催者のデフォルトのアプリ", + "organizer_default_conferencing_app_not_found": "{{organizer}}にはデフォルトの会議アプリがありません", "under_maintenance": "メンテナンスのため停止中", "under_maintenance_description": "{{appName}} チームが定期メンテナンスを行っています。質問がある場合には、サポートへとお問い合わせください。", "event_type_seats": "{{numberOfSeats}} 席", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "同じRound-Robinホストで再スケジュール", "reschedule_with_same_round_robin_host_description": "再スケジュールされたイベントは、最初にスケジュールされたのと同じホストに割り当てられます", "disable_input_if_prefilled": "URL識別子が事前入力されている場合は入力を無効にする", + "you_are_unauthorized_to_make_this_change_to_the_booking": "この予約の変更を行う権限がありません", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ この上に新しい文字列を追加してください ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/km/common.json b/apps/web/public/static/locales/km/common.json index 151e626d4ad071..a1e8070445c918 100644 --- a/apps/web/public/static/locales/km/common.json +++ b/apps/web/public/static/locales/km/common.json @@ -1147,6 +1147,7 @@ "set_location": "កំណត់ទីតាំង", "update_location": "ធ្វើបច្ចុប្បន្នភាពទីតាំង", "location_updated": "ទីតាំងត្រូវបានធ្វើបច្ចុប្បន្នភាព", + "location_update_failed": "ការធ្វើបច្ចុប្បន្នភាពទីតាំងបានបរាជ័យ", "guests_added": "ភ្ញៀវត្រូវបានបន្ថែម", "unable_to_add_guests": "មិនអាចបន្ថែមភ្ញៀវបានទេ", "email_validation_error": "វាមិនមើលទៅដូចអាសយដ្ឋានអ៊ីមែលទេ", @@ -1887,6 +1888,7 @@ "default_app_link_title": "កំណត់តំណភ្ជាប់កម្មវិធីលំនាំដើម", "default_app_link_description": "ការកំណត់តំណភ្ជាប់កម្មវិធីលំនាំដើមអនុញ្ញាតឱ្យប្រភេទព្រឹត្តិការណ៍ថ្មីទាំងអស់ប្រើតំណភ្ជាប់កម្មវិធីដែលអ្នកកំណត់។", "organizer_default_conferencing_app": "កម្មវិធីលំនាំដើមរបស់អ្នករៀបចំ", + "organizer_default_conferencing_app_not_found": "{{organizer}} មិនមានកម្មវិធីសន្និសីទលំនាំដើមទេ", "under_maintenance": "កំពុងថែទាំ", "under_maintenance_description": "ក្រុម {{appName}} កំពុងធ្វើការថែទាំតាមកាលវិភាគ។ ប្រសិនបើអ្នកមានសំណួរ សូមទាក់ទងផ្នែកគាំទ្រ។", "event_type_seats": "{{numberOfSeats}} កៅអី", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "កំណត់ពេលថ្មីជាមួយម្ចាស់ Round-Robin ដដែល", "reschedule_with_same_round_robin_host_description": "ព្រឹត្តិការណ៍ដែលបានកំណត់ពេលថ្មីនឹងត្រូវចាត់ចែងទៅម្ចាស់ដដែលដូចពេលដំបូង", "disable_input_if_prefilled": "បិទការបញ្ចូលប្រសិនបើ URL identifier ត្រូវបានបំពេញរួចហើយ", + "you_are_unauthorized_to_make_this_change_to_the_booking": "អ្នកមិនមានសិទ្ធិធ្វើការផ្លាស់ប្តូរនេះលើការកក់ទេ", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ បន្ថែមខ្សែអក្សរថ្មីរបស់អ្នកនៅខាងលើនេះ ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/ko/common.json b/apps/web/public/static/locales/ko/common.json index 59aab69173eebd..c33fcea66ee331 100644 --- a/apps/web/public/static/locales/ko/common.json +++ b/apps/web/public/static/locales/ko/common.json @@ -1147,6 +1147,7 @@ "set_location": "위치 설정", "update_location": "위치 업데이트", "location_updated": "위치 업데이트됨", + "location_update_failed": "위치 업데이트에 실패했습니다", "guests_added": "게스트 추가됨", "unable_to_add_guests": "게스트를 추가할 수 없습니다", "email_validation_error": "이메일 주소가 아닌 것 같습니다", @@ -1887,6 +1888,7 @@ "default_app_link_title": "기본 앱 링크 설정", "default_app_link_description": "기본 앱 링크를 설정하면 새로 생성된 모든 이벤트 유형에서 설정된 앱 링크를 사용할 수 있습니다.", "organizer_default_conferencing_app": "주최자의 기본 앱", + "organizer_default_conferencing_app_not_found": "{{organizer}}님은 기본 화상 회의 앱이 없습니다", "under_maintenance": "아래로 밀어 유지관리", "under_maintenance_description": "{{appName}} 팀에서 예정된 유지관리를 수행하고 있습니다. 궁금한 점이 있으면 지원팀에 문의하세요.", "event_type_seats": "{{numberOfSeats}}개 시트", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "동일한 Round-Robin 호스트와 다시 예약", "reschedule_with_same_round_robin_host_description": "다시 예약된 이벤트는 처음 예약된 동일한 호스트에게 할당됩니다", "disable_input_if_prefilled": "URL 식별자가 미리 채워진 경우 입력 비활성화", + "you_are_unauthorized_to_make_this_change_to_the_booking": "이 예약을 변경할 권한이 없습니다", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 여기에 새 문자열을 추가하세요 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/nl/common.json b/apps/web/public/static/locales/nl/common.json index b175b1df160a9d..85f7f4fc2ba9c6 100644 --- a/apps/web/public/static/locales/nl/common.json +++ b/apps/web/public/static/locales/nl/common.json @@ -1147,6 +1147,7 @@ "set_location": "Locatie instellen", "update_location": "Locatie bijwerken", "location_updated": "Locatie bijgewerkt", + "location_update_failed": "Locatie-update mislukt", "guests_added": "Gasten toegevoegd", "unable_to_add_guests": "Kan geen gasten toevoegen", "email_validation_error": "Dat lijkt niet op een e-mailadres", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Stel een standaard app-link in", "default_app_link_description": "Door een standaard app-link in te stellen kunnen alle nieuw gemaakte gebeurtenistypes de door u ingestelde app-link gebruiken.", "organizer_default_conferencing_app": "Standaardapp organisator", + "organizer_default_conferencing_app_not_found": "{{organizer}} heeft geen standaard vergaderapp", "under_maintenance": "Onbereikbaar wegens onderhoud", "under_maintenance_description": "Het {{appName}}-team voert gepland onderhoud uit. Neem contact op met de ondersteuning als u vragen heeft.", "event_type_seats": "{{numberOfSeats}} plaatsen", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Opnieuw plannen met dezelfde Round-Robin host", "reschedule_with_same_round_robin_host_description": "Opnieuw geplande evenementen worden toegewezen aan dezelfde host als oorspronkelijk gepland", "disable_input_if_prefilled": "Invoer uitschakelen als de URL-identificatie vooraf is ingevuld", + "you_are_unauthorized_to_make_this_change_to_the_booking": "U bent niet gemachtigd om deze wijziging in de boeking aan te brengen", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Voeg uw nieuwe strings hierboven toe ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/no/common.json b/apps/web/public/static/locales/no/common.json index 33ffd8ed345cbb..bac127adfa98c8 100644 --- a/apps/web/public/static/locales/no/common.json +++ b/apps/web/public/static/locales/no/common.json @@ -1147,6 +1147,7 @@ "set_location": "Velg Sted", "update_location": "Oppdater Sted", "location_updated": "Sted oppdatert", + "location_update_failed": "Oppdatering av plassering mislyktes", "guests_added": "Gjester lagt til", "unable_to_add_guests": "Kan ikke legge til gjester", "email_validation_error": "Det ser ikke ut som en e-postadresse", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Sett en standard applink", "default_app_link_description": "Å sette en standard applink gjør at alle nyopprettede arrangementstyper bruker applinken du har satt.", "organizer_default_conferencing_app": "Organisatorens standardapp", + "organizer_default_conferencing_app_not_found": "{{organizer}} har ingen standard konferanseapp", "under_maintenance": "Nede for vedlikehold", "under_maintenance_description": "{{appName}}-teamet utfører planlagt vedlikehold. Hvis du har spørsmål, vennligst kontakt support.", "event_type_seats": "{{numberOfSeats}} seter", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Omplanlegg med samme Round-Robin-vert", "reschedule_with_same_round_robin_host_description": "Omplanlagte hendelser vil bli tildelt samme vert som opprinnelig planlagt", "disable_input_if_prefilled": "Deaktiver inndata hvis URL-identifikatoren er forhåndsutfylt", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Du har ikke tillatelse til å gjøre denne endringen i bestillingen", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Legg til dine nye strenger over her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/pl/common.json b/apps/web/public/static/locales/pl/common.json index 1c8d3fc809c1b1..c4eed9933b158a 100644 --- a/apps/web/public/static/locales/pl/common.json +++ b/apps/web/public/static/locales/pl/common.json @@ -1147,6 +1147,7 @@ "set_location": "Określ lokalizację", "update_location": "Zaktualizuj lokalizację", "location_updated": "Lokalizacja zaktualizowana", + "location_update_failed": "Aktualizacja lokalizacji nie powiodła się", "guests_added": "Dodano gości", "unable_to_add_guests": "Nie można dodać gości", "email_validation_error": "To nie wygląda jak adres e-mail", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Ustaw domyślny link do aplikacji", "default_app_link_description": "Ustawienie domyślnego linku do aplikacji pozwala wszystkim nowo utworzonym typom wydarzeń na używanie ustawionego przez Ciebie linku do aplikacji.", "organizer_default_conferencing_app": "Domyślna aplikacja organizatora", + "organizer_default_conferencing_app_not_found": "{{organizer}} nie ma domyślnej aplikacji do konferencji", "under_maintenance": "Przerwa konserwacyjna", "under_maintenance_description": "Zespół {{appName}} przeprowadza zaplanowane prace konserwacyjne. Jeśli masz jakieś pytania, skontaktuj się z pomocą techniczną.", "event_type_seats": "Liczba stanowisk: {{numberOfSeats}}", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Przełóż z tym samym gospodarzem Round-Robin", "reschedule_with_same_round_robin_host_description": "Przełożone wydarzenia zostaną przypisane do tego samego gospodarza, co pierwotnie zaplanowane", "disable_input_if_prefilled": "Wyłącz wprowadzanie, jeśli identyfikator URL jest wypełniony", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Nie masz uprawnień do wprowadzenia tej zmiany w rezerwacji", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Dodaj nowe ciągi powyżej ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/pt-BR/common.json b/apps/web/public/static/locales/pt-BR/common.json index 8e5a49804911ab..d6f322ca862c59 100644 --- a/apps/web/public/static/locales/pt-BR/common.json +++ b/apps/web/public/static/locales/pt-BR/common.json @@ -1147,6 +1147,7 @@ "set_location": "Definir local", "update_location": "Atualizar local", "location_updated": "Local atualizado", + "location_update_failed": "Falha na atualização da localização", "guests_added": "Convidados adicionados", "unable_to_add_guests": "Não foi possível adicionar convidados", "email_validation_error": "Isso não parece um endereço de e-mail", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Defina um link padrão para o app", "default_app_link_description": "Definir um link padrão para o app permite que todos os tipos de evento recém-criados usem o link definido para o app.", "organizer_default_conferencing_app": "Aplicativo padrão do organizador", + "organizer_default_conferencing_app_not_found": "{{organizer}} não possui um aplicativo de conferência padrão", "under_maintenance": "Serviço interrompido para manutenção", "under_maintenance_description": "A equipe do {{appName}} está realizando uma manutenção programada. Se tiver alguma dúvida, fale com o suporte.", "event_type_seats": "{{numberOfSeats}} assentos", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Reagendar com o mesmo anfitrião Round-Robin", "reschedule_with_same_round_robin_host_description": "Eventos reagendados serão atribuídos ao mesmo anfitrião inicialmente agendado", "disable_input_if_prefilled": "Desativar entrada se o identificador da URL estiver preenchido", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Você não tem autorização para fazer essa alteração na reserva", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adicione suas novas strings aqui em cima ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/pt/common.json b/apps/web/public/static/locales/pt/common.json index 258a53aca4634f..e960bd54c67288 100644 --- a/apps/web/public/static/locales/pt/common.json +++ b/apps/web/public/static/locales/pt/common.json @@ -1147,6 +1147,7 @@ "set_location": "Definir localização", "update_location": "Actualizar localização", "location_updated": "Localização actualizada", + "location_update_failed": "Falha na atualização da localização", "guests_added": "Convidados adicionados", "unable_to_add_guests": "Não foi possível adicionar convidados", "email_validation_error": "Isto não parece ser um endereço de email", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Definir uma ligação predefinida de aplicação", "default_app_link_description": "Ao definir uma ligação predefinida da aplicação, permite que todos os tipos de eventos recém-criados utilizem a ligação de aplicação que definiu.", "organizer_default_conferencing_app": "Aplicação predefinida do organizador", + "organizer_default_conferencing_app_not_found": "{{organizer}} não tem uma aplicação de conferência padrão", "under_maintenance": "Em manutenção", "under_maintenance_description": "A equipa {{appName}} está a executar uma operação de manutenção programada. Se tiver alguma dúvida, por favor, entre em contacto com o suporte.", "event_type_seats": "{{numberOfSeats}} lugares", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Reagendar com o mesmo anfitrião Round-Robin", "reschedule_with_same_round_robin_host_description": "Os eventos reagendados serão atribuídos ao mesmo anfitrião inicialmente agendado", "disable_input_if_prefilled": "Desativar entrada se o identificador de URL estiver pré-preenchido", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Não tem autorização para fazer esta alteração na reserva", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/ro/common.json b/apps/web/public/static/locales/ro/common.json index f2a3f72c59b7ba..92c50f678d5e7a 100644 --- a/apps/web/public/static/locales/ro/common.json +++ b/apps/web/public/static/locales/ro/common.json @@ -1147,6 +1147,7 @@ "set_location": "Stabiliți locația", "update_location": "Actualizați locația", "location_updated": "Locație actualizată", + "location_update_failed": "Actualizarea locației a eșuat", "guests_added": "Invitați adăugați", "unable_to_add_guests": "Nu se pot adăuga invitați", "email_validation_error": "Aceasta nu pare să fie o adresă de e-mail", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Setați un link implicit pentru aplicații", "default_app_link_description": "Setarea unui link implicit pentru aplicații permite tuturor tipurilor de evenimente nou-create să utilizeze linkul pentru aplicații pe care l-ați setat.", "organizer_default_conferencing_app": "Aplicația implicită a organizatorului", + "organizer_default_conferencing_app_not_found": "{{organizer}} nu are o aplicație de conferință implicită", "under_maintenance": "Serviciu întrerupt pentru întreținere", "under_maintenance_description": "Echipa {{appName}} efectuează lucrări de întreținere programate. Dacă aveți întrebări, contactați serviciul de asistență.", "event_type_seats": "{{numberOfSeats}} (de) locuri", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Reprogramează cu același gazdă Round-Robin", "reschedule_with_same_round_robin_host_description": "Evenimentele reprogramate vor fi atribuite aceleași gazde ca inițial", "disable_input_if_prefilled": "Dezactivează introducerea dacă identificatorul URL este precompletat", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Nu aveți autorizația necesară pentru a face această modificare în rezervare", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adăugați stringurile noi deasupra acestui rând ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/sr/common.json b/apps/web/public/static/locales/sr/common.json index 446cccef7b1edb..d7991f4dc720df 100644 --- a/apps/web/public/static/locales/sr/common.json +++ b/apps/web/public/static/locales/sr/common.json @@ -1147,6 +1147,7 @@ "set_location": "Podesite lokaciju", "update_location": "Ažurirajte lokaciju", "location_updated": "Lokacija je ažurirana", + "location_update_failed": "Ažuriranje lokacije nije uspelo", "guests_added": "Gosti dodati", "unable_to_add_guests": "Nije moguće dodati goste", "email_validation_error": "To ne liči na imejl adresu", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Podesite podrazumevani link aplikacije", "default_app_link_description": "Podešavanje podrazumevanog linka aplikacije omogućava novokreiranim tipovima događaja da koriste link aplikacije koji ste postavili.", "organizer_default_conferencing_app": "Podrazumevana aplikacija organizatora", + "organizer_default_conferencing_app_not_found": "{{organizer}} nema podrazumevanu aplikaciju za konferencije", "under_maintenance": "Ne radi zbog održavanja", "under_maintenance_description": "Tim {{appName}} izvodi zakazano održavanje. Ako imate pitanja, obratite se korisničkoj podršci.", "event_type_seats": "{{numberOfSeats}} mesta", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Ponovo zakaži sa istim Round-Robin domaćinom", "reschedule_with_same_round_robin_host_description": "Ponovo zakazani događaji će biti dodeljeni istom domaćinu kao i prvobitno zakazani", "disable_input_if_prefilled": "Onemogući unos ako je URL identifikator unapred popunjen", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Nemate ovlašćenje da izvršite ovu promenu u rezervaciji", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Dodajte svoje nove stringove iznad ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/sv/common.json b/apps/web/public/static/locales/sv/common.json index 3ef4ea84242b9d..a47f4b9415ba30 100644 --- a/apps/web/public/static/locales/sv/common.json +++ b/apps/web/public/static/locales/sv/common.json @@ -1147,6 +1147,7 @@ "set_location": "Ställ in plats", "update_location": "Uppdatera plats", "location_updated": "Plats uppdaterad", + "location_update_failed": "Uppdatering av plats misslyckades", "guests_added": "Gäster tillagda", "unable_to_add_guests": "Kan inte lägga till gäster", "email_validation_error": "Det ser inte ut som en e-postadress", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Skapa en standardapplänk", "default_app_link_description": "Om du skapar en standardapplänk kan alla händelsetyper som nyligen skapats använda den applänk som du har angett.", "organizer_default_conferencing_app": "Arrangörens standardapp", + "organizer_default_conferencing_app_not_found": "{{organizer}} har ingen standardkonferensapp", "under_maintenance": "Nere för underhåll", "under_maintenance_description": "{{appName}}-teamet genomför planerat underhåll. Om du har några frågor kan du kontakta supporten.", "event_type_seats": "{{numberOfSeats}} platser", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Boka om med samma Round-Robin värd", "reschedule_with_same_round_robin_host_description": "Ombokade händelser kommer att tilldelas samma värd som ursprungligen bokades", "disable_input_if_prefilled": "Inaktivera inmatning om URL-identifieraren är förifylld", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Du har inte behörighet att göra denna ändring i bokningen", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/tr/common.json b/apps/web/public/static/locales/tr/common.json index 30fb394361edc3..85f55add81747e 100644 --- a/apps/web/public/static/locales/tr/common.json +++ b/apps/web/public/static/locales/tr/common.json @@ -1147,6 +1147,7 @@ "set_location": "Konumu Ayarla", "update_location": "Konumu Güncelle", "location_updated": "Konum güncellendi", + "location_update_failed": "Konum güncellemesi başarısız oldu", "guests_added": "Misafirler eklendi", "unable_to_add_guests": "Konuklar eklenemiyor", "email_validation_error": "Bu bir e-posta adresi gibi görünmüyor", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Varsayılan uygulama bağlantısını ayarlayın", "default_app_link_description": "Varsayılan uygulama bağlantısını ayarlamak, yeni oluşturulan tüm etkinlik türlerinin ayarladığınız uygulama bağlantısını kullanmasına olanak tanır.", "organizer_default_conferencing_app": "Organizatörün varsayılan uygulaması", + "organizer_default_conferencing_app_not_found": "{{organizer}}'ın varsayılan konferans uygulaması bulunamadı", "under_maintenance": "Bakımda", "under_maintenance_description": "{{appName}} ekibi planlı bir bakım çalışması gerçekleştiriyor. Herhangi bir sorunuz varsa lütfen destek ekibiyle iletişime geçin.", "event_type_seats": "{{numberOfSeats}} yer", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Aynı Round-Robin ev sahibi ile yeniden planla", "reschedule_with_same_round_robin_host_description": "Yeniden planlanan etkinlikler, başlangıçta planlandığı gibi aynı ev sahibine atanacaktır", "disable_input_if_prefilled": "URL tanımlayıcısı önceden doldurulmuşsa girişi devre dışı bırak", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Bu rezervasyonda değişiklik yapma yetkiniz yok", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni dizelerinizi yukarıya ekleyin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/uk/common.json b/apps/web/public/static/locales/uk/common.json index b30c90b9025543..bbfaa9f9991c57 100644 --- a/apps/web/public/static/locales/uk/common.json +++ b/apps/web/public/static/locales/uk/common.json @@ -1147,6 +1147,7 @@ "set_location": "Указати розташування", "update_location": "Оновити розташування", "location_updated": "Розташування оновлено", + "location_update_failed": "Не вдалося оновити місцезнаходження", "guests_added": "Гостей додано", "unable_to_add_guests": "Не вдалося додати гостей", "email_validation_error": "Це не схоже на адресу електронної пошти", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Установити посилання на додаток за замовчуванням", "default_app_link_description": "Посилання на додаток за замовчуванням використовуватиметься для всіх новостворених типів подій.", "organizer_default_conferencing_app": "Додаток організації за замовчуванням", + "organizer_default_conferencing_app_not_found": "{{organizer}} не має додатку для конференцій за замовчуванням", "under_maintenance": "На обслуговуванні", "under_maintenance_description": "Команда {{appName}} здійснює планове обслуговування. Якщо маєте запитання, зверніться в службу підтримки.", "event_type_seats": "Місць: {{numberOfSeats}}", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Перепланувати з тим самим ведучим Round-Robin", "reschedule_with_same_round_robin_host_description": "Переплановані події будуть призначені тому ж ведучому, що й спочатку", "disable_input_if_prefilled": "Вимкнути введення, якщо ідентифікатор URL попередньо заповнений", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Ви не маєте дозволу на внесення цієї зміни до бронювання", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/vi/common.json b/apps/web/public/static/locales/vi/common.json index abf899bdecc60f..38d30b71b71291 100644 --- a/apps/web/public/static/locales/vi/common.json +++ b/apps/web/public/static/locales/vi/common.json @@ -1147,6 +1147,7 @@ "set_location": "Đặt địa điểm", "update_location": "Cập nhật địa điểm", "location_updated": "Địa điểm đã cập nhật", + "location_update_failed": "Cập nhật vị trí thất bại", "guests_added": "Khách đã thêm", "unable_to_add_guests": "Không thể thêm khách", "email_validation_error": "Đó không giống địa chỉ email", @@ -1887,6 +1888,7 @@ "default_app_link_title": "Đặt một liên kết ứng dụng mặc định", "default_app_link_description": "Thao tác đặt liên kết ứng dụng mặc định sẽ giúp cho phép tất cả các loại sự kiện mới tạo có thể dùng liên kết ứng dụng mà bạn đã đặt.", "organizer_default_conferencing_app": "Ứng dụng mặc định của nhà tổ chức", + "organizer_default_conferencing_app_not_found": "{{organizer}} không có ứng dụng hội nghị mặc định", "under_maintenance": "Tạm ngừng để bảo trì", "under_maintenance_description": "Nhóm {{appName}} đang thực hiện việc bảo trì theo lịch. Nếu bạn có thắc mắc gì, vui lòng liên hệ bộ phận hỗ trợ.", "event_type_seats": "{{numberOfSeats}} chỗ ngồi", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "Đặt lại lịch với cùng một host Round-Robin", "reschedule_with_same_round_robin_host_description": "Các sự kiện được đặt lại sẽ được giao cho cùng một host như ban đầu", "disable_input_if_prefilled": "Vô hiệu hóa đầu vào nếu URL đã được điền trước", + "you_are_unauthorized_to_make_this_change_to_the_booking": "Bạn không có quyền thực hiện thay đổi này đối với đặt chỗ", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/zh-CN/common.json b/apps/web/public/static/locales/zh-CN/common.json index 5ec2b19ed1e0fe..d6c807e4f7d08a 100644 --- a/apps/web/public/static/locales/zh-CN/common.json +++ b/apps/web/public/static/locales/zh-CN/common.json @@ -1147,6 +1147,7 @@ "set_location": "设置位置", "update_location": "更新位置", "location_updated": "位置已更新", + "location_update_failed": "位置更新失败", "guests_added": "嘉宾已添加", "unable_to_add_guests": "无法添加嘉宾", "email_validation_error": "这看起来不像是电子邮件地址", @@ -1887,6 +1888,7 @@ "default_app_link_title": "设置默认应用链接", "default_app_link_description": "设置默认应用链接可以让所有新创建的活动类型使用您设置的应用链接。", "organizer_default_conferencing_app": "组织者的默认应用", + "organizer_default_conferencing_app_not_found": "{{organizer}} 没有默认的会议应用", "under_maintenance": "停机维护", "under_maintenance_description": "{{appName}} 团队正在进行计划维护。如果您有任何疑问,请联系支持。", "event_type_seats": "{{numberOfSeats}} 个位置", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "与同一轮询主持人重新安排", "reschedule_with_same_round_robin_host_description": "重新安排的事件将分配给最初安排的同一主持人", "disable_input_if_prefilled": "如果 URL 标识符已预填,则禁用输入", + "you_are_unauthorized_to_make_this_change_to_the_booking": "您无权对此预订进行更改", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/zh-TW/common.json b/apps/web/public/static/locales/zh-TW/common.json index b0036c8bddd6c4..013b3cfb131b57 100644 --- a/apps/web/public/static/locales/zh-TW/common.json +++ b/apps/web/public/static/locales/zh-TW/common.json @@ -1147,6 +1147,7 @@ "set_location": "設定地點", "update_location": "更新地點", "location_updated": "地點已更新", + "location_update_failed": "位置更新失敗", "guests_added": "賓客已新增", "unable_to_add_guests": "無法新增賓客", "email_validation_error": "這似乎不是電子郵件地址", @@ -1887,6 +1888,7 @@ "default_app_link_title": "設定預設應用程式連結", "default_app_link_description": "設定預設應用程式連結,即可讓所有全新建立的活動類型使用您設定的應用程式連結。", "organizer_default_conferencing_app": "主辦者的預設應用程式", + "organizer_default_conferencing_app_not_found": "{{organizer}} 沒有預設的會議應用程式", "under_maintenance": "維修停機中", "under_maintenance_description": "{{appName}} 團隊正在執行預定維修。如有任何疑問,請聯絡支援團隊。", "event_type_seats": "{{numberOfSeats}} 個座位", @@ -2593,5 +2595,6 @@ "reschedule_with_same_round_robin_host_title": "重新安排與相同的 Round-Robin 主機", "reschedule_with_same_round_robin_host_description": "重新安排的事件將分配給最初安排的相同主機", "disable_input_if_prefilled": "如果 URL 識別碼已填寫,則禁用輸入", + "you_are_unauthorized_to_make_this_change_to_the_booking": "您無權更改此預訂", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 請在此處新增您的字串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/i18n.lock b/i18n.lock index a57cbeffa725a1..f142447fdabb7d 100644 --- a/i18n.lock +++ b/i18n.lock @@ -1149,6 +1149,7 @@ checksums: set_location: 84a52cdae2ca02490055aaa40583fc1d update_location: 741758fb7a9c090a9ab9e87a6c62fedc location_updated: 639cd11ff17c5fea84384a06e686b782 + location_update_failed: a57b1dbaf515c1ab934522e6d837291a guests_added: a04cef448ca54083e9e741d1233ead0b unable_to_add_guests: 10d1eebffb88f731d40144bdf8e9929b email_validation_error: 0a26bc82b13d45d06d40a3e841877504 @@ -1889,6 +1890,7 @@ checksums: default_app_link_title: 449f7f5ed6e56536994089aa8f49e407 default_app_link_description: c5e6ef98d08cb425c3b2759b13b9a9c2 organizer_default_conferencing_app: 8cc159b92c6eb2bbcb90f0ddb70e7ee5 + organizer_default_conferencing_app_not_found: 783160f2c53f5d7fc92f8ae7430ff9cf under_maintenance: c07caeb218bfc9a104a00265857cd507 under_maintenance_description: b6da95c38efa2247c7bb8834523bf67d event_type_seats: a2dcd6b544ca1b20a2f1bf4166919e86 @@ -2595,4 +2597,5 @@ checksums: reschedule_with_same_round_robin_host_title: 0b315779fdae608827ceab1b1640423b reschedule_with_same_round_robin_host_description: d581417695b5275d6706a08b8d7dd39a disable_input_if_prefilled: 19415e3062d8ef1031beb38ce4a0b3c9 + you_are_unauthorized_to_make_this_change_to_the_booking: c770486d5b4b98a407ff485cbe60e563 ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS: 18323f2f3fabb169a7cb50ff62433850 From 2f2f4dba60fff18d47477d0ae1ffa630f335aa53 Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Wed, 11 Sep 2024 12:19:00 -0300 Subject: [PATCH 08/40] chore: v4.4.8 (#16597) --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index a1fbd6707a5053..251062f49ede5e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "4.4.7", + "version": "4.4.8", "private": true, "scripts": { "analyze": "ANALYZE=true next build", From 89250422ea0dc2fa0f4d7f2c5b7d3ae29842cd45 Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Wed, 11 Sep 2024 20:14:03 +0400 Subject: [PATCH 09/40] fix: API V1 slots add explicit orgSlug null fallback (#16593) * add explicit orgSlug null fallback * do it in schema * to nullish --- .../api/get-inbound-dynamic-variables.ts | 2 +- apps/web/test/lib/getSchedule.test.ts | 23 +++++++++++++++++++ .../getSchedule/futureLimit.timezone.test.ts | 16 +++++++++++++ .../trpc/server/routers/viewer/slots/types.ts | 9 +++++--- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/web/pages/api/get-inbound-dynamic-variables.ts b/apps/web/pages/api/get-inbound-dynamic-variables.ts index e7bc8ea99479ac..936da7f8a5b7af 100644 --- a/apps/web/pages/api/get-inbound-dynamic-variables.ts +++ b/apps/web/pages/api/get-inbound-dynamic-variables.ts @@ -85,7 +85,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const startTime = now.startOf("month").toISOString(); const endTime = now.add(2, "month").endOf("month").toISOString(); - const orgSlug = eventType?.team?.parent?.slug ?? undefined; + const orgSlug = eventType?.team?.parent?.slug ?? null; const availableSlots = await getAvailableSlots({ input: { diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 4d0a49c5763583..4946c304f6582a 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -78,6 +78,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -176,6 +177,7 @@ describe("getSchedule", () => { timeZone: Timezones["+5:30"], isTeamEvent: true, teamMemberEmail: "example@example.com", + orgSlug: null, }, }); @@ -195,6 +197,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: true, + orgSlug: null, }, }); @@ -321,6 +324,7 @@ describe("getSchedule", () => { timeZone: Timezones["+5:30"], isTeamEvent: true, teamMemberEmail: "example@example.com", + orgSlug: null, }, }); @@ -349,6 +353,7 @@ describe("getSchedule", () => { timeZone: Timezones["+5:30"], isTeamEvent: true, teamMemberEmail: "example1@example.com", + orgSlug: null, }, }); @@ -438,6 +443,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -470,6 +476,7 @@ describe("getSchedule", () => { endTime: `${plus3DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -529,6 +536,7 @@ describe("getSchedule", () => { const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); const scheduleForEventWith30Length = await getSchedule({ input: { + orgSlug: null, eventTypeId: 1, eventTypeSlug: "", startTime: `${plus1DateString}T18:30:00.000Z`, @@ -565,6 +573,7 @@ describe("getSchedule", () => { const scheduleForEventWith30minsLengthAndSlotInterval2hrs = await getSchedule({ input: { + orgSlug: null, eventTypeId: 2, eventTypeSlug: "", startTime: `${plus1DateString}T18:30:00.000Z`, @@ -630,6 +639,7 @@ describe("getSchedule", () => { endTime: `${todayDateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -653,6 +663,7 @@ describe("getSchedule", () => { endTime: `${todayDateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); expect(scheduleForEventWithBookingNotice10Hrs).toHaveTimeSlots( @@ -715,6 +726,7 @@ describe("getSchedule", () => { endTime: `${plus3DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -789,6 +801,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -847,6 +860,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -911,6 +925,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -989,6 +1004,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -1101,6 +1117,7 @@ describe("getSchedule", () => { endTime: `${plus3DateString}T23:59:59.999Z`, timeZone: Timezones["+6:00"], isTeamEvent: false, + orgSlug: null, }, }); @@ -1112,6 +1129,7 @@ describe("getSchedule", () => { endTime: `${plus3DateString}T23:59:59.999Z`, timeZone: Timezones["+6:00"], isTeamEvent: false, + orgSlug: null, }, }); @@ -1202,6 +1220,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -1300,6 +1319,7 @@ describe("getSchedule", () => { endTime: `${plus1DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: true, + orgSlug: null, }, }); @@ -1330,6 +1350,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: true, + orgSlug: null, }, }); @@ -1438,6 +1459,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: true, + orgSlug: null, }, }); // A user with blocked time in another event, still affects Team Event availability @@ -1466,6 +1488,7 @@ describe("getSchedule", () => { endTime: `${plus3DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: true, + orgSlug: null, }, }); // A user with blocked time in another event, still affects Team Event availability diff --git a/apps/web/test/lib/getSchedule/futureLimit.timezone.test.ts b/apps/web/test/lib/getSchedule/futureLimit.timezone.test.ts index a720eb43b00c0d..84a76dec7313a3 100644 --- a/apps/web/test/lib/getSchedule/futureLimit.timezone.test.ts +++ b/apps/web/test/lib/getSchedule/futureLimit.timezone.test.ts @@ -121,6 +121,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -208,6 +209,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -291,6 +293,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -385,6 +388,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -488,6 +492,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -577,6 +582,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -660,6 +666,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["-11:00"], isTeamEvent: false, + orgSlug: null, }, }); @@ -782,6 +789,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -879,6 +887,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -979,6 +988,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -1086,6 +1096,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -1205,6 +1216,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -1301,6 +1313,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -1409,6 +1422,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -1510,6 +1524,7 @@ describe("getSchedule", () => { endTime: `${plus5DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], isTeamEvent: false, + orgSlug: null, }, }); @@ -1594,6 +1609,7 @@ describe("getSchedule", () => { endTime: `2024-07-31T18:29:59.999Z`, timeZone: Timezones["-11:00"], isTeamEvent: false, + orgSlug: null, }, }); diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 851f2135818936..3e124615f942b5 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -20,17 +20,20 @@ export const getScheduleSchema = z .string() .optional() .transform((val) => val && parseInt(val)), - rescheduleUid: z.string().optional().nullable(), + rescheduleUid: z.string().nullish(), // whether to do team event or user event isTeamEvent: z.boolean().optional().default(false), - orgSlug: z.string().optional(), - teamMemberEmail: z.string().nullable().optional(), + orgSlug: z.string().nullish(), + teamMemberEmail: z.string().nullish(), }) .transform((val) => { // Need this so we can pass a single username in the query string form public API if (val.usernameList) { val.usernameList = Array.isArray(val.usernameList) ? val.usernameList : [val.usernameList]; } + if (!val.orgSlug) { + val.orgSlug = null; + } return val; }) .refine( From 74a9f3c19e23a7df40085366c36676db8f219714 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Wed, 11 Sep 2024 14:11:10 -0400 Subject: [PATCH 10/40] hotfix: availability/[schedule] (#16598) --- apps/web/app/future/availability/[schedule]/page.tsx | 2 +- apps/web/modules/availability/[schedule]/schedule-view.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/app/future/availability/[schedule]/page.tsx b/apps/web/app/future/availability/[schedule]/page.tsx index 88588d9de3fa0e..ec41afeb0329a7 100644 --- a/apps/web/app/future/availability/[schedule]/page.tsx +++ b/apps/web/app/future/availability/[schedule]/page.tsx @@ -71,7 +71,7 @@ const Page = async ({ params }: PageProps) => { travelSchedules = await TravelScheduleRepository.findTravelSchedulesByUserId(userId); } catch (e) {} - return ; + return ; }; export default WithLayout({ ServerPage: Page }); diff --git a/apps/web/modules/availability/[schedule]/schedule-view.tsx b/apps/web/modules/availability/[schedule]/schedule-view.tsx index 4876b29f47eddb..52b7b8ea2a3c50 100644 --- a/apps/web/modules/availability/[schedule]/schedule-view.tsx +++ b/apps/web/modules/availability/[schedule]/schedule-view.tsx @@ -15,12 +15,12 @@ import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { showToast } from "@calcom/ui"; type PageProps = { - schedule?: Awaited>; + scheduleFetched?: Awaited>; travelSchedules?: Awaited>; }; export const AvailabilitySettingsWebWrapper = ({ - schedule: scheduleProp, + scheduleFetched: scheduleProp, travelSchedules: travelSchedulesProp, }: PageProps) => { const searchParams = useCompatSearchParams(); From 4a59841b02ecbd614dcce110d09cf4f3a559a126 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Thu, 12 Sep 2024 06:26:04 +0530 Subject: [PATCH 11/40] feat: booking with phone number (#14461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Omar López Co-authored-by: Joe Au-Yeung Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> --- .../components/booking/BookingListItem.tsx | 42 +- apps/web/lib/booking.ts | 19 +- .../bookings/views/bookings-single-view.tsx | 12 +- apps/web/playwright/lib/testUtils.ts | 8 +- .../playwright/organization/booking.e2e.ts | 155 ++- apps/web/public/static/locales/en/common.json | 6 +- .../bookingScenario/MockPaymentService.ts | 4 +- .../utils/bookingScenario/bookingScenario.ts | 108 +- .../web/test/utils/bookingScenario/expects.ts | 80 +- .../getMockRequestDataForBooking.ts | 1 + .../app-store/paypal/lib/PaymentService.ts | 4 +- .../stripepayment/lib/PaymentService.ts | 20 +- .../app-store/stripepayment/lib/customer.ts | 8 +- packages/app-store/vital/lib/reschedule.ts | 31 +- .../wipemycalother/lib/reschedule.ts | 32 +- packages/emails/email-manager.ts | 104 +- packages/emails/src/components/WhoInfo.tsx | 20 +- packages/emails/templates/_base-email.ts | 6 + packages/features/bookings/Booker/Booker.tsx | 2 +- .../components/hooks/useInitialFormValues.ts | 4 +- packages/features/bookings/lib/SystemField.ts | 1 + .../features/bookings/lib/getBookingFields.ts | 29 +- .../lib/getBookingResponsesSchema.test.ts | 2 +- .../bookings/lib/getBookingResponsesSchema.ts | 14 +- .../bookings/lib/getCalEventResponses.ts | 12 + .../features/bookings/lib/getUserBooking.ts | 1 + .../bookings/lib/handleBookingRequested.ts | 5 +- .../bookings/lib/handleCancelBooking.ts | 16 +- .../bookings/lib/handleConfirmation.ts | 5 +- .../features/bookings/lib/handleNewBooking.ts | 76 +- .../lib/handleNewBooking/createBooking.ts | 2 + .../lib/handleNewBooking/getBookingData.ts | 2 + .../handleNewBooking/getEventTypesFromDB.ts | 12 +- .../getOriginalRescheduledBooking.ts | 1 + .../handleNewBooking/test/reschedule.test.ts | 214 +++- .../collective-scheduling.test.ts | 935 +++++++++++++++--- .../bookings/lib/handleNewBooking/types.ts | 1 + .../handleSeats/cancel/cancelAttendeeSeat.ts | 4 +- .../lib/handleSeats/create/createNewSeat.ts | 9 +- .../bookings/lib/handleSeats/handleSeats.ts | 5 +- .../attendeeRescheduleSeatedBooking.ts | 4 +- .../owner/combineTwoSeatedBookings.ts | 4 +- .../owner/moveSeatedBookingToNewTimeSlot.ts | 4 +- .../bookings/lib/handleSeats/types.d.ts | 1 + .../credentials/handleDeleteCredential.ts | 17 +- packages/features/ee/payments/api/webhook.ts | 4 +- .../ee/round-robin/roundRobinReassignment.ts | 7 +- .../lib/reminders/providers/twilioProvider.ts | 2 + .../lib/reminders/smsReminderManager.ts | 1 + .../eventtypes/lib/bookingFieldsManager.ts | 13 +- .../instant-meeting/handleInstantMeeting.ts | 3 +- packages/lib/CalEventParser.ts | 7 +- packages/lib/CalendarService.ts | 6 +- packages/lib/contructEmailFromPhoneNumber.ts | 4 + packages/lib/event-types/getEventTypeById.ts | 3 +- packages/lib/isSmsCalEmail.ts | 3 + packages/lib/payment/getBooking.ts | 9 + packages/lib/payment/handlePayment.ts | 9 +- packages/lib/payment/handlePaymentSuccess.ts | 4 +- .../atoms/booker/BookerWebWrapper.tsx | 2 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + packages/prisma/zod-utils.ts | 1 + packages/sms/attendee/awaiting-payment-sms.ts | 20 + packages/sms/attendee/cancelled-seat-sms.ts | 18 + packages/sms/attendee/event-cancelled-sms.ts | 23 + packages/sms/attendee/event-declined-sms.ts | 17 + .../attendee/event-location-changed-sms.ts | 17 + packages/sms/attendee/event-request-sms.ts | 21 + .../event-request-to-reschedule-sms.ts | 19 + .../sms/attendee/event-rescheduled-sms.ts | 20 + packages/sms/attendee/event-scheduled-sms.ts | 20 + packages/sms/sms-manager.ts | 99 ++ .../loggedInViewer/connectAndJoin.handler.ts | 17 +- .../viewer/bookings/confirm.handler.ts | 18 +- .../viewer/bookings/editLocation.handler.ts | 4 +- .../bookings/requestReschedule.handler.ts | 14 +- .../server/routers/viewer/bookings/types.ts | 2 +- .../server/routers/viewer/bookings/util.ts | 37 +- .../viewer/eventTypes/update.handler.ts | 2 + .../server/routers/viewer/eventTypes/util.ts | 26 + .../workflows/activateEventType.handler.ts | 4 +- packages/types/Calendar.d.ts | 2 + packages/types/PaymentService.d.ts | 6 +- .../popover/MeetingTimeInTimezones.tsx | 1 + 85 files changed, 2167 insertions(+), 363 deletions(-) create mode 100644 packages/lib/contructEmailFromPhoneNumber.ts create mode 100644 packages/lib/isSmsCalEmail.ts create mode 100644 packages/prisma/migrations/20240408155446_add_phone_number_in_attendee/migration.sql create mode 100644 packages/sms/attendee/awaiting-payment-sms.ts create mode 100644 packages/sms/attendee/cancelled-seat-sms.ts create mode 100644 packages/sms/attendee/event-cancelled-sms.ts create mode 100644 packages/sms/attendee/event-declined-sms.ts create mode 100644 packages/sms/attendee/event-location-changed-sms.ts create mode 100644 packages/sms/attendee/event-request-sms.ts create mode 100644 packages/sms/attendee/event-request-to-reschedule-sms.ts create mode 100644 packages/sms/attendee/event-rescheduled-sms.ts create mode 100644 packages/sms/attendee/event-scheduled-sms.ts create mode 100644 packages/sms/sms-manager.ts diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 69c61e2cbd6539..8076a673e68dbe 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -14,6 +14,7 @@ import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useGetTheme } from "@calcom/lib/hooks/useTheme"; +import isSmsCalEmail from "@calcom/lib/isSmsCalEmail"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -322,7 +323,7 @@ function BookingListItem(booking: BookingItemProps) { const urlSearchParams = new URLSearchParams({ allRemainingBookings: isTabRecurring.toString(), }); - if (booking.attendees[0]) urlSearchParams.set("email", booking.attendees[0].email); + if (booking.attendees[0].email) urlSearchParams.set("email", booking.attendees[0].email); return `/booking/${booking.uid}?${urlSearchParams.toString()}`; }; @@ -356,6 +357,7 @@ function BookingListItem(booking: BookingItemProps) { email: attendee.email, id: attendee.id, noShow: attendee.noShow || false, + phoneNumber: attendee.phoneNumber, }; }); return ( @@ -730,6 +732,7 @@ const FirstAttendee = ({ type AttendeeProps = { name?: string; email: string; + phoneNumber: string | null; id: number; noShow: boolean; }; @@ -740,7 +743,7 @@ type NoShowProps = { }; const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => { - const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee } = attendeeProps; + const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee, phoneNumber } = attendeeProps; const { t } = useLocale(); const [noShow, setNoShow] = useState(noShowAttendee); @@ -784,38 +787,43 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => { - - { - setOpenDropdown(false); - e.stopPropagation(); - }}> - {t("email")} - - + {!isSmsCalEmail(email) && ( + + { + setOpenDropdown(false); + e.stopPropagation(); + }}> + {t("email")} + + + )} + { e.preventDefault(); - copyToClipboard(email); + const isEmailCopied = isSmsCalEmail(email); + copyToClipboard(isEmailCopied ? email : phoneNumber ?? ""); setOpenDropdown(false); - showToast(t("email_copied"), "success"); + showToast(isEmailCopied ? t("email_copied") : t("phone_number_copied"), "success"); }}> {!isCopied ? t("copy") : t("copied")} + {isBookingInPast && ( {noShow ? ( { + e.preventDefault(); setOpenDropdown(false); toggleNoShow({ attendee: { noShow: false, email }, bookingUid }); - e.preventDefault(); }} StartIcon="eye"> {t("unmark_as_no_show")} @@ -824,9 +832,9 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => { { + e.preventDefault(); setOpenDropdown(false); toggleNoShow({ attendee: { noShow: true, email }, bookingUid }); - e.preventDefault(); }} StartIcon="eye-off"> {t("mark_as_no_show")} diff --git a/apps/web/lib/booking.ts b/apps/web/lib/booking.ts index c9033fa5aa96b9..81060f76f4ec15 100644 --- a/apps/web/lib/booking.ts +++ b/apps/web/lib/booking.ts @@ -39,6 +39,11 @@ export const getEventTypesFromDB = async (id: number) => { bookingFields: true, disableGuests: true, timeZone: true, + profile: { + select: { + organizationId: true, + }, + }, teamId: true, owner: { select: userSelect, @@ -82,11 +87,13 @@ export const getEventTypesFromDB = async (id: number) => { } const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); + const { profile, ...restEventType } = eventType; + const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId; return { isDynamic: false, - ...eventType, - bookingFields: getBookingFieldsWithSystemFields(eventType), + ...restEventType, + bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }), metadata, }; }; @@ -101,7 +108,7 @@ export const handleSeatsEventTypeOnBooking = async ( bookingInfo: Partial< Prisma.BookingGetPayload<{ include: { - attendees: { select: { name: true; email: true } }; + attendees: { select: { name: true; email: true; phoneNumber: true } }; seatsReferences: { select: { referenceUid: true } }; user: { select: { @@ -123,6 +130,7 @@ export const handleSeatsEventTypeOnBooking = async ( attendee: { email: string; name: string; + phoneNumber: string | null; }; id: number; data: Prisma.JsonValue; @@ -141,6 +149,7 @@ export const handleSeatsEventTypeOnBooking = async ( select: { name: true, email: true, + phoneNumber: true, }, }, }, @@ -158,7 +167,9 @@ export const handleSeatsEventTypeOnBooking = async ( if (!eventType.seatsShowAttendees && !isHost) { if (seatAttendee) { const attendee = bookingInfo?.attendees?.find((a) => { - return a.email === seatAttendee?.attendee?.email; + return ( + a.email === seatAttendee?.attendee?.email || a.phoneNumber === seatAttendee?.attendee?.phoneNumber + ); }); bookingInfo["attendees"] = attendee ? [attendee] : []; } else { diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index 56a9d2ec40af19..5079bfa7ffee86 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -42,6 +42,7 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import useTheme from "@calcom/lib/hooks/useTheme"; +import isSmsCalEmail from "@calcom/lib/isSmsCalEmail"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat"; import { localStorage } from "@calcom/lib/webstorage"; @@ -144,7 +145,7 @@ export default function Success(props: PageProps) { const attendees = bookingInfo?.attendees; - const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com")); + const isGmail = !!attendees.find((attendee) => attendee?.email?.includes("gmail.com")); const [is24h, setIs24h] = useState( props?.userTimeFormat ? props.userTimeFormat === 24 : isBrowserLocale24h() @@ -551,7 +552,14 @@ export default function Success(props: PageProps) { {attendee.name && (

{attendee.name}

)} -

{attendee.email}

+ {attendee.phoneNumber && ( +

+ {attendee.phoneNumber} +

+ )} + {!isSmsCalEmail(attendee.email) && ( +

{attendee.email}

+ )}
))} diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 7420bb5c40da71..2cdfb451cf3052 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -137,13 +137,19 @@ export async function bookFirstEvent(page: Page) { await bookEventOnThisPage(page); } -export const bookTimeSlot = async (page: Page, opts?: { name?: string; email?: string; title?: string }) => { +export const bookTimeSlot = async ( + page: Page, + opts?: { name?: string; email?: string; title?: string; attendeePhoneNumber?: string } +) => { // --- fill form await page.fill('[name="name"]', opts?.name ?? testName); await page.fill('[name="email"]', opts?.email ?? testEmail); if (opts?.title) { await page.fill('[name="title"]', opts.title); } + if (opts?.attendeePhoneNumber) { + await page.fill('[name="attendeePhoneNumber"]', opts.attendeePhoneNumber ?? "+918888888888"); + } await page.press('[name="email"]', "Enter"); }; diff --git a/apps/web/playwright/organization/booking.e2e.ts b/apps/web/playwright/organization/booking.e2e.ts index 8864798ef8135c..684cccfc0a0d2b 100644 --- a/apps/web/playwright/organization/booking.e2e.ts +++ b/apps/web/playwright/organization/booking.e2e.ts @@ -80,6 +80,63 @@ test.describe("Bookings", () => { // TODO: Assert whether the user received an email }); + test("Can create a booking for Collective EventType with only phone number", async ({ + page, + users, + orgs, + }) => { + const org = await orgs.create({ + name: "TestOrg", + }); + const teamMatesObj = [ + { name: "teammate-1" }, + { name: "teammate-2" }, + { name: "teammate-3" }, + { name: "teammate-4" }, + ]; + + const owner = await users.create( + { + username: "pro-user", + name: "pro-user", + organizationId: org.id, + roleInOrganization: MembershipRole.MEMBER, + }, + { + hasTeam: true, + teammates: teamMatesObj, + schedulingType: SchedulingType.COLLECTIVE, + } + ); + const { team } = await owner.getFirstTeamMembership(); + const teamEvent = await owner.getFirstTeamEvent(team.id); + await owner.apiLogin(); + + await markPhoneNumberAsRequiredAndEmailAsOptional(page, teamEvent.id); + + await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async () => { + await bookTeamEvent({ + page, + team, + event: teamEvent, + opts: { attendeePhoneNumber: "+918888888888" }, + }); + // All the teammates should be in the booking + for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) { + await expect(page.getByText(teammate.name, { exact: true })).toBeVisible(); + } + } + ); + + // TODO: Assert whether the user received an email + }); + test("Can create a booking for Round Robin EventType", async ({ page, users, orgs }) => { const org = await orgs.create({ name: "TestOrg", @@ -130,6 +187,69 @@ test.describe("Bookings", () => { // TODO: Assert whether the user received an email }); + test("Can create a booking for Round Robin EventType with both phone number and email required", async ({ + page, + users, + orgs, + }) => { + const org = await orgs.create({ + name: "TestOrg", + }); + const teamMatesObj = [ + { name: "teammate-1" }, + { name: "teammate-2" }, + { name: "teammate-3" }, + { name: "teammate-4" }, + ]; + const owner = await users.create( + { + username: "pro-user", + name: "pro-user", + organizationId: org.id, + roleInOrganization: MembershipRole.MEMBER, + }, + { + hasTeam: true, + teammates: teamMatesObj, + schedulingType: SchedulingType.ROUND_ROBIN, + } + ); + + const { team } = await owner.getFirstTeamMembership(); + const teamEvent = await owner.getFirstTeamEvent(team.id); + await owner.apiLogin(); + + await markPhoneNumberAsRequiredField(page, teamEvent.id); + + await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async () => { + await bookTeamEvent({ + page, + team, + event: teamEvent, + teamMatesObj, + opts: { attendeePhoneNumber: "+918888888888" }, + }); + + // Since all the users have the same leastRecentlyBooked value + // Anyone of the teammates could be the Host of the booking. + const chosenUser = await page.getByTestId("booking-host-name").textContent(); + expect(chosenUser).not.toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe( + true + ); + } + ); + // TODO: Assert whether the user received an email + }); + test("Can access booking page with event slug and team page in lowercase/uppercase/mixedcase", async ({ page, orgs, @@ -485,6 +605,7 @@ async function bookTeamEvent({ team, event, teamMatesObj, + opts, }: { page: Page; team: { @@ -493,6 +614,7 @@ async function bookTeamEvent({ }; event: { slug: string; title: string; schedulingType: SchedulingType | null }; teamMatesObj?: { name: string }[]; + opts?: { attendeePhoneNumber?: string }; }) { // Note that even though the default way to access a team booking in an organization is to not use /team in the URL, but it isn't testable with playwright as the rewrite is taken care of by Next.js config which can't handle on the fly org slug's handling // So, we are using /team in the URL to access the team booking @@ -501,7 +623,7 @@ async function bookTeamEvent({ await page.goto(`/team/${team.slug}/${event.slug}`); await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); + await bookTimeSlot(page, opts); await expect(page.getByTestId("success-page")).toBeVisible(); // The title of the booking @@ -526,3 +648,34 @@ async function expectPageToBeNotFound({ page, url }: { page: Page; url: string } await page.goto(`${url}`); await expect(page.getByTestId(`404-page`)).toBeVisible(); } + +const markPhoneNumberAsRequiredAndEmailAsOptional = async (page: Page, eventId: number) => { + // Make phone as required + await markPhoneNumberAsRequiredField(page, eventId); + + // Make email as not required + await page.locator('[data-testid="field-email"] [data-testid="edit-field-action"]').click(); + const emailRequiredFiled = await page.locator('[data-testid="field-required"]'); + await emailRequiredFiled.locator("> :nth-child(2)").click(); + await page.getByTestId("field-add-save").click(); + + const submitPromise = page.waitForResponse("/api/trpc/eventTypes/update?batch=1"); + await page.locator("[data-testid=update-eventtype]").click(); + const response = await submitPromise; + expect(response.status()).toBe(200); +}; + +const markPhoneNumberAsRequiredField = async (page: Page, eventId: number) => { + await page.goto(`/event-types/${eventId}?tabName=advanced`); + + await page.locator('[data-testid="field-attendeePhoneNumber"] [data-testid="toggle-field"]').click(); + await page.locator('[data-testid="field-attendeePhoneNumber"] [data-testid="edit-field-action"]').click(); + const phoneRequiredFiled = await page.locator('[data-testid="field-required"]'); + await phoneRequiredFiled.locator("> :nth-child(1)").click(); + await page.getByTestId("field-add-save").click(); + + const submitPromise = page.waitForResponse("/api/trpc/eventTypes/update?batch=1"); + await page.locator("[data-testid=update-eventtype]").click(); + const response = await submitPromise; + expect(response.status()).toBe(200); +}; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 0834ae22939b5b..b8028e77902c0e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -44,6 +44,7 @@ "event_request_cancelled": "Your scheduled event was canceled", "organizer": "Organizer", "need_to_reschedule_or_cancel": "Need to reschedule or cancel?", + "you_can_view_booking_details_with_this_url":"You can view the booking details from this url {{url}} and add the event to your calendar", "no_options_available": "No options available", "cancellation_reason": "Reason for cancellation (optional)", "cancellation_reason_placeholder": "Why are you cancelling?", @@ -181,7 +182,7 @@ "org_upgrade_banner_description": "Thank you for trialing our Organization plan. We noticed your Organization \"{{teamName}}\" needs to be upgraded.", "org_upgraded_successfully": "Your Organization was upgraded successfully!", "use_link_to_reset_password": "Use the link below to reset your password", - "hey_there": "Hey there,", + "hey_there": "Hey there", "forgot_your_password_calcom": "Forgot your password? - {{appName}}", "delete_webhook_confirmation_message": "Are you sure you want to delete this webhook? You will no longer receive {{appName}} meeting data at a specified URL, in real-time, when an event is scheduled or canceled.", "confirm_delete_webhook": "Yes, delete webhook", @@ -1904,6 +1905,7 @@ "not_enough_seats": "Not enough seats", "form_builder_field_already_exists": "A field with this name already exists", "show_on_booking_page": "Show on booking page", + "visit_cancelled_booking": "You can visit the canceled booking page", "get_started_zapier_templates": "Get started with Zapier templates", "team_is_unpublished": "{{team}} is unpublished", "org_is_unpublished_description": "This organization link is currently not available. Please contact the organization owner or ask them to publish it.", @@ -2459,6 +2461,7 @@ "add_emails": "Add Emails", "add_email_description": "Add an email address to replace your primary or to use as an alternative email on your event types.", "confirm_email": "Confirm your email", + "confirming_your_booking_sms":"$t(hey_there) {{name}}, confirming your booking on {{date}}.", "scheduler_first_name": "The first name of the person booking", "scheduler_last_name": "The last name of the person booking", "scheduler_name": "Scheduler Name", @@ -2549,6 +2552,7 @@ "team_select_info": "triggers for all team event types and all team members' personal event types", "no_show_updated": "No-show status updated for attendees", "email_copied": "Email copied", + "phone_number_copied": "Phone Number Copied", "USER_PENDING_MEMBER_OF_THE_ORG": "User is a pending member of the organization", "USER_ALREADY_INVITED_OR_MEMBER": "User is already invited or a member", "USER_MEMBER_OF_OTHER_ORGANIZATION": "User is member of an organization that this team is not a part of.", diff --git a/apps/web/test/utils/bookingScenario/MockPaymentService.ts b/apps/web/test/utils/bookingScenario/MockPaymentService.ts index 2eef10444ac644..a69c90b431e19b 100644 --- a/apps/web/test/utils/bookingScenario/MockPaymentService.ts +++ b/apps/web/test/utils/bookingScenario/MockPaymentService.ts @@ -4,7 +4,7 @@ import type { Payment, Prisma, PaymentOption, Booking } from "@prisma/client"; import { v4 as uuidv4 } from "uuid"; import "vitest-fetch-mock"; -import { sendAwaitingPaymentEmail } from "@calcom/emails"; +import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails"; import logger from "@calcom/lib/logger"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; @@ -64,7 +64,7 @@ export function getMockPaymentService() { paymentData: Payment ): Promise { // TODO: App implementing PaymentService is supposed to send email by itself at the moment. - await sendAwaitingPaymentEmail({ + await sendAwaitingPaymentEmailAndSMS({ ...event, paymentInfo: { link: createPaymentLink(/*{ diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 5a98727ae0400d..7f35c254c9f505 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -27,6 +27,7 @@ import type { SchedulingType, SMSLockState, TimeUnit } from "@calcom/prisma/enum import type { BookingStatus } from "@calcom/prisma/enums"; import type { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { userMetadataType } from "@calcom/prisma/zod-utils"; +import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import type { AppMeta } from "@calcom/types/App"; import type { NewCalendarEventType } from "@calcom/types/Calendar"; import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar"; @@ -34,6 +35,8 @@ import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar"; import { getMockPaymentService } from "./MockPaymentService"; import type { getMockRequestDataForBooking } from "./getMockRequestDataForBooking"; +type Fields = z.infer; + logger.settings.minLevel = 1; const log = logger.getSubLogger({ prefix: ["[bookingScenario]"] }); @@ -179,6 +182,7 @@ type WhiteListedBookingProps = { status: BookingStatus; attendees?: { email: string; + phoneNumber?: string; bookingSeat?: AttendeeBookingSeatInput | null; }[]; references?: (Omit, "credentialId"> & { @@ -1708,10 +1712,19 @@ export function mockCrmApp( }; } -export function getBooker({ name, email }: { name: string; email: string }) { +export function getBooker({ + name, + email, + attendeePhoneNumber, +}: { + name: string; + email: string; + attendeePhoneNumber?: string; +}) { return { name, email, + attendeePhoneNumber, }; } @@ -1770,8 +1783,11 @@ export function getMockBookingReference( } export function getMockBookingAttendee( - attendee: Omit & { + attendee: Omit & { bookingSeat?: AttendeeBookingSeatInput; + phoneNumber?: string | null; + email: string; + noShow?: boolean; } ) { return { @@ -1781,6 +1797,8 @@ export function getMockBookingAttendee( email: attendee.email, locale: attendee.locale, bookingSeat: attendee.bookingSeat || null, + phoneNumber: attendee.phoneNumber ?? undefined, + noShow: attendee.noShow ?? false, }; } @@ -1823,3 +1841,89 @@ export const replaceDates = (dates: string[], replacement: Record `${replacement[group1]}T`); }); }; + +export const getDefaultBookingFields = ({ + emailField, + bookingFields = [], +}: { + emailField?: Fields[number]; + bookingFields: Fields; +}) => { + return [ + { + name: "name", + type: "name", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system", + required: true, + defaultLabel: "your_name", + }, + !!emailField + ? emailField + : { + name: "email", + type: "email", + label: "", + hidden: false, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system", + required: true, + placeholder: "", + defaultLabel: "email_address", + }, + { + name: "location", + type: "radioInput", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system", + required: false, + defaultLabel: "location", + getOptionsAt: "locations", + optionsInputs: { + phone: { type: "phone", required: true, placeholder: "" }, + attendeeInPerson: { type: "address", required: true, placeholder: "" }, + }, + hideWhenJustOneOption: true, + }, + { + name: "title", + type: "text", + hidden: true, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: true, + defaultLabel: "what_is_this_meeting_about", + defaultPlaceholder: "", + }, + { + name: "notes", + type: "textarea", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + defaultLabel: "additional_notes", + defaultPlaceholder: "share_additional_notes", + }, + { + name: "guests", + type: "multiemail", + hidden: false, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + defaultLabel: "additional_guests", + defaultPlaceholder: "email", + }, + { + name: "rescheduleReason", + type: "textarea", + views: [{ id: "reschedule", label: "Reschedule View" }], + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + defaultLabel: "reason_for_reschedule", + defaultPlaceholder: "reschedule_placeholder", + }, + ...bookingFields, + ] as Fields; +}; diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 48f6f818e09eca..901b2c7687de5f 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -427,6 +427,16 @@ export async function expectBookingToBeInDatabase( ); } +export function expectSMSToBeTriggered({ sms, toNumber }: { sms: Fixtures["sms"]; toNumber: string }) { + expect(sms.get()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + to: toNumber, + }), + ]) + ); +} + export function expectSuccessfulBookingCreationEmails({ emails, organizer, @@ -818,7 +828,7 @@ export function expectBookingRequestedEmails({ }: { emails: Fixtures["emails"]; organizer: { email: string; name: string }; - booker: { email: string; name: string }; + booker?: { email: string; name: string }; }) { expect(emails).toHaveEmail( { @@ -829,14 +839,16 @@ export function expectBookingRequestedEmails({ `${organizer.email}` ); - expect(emails).toHaveEmail( - { - titleTag: "booking_submitted_subject", - to: `${booker.email}`, - noIcs: true, - }, - `${booker.email}` - ); + if (booker) { + expect(emails).toHaveEmail( + { + titleTag: "booking_submitted_subject", + to: `${booker.email}`, + noIcs: true, + }, + `${booker.email}` + ); + } } export function expectBookingRequestRescheduledEmails({ @@ -898,13 +910,17 @@ export function expectBookingRequestedWebhookToHaveBeenFired({ subscriberUrl, paidEvent, eventType, + isEmailHidden = false, + isAttendeePhoneNumberHidden = false, }: { organizer: { email: string; name: string }; - booker: { email: string; name: string }; + booker: { email: string; name: string; attendeePhoneNumber?: string }; subscriberUrl: string; location: string; paidEvent?: boolean; eventType: InputEventType; + isEmailHidden?: boolean; + isAttendeePhoneNumberHidden?: boolean; }) { // There is an inconsistency in the way we send the data to the webhook for paid events and unpaid events. Fix that and then remove this if statement. if (!paidEvent) { @@ -925,8 +941,17 @@ export function expectBookingRequestedWebhookToHaveBeenFired({ email: { label: "email_address", value: booker.email, - isHidden: false, + isHidden: isEmailHidden, }, + ...(booker.attendeePhoneNumber + ? { + attendeePhoneNumber: { + label: "phone_number", + value: booker.attendeePhoneNumber, + isHidden: isAttendeePhoneNumberHidden, + }, + } + : null), location: { label: "location", value: { optionValue: "", value: location }, @@ -947,6 +972,14 @@ export function expectBookingRequestedWebhookToHaveBeenFired({ responses: { name: { label: "name", value: booker.name }, email: { label: "email", value: booker.email }, + ...(booker.attendeePhoneNumber + ? { + attendeePhoneNumber: { + label: "phone_number", + value: booker.attendeePhoneNumber, + }, + } + : null), location: { label: "location", value: { optionValue: "", value: location }, @@ -963,13 +996,17 @@ export function expectBookingCreatedWebhookToHaveBeenFired({ subscriberUrl, paidEvent, videoCallUrl, + isEmailHidden = false, + isAttendeePhoneNumberHidden = false, }: { organizer: { email: string; name: string }; - booker: { email: string; name: string }; + booker: { email: string; name: string; attendeePhoneNumber?: string }; subscriberUrl: string; location: string; paidEvent?: boolean; videoCallUrl?: string | null; + isEmailHidden?: boolean; + isAttendeePhoneNumberHidden?: boolean; }) { if (!paidEvent) { expectWebhookToHaveBeenCalledWith(subscriberUrl, { @@ -980,7 +1017,16 @@ export function expectBookingCreatedWebhookToHaveBeenFired({ }, responses: { name: { label: "your_name", value: booker.name, isHidden: false }, - email: { label: "email_address", value: booker.email, isHidden: false }, + email: { label: "email_address", value: booker.email, isHidden: isEmailHidden }, + ...(booker.attendeePhoneNumber + ? { + attendeePhoneNumber: { + label: "phone_number", + value: booker.attendeePhoneNumber, + isHidden: isAttendeePhoneNumberHidden, + }, + } + : null), location: { label: "location", value: { optionValue: "", value: location }, @@ -1004,6 +1050,14 @@ export function expectBookingCreatedWebhookToHaveBeenFired({ label: "email", value: booker.email, }, + ...(booker.attendeePhoneNumber + ? { + attendeePhoneNumber: { + label: "phone_number", + value: booker.attendeePhoneNumber, + }, + } + : null), location: { label: "location", value: { optionValue: "", value: location }, diff --git a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts index 9a5581c6c8bceb..bf64ffc9d0fffd 100644 --- a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts +++ b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts @@ -33,6 +33,7 @@ export function getMockRequestDataForBooking({ email: string; name: string; location?: { optionValue: ""; value: string }; + attendeePhoneNumber?: string; smsReminderNumber?: string; }; }; diff --git a/packages/app-store/paypal/lib/PaymentService.ts b/packages/app-store/paypal/lib/PaymentService.ts index ce3b1862bb7a3c..23cb891af3a321 100644 --- a/packages/app-store/paypal/lib/PaymentService.ts +++ b/packages/app-store/paypal/lib/PaymentService.ts @@ -105,8 +105,8 @@ export class PaymentService implements IAbstractPaymentService { async collectCard( payment: Pick, bookingId: number, - _bookerEmail: string, - paymentOption: PaymentOption + paymentOption: PaymentOption, + _bookerEmail?: string | null ): Promise { // Ensure that the payment service can support the passed payment option if (paymentOptionEnum.parse(paymentOption) !== "HOLD") { diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index 89c78ddb85b456..d3c3a3816e1b09 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -3,7 +3,7 @@ import Stripe from "stripe"; import { v4 as uuidv4 } from "uuid"; import z from "zod"; -import { sendAwaitingPaymentEmail } from "@calcom/emails"; +import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; @@ -62,8 +62,9 @@ export class PaymentService implements IAbstractPaymentService { userId: Booking["userId"], username: string | null, bookerName: string, - bookerEmail: string, paymentOption: PaymentOption, + bookerEmail: string, + bookerPhoneNumber?: string | null, eventTitle?: string, bookingTitle?: string ) { @@ -78,8 +79,9 @@ export class PaymentService implements IAbstractPaymentService { } const customer = await retrieveOrCreateStripeCustomerByEmail( + this.credentials.stripe_user_id, bookerEmail, - this.credentials.stripe_user_id + bookerPhoneNumber ); const params: Stripe.PaymentIntentCreateParams = { @@ -93,7 +95,8 @@ export class PaymentService implements IAbstractPaymentService { calAccountId: userId, calUsername: username, bookerName, - bookerEmail, + bookerEmail: bookerEmail, + bookerPhoneNumber: bookerPhoneNumber ?? null, eventTitle: eventTitle || "", bookingTitle: bookingTitle || "", }, @@ -142,8 +145,9 @@ export class PaymentService implements IAbstractPaymentService { async collectCard( payment: Pick, bookingId: Booking["id"], + paymentOption: PaymentOption, bookerEmail: string, - paymentOption: PaymentOption + bookerPhoneNumber?: string | null ): Promise { try { if (!this.credentials) { @@ -156,8 +160,9 @@ export class PaymentService implements IAbstractPaymentService { } const customer = await retrieveOrCreateStripeCustomerByEmail( + this.credentials.stripe_user_id, bookerEmail, - this.credentials.stripe_user_id + bookerPhoneNumber ); const params = { @@ -165,6 +170,7 @@ export class PaymentService implements IAbstractPaymentService { payment_method_types: ["card"], metadata: { bookingId, + bookerPhoneNumber: bookerPhoneNumber ?? null, }, }; @@ -340,7 +346,7 @@ export class PaymentService implements IAbstractPaymentService { paymentData: Payment, eventTypeMetadata?: EventTypeMetadata ): Promise { - await sendAwaitingPaymentEmail( + await sendAwaitingPaymentEmailAndSMS( { ...event, paymentInfo: { diff --git a/packages/app-store/stripepayment/lib/customer.ts b/packages/app-store/stripepayment/lib/customer.ts index 3d68140aa5c5ba..78fbfef3bb5a67 100644 --- a/packages/app-store/stripepayment/lib/customer.ts +++ b/packages/app-store/stripepayment/lib/customer.ts @@ -83,7 +83,11 @@ export async function deleteStripeCustomer(user: UserType): Promise; +type PersonAttendeeCommonFields = Pick & { + phoneNumber?: string | null; +}; const Reschedule = async (bookingUid: string, cancellationReason: string) => { const bookingToReschedule = await prisma.booking.findFirstOrThrow({ @@ -30,6 +32,16 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { location: true, attendees: true, references: true, + eventType: { + include: { + team: { + select: { + id: true, + name: true, + }, + }, + }, + }, user: { select: { id: true, @@ -42,11 +54,6 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { destinationCalendar: true, }, }, - eventType: { - select: { - metadata: true, - }, - }, }, where: { uid: bookingUid, @@ -93,6 +100,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { username: user?.username || "", language: { translate: selectedLanguage, locale: user.locale || "en" }, timeZone: user?.timeZone, + phoneNumber: user?.phoneNumber, }; }); }; @@ -110,6 +118,13 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { tAttendees ), organizer: userOwnerAsPeopleType, + team: !!bookingToReschedule.eventType?.team + ? { + name: bookingToReschedule.eventType.team.name, + id: bookingToReschedule.eventType.team.id, + members: [], + } + : undefined, }); const director = new CalendarEventDirector(); director.setBuilder(builder); @@ -147,7 +162,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { // Send emails try { - await sendRequestRescheduleEmail( + await sendRequestRescheduleEmailAndSMS( builder.calendarEvent, { rescheduleLink: builder.rescheduleLink, diff --git a/packages/app-store/wipemycalother/lib/reschedule.ts b/packages/app-store/wipemycalother/lib/reschedule.ts index 91f3da71408c60..a0740bc0ab85cc 100644 --- a/packages/app-store/wipemycalother/lib/reschedule.ts +++ b/packages/app-store/wipemycalother/lib/reschedule.ts @@ -5,7 +5,7 @@ import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builde import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; import { deleteMeeting } from "@calcom/core/videoClient"; import dayjs from "@calcom/dayjs"; -import { sendRequestRescheduleEmail } from "@calcom/emails"; +import { sendRequestRescheduleEmailAndSMS } from "@calcom/emails"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; @@ -15,7 +15,9 @@ import type { Person } from "@calcom/types/Calendar"; import { getCalendar } from "../../_utils/getCalendar"; -type PersonAttendeeCommonFields = Pick; +type PersonAttendeeCommonFields = Pick & { + phoneNumber?: string | null; +}; const Reschedule = async (bookingUid: string, cancellationReason: string) => { const bookingToReschedule = await prisma.booking.findFirstOrThrow({ @@ -30,6 +32,17 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { location: true, attendees: true, references: true, + eventType: { + select: { + metadata: true, + team: { + select: { + id: true, + name: true, + }, + }, + }, + }, user: { select: { id: true, @@ -42,11 +55,6 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { destinationCalendar: true, }, }, - eventType: { - select: { - metadata: true, - }, - }, }, where: { uid: bookingUid, @@ -94,6 +102,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { username: user?.username || "", language: { translate: selectedLanguage, locale: user.locale || "en" }, timeZone: user?.timeZone, + phoneNumber: user?.phoneNumber, }; }); }; @@ -111,6 +120,13 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { tAttendees ), organizer: userOwnerAsPeopleType, + team: !!bookingToReschedule.eventType?.team + ? { + name: bookingToReschedule.eventType.team.name, + id: bookingToReschedule.eventType.team.id, + members: [], + } + : undefined, }); const director = new CalendarEventDirector(); director.setBuilder(builder); @@ -147,7 +163,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { // Send emails try { - await sendRequestRescheduleEmail( + await sendRequestRescheduleEmailAndSMS( builder.calendarEvent, { rescheduleLink: builder.rescheduleLink, diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 85a2a2c219647c..226be2c67b3987 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -12,6 +12,15 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; +import AwaitingPaymentSMS from "../sms/attendee/awaiting-payment-sms"; +import CancelledSeatSMS from "../sms/attendee/cancelled-seat-sms"; +import EventCancelledSMS from "../sms/attendee/event-cancelled-sms"; +import EventDeclinedSMS from "../sms/attendee/event-declined-sms"; +import EventLocationChangedSMS from "../sms/attendee/event-location-changed-sms"; +import EventRequestSMS from "../sms/attendee/event-request-sms"; +import EventRequestToRescheduleSMS from "../sms/attendee/event-request-to-reschedule-sms"; +import EventSuccessfullyReScheduledSMS from "../sms/attendee/event-rescheduled-sms"; +import EventSuccessfullyScheduledSMS from "../sms/attendee/event-scheduled-sms"; import type { MonthlyDigestEmailData } from "./src/templates/MonthlyDigestEmail"; import type { OrganizationAdminNoSlotsEmailInput } from "./src/templates/OrganizationAdminNoSlots"; import type { EmailVerifyLink } from "./templates/account-verify-email"; @@ -86,7 +95,7 @@ const eventTypeDisableHostEmail = (metadata?: EventTypeMetadata) => { return !!metadata?.disableStandardEmails?.all?.host; }; -export const sendScheduledEmails = async ( +export const sendScheduledEmailsAndSMS = async ( calEvent: CalendarEvent, eventNameObject?: EventNameObjectType, hostEmailDisabled?: boolean, @@ -129,62 +138,79 @@ export const sendScheduledEmails = async ( } await Promise.all(emailsToSend); + const successfullyScheduledSms = new EventSuccessfullyScheduledSMS(calEvent); + await successfullyScheduledSms.sendSMSToAttendees(); }; // for rescheduled round robin booking that assigned new members -export const sendRoundRobinScheduledEmails = async ( +export const sendRoundRobinScheduledEmailsAndSMS = async ( calEvent: CalendarEvent, members: Person[], eventTypeMetadata?: EventTypeMetadata ) => { if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const formattedCalEvent = formatCalEvent(calEvent); - const emailsToSend: Promise[] = []; + const emailsAndSMSToSend: Promise[] = []; + const eventScheduledSMS = new EventSuccessfullyScheduledSMS(calEvent); for (const teamMember of members) { - emailsToSend.push( + emailsAndSMSToSend.push( sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent, teamMember })) ); + if (teamMember.phoneNumber) { + emailsAndSMSToSend.push(eventScheduledSMS.sendSMSToAttendee(teamMember)); + } } - await Promise.all(emailsToSend); + await Promise.all(emailsAndSMSToSend); }; -export const sendRoundRobinRescheduledEmails = async ( +export const sendRoundRobinRescheduledEmailsAndSMS = async ( calEvent: CalendarEvent, - members: Person[], + teamMembersAndAttendees: Person[], eventTypeMetadata?: EventTypeMetadata ) => { if (eventTypeDisableHostEmail(eventTypeMetadata)) return; + const calendarEvent = formatCalEvent(calEvent); - const emailsToSend: Promise[] = []; + const emailsAndSMSToSend: Promise[] = []; + const successfullyReScheduledSMS = new EventSuccessfullyReScheduledSMS(calEvent); - for (const teamMember of members) { - emailsToSend.push( - sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember })) + for (const person of teamMembersAndAttendees) { + emailsAndSMSToSend.push( + sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember: person })) ); + if (person.phoneNumber) { + emailsAndSMSToSend.push(successfullyReScheduledSMS.sendSMSToAttendee(person)); + } } - await Promise.all(emailsToSend); + await Promise.all(emailsAndSMSToSend); }; -export const sendRoundRobinCancelledEmails = async ( +export const sendRoundRobinCancelledEmailsAndSMS = async ( calEvent: CalendarEvent, members: Person[], eventTypeMetadata?: EventTypeMetadata ) => { if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); - const emailsToSend: Promise[] = []; + const emailsAndSMSToSend: Promise[] = []; + const successfullyReScheduledSMS = new EventCancelledSMS(calEvent); for (const teamMember of members) { - emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember }))); + emailsAndSMSToSend.push( + sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember })) + ); + if (teamMember.phoneNumber) { + emailsAndSMSToSend.push(successfullyReScheduledSMS.sendSMSToAttendee(teamMember)); + } } - await Promise.all(emailsToSend); + await Promise.all(emailsAndSMSToSend); }; -export const sendRescheduledEmails = async ( +export const sendRescheduledEmailsAndSMS = async ( calEvent: CalendarEvent, eventTypeMetadata?: EventTypeMetadata ) => { @@ -212,9 +238,11 @@ export const sendRescheduledEmails = async ( } await Promise.all(emailsToSend); + const successfullyReScheduledSms = new EventSuccessfullyReScheduledSMS(calEvent); + await successfullyReScheduledSms.sendSMSToAttendees(); }; -export const sendRescheduledSeatEmail = async ( +export const sendRescheduledSeatEmailAndSMS = async ( calEvent: CalendarEvent, attendee: Person, eventTypeMetadata?: EventTypeMetadata @@ -229,10 +257,13 @@ export const sendRescheduledSeatEmail = async ( if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) emailsToSend.push(sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee))); + const successfullyReScheduledSMS = new EventSuccessfullyReScheduledSMS(calEvent); + await successfullyReScheduledSMS.sendSMSToAttendee(attendee); + await Promise.all(emailsToSend); }; -export const sendScheduledSeatsEmails = async ( +export const sendScheduledSeatsEmailsAndSMS = async ( calEvent: CalendarEvent, invitee: Person, newSeat: boolean, @@ -273,9 +304,11 @@ export const sendScheduledSeatsEmails = async ( ); } await Promise.all(emailsToSend); + const eventScheduledSMS = new EventSuccessfullyScheduledSMS(calendarEvent); + await eventScheduledSMS.sendSMSToAttendee(invitee); }; -export const sendCancelledSeatEmails = async ( +export const sendCancelledSeatEmailsAndSMS = async ( calEvent: CalendarEvent, cancelledAttendee: Person, eventTypeMetadata?: EventTypeMetadata @@ -292,6 +325,8 @@ export const sendCancelledSeatEmails = async ( ); await Promise.all(emailsToSend); + const cancelledSeatSMS = new CancelledSeatSMS(clonedCalEvent); + await cancelledSeatSMS.sendSMSToAttendee(cancelledAttendee); }; export const sendOrganizerRequestEmail = async ( @@ -314,18 +349,25 @@ export const sendOrganizerRequestEmail = async ( await Promise.all(emailsToSend); }; -export const sendAttendeeRequestEmail = async ( +export const sendAttendeeRequestEmailAndSMS = async ( calEvent: CalendarEvent, attendee: Person, eventTypeMetadata?: EventTypeMetadata ) => { if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return; + const calendarEvent = formatCalEvent(calEvent); await sendEmail(() => new AttendeeRequestEmail(calendarEvent, attendee)); + const eventRequestSms = new EventRequestSMS(calendarEvent); + await eventRequestSms.sendSMSToAttendee(attendee); }; -export const sendDeclinedEmails = async (calEvent: CalendarEvent, eventTypeMetadata?: EventTypeMetadata) => { +export const sendDeclinedEmailsAndSMS = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return; + const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -336,9 +378,11 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent, eventTypeMetad ); await Promise.all(emailsToSend); + const eventDeclindedSms = new EventDeclinedSMS(calEvent); + await eventDeclindedSms.sendSMSToAttendees(); }; -export const sendCancelledEmails = async ( +export const sendCancelledEmailsAndSMS = async ( calEvent: CalendarEvent, eventNameObject: Pick, eventTypeMetadata?: EventTypeMetadata @@ -394,6 +438,8 @@ export const sendCancelledEmails = async ( } await Promise.all(emailsToSend); + const eventCancelledSms = new EventCancelledSMS(calEvent); + await eventCancelledSms.sendSMSToAttendees(); }; export const sendOrganizerRequestReminderEmail = async ( @@ -416,7 +462,7 @@ export const sendOrganizerRequestReminderEmail = async ( } }; -export const sendAwaitingPaymentEmail = async ( +export const sendAwaitingPaymentEmailAndSMS = async ( calEvent: CalendarEvent, eventTypeMetadata?: EventTypeMetadata ) => { @@ -429,6 +475,8 @@ export const sendAwaitingPaymentEmail = async ( }) ); await Promise.all(emailsToSend); + const awaitingPaymentSMS = new AwaitingPaymentSMS(calEvent); + await awaitingPaymentSMS.sendSMSToAttendees(); }; export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => { @@ -474,7 +522,7 @@ export const sendChangeOfEmailVerificationLink = async (verificationInput: Chang await sendEmail(() => new ChangeOfEmailVerifyEmail(verificationInput)); }; -export const sendRequestRescheduleEmail = async ( +export const sendRequestRescheduleEmailAndSMS = async ( calEvent: CalendarEvent, metadata: { rescheduleLink: string }, eventTypeMetadata?: EventTypeMetadata @@ -490,9 +538,11 @@ export const sendRequestRescheduleEmail = async ( } await Promise.all(emailsToSend); + const eventRequestToReschedule = new EventRequestToRescheduleSMS(calendarEvent); + await eventRequestToReschedule.sendSMSToAttendees(); }; -export const sendLocationChangeEmails = async ( +export const sendLocationChangeEmailsAndSMS = async ( calEvent: CalendarEvent, eventTypeMetadata?: EventTypeMetadata ) => { @@ -521,6 +571,8 @@ export const sendLocationChangeEmails = async ( } await Promise.all(emailsToSend); + const eventLocationChangedSMS = new EventLocationChangedSMS(calendarEvent); + await eventLocationChangedSMS.sendSMSToAttendees(); }; export const sendAddGuestsEmails = async (calEvent: CalendarEvent, newGuests: string[]) => { const calendarEvent = formatCalEvent(calEvent); diff --git a/packages/emails/src/components/WhoInfo.tsx b/packages/emails/src/components/WhoInfo.tsx index c710028d0bf39c..ec852d9120c91e 100644 --- a/packages/emails/src/components/WhoInfo.tsx +++ b/packages/emails/src/components/WhoInfo.tsx @@ -1,17 +1,20 @@ import type { TFunction } from "next-i18next"; +import isSmsCalEmail from "@calcom/lib/isSmsCalEmail"; import type { CalendarEvent } from "@calcom/types/Calendar"; import { Info } from "./Info"; -const PersonInfo = ({ name = "", email = "", role = "" }) => ( +const PersonInfo = ({ name = "", email = "", role = "", phoneNumber = "" }) => (
- {name} - {role}{" "} - - - {email} - - + {name} - {role} {phoneNumber} + {!isSmsCalEmail(email) && ( + + + {email} + + + )}
); @@ -28,7 +31,7 @@ export function WhoInfo(props: { calEvent: CalendarEvent; t: TFunction }) { email={props.calEvent.organizer.email} /> {props.calEvent.team?.members.map((member) => ( - + ))} {props.calEvent.attendees.map((attendee) => ( ))} diff --git a/packages/emails/templates/_base-email.ts b/packages/emails/templates/_base-email.ts index d5574f1e54ed32..74c3a6e6f7a508 100644 --- a/packages/emails/templates/_base-email.ts +++ b/packages/emails/templates/_base-email.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import dayjs from "@calcom/dayjs"; import { getFeatureFlag } from "@calcom/features/flags/server/utils"; import { getErrorFromUnknown } from "@calcom/lib/errors"; +import isSmsCalEmail from "@calcom/lib/isSmsCalEmail"; import { serverConfig } from "@calcom/lib/serverConfig"; import { setTestEmail } from "@calcom/lib/testEmails"; import prisma from "@calcom/prisma"; @@ -52,6 +53,11 @@ export default class BaseEmail { const from = "from" in payload ? (payload.from as string) : ""; const to = "to" in payload ? (payload.to as string) : ""; + if (isSmsCalEmail(to)) { + console.log(`Skipped Sending Email to faux email: ${to}`); + return new Promise((r) => r(`Skipped Sending Email to faux email: ${to}`)); + } + const sanitizedFrom = sanitizeDisplayName(from); const sanitizedTo = sanitizeDisplayName(to); diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index bbc798d44d5805..1155156f5ffff6 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -176,7 +176,7 @@ const BookerComponent = ({ isVerificationCodeSending={isVerificationCodeSending} isPlatform={isPlatform}> <> - {verifyCode ? ( + {verifyCode && formEmail ? ( []; metadata: EventType["metadata"] | z.infer; @@ -79,6 +81,7 @@ export const getBookingFieldsWithSystemFields = ({ return ensureBookingInputsHaveSystemFields({ bookingFields: parsedBookingFields, disableGuests, + isOrgTeamEvent, disableBookingTitle, additionalNotesRequired: parsedMetaData?.additionalNotesRequired || false, customInputs: parsedCustomInputs, @@ -89,6 +92,7 @@ export const getBookingFieldsWithSystemFields = ({ export const ensureBookingInputsHaveSystemFields = ({ bookingFields, disableGuests, + isOrgTeamEvent, disableBookingTitle, additionalNotesRequired, customInputs, @@ -96,6 +100,7 @@ export const ensureBookingInputsHaveSystemFields = ({ }: { bookingFields: Fields; disableGuests: boolean; + isOrgTeamEvent: boolean; disableBookingTitle?: boolean; additionalNotesRequired: boolean; customInputs: z.infer[]; @@ -130,6 +135,8 @@ export const ensureBookingInputsHaveSystemFields = ({ }); }); + const isEmailFieldOptional = !!bookingFields.find((field) => field.name === "email" && !field.required); + // These fields should be added before other user fields const systemBeforeFields: typeof bookingFields = [ { @@ -152,8 +159,8 @@ export const ensureBookingInputsHaveSystemFields = ({ defaultLabel: "email_address", type: "email", name: "email", - required: true, - editable: "system", + required: !isEmailFieldOptional, + editable: isOrgTeamEvent ? "system-but-optional" : "system", sources: [ { label: "Default", @@ -162,6 +169,7 @@ export const ensureBookingInputsHaveSystemFields = ({ }, ], }, + { defaultLabel: "location", type: "radioInput", @@ -196,6 +204,23 @@ export const ensureBookingInputsHaveSystemFields = ({ ], }, ]; + if (isOrgTeamEvent) { + systemBeforeFields.splice(2, 0, { + defaultLabel: "phone_number", + type: "phone", + name: "attendeePhoneNumber", + required: false, + hidden: true, + editable: "system-but-optional", + sources: [ + { + label: "Default", + id: "default", + type: "default", + }, + ], + }); + } // These fields should be added after other user fields const systemAfterFields: typeof bookingFields = [ diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.test.ts b/packages/features/bookings/lib/getBookingResponsesSchema.test.ts index 8e0529773a64e9..0e5d21503c93b7 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.test.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.test.ts @@ -116,8 +116,8 @@ describe("getBookingResponsesSchema", () => { //@ts-ignore expect(parsedResponsesWithJustName.error.issues[0]).toEqual( expect.objectContaining({ - message: ZOD_REQUIRED_FIELD_ERROR_MSG, path: ["email"], + message: ZOD_REQUIRED_FIELD_ERROR_MSG, }) ); diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index 6260bbc1265a40..9e0a565b134ab0 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -117,6 +117,18 @@ function preprocess({ // if eventType has been deleted, we won't have bookingFields and thus we can't validate the responses. return; } + + const attendeePhoneNumberField = bookingFields.find((field) => field.name === "attendeePhoneNumber"); + const isAttendeePhoneNumberFieldHidden = attendeePhoneNumberField?.hidden; + + const emailField = bookingFields.find((field) => field.name === "email"); + const isEmailFieldHidden = !!emailField?.hidden; + + // To prevent using user's session email as attendee's email, we set email to empty string + if (isEmailFieldHidden && !isAttendeePhoneNumberFieldHidden) { + responses["email"] = ""; + } + for (const bookingField of bookingFields) { const value = responses[bookingField.name]; const stringSchema = z.string(); @@ -151,7 +163,7 @@ function preprocess({ if (bookingField.type === "email") { // Email RegExp to validate if the input is a valid email - if (!emailSchema.safeParse(value).success) { + if (!bookingField.hidden && !emailSchema.safeParse(value).success) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("email_validation_error"), diff --git a/packages/features/bookings/lib/getCalEventResponses.ts b/packages/features/bookings/lib/getCalEventResponses.ts index 51625e88266a7e..29e1e508edaf52 100644 --- a/packages/features/bookings/lib/getCalEventResponses.ts +++ b/packages/features/bookings/lib/getCalEventResponses.ts @@ -3,6 +3,7 @@ import type z from "zod"; import { SystemField } from "@calcom/features/bookings/lib/SystemField"; import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; +import { contructEmailFromPhoneNumber } from "@calcom/lib/contructEmailFromPhoneNumber"; import { getBookingWithResponses } from "@calcom/lib/getBooking"; import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -38,6 +39,16 @@ export const getCalEventResponses = ({ responses ?? (booking ? getBookingWithResponses(booking).responses : null); if (!backwardCompatibleResponses) throw new Error("Couldn't get responses"); + // To set placeholder email for the booking + if (!!!backwardCompatibleResponses.email) { + if (typeof backwardCompatibleResponses["attendeePhoneNumber"] !== "string") + throw new Error("Both Phone and Email are missing"); + + backwardCompatibleResponses.email = contructEmailFromPhoneNumber( + backwardCompatibleResponses["attendeePhoneNumber"] + ); + } + if (parsedBookingFields) { parsedBookingFields.forEach((field) => { const label = field.label || field.defaultLabel; @@ -58,6 +69,7 @@ export const getCalEventResponses = ({ isHidden: !!field.hidden, }; } + calEventResponses[field.name] = { label, value: backwardCompatibleResponses[field.name], diff --git a/packages/features/bookings/lib/getUserBooking.ts b/packages/features/bookings/lib/getUserBooking.ts index 71468a45cc0b29..1a6d50e6ed23e4 100644 --- a/packages/features/bookings/lib/getUserBooking.ts +++ b/packages/features/bookings/lib/getUserBooking.ts @@ -39,6 +39,7 @@ const getUserBooking = async (uid: string) => { name: true, email: true, timeZone: true, + phoneNumber: true, }, }, eventTypeId: true, diff --git a/packages/features/bookings/lib/handleBookingRequested.ts b/packages/features/bookings/lib/handleBookingRequested.ts index 9252315690665a..5c3c321fc3497c 100644 --- a/packages/features/bookings/lib/handleBookingRequested.ts +++ b/packages/features/bookings/lib/handleBookingRequested.ts @@ -1,6 +1,6 @@ import type { Prisma } from "@prisma/client"; -import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails"; +import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails"; import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWebhookPayloadForBooking"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; @@ -41,8 +41,9 @@ export async function handleBookingRequested(args: { const { evt, booking } = args; log.debug("Emails: Sending booking requested emails"); + await sendOrganizerRequestEmail({ ...evt }, booking?.eventType?.metadata as EventTypeMetadata); - await sendAttendeeRequestEmail( + await sendAttendeeRequestEmailAndSMS( { ...evt }, evt.attendees[0], booking?.eventType?.metadata as EventTypeMetadata diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index a54a98f4ca6ee3..a309a96fdcc424 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -5,7 +5,7 @@ import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApi import { DailyLocationType } from "@calcom/app-store/locations"; import EventManager from "@calcom/core/EventManager"; import dayjs from "@calcom/dayjs"; -import { sendCancelledEmails } from "@calcom/emails"; +import { sendCancelledEmailsAndSMS } from "@calcom/emails"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows"; import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; @@ -249,6 +249,7 @@ async function handler(req: CustomRequest) { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone, + phoneNumber: attendee.phoneNumber, language: { translate: await getTranslation(attendee.locale ?? "en", "common"), locale: attendee.locale ?? "en", @@ -310,9 +311,14 @@ async function handler(req: CustomRequest) { ? [bookingToDelete?.user.destinationCalendar] : [], cancellationReason: cancellationReason, - ...(teamMembers && { - team: { name: bookingToDelete?.eventType?.team?.name || "Nameless", members: teamMembers, id: teamId! }, - }), + ...(teamMembers && + teamId && { + team: { + name: bookingToDelete?.eventType?.team?.name || "Nameless", + members: teamMembers, + id: teamId, + }, + }), seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot, seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees, iCalUID: bookingToDelete.iCalUID, @@ -523,7 +529,7 @@ async function handler(req: CustomRequest) { try { // TODO: if emails fail try to requeue them if (!platformClientId || (platformClientId && arePlatformEmailsEnabled)) - await sendCancelledEmails( + await sendCancelledEmailsAndSMS( evt, { eventName: bookingToDelete?.eventType?.eventName }, bookingToDelete?.eventType?.metadata as EventTypeMetadata diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index 373a0b68076775..0c15709420145b 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -3,7 +3,7 @@ import type { Prisma } from "@prisma/client"; import type { EventManagerUser } from "@calcom/core/EventManager"; import EventManager from "@calcom/core/EventManager"; import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder"; -import { sendScheduledEmails } from "@calcom/emails"; +import { sendScheduledEmailsAndSMS } from "@calcom/emails"; import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, @@ -108,7 +108,7 @@ export async function handleConfirmation(args: { } } - await sendScheduledEmails( + await sendScheduledEmailsAndSMS( { ...evt, additionalInformation: metadata }, undefined, isHostConfirmationEmailsDisabled, @@ -126,6 +126,7 @@ export async function handleConfirmation(args: { attendees: { name: string; email: string; + phoneNumber?: string | null; }[]; startTime: Date; endTime: Date; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index a7ab6b65bd1d14..066bb65ef2aa3f 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -20,13 +20,13 @@ import { getEventName } from "@calcom/core/event"; import dayjs from "@calcom/dayjs"; import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder"; import { - sendAttendeeRequestEmail, + sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail, - sendRescheduledEmails, - sendRoundRobinCancelledEmails, - sendRoundRobinRescheduledEmails, - sendRoundRobinScheduledEmails, - sendScheduledEmails, + sendRescheduledEmailsAndSMS, + sendRoundRobinCancelledEmailsAndSMS, + sendRoundRobinRescheduledEmailsAndSMS, + sendRoundRobinScheduledEmailsAndSMS, + sendScheduledEmailsAndSMS, } from "@calcom/emails"; import getICalUID from "@calcom/emails/lib/getICalUID"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; @@ -215,9 +215,12 @@ async function handler( !req.body.eventTypeId && !!req.body.eventTypeSlug ? getDefaultEvent(req.body.eventTypeSlug) : await getEventTypesFromDB(req.body.eventTypeId); + + const isOrgTeamEvent = !!eventType?.team && !!eventType?.team?.parentId; + eventType = { ...eventType, - bookingFields: getBookingFieldsWithSystemFields(eventType), + bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }), }; const bookingDataSchema = bookingDataSchemaGetter({ @@ -239,6 +242,7 @@ async function handler( language, appsStatus: reqAppsStatus, name: bookerName, + attendeePhoneNumber: bookerPhoneNumber, email: bookerEmail, guests: reqGuests, location, @@ -726,6 +730,7 @@ async function handler( { email: bookerEmail, name: fullName, + phoneNumber: bookerPhoneNumber, firstName: (typeof bookerName === "object" && bookerName.firstName) || "", lastName: (typeof bookerName === "object" && bookerName.lastName) || "", timeZone: attendeeTimezone, @@ -963,6 +968,14 @@ async function handler( const workflows = await getAllWorkflowsFromEventType(eventType, organizerUser.id); + if (isTeamEventType) { + evt.team = { + members: teamMembers, + name: eventType.team?.name || "Nameless", + id: eventType.team?.id ?? 0, + }; + } + // For seats, if the booking already exists then we want to add the new attendee to the existing booking if (eventType.seatsPerTimeSlot) { const newBooking = await handleSeats({ @@ -975,6 +988,7 @@ async function handler( organizerUser, originalRescheduledBooking, bookerEmail, + bookerPhoneNumber, tAttendees, bookingSeat, reqUserId: req.userId, @@ -1022,13 +1036,7 @@ async function handler( }); } } - if (isTeamEventType) { - evt.team = { - members: teamMembers, - name: eventType.team?.name || "Nameless", - id: eventType.team?.id ?? 0, - }; - } + if (reqBody.recurringEventId && eventType.recurringEvent) { // Overriding the recurring event configuration count to be the actual number of events booked for // the recurring event (equal or less than recurring event configuration count) @@ -1105,7 +1113,10 @@ async function handler( if (booking && booking.id && eventType.seatsPerTimeSlot) { const currentAttendee = booking.attendees.find( - (attendee) => attendee.email === req.body.responses.email + (attendee) => + attendee.email === req.body.responses.email || + (req.body.responses.attendeePhoneNumber && + attendee.phoneNumber === req.body.responses.attendeePhoneNumber) ); // Save description to bookingSeat @@ -1321,6 +1332,7 @@ async function handler( name: user.name, email: user.email, timeZone: user.timeZone, + phoneNumber: user.phoneNumber, language: { translate, locale: user.locale ?? "en" }, }); } @@ -1339,26 +1351,39 @@ async function handler( .concat(copyEvent.organizer) .concat(copyEvent.attendees) || []; + const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) => { + return originalMember.email === newMember.email; + }; + // scheduled Emails const newBookedMembers = newBookingMemberEmails.filter( (member) => - !originalBookingMemberEmails.find((originalMember) => originalMember.email === member.email) + !originalBookingMemberEmails.find((originalMember) => + matchOriginalMemberWithNewMember(originalMember, member) + ) ); // cancelled Emails const cancelledMembers = originalBookingMemberEmails.filter( - (member) => !newBookingMemberEmails.find((newMember) => newMember.email === member.email) + (member) => + !newBookingMemberEmails.find((newMember) => matchOriginalMemberWithNewMember(member, newMember)) ); // rescheduled Emails const rescheduledMembers = newBookingMemberEmails.filter((member) => - originalBookingMemberEmails.find((orignalMember) => orignalMember.email === member.email) + originalBookingMemberEmails.find((orignalMember) => + matchOriginalMemberWithNewMember(orignalMember, member) + ) ); - sendRoundRobinRescheduledEmails(copyEventAdditionalInfo, rescheduledMembers, eventType.metadata); - sendRoundRobinScheduledEmails(copyEventAdditionalInfo, newBookedMembers, eventType.metadata); - sendRoundRobinCancelledEmails(copyEventAdditionalInfo, cancelledMembers, eventType.metadata); + sendRoundRobinRescheduledEmailsAndSMS( + copyEventAdditionalInfo, + rescheduledMembers, + eventType.metadata + ); + sendRoundRobinScheduledEmailsAndSMS(copyEventAdditionalInfo, newBookedMembers, eventType.metadata); + sendRoundRobinCancelledEmailsAndSMS(copyEventAdditionalInfo, cancelledMembers, eventType.metadata); } else { // send normal rescheduled emails (non round robin event, where organizers stay the same) - await sendRescheduledEmails( + await sendRescheduledEmailsAndSMS( { ...copyEvent, additionalInformation: metadata, @@ -1496,7 +1521,7 @@ async function handler( }) ); - await sendScheduledEmails( + await sendScheduledEmailsAndSMS( { ...evt, additionalInformation, @@ -1536,7 +1561,7 @@ async function handler( }) ); await sendOrganizerRequestEmail({ ...evt, additionalNotes }, eventType.metadata); - await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata); + await sendAttendeeRequestEmailAndSMS({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata); } if (booking.location?.startsWith("http")) { @@ -1607,7 +1632,8 @@ async function handler( eventTypePaymentAppCredential as IEventTypePaymentCredentialType, booking, fullName, - bookerEmail + bookerEmail, + bookerPhoneNumber ); const subscriberOptionsPaymentInitiated: GetSubscriberOptions = { userId: triggerForUser ? organizerUser.id : null, diff --git a/packages/features/bookings/lib/handleNewBooking/createBooking.ts b/packages/features/bookings/lib/handleNewBooking/createBooking.ts index 8246e194b9ea91..f36244586b9024 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts @@ -148,6 +148,7 @@ function getAttendeesData(evt: Pick) { email: attendee.email, timeZone: attendee.timeZone, locale: attendee.language.locale, + phoneNumber: attendee.phoneNumber, })); if (evt.team?.members) { @@ -157,6 +158,7 @@ function getAttendeesData(evt: Pick) { name: member.name, timeZone: member.timeZone, locale: member.language.locale, + phoneNumber: member.phoneNumber, })) ); } diff --git a/packages/features/bookings/lib/handleNewBooking/getBookingData.ts b/packages/features/bookings/lib/handleNewBooking/getBookingData.ts index 316630a1b85f75..1a2fffa542538c 100644 --- a/packages/features/bookings/lib/handleNewBooking/getBookingData.ts +++ b/packages/features/bookings/lib/handleNewBooking/getBookingData.ts @@ -51,6 +51,7 @@ export async function getBookingData({ calEventUserFieldsResponses: undefined, calEventResponses: undefined, customInputs: undefined, + attendeePhoneNumber: undefined, }; } if (!reqBody.responses) { @@ -67,6 +68,7 @@ export async function getBookingData({ ...reqBody, name: responses.name, email: responses.email, + attendeePhoneNumber: responses.attendeePhoneNumber, guests: responses.guests ? responses.guests : [], location: responses.location?.optionValue || responses.location?.value || "", smsReminderNumber: responses.smsReminderNumber, diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index ccd0841b7872a2..80f5dd52f54e66 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -24,6 +24,11 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { }, }, slug: true, + profile: { + select: { + organizationId: true, + }, + }, teamId: true, team: { select: { @@ -124,13 +129,16 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { }, }); + const { profile, ...restEventType } = eventType; + const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId; + return { - ...eventType, + ...restEventType, metadata: EventTypeMetaDataSchema.parse(eventType?.metadata || {}), recurringEvent: parseRecurringEvent(eventType?.recurringEvent), customInputs: customInputSchema.array().parse(eventType?.customInputs || []), locations: (eventType?.locations ?? []) as LocationObject[], - bookingFields: getBookingFieldsWithSystemFields(eventType || {}), + bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent } || {}), isDynamic: false, }; }; diff --git a/packages/features/bookings/lib/handleNewBooking/getOriginalRescheduledBooking.ts b/packages/features/bookings/lib/handleNewBooking/getOriginalRescheduledBooking.ts index 55930a095f4a26..021541a03704e4 100644 --- a/packages/features/bookings/lib/handleNewBooking/getOriginalRescheduledBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/getOriginalRescheduledBooking.ts @@ -18,6 +18,7 @@ export async function getOriginalRescheduledBooking(uid: string, seatsEventType? email: true, locale: true, timeZone: true, + phoneNumber: true, ...(seatsEventType && { bookingSeat: true, id: true }), }, }, diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index e6b106662dbab2..678dc7c641e6b7 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -17,6 +17,7 @@ import { getMockBookingAttendee, getMockFailingAppStatus, getMockPassingAppStatus, + getDefaultBookingFields, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; import { @@ -36,11 +37,12 @@ import { import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; -import { describe, expect } from "vitest"; +import { describe, expect, beforeEach } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; import { WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; +import { resetTestSMS } from "@calcom/lib/testSMS"; import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; @@ -50,6 +52,10 @@ const timeout = process.env.CI ? 5000 : 20000; describe("handleNewBooking", () => { setupAndTeardown(); + beforeEach(() => { + resetTestSMS(); + }); + describe("Reschedule", () => { describe("User event-type", () => { test( @@ -2174,6 +2180,210 @@ describe("handleNewBooking", () => { }, timeout ); + + test( + "[Event Type with Both Email and Attendee Phone Number as required fields] should send rescheduling emails when round robin is rescheduled to same host", + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const TEST_ATTENDEE_NUMBER = "+919876543210"; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + attendeePhoneNumber: TEST_ATTENDEE_NUMBER, + }); + + const roundRobinHost1 = getOrganizer({ + name: "RR Host 1", + email: "rrhost1@example.com", + id: 101, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + }, + }, + ], + }); + + const roundRobinHost2 = getOrganizer({ + name: "RR Host 2", + email: "rrhost2@example.com", + id: 102, + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + }, + }, + ], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 15, + teamId: 1, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + schedulingType: SchedulingType.ROUND_ROBIN, + bookingFields: getDefaultBookingFields({ + emailField: { + name: "email", + type: "email", + label: "", + hidden: false, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: true, + placeholder: "", + defaultLabel: "email_address", + }, + bookingFields: [ + { + name: "attendeePhoneNumber", + type: "phone", + hidden: false, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: true, + defaultLabel: "phone_number", + }, + ], + }), + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + }, + ], + organizer: roundRobinHost1, + usersApartFromOrganizer: [roundRobinHost2], + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + update: { + uid: "UPDATED_MOCK_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + user: roundRobinHost1.name, + rescheduleUid: uidOfBookingToBeRescheduled, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:15:00.000Z`, + responses: { + email: booker.email, + name: booker.name, + attendeePhoneNumber: booker.attendeePhoneNumber, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + const previousBooking = await prismaMock.booking.findUnique({ + where: { + uid: uidOfBookingToBeRescheduled, + }, + }); + + logger.silly({ + previousBooking, + allBookings: await prismaMock.booking.findMany(), + }); + + // Expect previous booking to be cancelled + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: uidOfBookingToBeRescheduled, + status: BookingStatus.CANCELLED, + }); + + expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); + /** + * Booking Time should be new time + */ + expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`); + expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + attendeePhoneNumber: booker.attendeePhoneNumber, + name: booker.name, + }), + }, + }); + + expectSuccessfulRoundRobinReschedulingEmails({ + prevOrganizer: roundRobinHost1, + newOrganizer: roundRobinHost1, // Round robin host 2 is not available and it will be rescheduled to same user + emails, + }); + }, + timeout + ); + test( "should reschedule event with same round robin host", async ({ emails }) => { @@ -2304,7 +2514,7 @@ describe("handleNewBooking", () => { expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`); // Expect both hosts for the event types to be the same - expect(createdBooking.userId).toBe(previousBooking.userId); + expect(createdBooking.userId).toBe(previousBooking?.userId ?? -1); await expectBookingInDBToBeRescheduledFromTo({ from: { diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts index ecb098639402f8..65caaf31a65e58 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts @@ -1,6 +1,6 @@ -import { createOrganization } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createBookingScenario, + createOrganization, getGoogleCalendarCredential, TestData, getOrganizer, @@ -13,6 +13,7 @@ import { getExpectedCalEventForBookingRequest, BookingLocations, getZoomAppCredential, + getDefaultBookingFields, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; import { @@ -22,18 +23,23 @@ import { expectBookingCreatedWebhookToHaveBeenFired, expectSuccessfulCalendarEventCreationInCalendar, expectSuccessfulVideoMeetingCreation, + expectSMSToBeTriggered, + expectBookingRequestedEmails, + expectBookingRequestedWebhookToHaveBeenFired, } from "@calcom/web/test/utils/bookingScenario/expects"; import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; -import { describe, expect } from "vitest"; +import { describe, expect, beforeEach } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations"; import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import { contructEmailFromPhoneNumber } from "@calcom/lib/contructEmailFromPhoneNumber"; import { ErrorCode } from "@calcom/lib/errorCodes"; +import { resetTestSMS } from "@calcom/lib/testSMS"; import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; @@ -46,6 +52,10 @@ const timeout = process.env.CI ? 5000 : 20000; describe("handleNewBooking", () => { setupAndTeardown(); + beforeEach(() => { + resetTestSMS(); + }); + describe("Team Events", () => { describe("Collective Assignment", () => { describe("When there is no schedule set on eventType - Hosts schedules would be used", () => { @@ -283,41 +293,633 @@ describe("handleNewBooking", () => { }); await createBookingScenario( - getScenarioData({ - webhooks: [ - { - userId: organizer.id, - eventTriggers: ["BOOKING_CREATED"], - subscriberUrl: "http://my-webhook.example.com", - active: true, - eventTypeId: 1, - appId: null, - }, - ], - eventTypes: [ - { - id: 1, - slotInterval: 15, - schedulingType: SchedulingType.COLLECTIVE, - length: 15, - users: [ - { - id: 101, - }, - { - id: 102, + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 15, + schedulingType: SchedulingType.COLLECTIVE, + length: 15, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:15:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => { + await handleNewBooking(req); + }).rejects.toThrowError(ErrorCode.HostsUnavailableForBooking); + }, + timeout + ); + }); + + describe("When there is a schedule set on eventType - Event Type common schedule would be used", () => { + test( + `succesfully creates a booking when the users are available as per the common schedule selected in the event-type + - Destination calendars for event-type and non-first hosts are used to create calendar events + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // No user schedules are here + schedules: [], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + // No user schedules are here + schedules: [], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 15, + schedulingType: SchedulingType.COLLECTIVE, + length: 15, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + // Common schedule is the morning shift + schedule: TestData.schedules.IstMorningShift, + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + const createdBooking = await handleNewBooking(req); + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + // expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + expectSuccessfulBookingCreationEmails({ + booking: { + uid: createdBooking.uid!, + }, + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, + }); + }, + timeout + ); + test( + `[Event Type with both Attendee Phone number and Email as required fields] succesfully creates a booking when the users are available as per the common schedule selected in the event-type + - Destination calendars for event-type and non-first hosts are used to create calendar events + `, + async ({ emails, sms }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const org = await createOrganization({ + name: "Test Org", + slug: "testorg", + }); + const TEST_ATTENDEE_NUMBER = "+918888888888"; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + attendeePhoneNumber: TEST_ATTENDEE_NUMBER, + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + organizationId: org.id, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + parentId: org.id, + }, + }, + ], + }); + + await createBookingScenario( + getScenarioData( + { + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + teamId: 1, + slotInterval: 15, + schedulingType: SchedulingType.COLLECTIVE, + length: 15, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + // Both Email and Attendee Phone Number Fields are required + bookingFields: getDefaultBookingFields({ + emailField: { + name: "email", + type: "email", + label: "", + hidden: false, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: true, + placeholder: "", + defaultLabel: "email_address", + }, + bookingFields: [ + { + name: "attendeePhoneNumber", + type: "phone", + hidden: false, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: true, + defaultLabel: "phone_number", + }, + ], + }), + // Common schedule is the morning shift + schedule: TestData.schedules.IstMorningShift, + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }, + { id: org.id } + ) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + attendeePhoneNumber: booker.attendeePhoneNumber, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + // expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + const WEBSITE_PROTOCOL = new URL(WEBSITE_URL).protocol; + expectSuccessfulBookingCreationEmails({ + booking: { + uid: createdBooking.uid!, + urlOrigin: `${WEBSITE_PROTOCOL}//team-1.cal.local:3000`, + }, + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, + }); + + expectSMSToBeTriggered({ sms, toNumber: TEST_ATTENDEE_NUMBER }); + }, + timeout + ); + + test( + `[Event Type with only Attendee Phone number as required field and Email as hidden field] succesfully creates a booking when the users are available as per the common schedule selected in the event-type + - Destination calendars for event-type and non-first hosts are used to create calendar events + `, + async ({ emails, sms }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const org = await createOrganization({ + name: "Test Org", + slug: "testorg", + }); + + const TEST_ATTENDEE_NUMBER = "+918888888888"; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + attendeePhoneNumber: TEST_ATTENDEE_NUMBER, + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + organizationId: org.id, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + parentId: org.id, + }, + }, + ], + }); + + await createBookingScenario( + getScenarioData( + { + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + teamId: 1, + slotInterval: 15, + schedulingType: SchedulingType.COLLECTIVE, + length: 15, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + // Both Email and Attendee Phone Number Fields are required + bookingFields: getDefaultBookingFields({ + emailField: { + name: "email", + type: "email", + label: "", + hidden: true, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + placeholder: "", + defaultLabel: "email_address", + }, + bookingFields: [ + { + name: "attendeePhoneNumber", + type: "phone", + hidden: false, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: true, + defaultLabel: "phone_number", + }, + ], + }), + // Common schedule is the morning shift + schedule: TestData.schedules.IstMorningShift, + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", }, - ], - destinationCalendar: { - integration: TestData.apps["google-calendar"].type, - externalId: "event-type-1@google-calendar.com", }, - }, - ], - organizer, - usersApartFromOrganizer: otherTeamMembers, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }, + { id: org.id } + ) ); mockSuccessfulVideoMeetingCreation({ @@ -345,6 +947,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, + attendeePhoneNumber: booker.attendeePhoneNumber, location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, @@ -355,24 +958,95 @@ describe("handleNewBooking", () => { body: mockBookingData, }); - await expect(async () => { - await handleNewBooking(req); - }).rejects.toThrowError(ErrorCode.HostsUnavailableForBooking); + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + attendeePhoneNumber: booker.attendeePhoneNumber, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + const WEBSITE_PROTOCOL = new URL(WEBSITE_URL).protocol; + expectSuccessfulBookingCreationEmails({ + booking: { + uid: createdBooking.uid!, + urlOrigin: `${WEBSITE_PROTOCOL}//team-1.cal.local:3000`, + }, + booker: { email: contructEmailFromPhoneNumber(TEST_ATTENDEE_NUMBER), name: booker.name }, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker: { email: contructEmailFromPhoneNumber(TEST_ATTENDEE_NUMBER), name: booker.name }, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, + isEmailHidden: true, + isAttendeePhoneNumberHidden: false, + }); + + expectSMSToBeTriggered({ sms, toNumber: TEST_ATTENDEE_NUMBER }); }, timeout ); - }); - - describe("When there is a schedule set on eventType - Event Type common schedule would be used", () => { test( - `succesfully creates a booking when the users are available as per the common schedule selected in the event-type + `[Event Type that requires confirmation with only Attendee Phone number as required field and Email as optional field] succesfully creates a booking when the users are available as per the common schedule selected in the event-type - Destination calendars for event-type and non-first hosts are used to create calendar events `, - async ({ emails }) => { + async ({ emails, sms }) => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const org = await createOrganization({ + name: "Test Org", + slug: "testorg", + }); + const subscriberUrl = "http://my-webhook.example.com"; + const TEST_ATTENDEE_NUMBER = "+918888888888"; + const booker = getBooker({ email: "booker@example.com", name: "Booker", + attendeePhoneNumber: TEST_ATTENDEE_NUMBER, }); const otherTeamMembers = [ @@ -383,8 +1057,7 @@ describe("handleNewBooking", () => { defaultScheduleId: null, email: "other-team-member-1@example.com", id: 102, - // No user schedules are here - schedules: [], + schedules: [TestData.schedules.IstEveningShift], credentials: [getGoogleCalendarCredential()], selectedCalendars: [TestData.selectedCalendars.google], destinationCalendar: { @@ -399,18 +1072,31 @@ describe("handleNewBooking", () => { email: "organizer@example.com", id: 101, defaultScheduleId: null, - // No user schedules are here - schedules: [], + organizationId: org.id, + schedules: [TestData.schedules.IstMorningShift], credentials: [getGoogleCalendarCredential()], selectedCalendars: [TestData.selectedCalendars.google], destinationCalendar: { integration: TestData.apps["google-calendar"].type, externalId: "organizer@google-calendar.com", }, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + parentId: org.id, + }, + }, + ], }); - await createBookingScenario( - getScenarioData({ + const scenarioData = getScenarioData( + { webhooks: [ { userId: organizer.id, @@ -424,7 +1110,9 @@ describe("handleNewBooking", () => { eventTypes: [ { id: 1, + teamId: 1, slotInterval: 15, + requiresConfirmation: true, schedulingType: SchedulingType.COLLECTIVE, length: 15, users: [ @@ -435,6 +1123,31 @@ describe("handleNewBooking", () => { id: 102, }, ], + // Both Email and Attendee Phone Number Fields are required + bookingFields: getDefaultBookingFields({ + emailField: { + name: "email", + type: "email", + label: "", + hidden: true, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + placeholder: "", + defaultLabel: "email_address", + }, + bookingFields: [ + { + name: "attendeePhoneNumber", + type: "phone", + hidden: false, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: true, + defaultLabel: "phone_number", + }, + ], + }), // Common schedule is the morning shift schedule: TestData.schedules.IstMorningShift, destinationCalendar: { @@ -446,8 +1159,10 @@ describe("handleNewBooking", () => { organizer, usersApartFromOrganizer: otherTeamMembers, apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) + }, + { id: org.id } ); + await createBookingScenario(scenarioData); mockSuccessfulVideoMeetingCreation({ metadataLookupKey: appStoreMetadata.dailyvideo.dirName, @@ -458,7 +1173,7 @@ describe("handleNewBooking", () => { }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", @@ -471,9 +1186,11 @@ describe("handleNewBooking", () => { start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`, end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`, eventTypeId: 1, + // No Email Passed responses: { - email: booker.email, name: booker.name, + email: "", + attendeePhoneNumber: booker.attendeePhoneNumber, location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, @@ -490,64 +1207,30 @@ describe("handleNewBooking", () => { description: "", location: BookingLocations.CalVideo, responses: expect.objectContaining({ - email: booker.email, + attendeePhoneNumber: booker.attendeePhoneNumber, name: booker.name, }), // eslint-disable-next-line @typescript-eslint/no-non-null-assertion uid: createdBooking.uid!, eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.ACCEPTED, - references: [ - { - type: appStoreMetadata.dailyvideo.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", - }, - { - type: TestData.apps["google-calendar"].type, - uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - }, - ], - }); - - // expectWorkflowToBeTriggered(); - expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { - destinationCalendars: [ - { - integration: TestData.apps["google-calendar"].type, - externalId: "event-type-1@google-calendar.com", - }, - { - integration: TestData.apps["google-calendar"].type, - externalId: "other-team-member-1@google-calendar.com", - }, - ], - videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + status: BookingStatus.PENDING, }); - expectSuccessfulBookingCreationEmails({ - booking: { - uid: createdBooking.uid!, - }, - booker, + expectBookingRequestedEmails({ organizer, - otherTeamMembers, emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }); - expectBookingCreatedWebhookToHaveBeenFired({ - booker, + expectBookingRequestedWebhookToHaveBeenFired({ + booker: { name: booker.name, email: contructEmailFromPhoneNumber(TEST_ATTENDEE_NUMBER) }, organizer, location: BookingLocations.CalVideo, - subscriberUrl: "http://my-webhook.example.com", - videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, + subscriberUrl, + eventType: scenarioData.eventTypes[0], + isEmailHidden: true, }); + + expectSMSToBeTriggered({ sms, toNumber: TEST_ATTENDEE_NUMBER }); }, timeout ); @@ -560,7 +1243,6 @@ describe("handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -579,7 +1261,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -594,7 +1275,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - await createBookingScenario( getScenarioData({ webhooks: [ @@ -633,7 +1313,6 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }) ); - mockSuccessfulVideoMeetingCreation({ metadataLookupKey: appStoreMetadata.dailyvideo.dirName, videoMeetingData: { @@ -642,14 +1321,12 @@ describe("handleNewBooking", () => { url: `http://mock-dailyvideo.example.com/meeting-1`, }, }); - mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - const mockBookingData = getMockRequestDataForBooking({ data: { start: `${getDate({ dateIncrement: 1 }).dateString}T03:30:00.000Z`, @@ -662,12 +1339,10 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - await expect(async () => { await handleNewBooking(req); }).rejects.toThrowError(ErrorCode.NoAvailableUsersFound); @@ -684,7 +1359,6 @@ describe("handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -702,7 +1376,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -716,7 +1389,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - const { eventTypes } = await createBookingScenario( getScenarioData({ webhooks: [ @@ -754,7 +1426,6 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }) ); - const videoMock = mockSuccessfulVideoMeetingCreation({ metadataLookupKey: appStoreMetadata.dailyvideo.dirName, videoMeetingData: { @@ -763,14 +1434,12 @@ describe("handleNewBooking", () => { url: `http://mock-dailyvideo.example.com/meeting-1`, }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - const mockBookingData = getMockRequestDataForBooking({ data: { start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, @@ -783,14 +1452,11 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - const createdBooking = await handleNewBooking(req); - await expectBookingToBeInDatabase({ description: "", location: BookingLocations.CalVideo, @@ -819,7 +1485,6 @@ describe("handleNewBooking", () => { }, ], }); - // expectWorkflowToBeTriggered(); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { destinationCalendars: [ @@ -834,7 +1499,6 @@ describe("handleNewBooking", () => { ], videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", }); - expectSuccessfulVideoMeetingCreation(videoMock, { credential: expect.objectContaining({ appId: "daily-video", @@ -849,7 +1513,6 @@ describe("handleNewBooking", () => { }) ), }); - expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -860,7 +1523,6 @@ describe("handleNewBooking", () => { emails, iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }); - expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, @@ -880,7 +1542,6 @@ describe("handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -904,7 +1565,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -926,7 +1586,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - const { eventTypes } = await createBookingScenario( getScenarioData({ webhooks: [ @@ -970,7 +1629,6 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["zoomvideo"]], }) ); - const videoMock = mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "zoomvideo", videoMeetingData: { @@ -979,14 +1637,12 @@ describe("handleNewBooking", () => { url: `http://mock-zoomvideo.example.com/meeting-1`, }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - const mockBookingData = getMockRequestDataForBooking({ data: { start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, @@ -999,14 +1655,11 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - const createdBooking = await handleNewBooking(req); - await expectBookingToBeInDatabase({ description: "", location: BookingLocations.ZoomVideo, @@ -1034,7 +1687,6 @@ describe("handleNewBooking", () => { }, ], }); - // expectWorkflowToBeTriggered(); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { destinationCalendars: [ @@ -1049,7 +1701,6 @@ describe("handleNewBooking", () => { ], videoCallUrl: "http://mock-zoomvideo.example.com/meeting-1", }); - expectSuccessfulVideoMeetingCreation(videoMock, { credential: expect.objectContaining({ appId: TestData.apps.zoomvideo.slug, @@ -1066,7 +1717,6 @@ describe("handleNewBooking", () => { }) ), }); - expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -1077,7 +1727,6 @@ describe("handleNewBooking", () => { emails, iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }); - expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, @@ -1097,7 +1746,6 @@ describe("handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -1121,7 +1769,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -1144,7 +1791,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - const { eventTypes } = await createBookingScenario( getScenarioData({ webhooks: [ @@ -1191,7 +1837,6 @@ describe("handleNewBooking", () => { ], }) ); - mockSuccessfulVideoMeetingCreation({ metadataLookupKey: appStoreMetadata.dailyvideo.dirName, videoMeetingData: { @@ -1200,14 +1845,12 @@ describe("handleNewBooking", () => { url: `http://mock-dailyvideo.example.com/meeting-1`, }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - const mockBookingData = getMockRequestDataForBooking({ data: { start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, @@ -1220,14 +1863,11 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - const createdBooking = await handleNewBooking(req); - await expectBookingToBeInDatabase({ description: "", location: BookingLocations.CalVideo, @@ -1257,8 +1897,6 @@ describe("handleNewBooking", () => { ], }); - // expectWorkflowToBeTriggered(); - expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { destinationCalendars: [ { @@ -1272,7 +1910,6 @@ describe("handleNewBooking", () => { ], videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", }); - expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -1283,7 +1920,6 @@ describe("handleNewBooking", () => { emails, iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }); - expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, @@ -1307,12 +1943,10 @@ describe("handleNewBooking", () => { name: "Test Org", slug: "testorg", }); - const booker = getBooker({ email: "booker@example.com", name: "Booker", }); - const otherTeamMembers = [ { name: "Other Team Member 1", @@ -1332,7 +1966,6 @@ describe("handleNewBooking", () => { }, }, ]; - const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -1361,7 +1994,6 @@ describe("handleNewBooking", () => { externalId: "organizer@google-calendar.com", }, }); - await createBookingScenario( getScenarioData({ webhooks: [ @@ -1401,7 +2033,6 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }) ); - mockSuccessfulVideoMeetingCreation({ metadataLookupKey: appStoreMetadata.dailyvideo.dirName, videoMeetingData: { @@ -1410,14 +2041,12 @@ describe("handleNewBooking", () => { url: `http://mock-dailyvideo.example.com/meeting-1`, }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - const mockBookingData = getMockRequestDataForBooking({ data: { // Try booking the first available free timeslot in both the users' schedules @@ -1431,14 +2060,11 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - const createdBooking = await handleNewBooking(req); - await expectBookingToBeInDatabase({ description: "", location: BookingLocations.CalVideo, @@ -1467,7 +2093,6 @@ describe("handleNewBooking", () => { }, ], }); - // expectWorkflowToBeTriggered(); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { destinationCalendars: [ @@ -1482,7 +2107,6 @@ describe("handleNewBooking", () => { ], videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", }); - expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -1495,7 +2119,6 @@ describe("handleNewBooking", () => { emails, iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }); - expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, diff --git a/packages/features/bookings/lib/handleNewBooking/types.ts b/packages/features/bookings/lib/handleNewBooking/types.ts index 2b42e2de13fad4..315b2606d08fc2 100644 --- a/packages/features/bookings/lib/handleNewBooking/types.ts +++ b/packages/features/bookings/lib/handleNewBooking/types.ts @@ -37,6 +37,7 @@ export type Invitee = { firstName: string; lastName: string; timeZone: string; + phoneNumber?: string; language: { translate: TFunction; locale: string; diff --git a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts index 2f77deadedcc86..dd164370e93510 100644 --- a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts +++ b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts @@ -1,6 +1,6 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { updateMeeting } from "@calcom/core/videoClient"; -import { sendCancelledSeatEmails } from "@calcom/emails"; +import { sendCancelledSeatEmailsAndSMS } from "@calcom/emails"; import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; import { HttpError } from "@calcom/lib/http-error"; @@ -107,7 +107,7 @@ async function cancelAttendeeSeat( const tAttendees = await getTranslation(attendee.locale ?? "en", "common"); - await sendCancelledSeatEmails( + await sendCancelledSeatEmailsAndSMS( evt, { ...attendee, diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index 6bedcc2f2ee277..ef12b0255b385c 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -3,7 +3,7 @@ import { cloneDeep } from "lodash"; import { uuid } from "short-uuid"; import EventManager from "@calcom/core/EventManager"; -import { sendScheduledSeatsEmails } from "@calcom/emails"; +import { sendScheduledSeatsEmailsAndSMS } from "@calcom/emails"; import { refreshCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/refreshCredentials"; import { allowDisablingAttendeeConfirmationEmails, @@ -37,6 +37,7 @@ const createNewSeat = async ( bookerEmail, responses, workflows, + bookerPhoneNumber, } = rescheduleSeatedBookingObject; let { evt } = rescheduleSeatedBookingObject; let resultBooking: HandleSeatsResultBooking; @@ -80,6 +81,7 @@ const createNewSeat = async ( attendees: { create: { email: inviteeToAdd.email, + phoneNumber: inviteeToAdd.phoneNumber, name: inviteeToAdd.name, timeZone: inviteeToAdd.timeZone, locale: inviteeToAdd.language.locale, @@ -132,7 +134,7 @@ const createNewSeat = async ( if (isAttendeeConfirmationEmailDisabled) { isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); } - await sendScheduledSeatsEmails( + await sendScheduledSeatsEmailsAndSMS( copyEvent, inviteeToAdd, newSeat, @@ -187,7 +189,8 @@ const createNewSeat = async ( eventTypePaymentAppCredential as IEventTypePaymentCredentialType, seatedBooking, fullName, - bookerEmail + bookerEmail, + bookerPhoneNumber ); resultBooking = { ...foundBooking }; diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index 1a342abee3295b..7c834727097191 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -77,7 +77,9 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { // See if attendee is already signed up for timeslot if ( - seatedBooking.attendees.find((attendee) => attendee.email === invitee[0].email) && + seatedBooking.attendees.find((attendee) => { + return attendee.email === invitee[0].email; + }) && dayjs.utc(seatedBooking.startTime).format() === evt.startTime ) { throw new HttpError({ statusCode: 409, message: ErrorCode.AlreadySignedUpForBooking }); @@ -119,6 +121,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { }, isNotConfirmed: evt.requiresConfirmation || false, isRescheduleEvent: !!rescheduleUid, + isFirstRecurringEvent: true, emailAttendeeSendToOverride: bookerEmail, seatReferenceUid: evt.attendeeSeatId, }); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts index c3a8369f79e97d..8eb88a60aaf950 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts @@ -2,7 +2,7 @@ import { cloneDeep } from "lodash"; import type EventManager from "@calcom/core/EventManager"; -import { sendRescheduledSeatEmail } from "@calcom/emails"; +import { sendRescheduledSeatEmailAndSMS } from "@calcom/emails"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import type { Person, CalendarEvent } from "@calcom/types/Calendar"; @@ -91,7 +91,7 @@ const attendeeRescheduleSeatedBooking = async ( await eventManager.updateCalendarAttendees(copyEvent, newTimeSlotBooking); - await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person, eventType.metadata); + await sendRescheduledSeatEmailAndSMS(copyEvent, seatAttendee as Person, eventType.metadata); const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => { return attendee.email !== bookerEmail; }); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts index 8b009e7ee0c223..5d1132f4754302 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts @@ -3,7 +3,7 @@ import { cloneDeep } from "lodash"; import { uuid } from "short-uuid"; import type EventManager from "@calcom/core/EventManager"; -import { sendRescheduledEmails } from "@calcom/emails"; +import { sendRescheduledEmailsAndSMS } from "@calcom/emails"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { HttpError } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; @@ -136,7 +136,7 @@ const combineTwoSeatedBookings = async ( if (noEmail !== true && isConfirmedByDefault) { // TODO send reschedule emails to attendees of the old booking loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats"); - await sendRescheduledEmails( + await sendRescheduledEmailsAndSMS( { ...copyEvent, additionalNotes, // Resets back to the additionalNote input and not the override value diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts index 42222aa9296602..524fc243f36e45 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts @@ -2,7 +2,7 @@ import { cloneDeep } from "lodash"; import type EventManager from "@calcom/core/EventManager"; -import { sendRescheduledEmails } from "@calcom/emails"; +import { sendRescheduledEmailsAndSMS } from "@calcom/emails"; import prisma from "@calcom/prisma"; import type { AdditionalInformation, AppsStatus } from "@calcom/types/Calendar"; @@ -90,7 +90,7 @@ const moveSeatedBookingToNewTimeSlot = async ( if (noEmail !== true && isConfirmedByDefault) { const copyEvent = cloneDeep(evt); loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats"); - await sendRescheduledEmails( + await sendRescheduledEmailsAndSMS( { ...copyEvent, additionalNotes, // Resets back to the additionalNote input and not the override value diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 4390ff74143022..ef639438c62d5f 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -18,6 +18,7 @@ export type NewSeatedBookingObject = { organizerUser: OrganizerUser; originalRescheduledBooking: OriginalRescheduledBooking; bookerEmail: string; + bookerPhoneNumber?: string | null; tAttendees: TFunction; bookingSeat: BookingSeat; reqUserId: number | undefined; diff --git a/packages/features/credentials/handleDeleteCredential.ts b/packages/features/credentials/handleDeleteCredential.ts index c5343fdf7cfad8..ebb46b82964719 100644 --- a/packages/features/credentials/handleDeleteCredential.ts +++ b/packages/features/credentials/handleDeleteCredential.ts @@ -4,7 +4,7 @@ import z from "zod"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { DailyLocationType } from "@calcom/core/location"; -import { sendCancelledEmails } from "@calcom/emails"; +import { sendCancelledEmailsAndSMS } from "@calcom/emails"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/scheduleTrigger"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; @@ -244,6 +244,12 @@ const handleDeleteCredential = async ({ seatsPerTimeSlot: true, seatsShowAttendees: true, eventName: true, + team: { + select: { + id: true, + name: true, + }, + }, metadata: true, }, }, @@ -303,7 +309,7 @@ const handleDeleteCredential = async ({ const attendeesList = await Promise.all(attendeesListPromises); const tOrganizer = await getTranslation(booking?.user?.locale ?? "en", "common"); - await sendCancelledEmails( + await sendCancelledEmailsAndSMS( { type: booking?.eventType?.title as string, title: booking.title, @@ -333,6 +339,13 @@ const handleDeleteCredential = async ({ cancellationReason: "Payment method removed by organizer", seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, seatsShowAttendees: booking.eventType?.seatsShowAttendees, + team: !!booking.eventType?.team + ? { + name: booking.eventType.team.name, + id: booking.eventType.team.id, + members: [], + } + : undefined, }, { eventName: booking?.eventType?.eventName, diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index 3992966b8d7963..2474b010dd64f4 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -5,7 +5,7 @@ import type Stripe from "stripe"; import stripe from "@calcom/app-store/stripepayment/lib/server"; import EventManager from "@calcom/core/EventManager"; -import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails"; +import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails"; import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { IS_PRODUCTION } from "@calcom/lib/constants"; @@ -108,7 +108,7 @@ const handleSetupSuccess = async (event: Stripe.Event) => { }); } else { await sendOrganizerRequestEmail({ ...evt }, eventType.metadata); - await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0], eventType.metadata); + await sendAttendeeRequestEmailAndSMS({ ...evt }, evt.attendees[0], eventType.metadata); } }; diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index b3223467fa38ed..98f3bc8adbdcc0 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -5,7 +5,7 @@ import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@cal import EventManager from "@calcom/core/EventManager"; import { getEventName } from "@calcom/core/event"; import dayjs from "@calcom/dayjs"; -import { sendRoundRobinCancelledEmails, sendRoundRobinScheduledEmails } from "@calcom/emails"; +import { sendRoundRobinCancelledEmailsAndSMS, sendRoundRobinScheduledEmailsAndSMS } from "@calcom/emails"; import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers"; @@ -202,6 +202,7 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number name: attendee.name, timeZone: attendee.timeZone, language: { translate: tAttendee, locale: attendee.locale ?? "en" }, + phoneNumber: attendee.phoneNumber || undefined, })) ); } @@ -369,7 +370,7 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number }); // Send to new RR host - await sendRoundRobinScheduledEmails(evt, [ + await sendRoundRobinScheduledEmailsAndSMS(evt, [ { ...reassignedRRHost, name: reassignedRRHost.name || "", @@ -410,7 +411,7 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number }); } - await sendRoundRobinCancelledEmails( + await sendRoundRobinCancelledEmailsAndSMS( cancelledRRHostEvt, [ { diff --git a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts index 99cc6c8c218ff1..806aeb3375f4f8 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts @@ -38,6 +38,8 @@ export const sendSMS = async ( teamId?: number | null, whatsapp = false ) => { + log.silly("sendSMS", JSON.stringify({ phoneNumber, body, sender, userId, teamId })); + const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId); if (isSMSSendingLocked) { diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index 3bff4f7b1f2542..08fab335bdcd81 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -29,6 +29,7 @@ export type AttendeeInBookingInfo = { firstName?: string; lastName?: string; email: string; + phoneNumber?: string | null; timeZone: string; language: { locale: string }; }; diff --git a/packages/features/eventtypes/lib/bookingFieldsManager.ts b/packages/features/eventtypes/lib/bookingFieldsManager.ts index f9a4656f1dea6f..780005747cd3e5 100644 --- a/packages/features/eventtypes/lib/bookingFieldsManager.ts +++ b/packages/features/eventtypes/lib/bookingFieldsManager.ts @@ -15,6 +15,11 @@ async function getEventType(eventTypeId: EventType["id"]) { }, include: { customInputs: true, + profile: { + select: { + organizationId: true, + }, + }, workflows: { select: { workflow: { @@ -29,9 +34,13 @@ async function getEventType(eventTypeId: EventType["id"]) { throw new Error(`EventType:${eventTypeId} not found`); } + const { profile, ...restEventType } = rawEventType; + + const isOrgTeamEvent = !!rawEventType?.teamId && !!profile?.organizationId; + const eventType = { - ...rawEventType, - bookingFields: getBookingFieldsWithSystemFields(rawEventType), + ...restEventType, + bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent }), }; return eventType; } diff --git a/packages/features/instant-meeting/handleInstantMeeting.ts b/packages/features/instant-meeting/handleInstantMeeting.ts index bc3c4076c4430f..0e45ad6ba04cfd 100644 --- a/packages/features/instant-meeting/handleInstantMeeting.ts +++ b/packages/features/instant-meeting/handleInstantMeeting.ts @@ -168,9 +168,10 @@ export type HandleInstantMeetingResponse = { async function handler(req: NextApiRequest) { let eventType = await getEventTypesFromDB(req.body.eventTypeId); + const isOrgTeamEvent = !!eventType?.team && !!eventType?.team?.parentId; eventType = { ...eventType, - bookingFields: getBookingFieldsWithSystemFields(eventType), + bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }), }; if (!eventType.team?.id) { diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index 9dfdea42ee60de..8a03f029f9abb3 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -6,6 +6,7 @@ import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import { WEBAPP_URL } from "./constants"; import getLabelValueMapFromResponses from "./getLabelValueMapFromResponses"; +import isSmsCalEmail from "./isSmsCalEmail"; const translator = short(); @@ -39,9 +40,11 @@ export const getWho = (calEvent: CalendarEvent, t: TFunction) => { .map((attendee) => { return ` ${attendee?.name || t("guest")} -${attendee.email} - `; +${!isSmsCalEmail(attendee.email) ? `${attendee.email}\n` : `${attendee.phoneNumber}\n`} + +`; }) + .join(""); const organizer = ` diff --git a/packages/lib/CalendarService.ts b/packages/lib/CalendarService.ts index 5aeca7ee1bcd30..d32c98af9b7a86 100644 --- a/packages/lib/CalendarService.ts +++ b/packages/lib/CalendarService.ts @@ -2,7 +2,7 @@ /// import type { Prisma } from "@prisma/client"; import ICAL from "ical.js"; -import type { Attendee, DateArray, DurationObject, Person } from "ics"; +import type { Attendee, DateArray, DurationObject } from "ics"; import { createEvent } from "ics"; import type { DAVAccount, DAVCalendar, DAVObject } from "tsdav"; import { @@ -18,6 +18,7 @@ import { v4 as uuidv4 } from "uuid"; import dayjs from "@calcom/dayjs"; import sanitizeCalendarObject from "@calcom/lib/sanitizeCalendarObject"; +import type { Person as AttendeeInCalendarEvent } from "@calcom/types/Calendar"; import type { Calendar, CalendarEvent, @@ -25,6 +26,7 @@ import type { EventBusyDate, IntegrationCalendar, NewCalendarEventType, + TeamMember, } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; @@ -95,7 +97,7 @@ const getDuration = (start: string, end: string): DurationObject => ({ minutes: dayjs(end).diff(dayjs(start), "minute"), }); -const mapAttendees = (attendees: Person[]): Attendee[] => +const mapAttendees = (attendees: AttendeeInCalendarEvent[] | TeamMember[]): Attendee[] => attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" })); export default abstract class BaseCalendarService implements Calendar { diff --git a/packages/lib/contructEmailFromPhoneNumber.ts b/packages/lib/contructEmailFromPhoneNumber.ts new file mode 100644 index 00000000000000..63d73a4e383f8a --- /dev/null +++ b/packages/lib/contructEmailFromPhoneNumber.ts @@ -0,0 +1,4 @@ +export const contructEmailFromPhoneNumber = (phoneNumber: string) => { + const cleanedPhoneNumber = phoneNumber.replace(/\+/g, ""); + return `${cleanedPhoneNumber}@sms.cal.com`; +}; diff --git a/packages/lib/event-types/getEventTypeById.ts b/packages/lib/event-types/getEventTypeById.ts index 935284697b88cf..cc661b058e17ab 100644 --- a/packages/lib/event-types/getEventTypeById.ts +++ b/packages/lib/event-types/getEventTypeById.ts @@ -194,11 +194,12 @@ export const getEventTypeById = async ({ }); } + const isOrgTeamEvent = !!eventType?.teamId && !!eventType.team?.parentId; const eventTypeObject = Object.assign({}, eventType, { users: eventTypeUsers, periodStartDate: eventType.periodStartDate?.toString() ?? null, periodEndDate: eventType.periodEndDate?.toString() ?? null, - bookingFields: getBookingFieldsWithSystemFields(eventType), + bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }), }); const isOrgEventType = !!eventTypeObject.team?.parentId; diff --git a/packages/lib/isSmsCalEmail.ts b/packages/lib/isSmsCalEmail.ts new file mode 100644 index 00000000000000..e4f3ccbfc9fa32 --- /dev/null +++ b/packages/lib/isSmsCalEmail.ts @@ -0,0 +1,3 @@ +export default function isSmsCalEmail(email: string) { + return email.endsWith("@sms.cal.com"); +} diff --git a/packages/lib/payment/getBooking.ts b/packages/lib/payment/getBooking.ts index e5b7a5d6672739..8e1a93ff42e430 100644 --- a/packages/lib/payment/getBooking.ts +++ b/packages/lib/payment/getBooking.ts @@ -60,6 +60,8 @@ export async function getBooking(bookingId: number) { bookingFields: true, team: { select: { + id: true, + name: true, parentId: true, }, }, @@ -152,6 +154,13 @@ export async function getBooking(bookingId: number) { language: { translate: t, locale: user.locale ?? "en" }, id: user.id, }, + team: !!booking.eventType?.team + ? { + name: booking.eventType.team.name, + id: booking.eventType.team.id, + members: [], + } + : undefined, attendees: attendeesList, location: booking.location, uid: booking.uid, diff --git a/packages/lib/payment/handlePayment.ts b/packages/lib/payment/handlePayment.ts index 2288dff7aa6d23..a2093051e0d7d9 100644 --- a/packages/lib/payment/handlePayment.ts +++ b/packages/lib/payment/handlePayment.ts @@ -25,7 +25,8 @@ const handlePayment = async ( uid: string; }, bookerName: string, - bookerEmail: string + bookerEmail: string, + bookerPhoneNumber?: string | null ) => { const paymentApp = (await appStore[ paymentAppCredentials?.app?.dirName as keyof typeof appStore @@ -50,8 +51,9 @@ const handlePayment = async ( currency: selectedEventType?.metadata?.apps?.[paymentAppCredentials.appId].currency, }, booking.id, + paymentOption, bookerEmail, - paymentOption + bookerPhoneNumber ); } else { paymentData = await paymentInstance.create( @@ -63,8 +65,9 @@ const handlePayment = async ( booking.userId, booking.user?.username ?? null, bookerName, - bookerEmail, paymentOption, + bookerEmail, + bookerPhoneNumber, selectedEventType.title, evt.title ); diff --git a/packages/lib/payment/handlePaymentSuccess.ts b/packages/lib/payment/handlePaymentSuccess.ts index 1e5eaa8fcffdd5..84db4bd57bc1ec 100644 --- a/packages/lib/payment/handlePaymentSuccess.ts +++ b/packages/lib/payment/handlePaymentSuccess.ts @@ -1,7 +1,7 @@ import type { Prisma } from "@prisma/client"; import EventManager from "@calcom/core/EventManager"; -import { sendScheduledEmails } from "@calcom/emails"; +import { sendScheduledEmailsAndSMS } from "@calcom/emails"; import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation"; import { handleBookingRequested } from "@calcom/features/bookings/lib/handleBookingRequested"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; @@ -76,7 +76,7 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number) log.debug(`handling booking request for eventId ${eventType.id}`); } } else { - await sendScheduledEmails({ ...evt }, undefined, undefined, undefined, eventType.metadata); + await sendScheduledEmailsAndSMS({ ...evt }, undefined, undefined, undefined, eventType.metadata); } throw new HttpCode({ diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx index d89e42787fe6b6..ac3a272e248529 100644 --- a/packages/platform/atoms/booker/BookerWebWrapper.tsx +++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx @@ -142,6 +142,8 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => { const verifyCode = useVerifyCode({ onSuccess: () => { + if (!bookerForm.formEmail) return; + verifyEmail.setVerifiedEmail(bookerForm.formEmail); verifyEmail.setEmailVerificationModalVisible(false); bookings.handleBookEvent(); diff --git a/packages/prisma/migrations/20240408155446_add_phone_number_in_attendee/migration.sql b/packages/prisma/migrations/20240408155446_add_phone_number_in_attendee/migration.sql new file mode 100644 index 00000000000000..0f9f527cf32296 --- /dev/null +++ b/packages/prisma/migrations/20240408155446_add_phone_number_in_attendee/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Attendee" ADD COLUMN "phoneNumber" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 7c48c344c0fb31..3a6c9d374776b8 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -541,6 +541,7 @@ model Attendee { email String name String timeZone String + phoneNumber String? locale String? @default("en") booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) bookingId Int? diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 6e8995dad6b486..e212a94556a74b 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -126,6 +126,7 @@ export type BookingFieldType = FormBuilderFieldType; export const bookingResponses = z .object({ email: z.string(), + attendeePhoneNumber: z.string().optional(), //TODO: Why don't we move name out of bookingResponses and let it be handled like user fields? name: z.union([ z.string(), diff --git a/packages/sms/attendee/awaiting-payment-sms.ts b/packages/sms/attendee/awaiting-payment-sms.ts new file mode 100644 index 00000000000000..931d915246bbb4 --- /dev/null +++ b/packages/sms/attendee/awaiting-payment-sms.ts @@ -0,0 +1,20 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import SMSManager from "../sms-manager"; + +export default class AwaitingPaymentSMS extends SMSManager { + constructor(calEvent: CalendarEvent) { + super(calEvent); + } + + getMessage(attendee: Person) { + const t = attendee.language.translate; + return `${t("meeting_awaiting_payment")}: ${t("complete_your_booking_subject", { + title: this.calEvent.title, + date: this.getFormattedDate(attendee.timeZone, attendee.language.locale), + })}. \n\n ${t("you_can_view_booking_details_with_this_url", { + url: `${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${this.calEvent.uid}?changes=true`, + })} `; + } +} diff --git a/packages/sms/attendee/cancelled-seat-sms.ts b/packages/sms/attendee/cancelled-seat-sms.ts new file mode 100644 index 00000000000000..35d9d033f0d632 --- /dev/null +++ b/packages/sms/attendee/cancelled-seat-sms.ts @@ -0,0 +1,18 @@ +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import SMSManager from "../sms-manager"; + +export default class CancelledSeatSMS extends SMSManager { + constructor(calEvent: CalendarEvent) { + super(calEvent); + } + + getMessage(attendee: Person) { + return `${attendee.language.translate("no_longer_attending", { + name: attendee.name, + })}. \n\n${attendee.language.translate("event_no_longer_attending_subject", { + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: this.getFormattedDate(attendee.timeZone, attendee.language.locale), + })} `; + } +} diff --git a/packages/sms/attendee/event-cancelled-sms.ts b/packages/sms/attendee/event-cancelled-sms.ts new file mode 100644 index 00000000000000..b24b369e1a9892 --- /dev/null +++ b/packages/sms/attendee/event-cancelled-sms.ts @@ -0,0 +1,23 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import SMSManager from "../sms-manager"; + +export default class EventCancelledSMS extends SMSManager { + constructor(calEvent: CalendarEvent) { + super(calEvent); + } + + getMessage(attendee: Person) { + const t = attendee.language.translate; + return `${t("hey_there")} ${attendee.name}, ${t("event_request_cancelled")}/n/n ${t( + "event_cancelled_subject", + { + title: this.calEvent.title, + date: this.getFormattedDate(attendee.timeZone, attendee.language.locale), + } + )}. /n/n ${t("visit_cancelled_booking")} ${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${ + this.calEvent.uid + } `; + } +} diff --git a/packages/sms/attendee/event-declined-sms.ts b/packages/sms/attendee/event-declined-sms.ts new file mode 100644 index 00000000000000..3b14099e9c99e3 --- /dev/null +++ b/packages/sms/attendee/event-declined-sms.ts @@ -0,0 +1,17 @@ +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import SMSManager from "../sms-manager"; + +export default class EventDeclinedSMS extends SMSManager { + constructor(calEvent: CalendarEvent) { + super(calEvent); + } + + getMessage(attendee: Person) { + const t = attendee.language.translate; + return `${t("hey_there")} ${attendee.name}, ${t("event_request_declined")} ${t("event_declined_subject", { + title: this.calEvent.title, + date: this.getFormattedDate(attendee.timeZone, attendee.language.locale), + })}`; + } +} diff --git a/packages/sms/attendee/event-location-changed-sms.ts b/packages/sms/attendee/event-location-changed-sms.ts new file mode 100644 index 00000000000000..0dab9aa1734b55 --- /dev/null +++ b/packages/sms/attendee/event-location-changed-sms.ts @@ -0,0 +1,17 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import SMSManager from "../sms-manager"; + +export default class EventLocationChangedSMS extends SMSManager { + constructor(calEvent: CalendarEvent) { + super(calEvent); + } + + getMessage(attendee: Person) { + const t = attendee.language.translate; + return `${t("event_location_changed")}. \n\n ${t("you_can_view_booking_details_with_this_url", { + url: `${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${this.calEvent.uid}?changes=true`, + })}`; + } +} diff --git a/packages/sms/attendee/event-request-sms.ts b/packages/sms/attendee/event-request-sms.ts new file mode 100644 index 00000000000000..2341b435a2e18e --- /dev/null +++ b/packages/sms/attendee/event-request-sms.ts @@ -0,0 +1,21 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import SMSManager from "../sms-manager"; + +export default class EventRequestSMS extends SMSManager { + constructor(calEvent: CalendarEvent) { + super(calEvent); + } + + getMessage(attendee: Person) { + const t = attendee.language.translate; + return `${t("booking_submitted", { + name: attendee.name, + })}. ${t("user_needs_to_confirm_or_reject_booking", { + user: this.calEvent.organizer.name, + })}\n\n ${t("you_can_view_booking_details_with_this_url", { + url: `${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${this.calEvent.uid}`, + })}`; + } +} diff --git a/packages/sms/attendee/event-request-to-reschedule-sms.ts b/packages/sms/attendee/event-request-to-reschedule-sms.ts new file mode 100644 index 00000000000000..5b99d43b59e72b --- /dev/null +++ b/packages/sms/attendee/event-request-to-reschedule-sms.ts @@ -0,0 +1,19 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import SMSManager from "../sms-manager"; + +export default class EventRequestToRescheduleSMS extends SMSManager { + constructor(calEvent: CalendarEvent) { + super(calEvent); + } + + getMessage(attendee: Person) { + const t = attendee.language.translate; + return `${t("request_reschedule_booking")}: ${t("request_reschedule_subtitle", { + organizer: this.calEvent.organizer.name, + })} \n\n${t("need_to_reschedule_or_cancel")} ${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${ + this.calEvent.uid + }?changes=true`; + } +} diff --git a/packages/sms/attendee/event-rescheduled-sms.ts b/packages/sms/attendee/event-rescheduled-sms.ts new file mode 100644 index 00000000000000..02d8b8361e6443 --- /dev/null +++ b/packages/sms/attendee/event-rescheduled-sms.ts @@ -0,0 +1,20 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import SMSManager from "../sms-manager"; + +export default class EventSuccessfullyReScheduledSMS extends SMSManager { + constructor(calEvent: CalendarEvent) { + super(calEvent); + } + + getMessage(attendee: Person) { + const t = attendee.language.translate; + return `${t("hey_there")} ${attendee.name}, ${t("event_type_has_been_rescheduled_on_time_date", { + title: this.calEvent.title, + date: this.getFormattedDate(attendee.timeZone, attendee.language.locale), + })} \n\n ${t("you_can_view_booking_details_with_this_url", { + url: `${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${this.calEvent.uid}`, + })}`; + } +} diff --git a/packages/sms/attendee/event-scheduled-sms.ts b/packages/sms/attendee/event-scheduled-sms.ts new file mode 100644 index 00000000000000..73c91f846f3717 --- /dev/null +++ b/packages/sms/attendee/event-scheduled-sms.ts @@ -0,0 +1,20 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import SMSManager from "../sms-manager"; + +export default class EventSuccessfullyScheduledSMS extends SMSManager { + constructor(calEvent: CalendarEvent) { + super(calEvent); + } + + getMessage(attendee: Person) { + const t = attendee.language.translate; + return `${t("confirming_your_booking_sms", { + name: attendee.name, + date: this.getFormattedDate(attendee.timeZone, attendee.language.locale), + })} \n\n ${t("you_can_view_booking_details_with_this_url", { + url: `${this.calEvent.bookerUrl ?? WEBAPP_URL}/booking/${this.calEvent.uid}`, + })}`; + } +} diff --git a/packages/sms/sms-manager.ts b/packages/sms/sms-manager.ts new file mode 100644 index 00000000000000..b08e32f0d6686f --- /dev/null +++ b/packages/sms/sms-manager.ts @@ -0,0 +1,99 @@ +import dayjs from "@calcom/dayjs"; +import { getSenderId } from "@calcom/features/ee/workflows/lib/alphanumericSenderIdSupport"; +import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider"; +import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError"; +import { SENDER_ID } from "@calcom/lib/constants"; +import { TimeFormat } from "@calcom/lib/timeFormat"; +import prisma from "@calcom/prisma"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +const handleSendingSMS = ({ + reminderPhone, + smsMessage, + senderID, + teamId, +}: { + reminderPhone: string; + smsMessage: string; + senderID: string; + teamId: number; +}) => { + return new Promise(async (resolve, reject) => { + try { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + select: { + parent: { + select: { + isOrganization: true, + }, + }, + }, + }); + + if (!team?.parent?.isOrganization) return; + + await checkSMSRateLimit({ identifier: `handleSendingSMS:team:${teamId}`, rateLimitingType: "sms" }); + const sms = twilio.sendSMS(reminderPhone, smsMessage, senderID, teamId); + resolve(sms); + } catch (e) { + reject(console.error(`twilio.sendSMS failed`, e)); + } + }); +}; + +export default abstract class SMSManager { + calEvent: CalendarEvent; + isTeamEvent = false; + teamId: number | undefined = undefined; + + constructor(calEvent: CalendarEvent) { + this.calEvent = calEvent; + this.teamId = this.calEvent?.team?.id; + this.isTeamEvent = !!this.calEvent?.team?.id; + } + + getFormattedTime( + timezone: string, + locale: string, + time: string, + format = `dddd, LL | ${TimeFormat.TWELVE_HOUR}` + ) { + return dayjs(time).tz(timezone).locale(locale).format(format); + } + + getFormattedDate(timezone: string, locale: string) { + return `${this.getFormattedTime(timezone, locale, this.calEvent.startTime)} - ${this.getFormattedTime( + timezone, + locale, + this.calEvent.endTime + )} (${timezone})`; + } + + abstract getMessage(attendee: Person): string; + + async sendSMSToAttendee(attendee: Person) { + const teamId = this.teamId; + if (!this.isTeamEvent || !teamId) return; + + const attendeePhoneNumber = attendee.phoneNumber; + if (!attendeePhoneNumber) return; + + const smsMessage = this.getMessage(attendee); + const senderID = getSenderId(attendeePhoneNumber, SENDER_ID); + return handleSendingSMS({ reminderPhone: attendeePhoneNumber, smsMessage, senderID, teamId }); + } + + async sendSMSToAttendees() { + if (!this.isTeamEvent) return; + const smsToSend: Promise[] = []; + + for (const attendee of this.calEvent.attendees) { + smsToSend.push(this.sendSMSToAttendee(attendee)); + } + + await Promise.all(smsToSend); + } +} diff --git a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts index 99dea2cc9628ec..9d658e4fe5756d 100644 --- a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts @@ -1,4 +1,4 @@ -import { sendScheduledEmails } from "@calcom/emails"; +import { sendScheduledEmailsAndSMS } from "@calcom/emails"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { isPrismaObjOrUndefined } from "@calcom/lib"; import { getTranslation } from "@calcom/lib/server/i18n"; @@ -128,6 +128,12 @@ export const Handler = async ({ ctx, input }: Options) => { metadata: true, customInputs: true, parentId: true, + team: { + select: { + id: true, + name: true, + }, + }, }, }, location: true, @@ -202,11 +208,18 @@ export const Handler = async ({ ctx, input }: Options) => { requiresConfirmation: false, eventTypeId: eventType?.id, videoCallData, + team: !!updatedBooking.eventType?.team + ? { + name: updatedBooking.eventType.team.name, + id: updatedBooking.eventType.team.id, + members: [], + } + : undefined, }; const eventTypeMetadata = EventTypeMetaDataSchema.parse(updatedBooking?.eventType?.metadata); - await sendScheduledEmails( + await sendScheduledEmailsAndSMS( { ...evt, }, diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index b0011a32c8911b..1bd0203b648a2c 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -1,13 +1,13 @@ import { Prisma } from "@prisma/client"; import appStore from "@calcom/app-store"; -import { getLocationValueForDB } from "@calcom/app-store/locations"; import type { LocationObject } from "@calcom/app-store/locations"; -import { workflowSelect } from "@calcom/ee/workflows/lib/getAllWorkflows"; -import { sendDeclinedEmails } from "@calcom/emails"; +import { getLocationValueForDB } from "@calcom/app-store/locations"; +import { sendDeclinedEmailsAndSMS } from "@calcom/emails"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; +import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows"; import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; @@ -74,6 +74,8 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { locations: true, team: { select: { + id: true, + name: true, parentId: true, members: true, }, @@ -165,6 +167,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone, + phoneNumber: attendee.phoneNumber, language: { translate, locale, @@ -217,6 +220,13 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { : [], requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false, eventTypeId: booking.eventType?.id, + team: !!booking.eventType?.team + ? { + name: booking.eventType.team.name, + id: booking.eventType.team.id, + members: [], + } + : undefined, }; const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent); @@ -371,7 +381,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { }); } - await sendDeclinedEmails(evt, booking.eventType?.metadata as EventTypeMetadata); + await sendDeclinedEmailsAndSMS(evt, booking.eventType?.metadata as EventTypeMetadata); const teamId = await getTeamIdFromEventType({ eventType: { diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index cb6ae567387774..3bf85bbf7d7831 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -3,7 +3,7 @@ import type { z } from "zod"; import { getEventLocationType, OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations"; import { getAppFromSlug } from "@calcom/app-store/utils"; import EventManager from "@calcom/core/EventManager"; -import { sendLocationChangeEmails } from "@calcom/emails"; +import { sendLocationChangeEmailsAndSMS } from "@calcom/emails"; import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import { buildCalEventFromBooking } from "@calcom/lib/buildCalEventFromBooking"; import logger from "@calcom/lib/logger"; @@ -277,7 +277,7 @@ export async function editLocationHandler({ ctx, input }: EditLocationOptions) { await updateBookingLocationInDb({ booking, evt, referencesToCreate: updatedResult.referencesToCreate }); try { - await sendLocationChangeEmails( + await sendLocationChangeEmailsAndSMS( { ...evt, additionalInformation: extractAdditionalInformation(updatedResult.results[0]) }, booking?.eventType?.metadata as EventTypeMetadata ); diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index dbb77d48a34f9d..d1e78ee5704908 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -6,7 +6,7 @@ import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builde import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; import { deleteMeeting } from "@calcom/core/videoClient"; import dayjs from "@calcom/dayjs"; -import { sendRequestRescheduleEmail } from "@calcom/emails"; +import { sendRequestRescheduleEmailAndSMS } from "@calcom/emails"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/scheduleTrigger"; @@ -58,6 +58,8 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule include: { team: { select: { + id: true, + name: true, parentId: true, }, }, @@ -170,6 +172,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule username: user?.username || "", language: { translate: selectedLanguage, locale: user.locale || "en" }, timeZone: user?.timeZone, + phoneNumber: user.phoneNumber, }; }); }; @@ -198,6 +201,13 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule ), organizer, iCalUID: bookingToReschedule.iCalUID, + team: !!bookingToReschedule.eventType?.team + ? { + name: bookingToReschedule.eventType.team.name, + id: bookingToReschedule.eventType.team.id, + members: [], + } + : undefined, }); const director = new CalendarEventDirector(); @@ -243,7 +253,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule log.debug("builder.calendarEvent", safeStringify(builder.calendarEvent)); // Send emails - await sendRequestRescheduleEmail( + await sendRequestRescheduleEmailAndSMS( builder.calendarEvent, { rescheduleLink: builder.rescheduleLink, diff --git a/packages/trpc/server/routers/viewer/bookings/types.ts b/packages/trpc/server/routers/viewer/bookings/types.ts index 1cca8e0ef09719..7596d28ac41bde 100644 --- a/packages/trpc/server/routers/viewer/bookings/types.ts +++ b/packages/trpc/server/routers/viewer/bookings/types.ts @@ -4,7 +4,7 @@ import { z } from "zod"; export type PersonAttendeeCommonFields = Pick< User, "id" | "email" | "name" | "locale" | "timeZone" | "username" ->; +> & { phoneNumber?: string | null }; // Common data for all endpoints under webhook export const commonBookingSchema = z.object({ diff --git a/packages/trpc/server/routers/viewer/bookings/util.ts b/packages/trpc/server/routers/viewer/bookings/util.ts index fdf5ad7b887ca1..de6f54305ce788 100644 --- a/packages/trpc/server/routers/viewer/bookings/util.ts +++ b/packages/trpc/server/routers/viewer/bookings/util.ts @@ -1,16 +1,15 @@ -import { - MembershipRole, - type Attendee, - type Booking, - type BookingReference, - type Credential, - type DestinationCalendar, - type EventType, - type User, +import type { + Booking, + EventType, + BookingReference, + Attendee, + Credential, + DestinationCalendar, + User, } from "@prisma/client"; import { prisma } from "@calcom/prisma"; -import { SchedulingType } from "@calcom/prisma/enums"; +import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -25,7 +24,17 @@ export const bookingsProcedure = authedProcedure const loggedInUser = ctx.user; const bookingInclude = { attendees: true, - eventType: true, + eventType: { + include: { + team: { + select: { + id: true, + name: true, + parentId: true, + }, + }, + }, + }, destinationCalendar: true, references: true, user: { @@ -92,7 +101,11 @@ export const bookingsProcedure = authedProcedure export type BookingsProcedureContext = { booking: Booking & { - eventType: EventType | null; + eventType: + | (EventType & { + team?: { id: number; name: string; parentId?: number | null } | null; + }) + | null; destinationCalendar: DestinationCalendar | null; user: | (User & { diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index f1ceb7ec474e90..91ed0377cb84ea 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -23,6 +23,7 @@ import type { TUpdateInputSchema } from "./update.schema"; import { addWeightAdjustmentToNewHosts, ensureUniqueBookingFields, + ensureEmailOrPhoneNumberIsPresent, handleCustomInputs, handlePeriodType, } from "./util"; @@ -141,6 +142,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const teamId = input.teamId || eventType.team?.id; ensureUniqueBookingFields(bookingFields); + ensureEmailOrPhoneNumberIsPresent(bookingFields); const data: Prisma.EventTypeUpdateInput = { ...rest, diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts index b464229f517078..d58bbdc83eb40b 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/util.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts @@ -161,6 +161,31 @@ export function ensureUniqueBookingFields(fields: z.infer); } +export function ensureEmailOrPhoneNumberIsPresent( + fields: z.infer["bookingFields"] +) { + if (!fields || fields.length === 0) { + return; + } + + const attendeePhoneNumberField = fields.find((field) => field.name === "attendeePhoneNumber"); + + const emailField = fields.find((field) => field.name === "email"); + + if (emailField?.hidden && attendeePhoneNumberField?.hidden) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Both Email and Attendee Phone Number cannot be hidden`, + }); + } + if (!emailField?.required && !attendeePhoneNumberField?.required) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `At least Email or Attendee Phone Number need to be required field.`, + }); + } +} + type Host = { userId: number; isFixed?: boolean | undefined; @@ -284,6 +309,7 @@ export async function addWeightAdjustmentToNewHosts({ return hostsWithWeightAdjustments; } + export const mapEventType = async (eventType: EventType) => ({ ...eventType, safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined, diff --git a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts index 0379145e4879ac..d42b2916535f43 100644 --- a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts @@ -282,7 +282,9 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType } break; case WorkflowActions.EMAIL_ATTENDEE: - sendTo = bookingInfo.attendees.map((attendee) => attendee.email); + sendTo = bookingInfo.attendees + .map((attendee) => attendee.email) + .filter((email): email is string => !!email); break; case WorkflowActions.EMAIL_ADDRESS: sendTo = step.sendTo ? [step.sendTo] : []; diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 494442484f856e..8e0d1c6d40f3b0 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -36,12 +36,14 @@ export type Person = { locale?: string | null; timeFormat?: TimeFormat; bookingSeat?: BookingSeat | null; + phoneNumber?: string | null; }; export type TeamMember = { id?: number; name: string; email: string; + phoneNumber?: string | null; timeZone: string; language: { translate: TFunction; locale: string }; }; diff --git a/packages/types/PaymentService.d.ts b/packages/types/PaymentService.d.ts index da9d1869cb9e5c..6e316eeb195a94 100644 --- a/packages/types/PaymentService.d.ts +++ b/packages/types/PaymentService.d.ts @@ -20,8 +20,9 @@ export interface IAbstractPaymentService { userId: Booking["userId"], username: string | null, bookerName: string | null, - bookerEmail: string, paymentOption: PaymentOption, + bookerEmail: string, + bookerPhoneNumber?: string | null, eventTitle?: string, bookingTitle?: string ): Promise; @@ -29,8 +30,9 @@ export interface IAbstractPaymentService { collectCard( payment: Pick, bookingId: Booking["id"], + paymentOption: PaymentOption, bookerEmail: string, - paymentOption: PaymentOption + bookerPhoneNumber?: string | null ): Promise; chargeCard( payment: Pick, diff --git a/packages/ui/components/popover/MeetingTimeInTimezones.tsx b/packages/ui/components/popover/MeetingTimeInTimezones.tsx index 5c429d0874c7b7..af711ee8366eaf 100644 --- a/packages/ui/components/popover/MeetingTimeInTimezones.tsx +++ b/packages/ui/components/popover/MeetingTimeInTimezones.tsx @@ -13,6 +13,7 @@ import { Icon } from "../.."; type Attendee = { id: number; email: string; + phoneNumber?: string | null; name: string; timeZone: string; locale: string | null; From 55e1e0fa3d62eb3f89bf6609c1487b4aa0db1d47 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Wed, 11 Sep 2024 21:14:19 -0400 Subject: [PATCH 12/40] chore: Remove passing optional prisma select prop to Repository classes (#16600) --- .../future/availability/[schedule]/page.tsx | 8 +-- .../video/meeting-not-started/[uid]/page.tsx | 2 +- .../web/lib/video/[uid]/getServerSideProps.ts | 28 +------- .../meeting-ended/[uid]/getServerSideProps.ts | 17 +---- .../[uid]/getServerSideProps.ts | 2 +- packages/lib/server/repository/booking.ts | 69 ++++++++++++++++--- packages/lib/server/repository/user.ts | 16 ++++- 7 files changed, 81 insertions(+), 61 deletions(-) diff --git a/apps/web/app/future/availability/[schedule]/page.tsx b/apps/web/app/future/availability/[schedule]/page.tsx index ec41afeb0329a7..8955baa8c7301e 100644 --- a/apps/web/app/future/availability/[schedule]/page.tsx +++ b/apps/web/app/future/availability/[schedule]/page.tsx @@ -43,12 +43,8 @@ const Page = async ({ params }: PageProps) => { let userData, schedule, travelSchedules; try { - userData = await UserRepository.findById({ - id: userId, - select: { - timeZone: true, - defaultScheduleId: true, - }, + userData = await UserRepository.getTimeZoneAndDefaultScheduleId({ + userId, }); if (!userData?.timeZone || !userData?.defaultScheduleId) { throw new Error("timeZone and defaultScheduleId not found"); diff --git a/apps/web/app/future/video/meeting-not-started/[uid]/page.tsx b/apps/web/app/future/video/meeting-not-started/[uid]/page.tsx index 790367733ee0df..3fc30bd6c2ac3e 100644 --- a/apps/web/app/future/video/meeting-not-started/[uid]/page.tsx +++ b/apps/web/app/future/video/meeting-not-started/[uid]/page.tsx @@ -12,7 +12,7 @@ import MeetingNotStarted from "~/videos/views/videos-meeting-not-started-single- export const generateMetadata = async ({ params, searchParams }: _PageProps) => { const p = { ...params, ...searchParams }; - const booking = await BookingRepository.findBookingByUidWithOptionalSelect({ + const booking = await BookingRepository.findBookingByUid({ bookingUid: typeof p?.uid === "string" ? p.uid : "", }); diff --git a/apps/web/lib/video/[uid]/getServerSideProps.ts b/apps/web/lib/video/[uid]/getServerSideProps.ts index ea41d739d61c0b..181f3b7925cdef 100644 --- a/apps/web/lib/video/[uid]/getServerSideProps.ts +++ b/apps/web/lib/video/[uid]/getServerSideProps.ts @@ -20,34 +20,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { const ssr = await ssrInit(context); - const booking = await BookingRepository.findBookingByUidWithOptionalSelect({ + const booking = await BookingRepository.findBookingForMeetingPage({ bookingUid: context.query.uid as string, - select: { - uid: true, - description: true, - isRecorded: true, - user: { - select: { - id: true, - timeZone: true, - name: true, - email: true, - username: true, - }, - }, - references: { - select: { - id: true, - uid: true, - type: true, - meetingUrl: true, - meetingPassword: true, - }, - where: { - type: "daily_video", - }, - }, - }, }); if (!booking || booking.references.length === 0 || !booking.references[0].meetingUrl) { diff --git a/apps/web/lib/video/meeting-ended/[uid]/getServerSideProps.ts b/apps/web/lib/video/meeting-ended/[uid]/getServerSideProps.ts index 9e073704ebdf51..3aa4fc476c751a 100644 --- a/apps/web/lib/video/meeting-ended/[uid]/getServerSideProps.ts +++ b/apps/web/lib/video/meeting-ended/[uid]/getServerSideProps.ts @@ -6,23 +6,8 @@ import { type inferSSRProps } from "@lib/types/inferSSRProps"; export type PageProps = inferSSRProps; export async function getServerSideProps(context: GetServerSidePropsContext) { - const booking = await BookingRepository.findBookingByUidWithOptionalSelect({ + const booking = await BookingRepository.findBookingForMeetingEndedPage({ bookingUid: context.query.uid as string, - select: { - uid: true, - user: { - select: { - credentials: true, - }, - }, - references: { - select: { - uid: true, - type: true, - meetingUrl: true, - }, - }, - }, }); if (!booking) { diff --git a/apps/web/lib/video/meeting-not-started/[uid]/getServerSideProps.ts b/apps/web/lib/video/meeting-not-started/[uid]/getServerSideProps.ts index 379a34bd81d048..ce1e6d3a9ccd67 100644 --- a/apps/web/lib/video/meeting-not-started/[uid]/getServerSideProps.ts +++ b/apps/web/lib/video/meeting-not-started/[uid]/getServerSideProps.ts @@ -4,7 +4,7 @@ import { BookingRepository } from "@calcom/lib/server/repository/booking"; // change the type export async function getServerSideProps(context: GetServerSidePropsContext) { - const booking = await BookingRepository.findBookingByUidWithOptionalSelect({ + const booking = await BookingRepository.findBookingByUid({ bookingUid: context.query.uid as string, }); diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 87581b3856d07e..a82c98d47c91b3 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -113,18 +113,71 @@ export class BookingRepository { return allBookings; } - static async findBookingByUidWithOptionalSelect({ - bookingUid, - select, - }: { - bookingUid: string; - select?: Prisma.BookingSelect; - }) { + static async findBookingByUid({ bookingUid }: { bookingUid: string }) { + return await prisma.booking.findUnique({ + where: { + uid: bookingUid, + }, + select: bookingMinimalSelect, + }); + } + + static async findBookingForMeetingPage({ bookingUid }: { bookingUid: string }) { return await prisma.booking.findUnique({ where: { uid: bookingUid, }, - select: { ...bookingMinimalSelect, ...select }, + select: { + ...bookingMinimalSelect, + uid: true, + description: true, + isRecorded: true, + user: { + select: { + id: true, + timeZone: true, + name: true, + email: true, + username: true, + }, + }, + references: { + select: { + id: true, + uid: true, + type: true, + meetingUrl: true, + meetingPassword: true, + }, + where: { + type: "daily_video", + }, + }, + }, + }); + } + + static async findBookingForMeetingEndedPage({ bookingUid }: { bookingUid: string }) { + return await prisma.booking.findUnique({ + where: { + uid: bookingUid, + }, + select: { + ...bookingMinimalSelect, + uid: true, + user: { + select: { + credentials: true, + }, + }, + references: { + select: { + uid: true, + type: true, + meetingUrl: true, + }, + }, + }, }); } diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index e67f3415995b93..4772332c7846a4 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -239,12 +239,12 @@ export class UserRepository { }; } - static async findById({ id, select }: { id: number; select?: Prisma.UserSelect }) { + static async findById({ id }: { id: number }) { const user = await prisma.user.findUnique({ where: { id, }, - select: { ...userSelect, ...select }, + select: userSelect, }); if (!user) { @@ -566,6 +566,18 @@ export class UserRepository { return teamIds; } + static async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) { + return await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + timeZone: true, + defaultScheduleId: true, + }, + }); + } + static async adminFindById(userId: number) { return await prisma.user.findUniqueOrThrow({ where: { From 91f5d49cd4be95719f6231842a7ef11a44c587e1 Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Thu, 12 Sep 2024 12:14:01 +0530 Subject: [PATCH 13/40] feat: update v2 calendar services logic to handle multiple calendars (#16478) * update calendar logic to handle multiple calendars * add method to link calendar to credential * remove logs * update platform libraries * update calendar logic to handle multiple calendars * update credentials repository * add selected calendars repository to destination calendars module * add method to get user selected calendars and rename fn * chore: PR feedback * resolve merge conflicts * fixup * make sure we cover all edge cases for apple calendar --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- .../services/apple-calendar.service.ts | 71 +++++++++++++++---- .../calendars/services/calendars.service.ts | 34 ++++++++- .../src/ee/calendars/services/gcal.service.ts | 40 ++++++++--- .../ee/calendars/services/outlook.service.ts | 35 +++++++-- .../credentials/credentials.repository.ts | 36 ++++++++-- .../destination-calendars.module.ts | 2 + .../selected-calendars.repository.ts | 14 +++- .../v2/src/modules/users/users.repository.ts | 1 - .../atoms/connect/apple/AppleConnect.tsx | 2 +- 9 files changed, 200 insertions(+), 35 deletions(-) diff --git a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts index e04ee36b7f2490..d54f4b0df597dd 100644 --- a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts +++ b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts @@ -5,7 +5,8 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common"; import { Injectable } from "@nestjs/common"; import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants"; -import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries"; +import { symmetricEncrypt, CalendarService, symmetricDecrypt } from "@calcom/platform-libraries"; +import { Credential } from "@calcom/prisma/client"; @Injectable() export class AppleCalendarService implements CredentialSyncCalendarApp { @@ -61,26 +62,70 @@ export class AppleCalendarService implements CredentialSyncCalendarApp { if (username.length <= 1 || password.length <= 1) throw new BadRequestException(`Username or password cannot be empty`); - const data = { - type: APPLE_CALENDAR_TYPE, - key: symmetricEncrypt( - JSON.stringify({ username, password }), - process.env.CALENDSO_ENCRYPTION_KEY || "" - ), - userId: userId, - teamId: null, - appId: APPLE_CALENDAR_ID, - invalid: false, - }; + const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId( + APPLE_CALENDAR_TYPE, + userId + ); + + let hasMatchingUsernameAndPassword = false; + + if (existingAppleCalendarCredentials.length > 0) { + const hasCalendarWithGivenCredentials = existingAppleCalendarCredentials.find( + (calendarCredential: Credential) => { + const decryptedKey = JSON.parse( + symmetricDecrypt(calendarCredential.key as string, process.env.CALENDSO_ENCRYPTION_KEY || "") + ); + + if (decryptedKey.username === username) { + if (decryptedKey.password === password) { + hasMatchingUsernameAndPassword = true; + } + + return true; + } + } + ); + + if (!!hasCalendarWithGivenCredentials && hasMatchingUsernameAndPassword) { + return { + status: SUCCESS_STATUS, + }; + } + + if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) { + await this.credentialRepository.upsertAppCredential( + APPLE_CALENDAR_TYPE, + symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""), + userId, + hasCalendarWithGivenCredentials.id + ); + + return { + status: SUCCESS_STATUS, + }; + } + } try { + const data = { + type: APPLE_CALENDAR_TYPE, + key: symmetricEncrypt( + JSON.stringify({ username, password }), + process.env.CALENDSO_ENCRYPTION_KEY || "" + ), + userId: userId, + teamId: null, + appId: APPLE_CALENDAR_ID, + invalid: false, + }; + const dav = new CalendarService({ id: 0, ...data, user: { email: userEmail }, }); await dav?.listCalendars(); - await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId); + await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId); } catch (reason) { throw new BadRequestException(`Could not add this apple calendar account: ${reason}`); } 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 4f509f19350109..ba2ce7b2a897ec 100644 --- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -6,6 +6,7 @@ import { } from "@/modules/credentials/credentials.repository"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; import { UsersRepository } from "@/modules/users/users.repository"; import { Injectable, @@ -15,9 +16,11 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { User } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { DateTime } from "luxon"; import { z } from "zod"; +import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants"; import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries"; import { Calendar } from "@calcom/platform-types"; import { PrismaClient } from "@calcom/prisma"; @@ -33,7 +36,8 @@ export class CalendarsService { private readonly calendarsRepository: CalendarsRepository, private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService, - private readonly config: ConfigService + private readonly config: ConfigService, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository ) {} async getCalendars(userId: number) { @@ -145,4 +149,32 @@ export class CalendarsService { throw new NotFoundException("Calendar credentials not found"); } } + + async createAndLinkCalendarEntry( + userId: number, + externalId: string, + key: Prisma.InputJsonValue, + calendarType: keyof typeof APPS_TYPE_ID_MAPPING, + credentialId?: number | null + ) { + const credential = await this.credentialsRepository.upsertAppCredential( + calendarType, + key, + userId, + credentialId + ); + + await this.selectedCalendarsRepository.upsertSelectedCalendar( + externalId, + credential.id, + userId, + calendarType + ); + } + + async checkCalendarCredentialValidity(userId: number, credentialId: number, type: string) { + const credential = await this.credentialsRepository.getUserCredentialById(userId, credentialId, type); + + return !credential?.invalid; + } } diff --git a/apps/api/v2/src/ee/calendars/services/gcal.service.ts b/apps/api/v2/src/ee/calendars/services/gcal.service.ts index 9f0d25471c8be2..ea1d220d41348b 100644 --- a/apps/api/v2/src/ee/calendars/services/gcal.service.ts +++ b/apps/api/v2/src/ee/calendars/services/gcal.service.ts @@ -131,11 +131,6 @@ export class GoogleCalendarService implements OAuthCalendarApp { const token = await oAuth2Client.getToken(parsedCode); // Google oAuth Credentials are stored in token.tokens const key = token.tokens; - const credential = await this.credentialRepository.createAppCredential( - GOOGLE_CALENDAR_TYPE, - key as Prisma.InputJsonValue, - ownerId - ); oAuth2Client.setCredentials(key); @@ -149,10 +144,39 @@ export class GoogleCalendarService implements OAuthCalendarApp { const primaryCal = cals.data.items?.find((cal) => cal.primary); if (primaryCal?.id) { - await this.selectedCalendarsRepository.createSelectedCalendar( - primaryCal.id, - credential.id, + const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar( + ownerId, + GOOGLE_CALENDAR_TYPE, + primaryCal.id + ); + + if (alreadyExistingSelectedCalendar) { + const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity( + ownerId, + alreadyExistingSelectedCalendar.credentialId ?? 0, + GOOGLE_CALENDAR_TYPE + ); + + // user credential probably got expired in this case + if (!isCredentialValid) { + await this.calendarsService.createAndLinkCalendarEntry( + ownerId, + alreadyExistingSelectedCalendar.externalId, + key as Prisma.InputJsonValue, + GOOGLE_CALENDAR_TYPE, + alreadyExistingSelectedCalendar.credentialId + ); + } + + return { + url: redir || origin, + }; + } + + await this.calendarsService.createAndLinkCalendarEntry( ownerId, + primaryCal.id, + key as Prisma.InputJsonValue, GOOGLE_CALENDAR_TYPE ); } diff --git a/apps/api/v2/src/ee/calendars/services/outlook.service.ts b/apps/api/v2/src/ee/calendars/services/outlook.service.ts index 060427fdcee819..eeab3027e1a858 100644 --- a/apps/api/v2/src/ee/calendars/services/outlook.service.ts +++ b/apps/api/v2/src/ee/calendars/services/outlook.service.ts @@ -168,16 +168,39 @@ export class OutlookService implements OAuthCalendarApp { const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token); if (defaultCalendar?.id) { - const credential = await this.credentialRepository.createAppCredential( + const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar( + ownerId, OFFICE_365_CALENDAR_TYPE, - office365OAuthCredentials, - ownerId + defaultCalendar.id ); - await this.selectedCalendarsRepository.createSelectedCalendar( - defaultCalendar.id, - credential.id, + if (alreadyExistingSelectedCalendar) { + const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity( + ownerId, + alreadyExistingSelectedCalendar.credentialId ?? 0, + OFFICE_365_CALENDAR_TYPE + ); + + // user credential probably got expired in this case + if (!isCredentialValid) { + await this.calendarsService.createAndLinkCalendarEntry( + ownerId, + alreadyExistingSelectedCalendar.externalId, + office365OAuthCredentials, + OFFICE_365_CALENDAR_TYPE, + alreadyExistingSelectedCalendar.credentialId + ); + } + + return { + url: redir || origin, + }; + } + + await this.calendarsService.createAndLinkCalendarEntry( ownerId, + defaultCalendar.id, + office365OAuthCredentials, OFFICE_365_CALENDAR_TYPE ); } diff --git a/apps/api/v2/src/modules/credentials/credentials.repository.ts b/apps/api/v2/src/modules/credentials/credentials.repository.ts index 5a496cc85fa4de..3ae5dd57982442 100644 --- a/apps/api/v2/src/modules/credentials/credentials.repository.ts +++ b/apps/api/v2/src/modules/credentials/credentials.repository.ts @@ -9,12 +9,12 @@ import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants"; export class CredentialsRepository { constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} - async createAppCredential( + async upsertAppCredential( type: keyof typeof APPS_TYPE_ID_MAPPING, key: Prisma.InputJsonValue, - userId: number + userId: number, + credentialId?: number | null ) { - const credential = await this.getByTypeAndUserId(type, userId); return this.dbWrite.prisma.credential.upsert({ create: { type, @@ -27,7 +27,7 @@ export class CredentialsRepository { invalid: false, }, where: { - id: credential?.id ?? 0, + id: credentialId ?? 0, }, }); } @@ -36,6 +36,10 @@ export class CredentialsRepository { return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } }); } + getAllUserCredentialsByTypeAndId(type: string, userId: number) { + return this.dbRead.prisma.credential.findMany({ where: { type, userId } }); + } + getUserCredentialsByIds(userId: number, credentialIds: number[]) { return this.dbRead.prisma.credential.findMany({ where: { @@ -60,6 +64,30 @@ export class CredentialsRepository { }, }); } + + async getUserCredentialById(userId: number, credentialId: number, type: string) { + return await this.dbRead.prisma.credential.findUnique({ + where: { + userId, + type, + id: credentialId, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + teamId: true, + appId: true, + invalid: true, + user: { + select: { + email: true, + }, + }, + }, + }); + } } export type CredentialsWithUserEmail = Awaited< diff --git a/apps/api/v2/src/modules/destination-calendars/destination-calendars.module.ts b/apps/api/v2/src/modules/destination-calendars/destination-calendars.module.ts index 0c0c29085a3a60..46107490752e38 100644 --- a/apps/api/v2/src/modules/destination-calendars/destination-calendars.module.ts +++ b/apps/api/v2/src/modules/destination-calendars/destination-calendars.module.ts @@ -6,6 +6,7 @@ import { DestinationCalendarsController } from "@/modules/destination-calendars/ import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository"; import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; import { UsersRepository } from "@/modules/users/users.repository"; import { Module } from "@nestjs/common"; @@ -19,6 +20,7 @@ import { Module } from "@nestjs/common"; UsersRepository, CredentialsRepository, AppsRepository, + SelectedCalendarsRepository, ], controllers: [DestinationCalendarsController], exports: [DestinationCalendarsRepository], diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts index bdf98d4fce12b9..eb0b8d989e7133 100644 --- a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts @@ -6,7 +6,7 @@ import { Injectable } from "@nestjs/common"; export class SelectedCalendarsRepository { constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} - createSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) { + upsertSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) { return this.dbWrite.prisma.selectedCalendar.upsert({ create: { userId, @@ -38,6 +38,18 @@ export class SelectedCalendarsRepository { }); } + getUserSelectedCalendar(userId: number, integration: string, externalId: string) { + return this.dbRead.prisma.selectedCalendar.findUnique({ + where: { + userId_integration_externalId: { + userId, + externalId, + integration, + }, + }, + }); + } + async addUserSelectedCalendar( userId: number, integration: string, diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index b6ee18fa74726e..444b87a17363fb 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -68,7 +68,6 @@ export class UsersRepository { } async findByIdWithProfile(userId: number): Promise { - console.log("findByIdWithProfile"); return this.dbRead.prisma.user.findUnique({ where: { id: userId, diff --git a/packages/platform/atoms/connect/apple/AppleConnect.tsx b/packages/platform/atoms/connect/apple/AppleConnect.tsx index 128cd970996cf5..83e655f58abe22 100644 --- a/packages/platform/atoms/connect/apple/AppleConnect.tsx +++ b/packages/platform/atoms/connect/apple/AppleConnect.tsx @@ -76,7 +76,7 @@ export const AppleConnect: FC>> = ({