feat: automatic no show (#16727)

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Udit Takkar
2024-10-10 10:57:04 -07:00
committed by GitHub
co-authored by Carina Wollendorfer Alex van Andel Peer Richelsen CarinaWolli zomars
parent ef88effd9f
commit 395381ddcc
43 changed files with 1103 additions and 69 deletions
+36
View File
@@ -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);
}
@@ -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) => {
@@ -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 {
+2
View File
@@ -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",
@@ -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</1>.",
@@ -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;
})
);
@@ -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<string, unknown>) {
return fetcher(endpoint, {
method: "POST",
@@ -111,7 +98,10 @@ async function processTranscriptsInBatches(transcriptIds: Array<string>) {
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);
+6 -3
View File
@@ -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();
@@ -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;
@@ -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<any>[] = [];
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
// );
};
@@ -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<FormValues>;
disabled: boolean;
};
const TimeUnitAddonSuffix = ({
DropdownItems,
timeUnitOptions,
form,
}: {
form: UseFormReturn<FormValues>;
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 (
<Dropdown onOpenChange={setIsDropdownOpen}>
@@ -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={
<TimeUnitAddonSuffix
form={form}
timeUnitOptions={timeUnitOptions}
DropdownItems={
<>
@@ -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]}
</DropdownItem>
@@ -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<Record<WorkflowTriggerEvents, string>> = {
[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 && (
<div className="mt-5">
<Label>{showTimeSectionAfter ? t("how_long_after") : t("how_long_before")}</Label>
<TimeTimeUnitInput form={form} disabled={props.readOnly} />
<Label>{timeSectionText}</Label>
<TimeTimeUnitInput disabled={props.readOnly} />
{!props.readOnly && (
<div className="mt-1 flex text-gray-500">
<Icon name="info" className="mr-1 mt-0.5 h-4 w-4" />
@@ -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 = [
@@ -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 };
@@ -80,6 +80,8 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
payloadTemplate: values.payloadTemplate,
secret: values.secret,
eventTypeId: eventType.id,
time: values.time,
timeUnit: values.timeUnit,
});
};
@@ -237,6 +239,8 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
payloadTemplate: values.payloadTemplate,
secret: values.secret,
eventTypeId: webhookToEdit?.eventTypeId || undefined,
timeUnit: values.timeUnit,
time: values.time,
});
}}
/>
+9 -1
View File
@@ -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);
}
+5 -4
View File
@@ -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<string> {
return Task.create(type, payload);
}
create: TaskerCreate = async (type, payload, options = {}): Promise<string> => {
const payloadString = typeof payload === "string" ? payload : JSON.stringify(payload);
return Task.create(type, payloadString, options);
};
async processQueue(): Promise<void> {
const tasks = await Task.getNextBatch();
const tasksPromises = tasks.map(async (task) => {
+20 -2
View File
@@ -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<void>;
export type TaskerCreate = <TaskKey extends keyof TaskPayloads>(
type: TaskKey,
payload: TaskPayloads[TaskKey],
options?: { scheduledAt?: Date; maxAttempts?: number }
) => Promise<string>;
export interface Tasker {
/** Create a new task with the given type and payload. */
create(type: TaskTypes, payload: string): Promise<string>;
create: TaskerCreate;
processQueue(): Promise<void>;
cleanup(): Promise<void>;
}
+4
View File
@@ -8,6 +8,10 @@ import type { TaskHandler, TaskTypes } from "../tasker";
const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
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"))),
};
@@ -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<ReturnType<typeof getBooking>>;
type Webhook = TWebhook;
export type Participants = TTriggerNoShowPayloadSchema["data"][number]["participants"];
export function getHosts(booking: Booking): Host[] {
const hostMap = new Map<number, Host>();
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<any> {
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 };
};
@@ -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;
};
@@ -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);
};
@@ -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<typeof ZWebhook>;
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<typeof triggerNoShowPayloadSchema>;
export const ZSendNoShowWebhookPayloadSchema = commonSchema.extend({
webhook: ZWebhook,
});
export type TSendNoShowWebhookPayloadSchema = z.infer<typeof ZSendNoShowWebhookPayloadSchema>;
@@ -0,0 +1,21 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { calculateMaxStartTime, sendWebhookPayload, prepareNoShowTrigger } from "./common";
export async function triggerGuestNoShow(payload: string): Promise<void> {
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
);
}
}
@@ -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
);
});
@@ -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<void> {
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);
}
@@ -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<string, WebhookTriggerEve
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",
},
],
"routing-forms": [{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" }],
} as const;
export type WebhookFormValues = {
subscriberUrl: string;
active: boolean;
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
payloadTemplate: string | undefined | null;
time?: number | null;
timeUnit?: TimeUnit | null;
};
const WebhookForm = (props: {
webhook?: WebhookFormData;
apps?: (keyof typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2)[];
@@ -94,6 +113,8 @@ const WebhookForm = (props: {
eventTriggers: getEventTriggers(),
secret: props?.webhook?.secret || "",
payloadTemplate: props?.webhook?.payloadTemplate || undefined,
timeUnit: props?.webhook?.timeUnit || undefined,
time: props?.webhook?.time || undefined,
},
});
@@ -102,6 +123,14 @@ const WebhookForm = (props: {
const [changeSecret, setChangeSecret] = useState<boolean>(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);
}}
/>
</div>
);
}}
/>
{showTimeSection && (
<div className="mt-5">
<Label>{t("how_long_after_user_no_show_minutes")}</Label>
<TimeTimeUnitInput disabled={false} />
</div>
)}
<Controller
name="secret"
control={formMethods.control}
@@ -18,6 +18,8 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
WebhookTriggerEvents.OOO_CREATED,
WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
] as const,
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const,
};
@@ -54,6 +54,9 @@ const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient =
payloadTemplate: true,
appId: true,
secret: true,
time: true,
timeUnit: true,
eventTriggers: true,
},
});
@@ -440,6 +440,12 @@ export async function updateTriggerForExistingBookings(
if (addedEventTriggers.length > 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 });
});
});
@@ -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)}
@@ -82,6 +82,8 @@ export const NewWebhookView = () => {
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
time: values.time,
timeUnit: values.timeUnit,
teamId,
platform,
});
+15
View File
@@ -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);
};
@@ -174,6 +174,8 @@ export class WebhookRepository {
teamId: true,
userId: true,
platform: true,
time: true,
timeUnit: true,
},
});
}
+2
View File
@@ -153,6 +153,8 @@ export const buildWebhook = (webhook?: Partial<Webhook>): Webhook => {
eventTriggers: [],
teamId: null,
platformOAuthClientId: null,
time: null,
timeUnit: null,
...webhook,
platform: false,
};
@@ -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';
@@ -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';
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "time" INTEGER,
ADD COLUMN "timeUnit" "TimeUnit";
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Webhook_active_idx" ON "Webhook"("active");
+7
View File
@@ -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 {
+3 -1
View File
@@ -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<typeof _WebhookModel> {
@@ -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<typeof ZCreateInputSchema>;
@@ -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<typeof ZEditInputSchema>;