From 395381ddcc4039d09beb6353e353584d9861eb4b Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Thu, 10 Oct 2024 23:27:04 +0530 Subject: [PATCH] feat: automatic no show (#16727) Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: Alex van Andel Co-authored-by: Peer Richelsen Co-authored-by: CarinaWolli Co-authored-by: zomars --- apps/web/cron-tester.ts | 36 ++ .../web/lib/video/[uid]/getServerSideProps.ts | 12 +- .../[clientId]/edit/edit-webhooks-view.tsx | 8 + apps/web/package.json | 2 + apps/web/public/static/locales/en/common.json | 5 + .../utils/bookingScenario/bookingScenario.ts | 12 + .../dailyvideo/lib/VideoApiAdapter.ts | 31 +- packages/app-store/dailyvideo/lib/types.ts | 9 +- .../features/bookings/lib/handleNewBooking.ts | 17 + .../scheduleNoShowTriggers.ts | 92 +++++ .../components/TimeTimeUnitInput.tsx | 16 +- .../components/WorkflowStepContainer.tsx | 51 +-- .../features/ee/workflows/lib/constants.ts | 2 + .../features/ee/workflows/lib/getOptions.ts | 10 +- .../tabs/webhooks/EventWebhooksTab.tsx | 4 + packages/features/tasker/api/cron.ts | 10 +- packages/features/tasker/internal-tasker.ts | 9 +- packages/features/tasker/tasker.ts | 22 +- packages/features/tasker/tasks/index.ts | 4 + .../tasker/tasks/triggerNoShow/common.ts | 153 +++++++++ .../tasker/tasks/triggerNoShow/getBooking.ts | 71 ++++ .../getMeetingSessionsFromRoomName.ts | 7 + .../tasker/tasks/triggerNoShow/schema.ts | 57 +++ .../tasks/triggerNoShow/triggerGuestNoShow.ts | 21 ++ .../triggerNoShow/triggerHostNoShow.test.ts | 324 ++++++++++++++++++ .../tasks/triggerNoShow/triggerHostNoShow.ts | 57 +++ .../webhooks/components/WebhookForm.tsx | 54 +++ packages/features/webhooks/lib/constants.ts | 2 + packages/features/webhooks/lib/getWebhooks.ts | 3 + .../features/webhooks/lib/scheduleTrigger.ts | 6 + .../webhooks/pages/webhook-edit-view.tsx | 2 + .../webhooks/pages/webhook-new-view.tsx | 2 + packages/lib/dailyApiFetcher.ts | 15 + packages/lib/server/repository/webhook.ts | 2 + packages/lib/test/builder.ts | 2 + .../migration.sql | 10 + .../migration.sql | 10 + .../migration.sql | 3 + .../20241010070020_add_active/migration.sql | 2 + packages/prisma/schema.prisma | 7 + packages/prisma/zod/webhook.ts | 4 +- .../routers/viewer/webhook/create.schema.ts | 3 + .../routers/viewer/webhook/edit.schema.ts | 3 + 43 files changed, 1103 insertions(+), 69 deletions(-) create mode 100644 apps/web/cron-tester.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts create mode 100644 packages/features/tasker/tasks/triggerNoShow/common.ts create mode 100644 packages/features/tasker/tasks/triggerNoShow/getBooking.ts create mode 100644 packages/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName.ts create mode 100644 packages/features/tasker/tasks/triggerNoShow/schema.ts create mode 100644 packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts create mode 100644 packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts create mode 100644 packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts create mode 100644 packages/lib/dailyApiFetcher.ts create mode 100644 packages/prisma/migrations/20240920100549_add_daily_no_show/migration.sql create mode 100644 packages/prisma/migrations/20240920192534_add_no_show_daily_webhook_trigger/migration.sql create mode 100644 packages/prisma/migrations/20240920195815_add_time_unit_in_webhooks/migration.sql create mode 100644 packages/prisma/migrations/20241010070020_add_active/migration.sql diff --git a/apps/web/cron-tester.ts b/apps/web/cron-tester.ts new file mode 100644 index 00000000000000..4e548f47782da2 --- /dev/null +++ b/apps/web/cron-tester.ts @@ -0,0 +1,36 @@ +import { CronJob } from "cron"; + +async function fetchCron(endpoint: string) { + const apiKey = process.env.CRON_API_KEY; + + const res = await fetch(`http://localhost:3000/api${endpoint}?${apiKey}`, { + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.CRON_SECRET}`, + }, + }); + const json = await res.json(); + console.log(endpoint, json); +} + +try { + console.log("⏳ Running cron endpoints"); + new CronJob( + // Each 5 seconds + "*/5 * * * * *", + async function () { + await Promise.allSettled([ + fetchCron("/tasks/cron"), + // fetchCron("/cron/calVideoNoShowWebhookTriggers"), + // + // fetchCron("/tasks/cleanup"), + ]); + }, + null, + true, + "America/Los_Angeles" + ); +} catch (_err) { + console.error("❌ ❌ ❌ Something went wrong ❌ ❌ ❌"); + process.exit(1); +} diff --git a/apps/web/lib/video/[uid]/getServerSideProps.ts b/apps/web/lib/video/[uid]/getServerSideProps.ts index 181f3b7925cdef..2b0581983dbd5c 100644 --- a/apps/web/lib/video/[uid]/getServerSideProps.ts +++ b/apps/web/lib/video/[uid]/getServerSideProps.ts @@ -3,7 +3,7 @@ import type { GetServerSidePropsContext } from "next"; import { generateGuestMeetingTokenFromOwnerMeetingToken, - setEnableRecordingUIForOrganizer, + setEnableRecordingUIAndUserIdForOrganizer, } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getCalVideoReference } from "@calcom/features/get-cal-video-reference"; @@ -81,18 +81,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { // set meetingPassword for guests if (session?.user.id !== bookingObj.user?.id) { const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken( - oldVideoReference.meetingPassword + oldVideoReference.meetingPassword, + session?.user.id ); bookingObj.references.forEach((bookRef) => { bookRef.meetingPassword = guestMeetingPassword; }); } - // Only for backward compatibility for organizer + // Only for backward compatibility and setting user id in particpants for organizer else { - const meetingPassword = await setEnableRecordingUIForOrganizer( + const meetingPassword = await setEnableRecordingUIAndUserIdForOrganizer( oldVideoReference.id, - oldVideoReference.meetingPassword + oldVideoReference.meetingPassword, + session?.user.id ); if (!!meetingPassword) { bookingObj.references.forEach((bookRef) => { diff --git a/apps/web/modules/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view.tsx b/apps/web/modules/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view.tsx index 0d4ae3714a7386..35583707afdec4 100644 --- a/apps/web/modules/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view.tsx +++ b/apps/web/modules/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view.tsx @@ -69,6 +69,14 @@ export default function EditOAuthClientWebhooks() { value: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED, label: "recording_transcription_generated", }, + { + value: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + label: "after_hosts_cal_video_no_show", + }, + { + value: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + label: "after_guests_cal_video_no_show", + }, ]} onSubmit={async (data) => { try { diff --git a/apps/web/package.json b/apps/web/package.json index 41036da068c786..81b813072a3f55 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,7 @@ "analyze:browser": "BUNDLE_ANALYZE=browser next build", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", "dev": "next dev", + "dev:cron": "ts-node cron-tester.ts", "dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https", "dx": "yarn dev", "test-codegen": "yarn playwright codegen http://localhost:3000", @@ -173,6 +174,7 @@ "@types/uuid": "8.3.1", "autoprefixer": "^10.4.12", "copy-webpack-plugin": "^11.0.0", + "cron": "^3.1.7", "deasync": "^0.1.30", "detect-port": "^1.3.0", "env-cmd": "^10.1.0", diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 6eb5d6e481f962..ded33df65048f1 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1227,6 +1227,8 @@ "number_provided": "Phone number will be provided", "before_event_trigger": "before event starts", "event_cancelled_trigger": "when event is canceled", + "after_hosts_cal_video_no_show": "After hosts don't join cal video", + "after_guests_cal_video_no_show": "After guests don't join cal video", "new_event_trigger": "when new event is booked", "email_host_action": "send email to host", "email_attendee_action": "send email to attendees", @@ -1643,6 +1645,9 @@ "email_address_action": "send email to a specific email address", "after_event_trigger": "after event ends", "how_long_after": "How long after event ends?", + "how_long_after_hosts_no_show": "How long after hosts don't show up on cal video meeting?", + "how_long_after_guests_no_show": "How long after guests don't show up on cal video meeting?", + "how_long_after_user_no_show_minutes": "How long after the users don't show up on cal video meeting?", "no_available_slots": "No Available slots", "time_available": "Time available", "cant_find_the_right_conferencing_app_visit_our_app_store": "Can't find the right conferencing app? Visit our <1>App Store.", diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 63625e3d99d673..bb5362bd06889b 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -193,6 +193,7 @@ type WhiteListedBookingProps = { // TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId credentialId?: number | null; })[]; + user?: { id: number }; bookingSeat?: Prisma.BookingSeatCreateInput[]; createdAt?: string; }; @@ -400,6 +401,7 @@ async function addBookingsToDb( bookings: (Prisma.BookingCreateInput & { // eslint-disable-next-line @typescript-eslint/no-explicit-any references: any[]; + user?: { id: number }; })[] ) { log.silly("TestData: Creating Bookings", JSON.stringify(bookings)); @@ -490,6 +492,16 @@ export async function addBookings(bookings: InputBooking[]) { }; } + if (booking?.user?.id) { + bookingCreate.user = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + connect: { + id: booking.user.id, + }, + }; + } + return bookingCreate; }) ); diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 05ea6422479c93..4a120b65393036 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -1,6 +1,7 @@ import { z } from "zod"; -import { handleErrorsJson } from "@calcom/lib/errors"; +import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKeys"; +import { fetcher } from "@calcom/lib/dailyApiFetcher"; import { prisma } from "@calcom/prisma"; import type { GetRecordingsResponseSchema, GetAccessLinkResponseSchema } from "@calcom/prisma/zod-utils"; import { @@ -15,7 +16,6 @@ import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapt import { ZSubmitBatchProcessorJobRes, ZGetTranscriptAccessLink } from "../zod"; import type { TSubmitBatchProcessorJobRes, TGetTranscriptAccessLink, batchProcessorBody } from "../zod"; -import { getDailyAppKeys } from "./getDailyAppKeys"; import { dailyReturnTypeSchema, getTranscripts, @@ -54,19 +54,6 @@ export const FAKE_DAILY_CREDENTIAL: CredentialPayload & { invalid: boolean } = { teamId: null, }; -export const fetcher = async (endpoint: string, init?: RequestInit | undefined) => { - const { api_key } = await getDailyAppKeys(); - return fetch(`https://api.daily.co/v1${endpoint}`, { - method: "GET", - headers: { - Authorization: `Bearer ${api_key}`, - "Content-Type": "application/json", - ...init?.headers, - }, - ...init, - }).then(handleErrorsJson); -}; - function postToDailyAPI(endpoint: string, body: Record) { return fetcher(endpoint, { method: "POST", @@ -111,7 +98,10 @@ async function processTranscriptsInBatches(transcriptIds: Array) { return allTranscriptsAccessLinks; } -export const generateGuestMeetingTokenFromOwnerMeetingToken = async (meetingToken: string | null) => { +export const generateGuestMeetingTokenFromOwnerMeetingToken = async ( + meetingToken: string | null, + userId?: number +) => { if (!meetingToken) return null; const token = await fetcher(`/meeting-tokens/${meetingToken}`).then(ZGetMeetingTokenResponseSchema.parse); @@ -120,6 +110,7 @@ export const generateGuestMeetingTokenFromOwnerMeetingToken = async (meetingToke room_name: token.room_name, exp: token.exp, enable_recording_ui: false, + user_id: userId, }, }).then(meetingTokenSchema.parse); @@ -127,14 +118,15 @@ export const generateGuestMeetingTokenFromOwnerMeetingToken = async (meetingToke }; // Only for backward compatibility -export const setEnableRecordingUIForOrganizer = async ( +export const setEnableRecordingUIAndUserIdForOrganizer = async ( bookingReferenceId: number, - meetingToken: string | null + meetingToken: string | null, + userId?: number ) => { if (!meetingToken) return null; const token = await fetcher(`/meeting-tokens/${meetingToken}`).then(ZGetMeetingTokenResponseSchema.parse); - if (token.enable_recording_ui === false) return null; + if (token.enable_recording_ui === false && !!token.user_id) return null; const organizerMeetingToken = await postToDailyAPI("/meeting-tokens", { properties: { @@ -142,6 +134,7 @@ export const setEnableRecordingUIForOrganizer = async ( exp: token.exp, enable_recording_ui: false, is_owner: true, + user_id: userId, }, }).then(meetingTokenSchema.parse); diff --git a/packages/app-store/dailyvideo/lib/types.ts b/packages/app-store/dailyvideo/lib/types.ts index 6596997a0dd336..6a167a5c6e6d0c 100644 --- a/packages/app-store/dailyvideo/lib/types.ts +++ b/packages/app-store/dailyvideo/lib/types.ts @@ -54,14 +54,17 @@ export const getRooms = z }) .passthrough(); -export const meetingTokenSchema = z.object({ - token: z.string(), -}); +export const meetingTokenSchema = z + .object({ + token: z.string(), + }) + .passthrough(); export const ZGetMeetingTokenResponseSchema = z .object({ room_name: z.string(), exp: z.number(), enable_recording_ui: z.boolean().optional(), + user_id: z.number().optional(), }) .passthrough(); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 16d23f890c1c87..58ea737248028c 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -12,6 +12,7 @@ import { OrganizerDefaultConferencingAppType, getLocationValueForDB, } from "@calcom/app-store/locations"; +import { DailyLocationType } from "@calcom/app-store/locations"; import { getAppFromSlug } from "@calcom/app-store/utils"; import EventManager from "@calcom/core/EventManager"; import { getEventName } from "@calcom/core/event"; @@ -88,6 +89,7 @@ import { getSeatedBooking } from "./handleNewBooking/getSeatedBooking"; import { getVideoCallDetails } from "./handleNewBooking/getVideoCallDetails"; import { handleAppsStatus } from "./handleNewBooking/handleAppsStatus"; import { loadAndValidateUsers } from "./handleNewBooking/loadAndValidateUsers"; +import { scheduleNoShowTriggers } from "./handleNewBooking/scheduleNoShowTriggers"; import type { Invitee, IEventTypePaymentCredentialType, @@ -1654,6 +1656,21 @@ async function handler( loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error })); } + try { + if (isConfirmedByDefault && (booking.location === DailyLocationType || booking.location?.trim() === "")) { + await scheduleNoShowTriggers({ + booking: { startTime: booking.startTime, id: booking.id }, + triggerForUser, + organizerUser: { id: organizerUser.id }, + eventTypeId, + teamId, + orgId, + }); + } + } catch (error) { + loggerWithEventDetails.error("Error while scheduling no show triggers", JSON.stringify({ error })); + } + // booking successful req.statusCode = 201; diff --git a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts new file mode 100644 index 00000000000000..8c22c18b46c0de --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts @@ -0,0 +1,92 @@ +import dayjs from "@calcom/dayjs"; +import tasker from "@calcom/features/tasker"; +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; + +type ScheduleNoShowTriggersArgs = { + booking: { + startTime: Date; + id: number; + }; + triggerForUser: number | true | null; + organizerUser: { id: number }; + eventTypeId: number; + teamId?: number | null; + orgId?: number | null; +}; + +export const scheduleNoShowTriggers = async (args: ScheduleNoShowTriggersArgs) => { + const { booking, triggerForUser, organizerUser, eventTypeId, teamId, orgId } = args; + // Add task for automatic no show in cal video + const noShowPromises: Promise[] = []; + + const subscribersHostsNoShowStarted = await getWebhooks({ + userId: triggerForUser ? organizerUser.id : null, + eventTypeId, + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + teamId, + orgId, + }); + + noShowPromises.push( + ...subscribersHostsNoShowStarted.map((webhook) => { + if (booking?.startTime && webhook.time && webhook.timeUnit) { + const scheduledAt = dayjs(booking.startTime) + .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) + .toDate(); + return tasker.create( + "triggerHostNoShowWebhook", + { + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + bookingId: booking.id, + // Prevents null values from being serialized + webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, + }, + { scheduledAt } + ); + } + return Promise.resolve(); + }) + ); + + const subscribersGuestsNoShowStarted = await getWebhooks({ + userId: triggerForUser ? organizerUser.id : null, + eventTypeId, + triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + teamId, + orgId, + }); + + noShowPromises.push( + ...subscribersGuestsNoShowStarted.map((webhook) => { + if (booking?.startTime && webhook.time && webhook.timeUnit) { + const scheduledAt = dayjs(booking.startTime) + .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) + .toDate(); + + return tasker.create( + "triggerGuestNoShowWebhook", + { + triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + bookingId: booking.id, + // Prevents null values from being serialized + webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, + }, + { scheduledAt } + ); + } + + return Promise.resolve(); + }) + ); + + await Promise.all(noShowPromises); + + // TODO: Support no show workflows + // const workflowHostsNoShow = workflows.filter( + // (workflow) => workflow.trigger === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW + // ); + // const workflowGuestsNoShow = workflows.filter( + // (workflow) => workflow.trigger === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW + // ); +}; diff --git a/packages/features/ee/workflows/components/TimeTimeUnitInput.tsx b/packages/features/ee/workflows/components/TimeTimeUnitInput.tsx index da4f8e6fe73f42..7561d8250a7b01 100644 --- a/packages/features/ee/workflows/components/TimeTimeUnitInput.tsx +++ b/packages/features/ee/workflows/components/TimeTimeUnitInput.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import type { UseFormReturn } from "react-hook-form"; +import { useFormContext } from "react-hook-form"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { TimeUnit } from "@calcom/prisma/enums"; @@ -13,27 +13,23 @@ import { TextField, } from "@calcom/ui"; -import type { FormValues } from "../pages/workflow"; - const TIME_UNITS = [TimeUnit.DAY, TimeUnit.HOUR, TimeUnit.MINUTE] as const; type Props = { - form: UseFormReturn; disabled: boolean; }; const TimeUnitAddonSuffix = ({ DropdownItems, timeUnitOptions, - form, }: { - form: UseFormReturn; DropdownItems: JSX.Element; timeUnitOptions: { [x: string]: string }; }) => { // because isDropdownOpen already triggers a render cycle we can use getValues() // instead of watch() function - const timeUnit = form.getValues("timeUnit"); + const form = useFormContext(); + const timeUnit = form.getValues("timeUnit") ?? TimeUnit.MINUTE; const [isDropdownOpen, setIsDropdownOpen] = useState(false); return ( @@ -51,7 +47,8 @@ const TimeUnitAddonSuffix = ({ }; export const TimeTimeUnitInput = (props: Props) => { - const { form } = props; + const form = useFormContext(); + const { t } = useLocale(); const timeUnitOptions = TIME_UNITS.reduce((acc, option) => { acc[option] = t(`${option.toLowerCase()}_timeUnit`); @@ -70,7 +67,6 @@ export const TimeTimeUnitInput = (props: Props) => { {...form.register("time", { valueAsNumber: true })} addOnSuffix={ @@ -80,7 +76,7 @@ export const TimeTimeUnitInput = (props: Props) => { key={index} type="button" onClick={() => { - form.setValue("timeUnit", timeUnit); + form.setValue("timeUnit", timeUnit, { shouldDirty: true }); }}> {timeUnitOptions[timeUnit]} diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index 4744d946dd1cfc..5dd72e8d5fc6ca 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -1,4 +1,5 @@ import type { WorkflowStep } from "@prisma/client"; +import { type TFunction } from "i18next"; import type { Dispatch, SetStateAction } from "react"; import { useEffect, useRef, useState } from "react"; import type { UseFormReturn } from "react-hook-form"; @@ -69,6 +70,17 @@ type WorkflowStepProps = { readOnly: boolean; }; +const getTimeSectionText = (trigger: WorkflowTriggerEvents, t: TFunction) => { + const triggerMap: Partial> = { + [WorkflowTriggerEvents.AFTER_EVENT]: "how_long_after", + [WorkflowTriggerEvents.BEFORE_EVENT]: "how_long_before", + [WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: "how_long_after_hosts_no_show", + [WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: "how_long_after_guests_no_show", + }; + if (!triggerMap[trigger]) return null; + return t(triggerMap[trigger]!); +}; + export default function WorkflowStepContainer(props: WorkflowStepProps) { const { t } = useLocale(); const utils = trpc.useUtils(); @@ -114,14 +126,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { : false ); - const [showTimeSection, setShowTimeSection] = useState( - form.getValues("trigger") === WorkflowTriggerEvents.BEFORE_EVENT || - form.getValues("trigger") === WorkflowTriggerEvents.AFTER_EVENT - ); - - const [showTimeSectionAfter, setShowTimeSectionAfter] = useState( - form.getValues("trigger") === WorkflowTriggerEvents.AFTER_EVENT - ); + const [timeSectionText, setTimeSectionText] = useState(getTimeSectionText(form.getValues("trigger"), t)); const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery(); const triggerOptions = getWorkflowTriggerOptions(t); @@ -312,21 +317,21 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { onChange={(val) => { if (val) { form.setValue("trigger", val.value); - if ( - val.value === WorkflowTriggerEvents.BEFORE_EVENT || - val.value === WorkflowTriggerEvents.AFTER_EVENT - ) { - setShowTimeSection(true); - if (val.value === WorkflowTriggerEvents.AFTER_EVENT) { - setShowTimeSectionAfter(true); + const newTimeSectionText = getTimeSectionText(val.value, t); + if (newTimeSectionText) { + setTimeSectionText(newTimeSectionText); + if ( + val.value === WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW || + val.value === WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW + ) { + form.setValue("time", 5); + form.setValue("timeUnit", TimeUnit.MINUTE); } else { - setShowTimeSectionAfter(false); + form.setValue("time", 24); + form.setValue("timeUnit", TimeUnit.HOUR); } - form.setValue("time", 24); - form.setValue("timeUnit", TimeUnit.HOUR); } else { - setShowTimeSection(false); - setShowTimeSectionAfter(false); + setTimeSectionText(null); form.unregister("time"); form.unregister("timeUnit"); } @@ -338,10 +343,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { ); }} /> - {showTimeSection && ( + {!!timeSectionText && (
- - + + {!props.readOnly && (
diff --git a/packages/features/ee/workflows/lib/constants.ts b/packages/features/ee/workflows/lib/constants.ts index c127b471353f33..1445a1de6b0f0f 100644 --- a/packages/features/ee/workflows/lib/constants.ts +++ b/packages/features/ee/workflows/lib/constants.ts @@ -6,6 +6,8 @@ export const WORKFLOW_TRIGGER_EVENTS = [ WorkflowTriggerEvents.NEW_EVENT, WorkflowTriggerEvents.AFTER_EVENT, WorkflowTriggerEvents.RESCHEDULE_EVENT, + WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, ] as const; export const WORKFLOW_ACTIONS = [ diff --git a/packages/features/ee/workflows/lib/getOptions.ts b/packages/features/ee/workflows/lib/getOptions.ts index f8d74275d652c6..d015eef14a86ed 100644 --- a/packages/features/ee/workflows/lib/getOptions.ts +++ b/packages/features/ee/workflows/lib/getOptions.ts @@ -1,6 +1,7 @@ import type { TFunction } from "next-i18next"; import type { WorkflowActions } from "@calcom/prisma/enums"; +import { WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { isSMSOrWhatsappAction, isWhatsappAction, isEmailToAttendeeAction } from "./actionHelperFunctions"; import { @@ -24,7 +25,14 @@ export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, is } export function getWorkflowTriggerOptions(t: TFunction) { - return WORKFLOW_TRIGGER_EVENTS.map((triggerEvent) => { + // TODO: remove this after workflows are supported + const filterdWorkflowTriggerEvents = WORKFLOW_TRIGGER_EVENTS.filter( + (event) => + event !== WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW && + event !== WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW + ); + + return filterdWorkflowTriggerEvents.map((triggerEvent) => { const triggerString = t(`${triggerEvent.toLowerCase()}_trigger`); return { label: triggerString.charAt(0).toUpperCase() + triggerString.slice(1), value: triggerEvent }; diff --git a/packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx b/packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx index 869899e7b47027..0c62c2c09b4fb6 100644 --- a/packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx +++ b/packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx @@ -80,6 +80,8 @@ export const EventWebhooksTab = ({ eventType }: Pick diff --git a/packages/features/tasker/api/cron.ts b/packages/features/tasker/api/cron.ts index f0f28296985786..df2717f1209314 100644 --- a/packages/features/tasker/api/cron.ts +++ b/packages/features/tasker/api/cron.ts @@ -3,7 +3,7 @@ import { NextResponse } from "next/server"; import tasker from ".."; -export async function GET(request: NextRequest) { +async function handler(request: NextRequest) { const authHeader = request.headers.get("authorization"); if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { return new Response("Unauthorized", { status: 401 }); @@ -11,3 +11,11 @@ export async function GET(request: NextRequest) { await tasker.processQueue(); return NextResponse.json({ success: true }); } + +export async function GET(request: NextRequest) { + return await handler(request); +} + +export async function POST(request: NextRequest) { + return await handler(request); +} diff --git a/packages/features/tasker/internal-tasker.ts b/packages/features/tasker/internal-tasker.ts index 3e18193070a9a6..108c43247dd331 100644 --- a/packages/features/tasker/internal-tasker.ts +++ b/packages/features/tasker/internal-tasker.ts @@ -1,5 +1,5 @@ import { Task } from "./repository"; -import { type Tasker, type TaskTypes } from "./tasker"; +import { type TaskerCreate, type Tasker } from "./tasker"; import tasksMap from "./tasks"; /** @@ -9,9 +9,10 @@ import tasksMap from "./tasks"; * Then, you can use the TaskerFactory to select the new Tasker. */ export class InternalTasker implements Tasker { - async create(type: TaskTypes, payload: string): Promise { - return Task.create(type, payload); - } + create: TaskerCreate = async (type, payload, options = {}): Promise => { + const payloadString = typeof payload === "string" ? payload : JSON.stringify(payload); + return Task.create(type, payloadString, options); + }; async processQueue(): Promise { const tasks = await Task.getNextBatch(); const tasksPromises = tasks.map(async (task) => { diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index 9659632a4b3b1a..feec7869f0f35b 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -1,9 +1,27 @@ +import type { z } from "zod"; + export type TaskerTypes = "internal" | "redis"; -export type TaskTypes = "sendEmail" | "sendWebhook" | "sendSms"; +type TaskPayloads = { + sendEmail: string; + sendWebhook: string; + sendSms: string; + triggerHostNoShowWebhook: z.infer< + typeof import("./tasks/triggerNoShow/schema").ZSendNoShowWebhookPayloadSchema + >; + triggerGuestNoShowWebhook: z.infer< + typeof import("./tasks/triggerNoShow/schema").ZSendNoShowWebhookPayloadSchema + >; +}; +export type TaskTypes = keyof TaskPayloads; export type TaskHandler = (payload: string) => Promise; +export type TaskerCreate = ( + type: TaskKey, + payload: TaskPayloads[TaskKey], + options?: { scheduledAt?: Date; maxAttempts?: number } +) => Promise; export interface Tasker { /** Create a new task with the given type and payload. */ - create(type: TaskTypes, payload: string): Promise; + create: TaskerCreate; processQueue(): Promise; cleanup(): Promise; } diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index 2e688090e7279a..c2bd33ff5592c9 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -8,6 +8,10 @@ import type { TaskHandler, TaskTypes } from "../tasker"; const tasks: Record Promise> = { sendEmail: () => import("./sendEmail").then((module) => module.sendEmail), sendWebhook: () => import("./sendWebook").then((module) => module.sendWebhook), + triggerHostNoShowWebhook: () => + import("./triggerNoShow/triggerHostNoShow").then((module) => module.triggerHostNoShow), + triggerGuestNoShowWebhook: () => + import("./triggerNoShow/triggerGuestNoShow").then((module) => module.triggerGuestNoShow), sendSms: () => Promise.resolve(() => Promise.reject(new Error("Not implemented"))), }; diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts new file mode 100644 index 00000000000000..1bf8fc0c372b34 --- /dev/null +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -0,0 +1,153 @@ +import dayjs from "@calcom/dayjs"; +import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import type { TimeUnit } from "@calcom/prisma/enums"; +import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; + +import { getBooking } from "./getBooking"; +import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName"; +import type { TWebhook, TTriggerNoShowPayloadSchema } from "./schema"; +import { ZSendNoShowWebhookPayloadSchema } from "./schema"; + +export type Host = { + id: number; + email: string; +}; + +export type Booking = Awaited>; +type Webhook = TWebhook; +export type Participants = TTriggerNoShowPayloadSchema["data"][number]["participants"]; + +export function getHosts(booking: Booking): Host[] { + const hostMap = new Map(); + + const addHost = (id: number, email: string) => { + if (!hostMap.has(id)) { + hostMap.set(id, { id, email }); + } + }; + + booking?.eventType?.hosts?.forEach((host) => addHost(host.userId, host.user.email)); + booking?.eventType?.users?.forEach((user) => addHost(user.id, user.email)); + + // Add booking.user if not already included + if (booking?.user?.id && booking?.user?.email) { + addHost(booking.user.id, booking.user.email); + } + + // Filter hosts to only include those who are also attendees + const attendeeEmails = new Set(booking.attendees?.map((attendee) => attendee.email)); + const filteredHosts = Array.from(hostMap.values()).filter( + (host) => attendeeEmails.has(host.email) || host.id === booking.user?.id + ); + + return filteredHosts; +} + +export function sendWebhookPayload( + webhook: Webhook, + triggerEvent: WebhookTriggerEvents, + booking: Booking, + maxStartTime: number, + hostEmail?: string +): Promise { + const maxStartTimeHumanReadable = dayjs.unix(maxStartTime).format("YYYY-MM-DD HH:mm:ss Z"); + + return sendGenericWebhookPayload({ + secretKey: webhook.secret, + triggerEvent, + createdAt: new Date().toISOString(), + webhook, + data: { + bookingId: booking.id, + bookingUid: booking.uid, + startTime: booking.startTime, + endTime: booking.endTime, + ...(triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW ? { email: hostEmail } : {}), + eventType: { + ...booking.eventType, + id: booking.eventTypeId, + }, + message: + triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW + ? `Guest didn't join the call or didn't join before ${maxStartTimeHumanReadable}` + : `Host with email ${hostEmail} didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, + }, + }).catch((e) => { + console.error( + `Error executing webhook for event: ${triggerEvent}, URL: ${webhook.subscriberUrl}`, + webhook, + e + ); + }); +} + +export function calculateMaxStartTime(startTime: Date, time: number, timeUnit: TimeUnit): number { + return dayjs(startTime) + .add(time, timeUnit.toLowerCase() as dayjs.ManipulateType) + .unix(); +} + +export function checkIfUserJoinedTheCall(userId: number, allParticipants: Participants): boolean { + return allParticipants.some( + (participant) => participant.user_id && parseInt(participant.user_id) === userId + ); +} + +export const log = logger.getSubLogger({ prefix: ["triggerNoShowTask"] }); + +export const prepareNoShowTrigger = async ( + payload: string +): Promise<{ + booking: Booking; + webhook: TWebhook; + hostsThatDidntJoinTheCall: Host[]; + numberOfHostsThatJoined: number; + didGuestJoinTheCall: boolean; +} | void> => { + const { bookingId, webhook } = ZSendNoShowWebhookPayloadSchema.parse(JSON.parse(payload)); + + const booking = await getBooking(bookingId); + + if (booking.status !== BookingStatus.ACCEPTED) { + log.debug( + "Booking is not accepted", + safeStringify({ + bookingId, + webhook: { id: webhook.id }, + }) + ); + + return; + } + + const dailyVideoReference = booking.references.find((reference) => reference.type === "daily_video"); + + if (!dailyVideoReference) { + log.error( + "Daily video reference not found", + safeStringify({ + bookingId, + webhook: { id: webhook.id }, + }) + ); + throw new Error(`Daily video reference not found in triggerHostNoShow with bookingId ${bookingId}`); + } + const meetingDetails = await getMeetingSessionsFromRoomName(dailyVideoReference.uid); + + const hosts = getHosts(booking); + const allParticipants = meetingDetails.data.flatMap((meeting) => meeting.participants); + + const hostsThatDidntJoinTheCall = hosts.filter( + (host) => !checkIfUserJoinedTheCall(host.id, allParticipants) + ); + + const numberOfHostsThatJoined = hosts.length - hostsThatDidntJoinTheCall.length; + + const didGuestJoinTheCall = meetingDetails.data.some( + (meeting) => meeting.max_participants < numberOfHostsThatJoined + ); + + return { hostsThatDidntJoinTheCall, booking, numberOfHostsThatJoined, webhook, didGuestJoinTheCall }; +}; diff --git a/packages/features/tasker/tasks/triggerNoShow/getBooking.ts b/packages/features/tasker/tasks/triggerNoShow/getBooking.ts new file mode 100644 index 00000000000000..b5889f992bc141 --- /dev/null +++ b/packages/features/tasker/tasks/triggerNoShow/getBooking.ts @@ -0,0 +1,71 @@ +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma, { bookingMinimalSelect } from "@calcom/prisma"; + +const log = logger.getSubLogger({ prefix: ["trigger-no-show-handler"] }); + +export const getBooking = async (bookingId: number) => { + const booking = await prisma.booking.findUniqueOrThrow({ + where: { + id: bookingId, + }, + select: { + ...bookingMinimalSelect, + uid: true, + location: true, + status: true, + isRecorded: true, + eventTypeId: true, + references: true, + eventType: { + select: { + id: true, + teamId: true, + parentId: true, + hosts: { + select: { + userId: true, + user: { + select: { + email: true, + }, + }, + }, + }, + users: { + select: { + id: true, + email: true, + }, + }, + }, + }, + user: { + select: { + id: true, + timeZone: true, + email: true, + name: true, + locale: true, + destinationCalendar: true, + }, + }, + }, + }); + + if (!booking) { + log.error( + "Couldn't find Booking Id:", + safeStringify({ + bookingId, + }) + ); + + throw new HttpError({ + message: `Booking of id ${bookingId} does not exist or does not contain daily video as location`, + statusCode: 404, + }); + } + return booking; +}; diff --git a/packages/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName.ts b/packages/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName.ts new file mode 100644 index 00000000000000..7f6cebf6197e44 --- /dev/null +++ b/packages/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName.ts @@ -0,0 +1,7 @@ +import { fetcher } from "@calcom/lib/dailyApiFetcher"; + +import { triggerNoShowPayloadSchema } from "./schema"; + +export const getMeetingSessionsFromRoomName = async (roomName: string) => { + return fetcher(`/meetings?room=${roomName}`).then(triggerNoShowPayloadSchema.parse); +}; diff --git a/packages/features/tasker/tasks/triggerNoShow/schema.ts b/packages/features/tasker/tasks/triggerNoShow/schema.ts new file mode 100644 index 00000000000000..355b6cbd0c02b7 --- /dev/null +++ b/packages/features/tasker/tasks/triggerNoShow/schema.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +import { TIME_UNIT } from "@calcom/features/ee/workflows/lib/constants"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; + +const commonSchema = z.object({ + triggerEvent: z.enum([ + WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + ]), + bookingId: z.number(), +}); + +export const ZWebhook = z.object({ + id: z.string(), + subscriberUrl: z.string().url(), + appId: z.string().nullable(), + secret: z.string().nullable(), + time: z.number(), + timeUnit: z.enum(TIME_UNIT), + eventTriggers: z.array(z.string()), + payloadTemplate: z.string().nullable(), +}); + +export type TWebhook = z.infer; + +export const triggerNoShowPayloadSchema = z.object({ + total_count: z.number(), + data: z.array( + z + .object({ + id: z.string(), + room: z.string(), + start_time: z.number(), + duration: z.number(), + max_participants: z.number(), + participants: z.array( + z.object({ + user_id: z.string().nullable(), + participant_id: z.string(), + user_name: z.string(), + join_time: z.number(), + duration: z.number(), + }) + ), + }) + .passthrough() + ), +}); + +export type TTriggerNoShowPayloadSchema = z.infer; + +export const ZSendNoShowWebhookPayloadSchema = commonSchema.extend({ + webhook: ZWebhook, +}); + +export type TSendNoShowWebhookPayloadSchema = z.infer; diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts new file mode 100644 index 00000000000000..3c9f82e4d1a4ee --- /dev/null +++ b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts @@ -0,0 +1,21 @@ +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; + +import { calculateMaxStartTime, sendWebhookPayload, prepareNoShowTrigger } from "./common"; + +export async function triggerGuestNoShow(payload: string): Promise { + const result = await prepareNoShowTrigger(payload); + if (!result) return; + + const { webhook, booking, didGuestJoinTheCall } = result; + + const maxStartTime = calculateMaxStartTime(booking.startTime, webhook.time, webhook.timeUnit); + + if (!didGuestJoinTheCall) { + await sendWebhookPayload( + webhook, + WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + booking, + maxStartTime + ); + } +} diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts new file mode 100644 index 00000000000000..c6daa17c0d4b05 --- /dev/null +++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts @@ -0,0 +1,324 @@ +import { + createBookingScenario, + getDate, + getGoogleCalendarCredential, + TestData, + getOrganizer, + getScenarioData, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { expectWebhookToHaveBeenCalledWith } from "@calcom/web/test/utils/bookingScenario/expects"; + +import { describe, vi, test } from "vitest"; + +import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; +import dayjs from "@calcom/dayjs"; +import { TimeUnit } from "@calcom/prisma/enums"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import { calculateMaxStartTime } from "./common"; +import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName"; +import type { TSendNoShowWebhookPayloadSchema } from "./schema"; +import { triggerHostNoShow } from "./triggerHostNoShow"; + +vi.mock( + "@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", + async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getMeetingSessionsFromRoomName: vi.fn(), + }; + } +); + +const timeout = process.env.CI ? 5000 : 20000; + +const EMPTY_MEETING_SESSIONS = { + total_count: 0, + data: [], +}; + +describe("Trigger Host No Show:", () => { + test( + `Should trigger host no show webhook when no one joined the call`, + async () => { + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + const uidOfBooking = "n5Wv3eHgconAED2j4gcVhP"; + const iCalUID = `${uidOfBooking}@Cal.com`; + const subscriberUrl = "http://my-webhook.example.com"; + const bookingStartTime = `${plus1DateString}T05:00:00.000Z`; + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + id: "22", + userId: organizer.id, + eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + time: 5, + timeUnit: TimeUnit.MINUTE, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 15, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + id: 222, + uid: uidOfBooking, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: `${plus1DateString}T05:15:00.000Z`, + user: { id: organizer.id }, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: undefined, + }, + ], + iCalUID, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + vi.mocked(getMeetingSessionsFromRoomName).mockResolvedValue(EMPTY_MEETING_SESSIONS); + + const payload = JSON.stringify({ + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + bookingId: 222, + webhook: { + id: "22", + eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + time: 5, + timeUnit: TimeUnit.MINUTE, + payloadTemplate: null, + secret: null, + }, + } satisfies TSendNoShowWebhookPayloadSchema); + + await triggerHostNoShow(payload); + const maxStartTime = calculateMaxStartTime(bookingStartTime, 5, TimeUnit.MINUTE); + const maxStartTimeHumanReadable = dayjs.unix(maxStartTime).format("YYYY-MM-DD HH:mm:ss Z"); + + await expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + payload: { + bookingId: 222, + bookingUid: uidOfBooking, + email: "organizer@example.com", + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + eventType: { + id: 1, + teamId: null, + parentId: null, + hosts: [], + users: [{ id: organizer.id, email: organizer.email }], + }, + message: `Host with email ${organizer.email} didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, + }, + }); + }, + timeout + ); + + test( + `Should trigger host no show webhook when host didn't joined the call`, + async () => { + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + const uidOfBooking = "n5Wv3eHgconAED2j4gcVhP"; + const iCalUID = `${uidOfBooking}@Cal.com`; + const subscriberUrl = "http://my-webhook.example.com"; + const bookingStartTime = `${plus1DateString}T05:00:00.000Z`; + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + id: "22", + userId: organizer.id, + eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + time: 5, + timeUnit: TimeUnit.MINUTE, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 15, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + id: 222, + uid: uidOfBooking, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: `${plus1DateString}T05:15:00.000Z`, + user: { id: organizer.id }, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: undefined, + }, + ], + iCalUID, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const MOCKED_MEETING_SESSIONS = { + total_count: 1, + data: [ + { + id: "MOCK_ID", + room: "MOCK_ROOM", + start_time: "MOCK_START_TIME", + duration: 15, + max_participants: 1, + // User with id 101 is not in the participants list + participants: [ + { + user_id: null, + participant_id: "MOCK_PARTICIPANT_ID", + user_name: "MOCK_USER_NAME", + join_time: 0, + duration: 15, + }, + ], + }, + ], + }; + + vi.mocked(getMeetingSessionsFromRoomName).mockResolvedValue(MOCKED_MEETING_SESSIONS); + + const payload = JSON.stringify({ + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + bookingId: 222, + webhook: { + id: "22", + eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + time: 5, + timeUnit: TimeUnit.MINUTE, + payloadTemplate: null, + secret: null, + }, + } satisfies TSendNoShowWebhookPayloadSchema); + + await triggerHostNoShow(payload); + + const maxStartTime = calculateMaxStartTime(bookingStartTime as unknown as Date, 5, TimeUnit.MINUTE); + const maxStartTimeHumanReadable = dayjs.unix(maxStartTime).format("YYYY-MM-DD HH:mm:ss Z"); + + await expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + payload: { + bookingId: 222, + bookingUid: uidOfBooking, + email: "organizer@example.com", + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + eventType: { + id: 1, + teamId: null, + parentId: null, + hosts: [], + users: [{ id: organizer.id, email: organizer.email }], + }, + message: `Host with email ${organizer.email} didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, + }, + }); + }, + timeout + ); +}); diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts new file mode 100644 index 00000000000000..dee91eb04bfa50 --- /dev/null +++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts @@ -0,0 +1,57 @@ +import { prisma } from "@calcom/prisma"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; + +import type { Booking, Host } from "./common"; +import { calculateMaxStartTime, sendWebhookPayload, prepareNoShowTrigger, log } from "./common"; + +const markHostsAsNoShowInBooking = async (booking: Booking, hostsThatDidntJoinTheCall: Host[]) => { + try { + await Promise.allSettled( + hostsThatDidntJoinTheCall.map((host) => { + if (booking?.user?.id === host.id) { + return prisma.booking.update({ + where: { + uid: booking.uid, + }, + data: { + noShowHost: true, + }, + }); + } + // If there are more than one host then it is stored in attendees table + else if (booking.attendees?.some((attendee) => attendee.email === host.email)) { + return prisma.attendee.update({ + where: { id: host.id }, + data: { noShow: true }, + }); + } + return Promise.resolve(); + }) + ); + } catch (error) { + log.error("Error marking hosts as no show in booking", error); + } +}; + +export async function triggerHostNoShow(payload: string): Promise { + const result = await prepareNoShowTrigger(payload); + if (!result) return; + + const { booking, webhook, hostsThatDidntJoinTheCall } = result; + + const maxStartTime = calculateMaxStartTime(booking.startTime, webhook.time, webhook.timeUnit); + + const hostsNoShowPromises = hostsThatDidntJoinTheCall.map((host) => { + return sendWebhookPayload( + webhook, + WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + booking, + maxStartTime, + host.email + ); + }); + + await Promise.all(hostsNoShowPromises); + + await markHostsAsNoShowInBooking(booking, hostsThatDidntJoinTheCall); +} diff --git a/packages/features/webhooks/components/WebhookForm.tsx b/packages/features/webhooks/components/WebhookForm.tsx index 49573b0b85af73..92433a213bc21c 100644 --- a/packages/features/webhooks/components/WebhookForm.tsx +++ b/packages/features/webhooks/components/WebhookForm.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; +import { TimeTimeUnitInput } from "@calcom/features/ee/workflows/components/TimeTimeUnitInput"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { TimeUnit } from "@calcom/prisma/enums"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { Button, Form, Label, Select, Switch, TextArea, TextField, ToggleGroup } from "@calcom/ui"; @@ -20,6 +22,8 @@ export type WebhookFormData = { eventTriggers: WebhookTriggerEvents[]; secret: string | null; payloadTemplate: string | undefined | null; + time?: number | null; + timeUnit?: TimeUnit | null; }; export type WebhookFormSubmitData = WebhookFormData & { @@ -48,10 +52,25 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record(false); const hasSecretKey = !!props?.webhook?.secret; + const [showTimeSection, setShowTimeSection] = useState( + !!triggerOptions.find( + (trigger) => + trigger.value === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW || + trigger.value === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW + ) + ); + useEffect(() => { if (changeSecret) { formMethods.unregister("secret", { keepDefaultValue: false }); @@ -170,12 +199,37 @@ const WebhookForm = (props: { value={selectValue} onChange={(event) => { onChange(event.map((selection) => selection.value)); + const noShowWebhookTriggerExists = !!event.find( + (trigger) => + trigger.value === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW || + trigger.value === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW + ); + + if (noShowWebhookTriggerExists) { + formMethods.setValue("time", props.webhook?.time ?? 5, { shouldDirty: true }); + formMethods.setValue("timeUnit", props.webhook?.timeUnit ?? TimeUnit.MINUTE, { + shouldDirty: true, + }); + } else { + formMethods.setValue("time", undefined, { shouldDirty: true }); + formMethods.setValue("timeUnit", undefined, { shouldDirty: true }); + } + + setShowTimeSection(noShowWebhookTriggerExists); }} />
); }} /> + + {showTimeSection && ( +
+ + +
+ )} + 0) { const promise = bookings.map((booking) => { return addedEventTriggers.map((triggerEvent) => { + if ( + triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW || + triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW + ) + return Promise.resolve(); + scheduleTrigger({ booking, subscriberUrl: webhook.subscriberUrl, subscriber: webhook, triggerEvent }); }); }); diff --git a/packages/features/webhooks/pages/webhook-edit-view.tsx b/packages/features/webhooks/pages/webhook-edit-view.tsx index a067d4be5bb178..d8c3ed4c14c0d8 100644 --- a/packages/features/webhooks/pages/webhook-edit-view.tsx +++ b/packages/features/webhooks/pages/webhook-edit-view.tsx @@ -88,6 +88,8 @@ export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) { active: values.active, payloadTemplate: values.payloadTemplate, secret: values.secret, + time: values.time, + timeUnit: values.timeUnit, }); }} apps={installedApps?.items.map((app) => app.slug)} diff --git a/packages/features/webhooks/pages/webhook-new-view.tsx b/packages/features/webhooks/pages/webhook-new-view.tsx index de24bfa327b879..90a9b1a8158499 100644 --- a/packages/features/webhooks/pages/webhook-new-view.tsx +++ b/packages/features/webhooks/pages/webhook-new-view.tsx @@ -82,6 +82,8 @@ export const NewWebhookView = () => { active: values.active, payloadTemplate: values.payloadTemplate, secret: values.secret, + time: values.time, + timeUnit: values.timeUnit, teamId, platform, }); diff --git a/packages/lib/dailyApiFetcher.ts b/packages/lib/dailyApiFetcher.ts new file mode 100644 index 00000000000000..5e50538ea7b037 --- /dev/null +++ b/packages/lib/dailyApiFetcher.ts @@ -0,0 +1,15 @@ +import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKeys"; +import { handleErrorsJson } from "@calcom/lib/errors"; + +export const fetcher = async (endpoint: string, init?: RequestInit | undefined) => { + const { api_key } = await getDailyAppKeys(); + return fetch(`https://api.daily.co/v1${endpoint}`, { + method: "GET", + headers: { + Authorization: `Bearer ${api_key}`, + "Content-Type": "application/json", + ...init?.headers, + }, + ...init, + }).then(handleErrorsJson); +}; diff --git a/packages/lib/server/repository/webhook.ts b/packages/lib/server/repository/webhook.ts index ba02b96f1d81df..8c49dd79574c64 100644 --- a/packages/lib/server/repository/webhook.ts +++ b/packages/lib/server/repository/webhook.ts @@ -174,6 +174,8 @@ export class WebhookRepository { teamId: true, userId: true, platform: true, + time: true, + timeUnit: true, }, }); } diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index b4c30bb3c8b735..c9abad3f27478d 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -153,6 +153,8 @@ export const buildWebhook = (webhook?: Partial): Webhook => { eventTriggers: [], teamId: null, platformOAuthClientId: null, + time: null, + timeUnit: null, ...webhook, platform: false, }; diff --git a/packages/prisma/migrations/20240920100549_add_daily_no_show/migration.sql b/packages/prisma/migrations/20240920100549_add_daily_no_show/migration.sql new file mode 100644 index 00000000000000..982ab9fc8dbcf5 --- /dev/null +++ b/packages/prisma/migrations/20240920100549_add_daily_no_show/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "WorkflowTriggerEvents" ADD VALUE 'AFTER_HOSTS_CAL_VIDEO_NO_SHOW'; +ALTER TYPE "WorkflowTriggerEvents" ADD VALUE 'AFTER_GUESTS_CAL_VIDEO_NO_SHOW'; diff --git a/packages/prisma/migrations/20240920192534_add_no_show_daily_webhook_trigger/migration.sql b/packages/prisma/migrations/20240920192534_add_no_show_daily_webhook_trigger/migration.sql new file mode 100644 index 00000000000000..18c69325943248 --- /dev/null +++ b/packages/prisma/migrations/20240920192534_add_no_show_daily_webhook_trigger/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'AFTER_HOSTS_CAL_VIDEO_NO_SHOW'; +ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'AFTER_GUESTS_CAL_VIDEO_NO_SHOW'; diff --git a/packages/prisma/migrations/20240920195815_add_time_unit_in_webhooks/migration.sql b/packages/prisma/migrations/20240920195815_add_time_unit_in_webhooks/migration.sql new file mode 100644 index 00000000000000..b041ff6de3ae73 --- /dev/null +++ b/packages/prisma/migrations/20240920195815_add_time_unit_in_webhooks/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Webhook" ADD COLUMN "time" INTEGER, +ADD COLUMN "timeUnit" "TimeUnit"; diff --git a/packages/prisma/migrations/20241010070020_add_active/migration.sql b/packages/prisma/migrations/20241010070020_add_active/migration.sql new file mode 100644 index 00000000000000..9203e22c9b1c9a --- /dev/null +++ b/packages/prisma/migrations/20241010070020_add_active/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "Webhook_active_idx" ON "Webhook"("active"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 07a34668b3a726..afb86fd9c0d515 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -768,6 +768,8 @@ enum WebhookTriggerEvents { INSTANT_MEETING RECORDING_TRANSCRIPTION_GENERATED OOO_CREATED + AFTER_HOSTS_CAL_VIDEO_NO_SHOW + AFTER_GUESTS_CAL_VIDEO_NO_SHOW } model Webhook { @@ -791,9 +793,12 @@ model Webhook { secret String? platform Boolean @default(false) scheduledTriggers WebhookScheduledTriggers[] + time Int? + timeUnit TimeUnit? @@unique([userId, subscriberUrl], name: "courseIdentifier") @@unique([platformOAuthClientId, subscriberUrl], name: "oauthclientwebhook") + @@index([active]) } model Impersonations { @@ -969,6 +974,8 @@ enum WorkflowTriggerEvents { NEW_EVENT AFTER_EVENT RESCHEDULE_EVENT + AFTER_HOSTS_CAL_VIDEO_NO_SHOW + AFTER_GUESTS_CAL_VIDEO_NO_SHOW } enum WorkflowActions { diff --git a/packages/prisma/zod/webhook.ts b/packages/prisma/zod/webhook.ts index 1e502ece7d9769..56558ddceb771f 100755 --- a/packages/prisma/zod/webhook.ts +++ b/packages/prisma/zod/webhook.ts @@ -1,6 +1,6 @@ import * as z from "zod" import * as imports from "../zod-utils" -import { WebhookTriggerEvents } from "@prisma/client" +import { WebhookTriggerEvents, TimeUnit } from "@prisma/client" import { CompleteUser, UserModel, CompleteTeam, TeamModel, CompleteEventType, EventTypeModel, CompletePlatformOAuthClient, PlatformOAuthClientModel, CompleteApp, AppModel, CompleteWebhookScheduledTriggers, WebhookScheduledTriggersModel } from "./index" export const _WebhookModel = z.object({ @@ -17,6 +17,8 @@ export const _WebhookModel = z.object({ appId: z.string().nullish(), secret: z.string().nullish(), platform: z.boolean(), + time: z.number().int().nullish(), + timeUnit: z.nativeEnum(TimeUnit).nullish(), }) export interface CompleteWebhook extends z.infer { diff --git a/packages/trpc/server/routers/viewer/webhook/create.schema.ts b/packages/trpc/server/routers/viewer/webhook/create.schema.ts index ad0616e66c12ff..661e0f9144ac2d 100644 --- a/packages/trpc/server/routers/viewer/webhook/create.schema.ts +++ b/packages/trpc/server/routers/viewer/webhook/create.schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { TIME_UNIT } from "@calcom/features/ee/workflows/lib/constants"; import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; import { webhookIdAndEventTypeIdSchema } from "./types"; @@ -14,6 +15,8 @@ export const ZCreateInputSchema = webhookIdAndEventTypeIdSchema.extend({ secret: z.string().optional().nullable(), teamId: z.number().optional(), platform: z.boolean().optional(), + time: z.number().nullable().optional(), + timeUnit: z.enum(TIME_UNIT).nullable().optional(), }); export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/webhook/edit.schema.ts b/packages/trpc/server/routers/viewer/webhook/edit.schema.ts index 183df2f74b50d1..1bb505b43835a7 100644 --- a/packages/trpc/server/routers/viewer/webhook/edit.schema.ts +++ b/packages/trpc/server/routers/viewer/webhook/edit.schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { TIME_UNIT } from "@calcom/features/ee/workflows/lib/constants"; import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; import { webhookIdAndEventTypeIdSchema } from "./types"; @@ -13,6 +14,8 @@ export const ZEditInputSchema = webhookIdAndEventTypeIdSchema.extend({ eventTypeId: z.number().optional(), appId: z.string().optional().nullable(), secret: z.string().optional().nullable(), + time: z.number().nullable().optional(), + timeUnit: z.enum(TIME_UNIT).nullable().optional(), }); export type TEditInputSchema = z.infer;