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:
co-authored by
Carina Wollendorfer
Alex van Andel
Peer Richelsen
CarinaWolli
zomars
parent
ef88effd9f
commit
395381ddcc
@@ -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) => {
|
||||
|
||||
+8
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
+10
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user