feat: add webhook versioning (#23861)

* add webhook version schema

* version the code

* update version from numeric to date val

* migration

* update schema and build factory

* update string

* move version picker

* tooltip instead of infobadge

* --

* fix type

* --

* fix type

* fix type

* --

* fix messed up merge

* improvements to payloadfactory

* extract version off of DB and instead keep it in IWebhookRepository

* fix webhookform

* fix type safety and routing ambiguity

* scalable with easier factory extensions and base definition

* fix types

* --

* --

* clean up prisma/client type imports

* fix

* type fix

* type fix

* cleanup

* add tests and registry changes

* unintended file inclusion

* type-fix

* select in repo

* --

* explicit return type

* --

* fix type

* fixes

* feedback 1

* feedback 2

* use enum instead of string

* fixes
This commit is contained in:
Syed Ali Shahbaz
2025-12-18 09:36:22 +02:00
committed by GitHub
parent 42bd4b4057
commit 056922b6ee
71 changed files with 3319 additions and 1002 deletions
+4
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { WebhookSchema } from "@calcom/prisma/zod/modelSchema/WebhookSchema";
const schemaWebhookBaseBodyParams = WebhookSchema.pick({
@@ -21,6 +22,7 @@ export const schemaWebhookCreateParams = z
eventTypeId: z.number().optional(),
userId: z.number().optional(),
secret: z.string().optional().nullable(),
version: z.nativeEnum(WebhookVersion).optional(),
// API shouldn't mess with Apps webhooks yet (ie. Zapier)
// appId: z.string().optional().nullable(),
})
@@ -33,6 +35,7 @@ export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams
z.object({
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
secret: z.string().optional().nullable(),
version: z.nativeEnum(WebhookVersion).optional(),
})
)
.partial()
@@ -44,6 +47,7 @@ export const schemaWebhookReadPublic = WebhookSchema.pick({
eventTypeId: true,
payloadTemplate: true,
eventTriggers: true,
version: true,
// FIXME: We have some invalid urls saved in the DB
// subscriberUrl: true,
/** @todo: find out how to properly add back and validate those. */
@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
import { WebhookTriggerEvents } from "@calcom/platform-libraries";
import { WebhookTriggerEvents, WebhookVersion } from "@calcom/platform-libraries";
export class CreateWebhookInputDto {
@IsString()
@@ -48,6 +48,15 @@ export class CreateWebhookInputDto {
@IsOptional()
@ApiPropertyOptional()
secret?: string;
@IsOptional()
@IsEnum(WebhookVersion)
@ApiPropertyOptional({
description: "The version of the webhook",
example: WebhookVersion.V_2021_10_20,
enum: WebhookVersion,
})
version?: WebhookVersion;
}
export class UpdateWebhookInputDto extends PartialType(CreateWebhookInputDto) {}
@@ -1,7 +1,6 @@
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { _generateMetadata } from "app/_utils";
import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/SettingsHeaderWithBackButton";
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
import { APP_NAME } from "@calcom/lib/constants";
@@ -16,21 +15,13 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
);
const Page = async ({ params: _params }: PageProps) => {
const t = await getTranslate();
const params = await _params;
const id = typeof params?.id === "string" ? params.id : undefined;
const webhookRepository = WebhookRepository.getInstance();
const webhook = await webhookRepository.findByWebhookId(id);
return (
<SettingsHeaderWithBackButton
title={t("edit_webhook")}
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}>
<EditWebhookView webhook={webhook} />
</SettingsHeaderWithBackButton>
);
return <EditWebhookView webhook={webhook} />;
};
export default Page;
@@ -2,6 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import type { ApiSuccessResponse } from "@calcom/platform-types";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
type Input = {
active: boolean;
@@ -19,6 +20,7 @@ type Output = {
triggers: WebhookTriggerEvents[];
secret?: string;
payloadTemplate: string | undefined | null;
version?: WebhookVersion;
};
export const useOAuthClientWebhooks = (clientId: string) => {
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { WebhookForm } from "@calcom/features/webhooks/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { DEFAULT_WEBHOOK_VERSION } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { showToast } from "@calcom/ui/components/toast";
@@ -82,6 +83,7 @@ export default function EditOAuthClientWebhooks() {
subscriberUrl: data.subscriberUrl,
triggers: data.eventTriggers,
secret: data.secret ?? undefined,
version: data.version,
};
if (webhook) {
await updateWebhook({
@@ -95,7 +97,7 @@ export default function EditOAuthClientWebhooks() {
}
await refetchWebhooks();
router.push("/settings/platform/");
} catch (err) {
} catch {
showToast(t(webhookId ? "webhook_update_failed" : "webhook_create_failed"), "error");
}
}}
@@ -105,7 +107,12 @@ export default function EditOAuthClientWebhooks() {
noRoutingFormTriggers={true}
webhook={
webhook
? { ...webhook, eventTriggers: webhook.triggers, secret: webhook.secret ?? null }
? {
...webhook,
eventTriggers: webhook.triggers,
secret: webhook.secret ?? null,
version: webhook.version ?? DEFAULT_WEBHOOK_VERSION,
}
: undefined
}
/>
@@ -3835,6 +3835,7 @@
"webhook_attendee_email": "Email of the first attendee",
"webhook_attendee_timezone": "Timezone of the first attendee",
"webhook_attendee_locale": "Locale of the first attendee",
"webhook_version": "Webhook Version",
"webhook_team_name": "Name of the team booked",
"webhook_team_members": "Members of the team booked",
"webhook_video_call_url": "Video call URL for the meeting",
@@ -5,7 +5,7 @@ import { describe, it, vi, expect, beforeEach, afterEach } from "vitest";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { GetWebhooksReturnType } from "@calcom/features/webhooks/lib/getWebhooks";
import type { WebhookSubscriber } from "@calcom/features/webhooks/lib/dto/types";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import {
@@ -15,6 +15,7 @@ import {
WorkflowTemplates,
TimeUnit,
} from "@calcom/prisma/enums";
import { WebhookVersion as WebhookVersionEnum } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import type { FormResponse, Field } from "../types/types";
import { _onFormSubmission } from "./formSubmissionUtils";
@@ -100,7 +101,7 @@ describe("_onFormSubmission", () => {
describe("Webhooks", () => {
it("should call FORM_SUBMITTED webhooks", async () => {
const mockWebhook: GetWebhooksReturnType[number] = {
const mockWebhook: WebhookSubscriber = {
id: "wh-1",
secret: "secret",
subscriberUrl: "https://example.com/webhook",
@@ -109,6 +110,7 @@ describe("_onFormSubmission", () => {
eventTriggers: [WebhookTriggerEvents.FORM_SUBMITTED],
time: null,
timeUnit: null,
version: WebhookVersionEnum.V_2021_10_20,
};
vi.mocked(getWebhooks).mockResolvedValueOnce([mockWebhook]);
@@ -141,7 +143,7 @@ describe("_onFormSubmission", () => {
"field-1": { label: "Attendee Name", value: "John Doe" },
};
const mockWebhook: GetWebhooksReturnType[number] = {
const mockWebhook: WebhookSubscriber = {
id: "wh-1",
secret: "secret",
subscriberUrl: "https://example.com/webhook",
@@ -150,6 +152,7 @@ describe("_onFormSubmission", () => {
eventTriggers: [WebhookTriggerEvents.FORM_SUBMITTED],
time: null,
timeUnit: null,
version: WebhookVersionEnum.V_2021_10_20,
};
vi.mocked(getWebhooks).mockResolvedValueOnce([mockWebhook]);
@@ -8,13 +8,8 @@ export const WEBHOOK_TOKENS = {
OOO_WEBHOOK_SERVICE: Symbol("OOO_WEBHOOK_SERVICE"),
WEBHOOK_NOTIFICATION_HANDLER: Symbol("WebhookNotificationHandler"),
// Payload builders
BOOKING_PAYLOAD_BUILDER: Symbol("BookingPayloadBuilder"),
FORM_PAYLOAD_BUILDER: Symbol("FormPayloadBuilder"),
OOO_PAYLOAD_BUILDER: Symbol("OOOPayloadBuilder"),
RECORDING_PAYLOAD_BUILDER: Symbol("RecordingPayloadBuilder"),
MEETING_PAYLOAD_BUILDER: Symbol("MeetingPayloadBuilder"),
INSTANT_MEETING_BUILDER: Symbol("InstantMeetingBuilder"),
// Payload builder factory (versioning)
PAYLOAD_BUILDER_FACTORY: Symbol("PayloadBuilderFactory"),
// Repositories
WEBHOOK_REPOSITORY: Symbol("IWebhookRepository"),
@@ -19,12 +19,7 @@ webhookContainer.load(WEBHOOK_TOKENS.BOOKING_WEBHOOK_SERVICE, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.FORM_WEBHOOK_SERVICE, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.RECORDING_WEBHOOK_SERVICE, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.OOO_WEBHOOK_SERVICE, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.BOOKING_PAYLOAD_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.FORM_PAYLOAD_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.OOO_PAYLOAD_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.RECORDING_PAYLOAD_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.MEETING_PAYLOAD_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.INSTANT_MEETING_BUILDER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.PAYLOAD_BUILDER_FACTORY, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.WEBHOOK_NOTIFICATION_HANDLER, webhookModule);
webhookContainer.load(WEBHOOK_TOKENS.WEBHOOK_NOTIFIER, webhookModule);
@@ -1,11 +1,6 @@
import { createModule } from "@evyweb/ioctopus";
import { BookingPayloadBuilder } from "@calcom/features/webhooks/lib/factory/BookingPayloadBuilder";
import { FormPayloadBuilder } from "@calcom/features/webhooks/lib/factory/FormPayloadBuilder";
import { InstantMeetingBuilder } from "@calcom/features/webhooks/lib/factory/InstantMeetingBuilder";
import { MeetingPayloadBuilder } from "@calcom/features/webhooks/lib/factory/MeetingPayloadBuilder";
import { OOOPayloadBuilder } from "@calcom/features/webhooks/lib/factory/OOOPayloadBuilder";
import { RecordingPayloadBuilder } from "@calcom/features/webhooks/lib/factory/RecordingPayloadBuilder";
import { createPayloadBuilderFactory } from "@calcom/features/webhooks/lib/factory/versioned/registry";
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
import { BookingWebhookService } from "@calcom/features/webhooks/lib/service/BookingWebhookService";
import { FormWebhookService } from "@calcom/features/webhooks/lib/service/FormWebhookService";
@@ -54,25 +49,15 @@ webhookModule
SHARED_TOKENS.LOGGER,
]);
// Bind payload builders
webhookModule.bind(WEBHOOK_TOKENS.BOOKING_PAYLOAD_BUILDER).toClass(BookingPayloadBuilder);
webhookModule.bind(WEBHOOK_TOKENS.FORM_PAYLOAD_BUILDER).toClass(FormPayloadBuilder);
webhookModule.bind(WEBHOOK_TOKENS.OOO_PAYLOAD_BUILDER).toClass(OOOPayloadBuilder);
webhookModule.bind(WEBHOOK_TOKENS.RECORDING_PAYLOAD_BUILDER).toClass(RecordingPayloadBuilder);
webhookModule.bind(WEBHOOK_TOKENS.MEETING_PAYLOAD_BUILDER).toClass(MeetingPayloadBuilder);
webhookModule.bind(WEBHOOK_TOKENS.INSTANT_MEETING_BUILDER).toClass(InstantMeetingBuilder);
// Bind payload builder factory (composition root for versioning)
webhookModule.bind(WEBHOOK_TOKENS.PAYLOAD_BUILDER_FACTORY).toFactory(() => createPayloadBuilderFactory());
// Bind notification handler
// Bind notification handler with factory
webhookModule
.bind(WEBHOOK_TOKENS.WEBHOOK_NOTIFICATION_HANDLER)
.toClass(WebhookNotificationHandler, [
WEBHOOK_TOKENS.WEBHOOK_SERVICE,
WEBHOOK_TOKENS.BOOKING_PAYLOAD_BUILDER,
WEBHOOK_TOKENS.FORM_PAYLOAD_BUILDER,
WEBHOOK_TOKENS.OOO_PAYLOAD_BUILDER,
WEBHOOK_TOKENS.RECORDING_PAYLOAD_BUILDER,
WEBHOOK_TOKENS.MEETING_PAYLOAD_BUILDER,
WEBHOOK_TOKENS.INSTANT_MEETING_BUILDER,
WEBHOOK_TOKENS.PAYLOAD_BUILDER_FACTORY,
SHARED_TOKENS.LOGGER,
]);
@@ -9,11 +9,10 @@ import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequir
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { EventTypeSetup, FormValues, AvailabilityOption } from "@calcom/features/eventtypes/lib/types";
import { WebhookForm } from "@calcom/features/webhooks/components";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import type { TWebhook, WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Webhook } from "@calcom/prisma/client";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
@@ -290,7 +289,7 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [webhookToEdit, setWebhookToEdit] = useState<Webhook>();
const [webhookToEdit, setWebhookToEdit] = useState<TWebhook>();
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
async onSuccess() {
@@ -7,12 +7,11 @@ import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hook
import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import { WebhookForm } from "@calcom/features/webhooks/components";
import EventTypeWebhookListItem from "@calcom/features/webhooks/components/EventTypeWebhookListItem";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import type { TWebhook, WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Webhook } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
@@ -36,7 +35,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [webhookToEdit, setWebhookToEdit] = useState<Webhook>();
const [webhookToEdit, setWebhookToEdit] = useState<TWebhook>();
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
async onSuccess() {
@@ -6,8 +6,9 @@ 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 { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { WebhookVersion } from "../lib/interface/IWebhookRepository";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
@@ -33,6 +34,7 @@ export type WebhookFormData = {
payloadTemplate: string | undefined | null;
time?: number | null;
timeUnit?: TimeUnit | null;
version: WebhookVersion;
};
export type WebhookFormSubmitData = WebhookFormData & {
@@ -241,16 +243,21 @@ export type WebhookFormValues = {
payloadTemplate: string | undefined | null;
time?: number | null;
timeUnit?: TimeUnit | null;
version: WebhookVersion;
};
const WebhookForm = (props: {
webhook?: WebhookFormData;
webhook?: TWebhook | WebhookFormData;
apps?: (keyof typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2)[];
overrideTriggerOptions?: (typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2)["core"];
onSubmit: (event: WebhookFormSubmitData) => void;
onCancel?: () => void;
noRoutingFormTriggers: boolean;
selectOnlyInstantMeetingOption?: boolean;
headerWrapper?: (
formMethods: ReturnType<typeof useForm<WebhookFormValues>>,
children: React.ReactNode
) => React.ReactNode;
}) => {
const { apps = [], selectOnlyInstantMeetingOption = false, overrideTriggerOptions } = props;
const { t } = useLocale();
@@ -279,7 +286,7 @@ const WebhookForm = (props: {
).map((option) => option.value);
};
const formMethods = useForm({
const formMethods = useForm<WebhookFormValues>({
defaultValues: {
subscriberUrl: props.webhook?.subscriberUrl || "",
active: props.webhook ? props.webhook.active : true,
@@ -288,9 +295,12 @@ const WebhookForm = (props: {
payloadTemplate: props?.webhook?.payloadTemplate || undefined,
timeUnit: props?.webhook?.timeUnit || undefined,
time: props?.webhook?.time || undefined,
version: props?.webhook?.version || WebhookVersion.V_2021_10_20,
},
});
formMethods.register("version");
const triggers = formMethods.watch("eventTriggers") || [];
const subscriberUrl = formMethods.watch("subscriberUrl");
const time = formMethods.watch("time");
@@ -353,267 +363,274 @@ const WebhookForm = (props: {
}
}, [changeSecret, formMethods]);
return (
const formContent = (
<Form
form={formMethods}
handleSubmit={(values) => props.onSubmit({ ...values, changeSecret, newSecret })}>
<div className="border-subtle border p-6">
<Controller
name="subscriberUrl"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<TextField
name="subscriberUrl"
label={t("subscriber_url")}
labelClassName="font-medium text-emphasis font-sm"
value={value}
required
type="url"
onChange={(e) => {
formMethods.setValue("subscriberUrl", e?.target.value, { shouldDirty: true });
if (hasTemplateIntegration({ url: e.target.value })) {
setUseCustomTemplate(true);
formMethods.setValue("payloadTemplate", customTemplate({ url: e.target.value }), {
shouldDirty: true,
});
}
}}
/>
</>
)}
/>
<Controller
name="active"
control={formMethods.control}
render={({ field: { value } }) => (
<div className="font-sm text-emphasis mt-6 font-medium">
<Switch
label={t("enable_webhook")}
checked={value}
// defaultChecked={props?.webhook?.active ? props?.webhook?.active : true}
onCheckedChange={(value) => {
formMethods.setValue("active", value, { shouldDirty: true });
}}
/>
</div>
)}
/>
<Controller
name="eventTriggers"
control={formMethods.control}
render={({ field: { onChange, value } }) => {
const selectValue = translatedTriggerOptions.filter((option) => value.includes(option.value));
return (
<div className="mt-6">
<Label className="font-sm text-emphasis font-medium">
<>{t("event_triggers")}</>
</Label>
<Select
grow
options={translatedTriggerOptions}
isMulti
styles={{
indicatorsContainer: (base) => ({
...base,
alignItems: "flex-start",
}),
}}
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 });
}
}}
/>
</div>
);
}}
/>
{showTimeSection && (
<div className="mt-5">
<Label>{t("how_long_after_user_no_show_minutes")}</Label>
<TimeTimeUnitInput disabled={false} defaultTime={5} />
</div>
)}
<Controller
name="secret"
control={formMethods.control}
render={({ field: { value } }) => (
<div className="mt-6">
{!!hasSecretKey && !changeSecret && (
<>
<Label className="font-sm text-emphasis font-medium">Secret</Label>
<div className="bg-default stack-y-0 rounded-md border-0 border-neutral-200 sm:mx-0 md:border">
<div className="text-emphasis rounded-sm border-b p-2 text-sm">
{t("forgotten_secret_description")}
</div>
<div className="p-2">
<Button
color="secondary"
type="button"
onClick={() => {
setChangeSecret(true);
}}>
{t("change_secret")}
</Button>
</div>
</div>
</>
)}
{!!hasSecretKey && changeSecret && (
<>
<TextField
autoComplete="off"
label={t("secret")}
labelClassName="font-medium text-emphasis font-sm"
{...formMethods.register("secret")}
value={newSecret}
onChange={(event) => setNewSecret(event.currentTarget.value)}
type="text"
placeholder={t("leave_blank_to_remove_secret")}
/>
<Button
color="secondary"
type="button"
className="py-1 text-xs"
onClick={() => {
setChangeSecret(false);
}}>
{t("cancel")}
</Button>
</>
)}
{!hasSecretKey && (
form={formMethods}
handleSubmit={(values) => props.onSubmit({ ...values, changeSecret, newSecret })}>
<div className="border-subtle border p-6">
<Controller
name="subscriberUrl"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<TextField
name="secret"
label={t("secret")}
name="subscriberUrl"
label={t("subscriber_url")}
labelClassName="font-medium text-emphasis font-sm"
value={value}
required
type="url"
onChange={(e) => {
formMethods.setValue("secret", e?.target.value, { shouldDirty: true });
}}
/>
)}
</div>
)}
/>
<Controller
name="payloadTemplate"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="font-sm text-emphasis mt-6">
<>{t("payload_template")}</>
</Label>
<div className="mb-2">
<ToggleGroup
onValueChange={(val) => {
if (val === "default") {
setUseCustomTemplate(false);
formMethods.setValue("payloadTemplate", undefined, { shouldDirty: true });
} else {
formMethods.setValue("subscriberUrl", e?.target.value, { shouldDirty: true });
if (hasTemplateIntegration({ url: e.target.value })) {
setUseCustomTemplate(true);
formMethods.setValue("payloadTemplate", customTemplate({ url: e.target.value }), {
shouldDirty: true,
});
}
}}
value={useCustomTemplate ? "custom" : "default"}
options={[
{ value: "default", label: t("default") },
{ value: "custom", label: t("custom") },
]}
isFullWidth={true}
/>
</>
)}
/>
<Controller
name="active"
control={formMethods.control}
render={({ field: { value } }) => (
<div className="font-sm text-emphasis mt-6 font-medium">
<Switch
label={t("enable_webhook")}
checked={value}
// defaultChecked={props?.webhook?.active ? props?.webhook?.active : true}
onCheckedChange={(value) => {
formMethods.setValue("active", value, { shouldDirty: true });
}}
/>
</div>
{useCustomTemplate && (
<div className="stack-y-3">
<TextArea
name="customPayloadTemplate"
rows={8}
value={value || ""}
placeholder={`{\n\n}`}
onChange={(e) =>
formMethods.setValue("payloadTemplate", e?.target.value, { shouldDirty: true })
}
)}
/>
<Controller
name="eventTriggers"
control={formMethods.control}
render={({ field: { onChange, value } }) => {
const selectValue = translatedTriggerOptions.filter((option) => value.includes(option.value));
return (
<div className="mt-6">
<Label className="font-sm text-emphasis font-medium">
<>{t("event_triggers")}</>
</Label>
<Select
grow
options={translatedTriggerOptions}
isMulti
styles={{
indicatorsContainer: (base) => ({
...base,
alignItems: "flex-start",
}),
}}
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 });
}
}}
/>
<Button type="button" color="secondary" onClick={() => setShowVariables(!showVariables)}>
{showVariables ? t("webhook_hide_variables") : t("webhook_show_variable")}
</Button>
{showVariables && (
<div className="border-muted max-h-80 overflow-y-auto rounded-md border p-3">
{webhookVariables.map(({ category, variables }) => (
<div key={category} className="mb-4">
<h4 className="mb-2 text-sm font-medium">{category}</h4>
<div className="stack-y-2">
{variables.map(({ name, variable, description }) => (
<div
key={name}
className="hover:bg-cal-muted cursor-pointer rounded p-2 text-sm transition-colors"
onClick={() => {
const currentValue = formMethods.getValues("payloadTemplate") || "{}";
const updatedValue = insertVariableIntoTemplate(
currentValue,
name,
variable
);
formMethods.setValue("payloadTemplate", updatedValue, {
shouldDirty: true,
});
}}>
<div className="text-emphasis font-mono">{variable}</div>
<div className="text-muted mt-1 text-xs">{description}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
/>
</div>
<SectionBottomActions align="end">
<Button
type="button"
color="minimal"
onClick={props.onCancel}
{...(!props.onCancel ? { href: `${WEBAPP_URL}/settings/developer/webhooks` } : {})}>
{t("cancel")}
</Button>
<Button
type="submit"
data-testid="create_webhook"
disabled={!canSubmit}
loading={formMethods.formState.isSubmitting}>
{props?.webhook?.id ? t("save") : t("create_webhook")}
</Button>
</SectionBottomActions>
);
}}
/>
<div className="mb-4 mt-6 rounded-md">
<WebhookTestDisclosure />
</div>
</Form>
{showTimeSection && (
<div className="mt-5">
<Label>{t("how_long_after_user_no_show_minutes")}</Label>
<TimeTimeUnitInput disabled={false} defaultTime={5} />
</div>
)}
<Controller
name="secret"
control={formMethods.control}
render={({ field: { value } }) => (
<div className="mt-6">
{!!hasSecretKey && !changeSecret && (
<>
<Label className="font-sm text-emphasis font-medium">Secret</Label>
<div className="bg-default stack-y-0 rounded-md border-0 border-neutral-200 sm:mx-0 md:border">
<div className="text-emphasis rounded-sm border-b p-2 text-sm">
{t("forgotten_secret_description")}
</div>
<div className="p-2">
<Button
color="secondary"
type="button"
onClick={() => {
setChangeSecret(true);
}}>
{t("change_secret")}
</Button>
</div>
</div>
</>
)}
{!!hasSecretKey && changeSecret && (
<>
<TextField
autoComplete="off"
label={t("secret")}
labelClassName="font-medium text-emphasis font-sm"
{...formMethods.register("secret")}
value={newSecret}
onChange={(event) => setNewSecret(event.currentTarget.value)}
type="text"
placeholder={t("leave_blank_to_remove_secret")}
/>
<Button
color="secondary"
type="button"
className="py-1 text-xs"
onClick={() => {
setChangeSecret(false);
}}>
{t("cancel")}
</Button>
</>
)}
{!hasSecretKey && (
<TextField
name="secret"
label={t("secret")}
labelClassName="font-medium text-emphasis font-sm"
value={value ?? ""}
onChange={(e) => {
formMethods.setValue("secret", e?.target.value, { shouldDirty: true });
}}
/>
)}
</div>
)}
/>
<Controller
name="payloadTemplate"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="font-sm text-emphasis mt-6">
<>{t("payload_template")}</>
</Label>
<div className="mb-2">
<ToggleGroup
onValueChange={(val) => {
if (val === "default") {
setUseCustomTemplate(false);
formMethods.setValue("payloadTemplate", undefined, { shouldDirty: true });
} else {
setUseCustomTemplate(true);
}
}}
value={useCustomTemplate ? "custom" : "default"}
options={[
{ value: "default", label: t("default") },
{ value: "custom", label: t("custom") },
]}
isFullWidth={true}
/>
</div>
{useCustomTemplate && (
<div className="stack-y-3">
<TextArea
name="customPayloadTemplate"
rows={8}
value={value || ""}
placeholder={`{\n\n}`}
onChange={(e) =>
formMethods.setValue("payloadTemplate", e?.target.value, { shouldDirty: true })
}
/>
<Button type="button" color="secondary" onClick={() => setShowVariables(!showVariables)}>
{showVariables ? t("webhook_hide_variables") : t("webhook_show_variable")}
</Button>
{showVariables && (
<div className="border-muted max-h-80 overflow-y-auto rounded-md border p-3">
{webhookVariables.map(({ category, variables }) => (
<div key={category} className="mb-4">
<h4 className="mb-2 text-sm font-medium">{category}</h4>
<div className="stack-y-2">
{variables.map(({ name, variable, description }) => (
<div
key={name}
className="hover:bg-cal-muted cursor-pointer rounded p-2 text-sm transition-colors"
onClick={() => {
const currentValue = formMethods.getValues("payloadTemplate") || "{}";
const updatedValue = insertVariableIntoTemplate(
currentValue,
name,
variable
);
formMethods.setValue("payloadTemplate", updatedValue, {
shouldDirty: true,
});
}}>
<div className="text-emphasis font-mono">{variable}</div>
<div className="text-muted mt-1 text-xs">{description}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
/>
</div>
<SectionBottomActions align="end">
<Button
type="button"
color="minimal"
onClick={props.onCancel}
{...(!props.onCancel ? { href: `${WEBAPP_URL}/settings/developer/webhooks` } : {})}>
{t("cancel")}
</Button>
<Button
type="submit"
data-testid="create_webhook"
disabled={!canSubmit}
loading={formMethods.formState.isSubmitting}>
{props?.webhook?.id ? t("save") : t("create_webhook")}
</Button>
</SectionBottomActions>
<div className="mb-4 mt-6 rounded-md">
<WebhookTestDisclosure />
</div>
</Form>
);
// If headerWrapper is provided, wrap the form content with it
if (props.headerWrapper) {
return <>{props.headerWrapper(formMethods, formContent)}</>;
}
return formContent;
};
export default WebhookForm;
@@ -1,7 +1,9 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { Webhook } from "../lib/dto/types";
import { getWebhookVersionLabel } from "../lib/constants";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
@@ -17,22 +19,12 @@ import {
import { Switch } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
type WebhookProps = {
id: string;
subscriberUrl: string;
payloadTemplate: string | null;
active: boolean;
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
eventTypeId: number | null;
teamId: number | null;
};
export default function WebhookListItem(props: {
webhook: WebhookProps;
webhook: Webhook;
canEditWebhook?: boolean;
onEditWebhook: () => void;
lastItem: boolean;
@@ -94,6 +86,13 @@ export default function WebhookListItem(props: {
{t("readonly")}
</Badge>
)}
<Tooltip content={t("webhook_version")}>
<div className="flex items-center">
<Badge variant="blue" className="ml-2">
{getWebhookVersionLabel(webhook.version)}
</Badge>
</div>
</Tooltip>
</div>
<Tooltip content={t("triggers_when")}>
<div className="flex w-4/5 flex-wrap">
@@ -115,10 +114,10 @@ export default function WebhookListItem(props: {
defaultChecked={webhook.active}
data-testid="webhook-switch"
disabled={!props.permissions.canEditWebhook}
onCheckedChange={() =>
onCheckedChange={(checked) =>
toggleWebhook.mutate({
id: webhook.id,
active: !webhook.active,
active: checked,
payloadTemplate: webhook.payloadTemplate,
eventTypeId: webhook.eventTypeId || undefined,
})
@@ -2,9 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookSubscriber } from "./dto/types";
import { WebhookService } from "./WebhookService";
import type { GetWebhooksReturnType } from "./getWebhooks";
import getWebhooks from "./getWebhooks";
import { WebhookVersion as WebhookVersionEnum } from "./interface/IWebhookRepository";
vi.mock("./getWebhooks", () => ({
default: vi.fn(),
@@ -41,7 +42,7 @@ describe("WebhookService", () => {
});
it("should initialize with options and webhooks", async () => {
const mockWebhooks: GetWebhooksReturnType = [
const mockWebhooks: WebhookSubscriber[] = [
{
id: "webhookId",
subscriberUrl: "url",
@@ -51,6 +52,7 @@ describe("WebhookService", () => {
eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
timeUnit: "MINUTE",
time: 5,
version: WebhookVersionEnum.V_2021_10_20,
},
];
vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks);
@@ -1,15 +1,16 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { WebhookSubscriber } from "./dto/types";
import getWebhooks from "./getWebhooks";
import type { GetSubscriberOptions, GetWebhooksReturnType } from "./getWebhooks";
import type { GetSubscriberOptions } from "./getWebhooks";
import sendOrSchedulePayload from "./sendOrSchedulePayload";
const log = logger.getSubLogger({ prefix: ["[WebhookService] "] });
/** This is a WIP. With minimal methods until the API matures and stabilizes */
export class WebhookService {
private constructor(private options: GetSubscriberOptions, private webhooks: GetWebhooksReturnType) {}
private constructor(private options: GetSubscriberOptions, private webhooks: WebhookSubscriber[]) {}
static async init(options: GetSubscriberOptions) {
const webhooks = await getWebhooks(options);
return new WebhookService(options, webhooks);
@@ -1,7 +1,33 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { WebhookVersion } from "./interface/IWebhookRepository";
// this is exported as we can't use `WebhookTriggerEvents` in the frontend straight-off
/**
* Version label map - transforms version enum values to display labels.
* Since our version values ARE date strings (e.g., "2021-10-20"), labels match values.
* This map allows for custom labels if needed in the future.
*/
export const WEBHOOK_VERSION_LABELS: Record<WebhookVersion, string> = {
[WebhookVersion.V_2021_10_20]: "2021-10-20",
// Add new versions here: [WebhookVersion.V_YYYY_MM_DD]: "YYYY-MM-DD",
};
/**
* Pre-built options for Select components - automatically generated from all versions
*/
export const WEBHOOK_VERSION_OPTIONS = Object.values(WebhookVersion).map((version) => ({
value: version,
label: WEBHOOK_VERSION_LABELS[version] ?? version,
}));
/**
* Get display label for a version
*/
export const getWebhookVersionLabel = (version: WebhookVersion): string =>
WEBHOOK_VERSION_LABELS[version] ?? version;
export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
core: [
WebhookTriggerEvents.BOOKING_CANCELLED,
@@ -1,6 +1,8 @@
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookVersion } from "../interface/IWebhookRepository";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
export interface BaseEventDTO {
@@ -402,6 +404,33 @@ export interface WebhookTriggerArgs {
paymentData?: Record<string, unknown>;
}
/**
* Full webhook entity - used for CRUD operations and UI display
* Contains all fields needed for webhook management
*/
export interface Webhook {
id: string;
subscriberUrl: string;
payloadTemplate: string | null;
appId: string | null;
secret: string | null;
active: boolean;
eventTriggers: WebhookTriggerEvents[];
eventTypeId: number | null;
teamId: number | null;
userId: number | null;
time: number | null;
timeUnit: TimeUnit | null;
version: WebhookVersion;
createdAt: Date;
platform: boolean;
platformOAuthClientId: string | null;
}
/**
* Webhook subscriber - minimal type for webhook delivery/payload sending
* Subset of Webhook with only fields needed for triggering webhooks
*/
export interface WebhookSubscriber {
id: string;
subscriberUrl: string;
@@ -411,6 +440,7 @@ export interface WebhookSubscriber {
time?: number | null;
timeUnit?: TimeUnit | null;
eventTriggers: WebhookTriggerEvents[];
version: WebhookVersion;
}
export interface WebhookDeliveryResult {
@@ -422,6 +452,35 @@ export interface WebhookDeliveryResult {
webhookId: string;
}
/**
* Payment data associated with webhook events
* Used when sending booking payment webhooks
*/
export interface PaymentData {
id: number;
fee: number;
currency: string;
success: boolean;
refunded: boolean;
externalId: string;
data: unknown;
appId: string | null;
bookingId: number;
}
/**
* Minimal webhook shape for URL reservation check
* Used to check if a subscriber URL is already in use within a scope
*/
export interface WebhookForReservationCheck {
id: string;
subscriberUrl: string;
teamId: number | null;
userId: number | null;
eventTypeId: number | null;
platform: boolean;
}
// Legacy types moved from sendPayload.ts for backward compatibility
export type EventTypeInfo = {
eventTitle?: string | null;
@@ -536,3 +595,20 @@ export type BookingWebhookEventDTO =
| BookingPaymentInitiatedDTO
| BookingRejectedDTO
| BookingNoShowDTO;
/**
* Grouped webhooks for UI display with profile and permission metadata
*/
export interface WebhookGroup {
teamId?: number | null;
profile: {
slug: string | null;
name: string | null;
image?: string;
};
metadata?: {
canModify: boolean;
canDelete: boolean;
};
webhooks: Webhook[]; // Full webhook entities for UI display
}
@@ -1,209 +0,0 @@
import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { EventTypeInfo, BookingWebhookEventDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
type BookingExtraDataMap = {
[WebhookTriggerEvents.BOOKING_CREATED]: null;
[WebhookTriggerEvents.BOOKING_CANCELLED]: { cancelledBy?: string; cancellationReason?: string };
[WebhookTriggerEvents.BOOKING_REQUESTED]: null;
[WebhookTriggerEvents.BOOKING_REJECTED]: null;
[WebhookTriggerEvents.BOOKING_RESCHEDULED]: {
rescheduleId?: number;
rescheduleUid?: string;
rescheduleStartTime?: string;
rescheduleEndTime?: string;
rescheduledBy?: string;
};
[WebhookTriggerEvents.BOOKING_PAID]: { paymentId?: number; paymentData?: Record<string, unknown> };
[WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED]: {
paymentId?: number;
paymentData?: Record<string, unknown>;
};
};
interface CreateBookingWebhookPayloadParams<T extends keyof BookingExtraDataMap> {
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
};
eventType: EventTypeInfo;
evt: CalendarEvent;
status: BookingStatus;
triggerEvent: WebhookTriggerEvents;
createdAt: string;
extra?: BookingExtraDataMap[T];
}
function createBookingWebhookPayload<T extends keyof BookingExtraDataMap>(
params: CreateBookingWebhookPayloadParams<T>
): WebhookPayload {
const utcOffsetOrganizer = getUTCOffsetByTimezone(params.evt.organizer?.timeZone, params.evt.startTime);
const organizer = { ...params.evt.organizer, utcOffset: utcOffsetOrganizer };
return {
triggerEvent: params.triggerEvent,
createdAt: params.createdAt,
payload: {
...params.evt,
bookingId: params.booking.id,
startTime: params.evt.startTime,
endTime: params.evt.endTime,
title: params.evt.title,
type: params.evt.type,
organizer,
attendees:
params.evt.attendees?.map((a) => ({
...a,
utcOffset: getUTCOffsetByTimezone(a.timeZone, params.evt.startTime),
})) ?? [],
location: params.evt.location,
uid: params.evt.uid,
customInputs: params.evt.customInputs,
responses: params.evt.responses,
userFieldsResponses: params.evt.userFieldsResponses,
status: params.status,
eventTitle: params.eventType?.eventTitle,
eventDescription: params.eventType?.eventDescription,
requiresConfirmation: params.eventType?.requiresConfirmation,
price: params.eventType?.price,
currency: params.eventType?.currency,
length: params.eventType?.length,
smsReminderNumber: params.booking.smsReminderNumber || undefined,
description: params.evt.description || params.evt.additionalNotes,
...(params.extra || {}),
},
};
}
const BOOKING_WEBHOOK_EVENTS: WebhookTriggerEvents[] = [
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_REQUESTED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.BOOKING_REJECTED,
WebhookTriggerEvents.BOOKING_PAID,
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
];
export class BookingPayloadBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return BOOKING_WEBHOOK_EVENTS.includes(triggerEvent);
}
build(dto: BookingWebhookEventDTO): WebhookPayload {
switch (dto.triggerEvent) {
case WebhookTriggerEvents.BOOKING_CREATED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
});
case WebhookTriggerEvents.BOOKING_CANCELLED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.CANCELLED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
cancelledBy: dto.cancelledBy,
cancellationReason: dto.cancellationReason,
},
});
case WebhookTriggerEvents.BOOKING_REQUESTED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.PENDING,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
});
case WebhookTriggerEvents.BOOKING_REJECTED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.REJECTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
});
case WebhookTriggerEvents.BOOKING_RESCHEDULED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
rescheduleId: dto.rescheduleId,
rescheduleUid: dto.rescheduleUid,
rescheduleStartTime: dto.rescheduleStartTime,
rescheduleEndTime: dto.rescheduleEndTime,
rescheduledBy: dto.rescheduledBy,
},
});
case WebhookTriggerEvents.BOOKING_PAID:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
paymentId: dto.paymentId,
paymentData: dto.paymentData,
},
});
case WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED:
return createBookingWebhookPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
paymentId: dto.paymentId,
paymentData: dto.paymentData,
},
});
case WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED:
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
bookingUid: dto.bookingUid,
bookingId: dto.bookingId,
attendees: dto.attendees,
message: dto.message,
},
};
default: {
// TypeScript exhaustiveness check - this should never happen if all cases are covered
const _exhaustiveCheck: never = dto;
throw new Error(`Unsupported booking trigger: ${JSON.stringify(_exhaustiveCheck)}`);
}
}
}
}
@@ -1,26 +0,0 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { InstantMeetingDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
export class InstantMeetingBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return triggerEvent === WebhookTriggerEvents.INSTANT_MEETING;
}
build(dto: InstantMeetingDTO): WebhookPayload {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
title: dto.title,
body: dto.body,
icon: dto.icon,
url: dto.url,
actions: dto.actions,
requireInteraction: dto.requireInteraction,
type: dto.type,
},
};
}
}
@@ -1,21 +0,0 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { MeetingStartedDTO, MeetingEndedDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
export class MeetingPayloadBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return (
triggerEvent === WebhookTriggerEvents.MEETING_STARTED ||
triggerEvent === WebhookTriggerEvents.MEETING_ENDED
);
}
build(dto: MeetingStartedDTO | MeetingEndedDTO): WebhookPayload {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: { ...dto.booking },
};
}
}
@@ -1,18 +0,0 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { OOOCreatedDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
export class OOOPayloadBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return triggerEvent === WebhookTriggerEvents.OOO_CREATED;
}
build(dto: OOOCreatedDTO): WebhookPayload {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: { oooEntry: dto.oooEntry },
};
}
}
@@ -1,31 +0,0 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
export class RecordingPayloadBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return (
triggerEvent === WebhookTriggerEvents.RECORDING_READY ||
triggerEvent === WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED
);
}
build(dto: RecordingReadyDTO | TranscriptionGeneratedDTO): WebhookPayload {
if (dto.triggerEvent === WebhookTriggerEvents.RECORDING_READY) {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: { downloadLink: dto.downloadLink },
};
}
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
downloadLinks: dto.downloadLinks,
},
};
}
}
@@ -0,0 +1,213 @@
import { describe, it, expect, vi } from "vitest";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { BookingWebhookEventDTO, EventTypeInfo } from "../../dto/types";
import { BookingPayloadBuilder } from "../versioned/v2021-10-20/BookingPayloadBuilder";
vi.mock("@calcom/lib/dayjs", () => ({
getUTCOffsetByTimezone: vi.fn(() => 0),
}));
describe("BookingPayloadBuilder (v2021-10-20)", () => {
const mockEventType: EventTypeInfo = {
eventTitle: "Test Event",
eventDescription: "Test Description",
requiresConfirmation: false,
price: 0,
currency: "USD",
length: 30,
};
const mockCalendarEvent: CalendarEvent = {
type: "test-event",
title: "Test Meeting",
description: "Meeting description",
additionalNotes: "Additional notes",
startTime: "2024-01-15T10:00:00Z",
endTime: "2024-01-15T10:30:00Z",
organizer: {
id: 1,
email: "organizer@test.com",
name: "Test Organizer",
timeZone: "UTC",
language: { locale: "en" },
},
attendees: [
{
email: "attendee@test.com",
name: "Test Attendee",
timeZone: "UTC",
language: { locale: "en" },
},
],
location: "https://cal.com/video/123",
uid: "booking-uid-123",
customInputs: {},
responses: {},
userFieldsResponses: {},
};
const createMockDTO = (
triggerEvent: WebhookTriggerEvents,
extra: Partial<BookingWebhookEventDTO> = {}
): BookingWebhookEventDTO => ({
triggerEvent,
createdAt: "2024-01-15T10:00:00Z",
booking: {
id: 1,
eventTypeId: 1,
userId: 1,
smsReminderNumber: null,
},
eventType: mockEventType,
evt: mockCalendarEvent,
...extra,
});
const builder = new BookingPayloadBuilder();
describe("BOOKING_CREATED", () => {
it("should build payload with ACCEPTED status", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CREATED);
expect(payload.payload.status).toBe(BookingStatus.ACCEPTED);
expect(payload.payload.bookingId).toBe(1);
expect(payload.payload.title).toBe("Test Meeting");
expect(payload.payload.organizer.email).toBe("organizer@test.com");
expect(payload.payload.attendees).toHaveLength(1);
});
it("should include eventType fields", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
const payload = builder.build(dto);
expect(payload.payload.eventTitle).toBe("Test Event");
expect(payload.payload.eventDescription).toBe("Test Description");
expect(payload.payload.price).toBe(0);
expect(payload.payload.currency).toBe("USD");
expect(payload.payload.length).toBe(30);
});
});
describe("BOOKING_CANCELLED", () => {
it("should build payload with CANCELLED status and extra fields", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CANCELLED, {
cancelledBy: "user@test.com",
cancellationReason: "Schedule conflict",
});
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CANCELLED);
expect(payload.payload.status).toBe(BookingStatus.CANCELLED);
expect(payload.payload.cancelledBy).toBe("user@test.com");
expect(payload.payload.cancellationReason).toBe("Schedule conflict");
});
});
describe("BOOKING_REQUESTED", () => {
it("should build payload with PENDING status", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED);
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_REQUESTED);
expect(payload.payload.status).toBe(BookingStatus.PENDING);
});
});
describe("BOOKING_REJECTED", () => {
it("should build payload with REJECTED status", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REJECTED);
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_REJECTED);
expect(payload.payload.status).toBe(BookingStatus.REJECTED);
});
});
describe("BOOKING_RESCHEDULED", () => {
it("should build payload with reschedule extra fields", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_RESCHEDULED, {
rescheduleId: 2,
rescheduleUid: "reschedule-uid-456",
rescheduleStartTime: "2024-01-16T10:00:00Z",
rescheduleEndTime: "2024-01-16T10:30:00Z",
rescheduledBy: "user@test.com",
});
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_RESCHEDULED);
expect(payload.payload.status).toBe(BookingStatus.ACCEPTED);
expect(payload.payload.rescheduleId).toBe(2);
expect(payload.payload.rescheduleUid).toBe("reschedule-uid-456");
expect(payload.payload.rescheduledBy).toBe("user@test.com");
});
});
describe("BOOKING_PAID", () => {
it("should build payload with payment extra fields", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_PAID, {
paymentId: 123,
paymentData: { stripeId: "stripe_123" },
});
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_PAID);
expect(payload.payload.paymentId).toBe(123);
expect(payload.payload.paymentData).toEqual({ stripeId: "stripe_123" });
});
});
describe("BOOKING_PAYMENT_INITIATED", () => {
it("should build payload with payment extra fields", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, {
paymentId: 456,
paymentData: { status: "pending" },
});
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED);
expect(payload.payload.paymentId).toBe(456);
});
});
describe("BOOKING_NO_SHOW_UPDATED", () => {
it("should build no-show specific payload", () => {
const dto: BookingWebhookEventDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
createdAt: "2024-01-15T10:00:00Z",
bookingUid: "booking-uid-123",
bookingId: 1,
attendees: [{ email: "attendee@test.com", noShow: true }],
message: "Attendee marked as no-show",
} as BookingWebhookEventDTO;
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED);
expect(payload.payload.bookingUid).toBe("booking-uid-123");
expect(payload.payload.bookingId).toBe(1);
expect(payload.payload.message).toBe("Attendee marked as no-show");
});
});
describe("attendee UTC offset", () => {
it("should add utcOffset to each attendee", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
const payload = builder.build(dto);
expect(payload.payload.attendees[0]).toHaveProperty("utcOffset");
});
});
describe("organizer UTC offset", () => {
it("should add utcOffset to organizer", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
const payload = builder.build(dto);
expect(payload.payload.organizer).toHaveProperty("utcOffset");
});
});
});
@@ -0,0 +1,61 @@
import type { BookingStatus } from "@calcom/prisma/enums";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { EventTypeInfo, BookingWebhookEventDTO } from "../../dto/types";
import type { WebhookPayload } from "../types";
import type { IBookingPayloadBuilder } from "../versioned/PayloadBuilderFactory";
/**
* Extra data shape per booking trigger event
*/
export type BookingExtraDataMap = {
[WebhookTriggerEvents.BOOKING_CREATED]: null;
[WebhookTriggerEvents.BOOKING_CANCELLED]: { cancelledBy?: string; cancellationReason?: string };
[WebhookTriggerEvents.BOOKING_REQUESTED]: null;
[WebhookTriggerEvents.BOOKING_REJECTED]: null;
[WebhookTriggerEvents.BOOKING_RESCHEDULED]: {
rescheduleId?: number;
rescheduleUid?: string;
rescheduleStartTime?: string;
rescheduleEndTime?: string;
rescheduledBy?: string;
};
[WebhookTriggerEvents.BOOKING_PAID]: { paymentId?: number; paymentData?: Record<string, unknown> };
[WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED]: {
paymentId?: number;
paymentData?: Record<string, unknown>;
};
};
export interface BookingPayloadParams<T extends keyof BookingExtraDataMap> {
booking: {
id: number;
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
};
eventType: EventTypeInfo;
evt: CalendarEvent;
status: BookingStatus;
triggerEvent: WebhookTriggerEvents;
createdAt: string;
extra?: BookingExtraDataMap[T];
}
/**
* Abstract base class for booking payload builders.
*
* This class defines the interface that all version-specific booking payload
* builders must implement. It does NOT contain any version-specific payload logic.
*
* Each webhook version should have its own concrete implementation in
* versioned/v{VERSION}/BookingPayloadBuilder.ts
*/
export abstract class BaseBookingPayloadBuilder implements IBookingPayloadBuilder {
/**
* Build the booking webhook payload.
* Each version must implement this method with its specific payload structure.
*/
abstract build(dto: BookingWebhookEventDTO): WebhookPayload;
}
@@ -0,0 +1,21 @@
import type { DelegationCredentialErrorDTO } from "../../dto/types";
import type { WebhookPayload } from "../types";
import type { IDelegationPayloadBuilder } from "../versioned/PayloadBuilderFactory";
/**
* Abstract base class for delegation payload builders.
*
* This class defines the interface that all version-specific delegation payload
* builders must implement. It does NOT contain any version-specific payload logic.
*
* Each webhook version should have its own concrete implementation in
* versioned/v{VERSION}/DelegationPayloadBuilder.ts
*/
export abstract class BaseDelegationPayloadBuilder implements IDelegationPayloadBuilder {
/**
* Build the delegation webhook payload.
* Each version must implement this method with its specific payload structure.
*/
abstract build(dto: DelegationCredentialErrorDTO): WebhookPayload;
}
@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../../dto/types";
import { FormPayloadBuilder } from "../versioned/v2021-10-20/FormPayloadBuilder";
describe("FormPayloadBuilder (v2021-10-20)", () => {
const builder = new FormPayloadBuilder();
const createMockDTO = (
triggerEvent: typeof WebhookTriggerEvents.FORM_SUBMITTED | typeof WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT
): FormSubmittedDTO | FormSubmittedNoEventDTO =>
({
triggerEvent,
createdAt: "2024-01-15T10:00:00Z",
form: {
id: "form-123",
name: "Test Form",
},
teamId: 1,
response: {
data: {
name: { value: "John Doe", response: "John Doe" },
email: { value: "john@test.com", response: "john@test.com" },
},
},
}) as FormSubmittedDTO | FormSubmittedNoEventDTO;
describe("FORM_SUBMITTED", () => {
it("should build payload with form details", () => {
const dto = createMockDTO(WebhookTriggerEvents.FORM_SUBMITTED);
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.FORM_SUBMITTED);
expect(payload.payload.formId).toBe("form-123");
expect(payload.payload.formName).toBe("Test Form");
expect(payload.payload.teamId).toBe(1);
});
it("should include responses", () => {
const dto = createMockDTO(WebhookTriggerEvents.FORM_SUBMITTED);
const payload = builder.build(dto);
expect(payload.payload.responses).toBeDefined();
expect(payload.payload.responses.name).toEqual({
value: "John Doe",
response: "John Doe",
});
});
it("should add backwards compatibility fields at root level", () => {
const dto = createMockDTO(WebhookTriggerEvents.FORM_SUBMITTED);
const payload = builder.build(dto);
// Field values should be at root level for backwards compatibility
expect(payload.payload.name).toBe("John Doe");
expect(payload.payload.email).toBe("john@test.com");
});
});
describe("FORM_SUBMITTED_NO_EVENT", () => {
it("should build payload for forms without events", () => {
const dto = createMockDTO(WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT);
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT);
expect(payload.payload.formId).toBe("form-123");
});
});
describe("null teamId", () => {
it("should handle null teamId", () => {
const dto = {
...createMockDTO(WebhookTriggerEvents.FORM_SUBMITTED),
teamId: undefined,
} as FormSubmittedDTO;
const payload = builder.build(dto);
expect(payload.payload.teamId).toBeNull();
});
});
});
@@ -0,0 +1,20 @@
import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../../dto/types";
import type { WebhookPayload } from "../types";
import type { IFormPayloadBuilder } from "../versioned/PayloadBuilderFactory";
/**
* Abstract base class for form payload builders.
*
* This class defines the interface that all version-specific form payload
* builders must implement. It does NOT contain any version-specific payload logic.
*
* Each webhook version should have its own concrete implementation in
* versioned/v{VERSION}/FormPayloadBuilder.ts
*/
export abstract class BaseFormPayloadBuilder implements IFormPayloadBuilder {
/**
* Build the form webhook payload.
* Each version must implement this method with its specific payload structure.
*/
abstract build(dto: FormSubmittedDTO | FormSubmittedNoEventDTO): WebhookPayload;
}
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { InstantMeetingDTO } from "../../dto/types";
import { InstantMeetingBuilder } from "../versioned/v2021-10-20/InstantMeetingBuilder";
describe("InstantMeetingBuilder (v2021-10-20)", () => {
const builder = new InstantMeetingBuilder();
describe("INSTANT_MEETING", () => {
it("should build payload with all instant meeting fields", () => {
const dto: InstantMeetingDTO = {
triggerEvent: WebhookTriggerEvents.INSTANT_MEETING,
createdAt: "2024-01-15T10:00:00Z",
title: "Instant Meeting Request",
body: "Someone is requesting an instant meeting",
icon: "https://cal.com/icon.png",
url: "https://cal.com/meeting/instant-123",
actions: [
{ action: "accept", title: "Accept" },
{ action: "decline", title: "Decline" },
],
requireInteraction: true,
type: "instant",
};
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.INSTANT_MEETING);
expect(payload.createdAt).toBe("2024-01-15T10:00:00Z");
expect(payload.payload.title).toBe("Instant Meeting Request");
expect(payload.payload.body).toBe("Someone is requesting an instant meeting");
expect(payload.payload.icon).toBe("https://cal.com/icon.png");
expect(payload.payload.url).toBe("https://cal.com/meeting/instant-123");
expect(payload.payload.actions).toHaveLength(2);
expect(payload.payload.requireInteraction).toBe(true);
expect(payload.payload.type).toBe("instant");
});
});
});
@@ -0,0 +1,20 @@
import type { InstantMeetingDTO } from "../../dto/types";
import type { WebhookPayload } from "../types";
import type { IInstantMeetingBuilder } from "../versioned/PayloadBuilderFactory";
/**
* Abstract base class for instant meeting payload builders.
*
* This class defines the interface that all version-specific instant meeting
* payload builders must implement. It does NOT contain any version-specific payload logic.
*
* Each webhook version should have its own concrete implementation in
* versioned/v{VERSION}/InstantMeetingBuilder.ts
*/
export abstract class BaseInstantMeetingBuilder implements IInstantMeetingBuilder {
/**
* Build the instant meeting webhook payload.
* Each version must implement this method with its specific payload structure.
*/
abstract build(dto: InstantMeetingDTO): WebhookPayload;
}
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { MeetingStartedDTO, MeetingEndedDTO } from "../../dto/types";
import { MeetingPayloadBuilder } from "../versioned/v2021-10-20/MeetingPayloadBuilder";
describe("MeetingPayloadBuilder (v2021-10-20)", () => {
const builder = new MeetingPayloadBuilder();
const mockBookingData = {
id: 1,
uid: "booking-uid-123",
title: "Test Meeting",
startTime: "2024-01-15T10:00:00Z",
endTime: "2024-01-15T10:30:00Z",
};
describe("MEETING_STARTED", () => {
it("should build payload with booking data", () => {
const dto: MeetingStartedDTO = {
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
createdAt: "2024-01-15T10:00:00Z",
booking: mockBookingData,
};
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.MEETING_STARTED);
expect(payload.createdAt).toBe("2024-01-15T10:00:00Z");
expect(payload.payload.id).toBe(1);
expect(payload.payload.uid).toBe("booking-uid-123");
expect(payload.payload.title).toBe("Test Meeting");
});
});
describe("MEETING_ENDED", () => {
it("should build payload with booking data", () => {
const dto: MeetingEndedDTO = {
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
createdAt: "2024-01-15T10:30:00Z",
booking: mockBookingData,
};
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.MEETING_ENDED);
expect(payload.createdAt).toBe("2024-01-15T10:30:00Z");
expect(payload.payload).toEqual(mockBookingData);
});
});
});
@@ -0,0 +1,27 @@
import type {
MeetingStartedDTO,
MeetingEndedDTO,
AfterHostsNoShowDTO,
AfterGuestsNoShowDTO,
} from "../../dto/types";
import type { WebhookPayload } from "../types";
import type { IMeetingPayloadBuilder } from "../versioned/PayloadBuilderFactory";
/**
* Abstract base class for meeting payload builders.
*
* This class defines the interface that all version-specific meeting payload
* builders must implement. It does NOT contain any version-specific payload logic.
*
* Each webhook version should have its own concrete implementation in
* versioned/v{VERSION}/MeetingPayloadBuilder.ts
*/
export abstract class BaseMeetingPayloadBuilder implements IMeetingPayloadBuilder {
/**
* Build the meeting webhook payload.
* Each version must implement this method with its specific payload structure.
*/
abstract build(
dto: MeetingStartedDTO | MeetingEndedDTO | AfterHostsNoShowDTO | AfterGuestsNoShowDTO
): WebhookPayload;
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { OOOCreatedDTO } from "../../dto/types";
import { OOOPayloadBuilder } from "../versioned/v2021-10-20/OOOPayloadBuilder";
describe("OOOPayloadBuilder (v2021-10-20)", () => {
const builder = new OOOPayloadBuilder();
describe("OOO_CREATED", () => {
it("should build payload with OOO entry", () => {
const dto: OOOCreatedDTO = {
triggerEvent: WebhookTriggerEvents.OOO_CREATED,
createdAt: "2024-01-15T10:00:00Z",
oooEntry: {
id: 1,
userId: 1,
start: "2024-01-20T00:00:00Z",
end: "2024-01-25T00:00:00Z",
reason: "Vacation",
},
};
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.OOO_CREATED);
expect(payload.createdAt).toBe("2024-01-15T10:00:00Z");
expect(payload.payload.oooEntry).toEqual({
id: 1,
userId: 1,
start: "2024-01-20T00:00:00Z",
end: "2024-01-25T00:00:00Z",
reason: "Vacation",
});
});
});
});
@@ -0,0 +1,20 @@
import type { OOOCreatedDTO } from "../../dto/types";
import type { WebhookPayload } from "../types";
import type { IOOOPayloadBuilder } from "../versioned/PayloadBuilderFactory";
/**
* Abstract base class for OOO (Out of Office) payload builders.
*
* This class defines the interface that all version-specific OOO payload
* builders must implement. It does NOT contain any version-specific payload logic.
*
* Each webhook version should have its own concrete implementation in
* versioned/v{VERSION}/OOOPayloadBuilder.ts
*/
export abstract class BaseOOOPayloadBuilder implements IOOOPayloadBuilder {
/**
* Build the OOO webhook payload.
* Each version must implement this method with its specific payload structure.
*/
abstract build(dto: OOOCreatedDTO): WebhookPayload;
}
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../../dto/types";
import { RecordingPayloadBuilder } from "../versioned/v2021-10-20/RecordingPayloadBuilder";
describe("RecordingPayloadBuilder (v2021-10-20)", () => {
const builder = new RecordingPayloadBuilder();
describe("RECORDING_READY", () => {
it("should build payload with download link", () => {
const dto: RecordingReadyDTO = {
triggerEvent: WebhookTriggerEvents.RECORDING_READY,
createdAt: "2024-01-15T10:00:00Z",
downloadLink: "https://storage.example.com/recording-123.mp4",
};
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.RECORDING_READY);
expect(payload.createdAt).toBe("2024-01-15T10:00:00Z");
expect(payload.payload.downloadLink).toBe("https://storage.example.com/recording-123.mp4");
});
});
describe("RECORDING_TRANSCRIPTION_GENERATED", () => {
it("should build payload with download links", () => {
const dto: TranscriptionGeneratedDTO = {
triggerEvent: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
createdAt: "2024-01-15T11:00:00Z",
downloadLinks: {
transcript: "https://storage.example.com/transcript-123.txt",
srt: "https://storage.example.com/transcript-123.srt",
},
};
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED);
expect(payload.payload.downloadLinks).toEqual({
transcript: "https://storage.example.com/transcript-123.txt",
srt: "https://storage.example.com/transcript-123.srt",
});
});
});
});
@@ -0,0 +1,20 @@
import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../../dto/types";
import type { WebhookPayload } from "../types";
import type { IRecordingPayloadBuilder } from "../versioned/PayloadBuilderFactory";
/**
* Abstract base class for recording payload builders.
*
* This class defines the interface that all version-specific recording payload
* builders must implement. It does NOT contain any version-specific payload logic.
*
* Each webhook version should have its own concrete implementation in
* versioned/v{VERSION}/RecordingPayloadBuilder.ts
*/
export abstract class BaseRecordingPayloadBuilder implements IRecordingPayloadBuilder {
/**
* Build the recording webhook payload.
* Each version must implement this method with its specific payload structure.
*/
abstract build(dto: RecordingReadyDTO | TranscriptionGeneratedDTO): WebhookPayload;
}
@@ -0,0 +1,22 @@
/**
* Abstract base payload builder classes.
*
* These define the interfaces that version-specific builders must implement.
* They do NOT contain version-specific payload logic - that belongs in
* the versioned/ directory.
*
* When creating a new webhook version:
* 1. Create a new directory: versioned/v{NEW_VERSION}/
* 2. Create concrete builder classes that extend these base classes
* 3. Implement the abstract build() method with version-specific payload logic
* 4. Register the new version in registry.ts
*/
export { BaseBookingPayloadBuilder } from "./BaseBookingPayloadBuilder";
export { BaseFormPayloadBuilder } from "./BaseFormPayloadBuilder";
export { BaseMeetingPayloadBuilder } from "./BaseMeetingPayloadBuilder";
export { BaseRecordingPayloadBuilder } from "./BaseRecordingPayloadBuilder";
export { BaseOOOPayloadBuilder } from "./BaseOOOPayloadBuilder";
export { BaseInstantMeetingBuilder } from "./BaseInstantMeetingBuilder";
export { BaseDelegationPayloadBuilder } from "./BaseDelegationPayloadBuilder";
export type { BookingExtraDataMap, BookingPayloadParams } from "./BaseBookingPayloadBuilder";
@@ -1,4 +1,9 @@
import type { EventPayloadType, OOOEntryPayloadType, BookingNoShowUpdatedPayload } from "../dto/types";
import type {
EventPayloadType,
OOOEntryPayloadType,
BookingNoShowUpdatedPayload,
DelegationCredentialErrorPayloadType,
} from "../dto/types";
export interface FormSubmittedPayload {
formId: string;
@@ -90,5 +95,6 @@ export interface WebhookPayload {
| RecordingPayload
| MeetingPayload
| InstantMeetingPayload
| NoShowWebhookPayload;
| NoShowWebhookPayload
| DelegationCredentialErrorPayloadType;
}
@@ -0,0 +1,256 @@
import { describe, it, expect, beforeEach } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { BookingWebhookEventDTO } from "../../dto/types";
import { PayloadBuilderFactory, type PayloadBuilderSet } from "./PayloadBuilderFactory";
import * as V2021_10_20 from "./v2021-10-20";
import { WebhookVersion as WebhookVersionEnum } from "../../interface/IWebhookRepository";
describe("PayloadBuilderFactory", () => {
let factory: PayloadBuilderFactory;
let defaultBuilders: PayloadBuilderSet;
beforeEach(() => {
defaultBuilders = {
booking: new V2021_10_20.BookingPayloadBuilder(),
form: new V2021_10_20.FormPayloadBuilder(),
ooo: new V2021_10_20.OOOPayloadBuilder(),
recording: new V2021_10_20.RecordingPayloadBuilder(),
meeting: new V2021_10_20.MeetingPayloadBuilder(),
instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
};
factory = new PayloadBuilderFactory(WebhookVersionEnum.V_2021_10_20, defaultBuilders);
});
describe("Constructor", () => {
it("should initialize with default version and builders", () => {
expect(factory).toBeInstanceOf(PayloadBuilderFactory);
expect(factory.getRegisteredVersions()).toContain(WebhookVersionEnum.V_2021_10_20);
});
it("should enforce required parameters at compile-time", () => {
// TypeScript ensures parameters are provided - no runtime check needed
expect(factory).toBeDefined();
expect(factory.getRegisteredVersions()).toHaveLength(1);
});
});
describe("Version Registration", () => {
it("should register new version with complete builder set", () => {
const newVersionBuilders: PayloadBuilderSet = {
booking: new V2021_10_20.BookingPayloadBuilder(),
form: new V2021_10_20.FormPayloadBuilder(),
ooo: new V2021_10_20.OOOPayloadBuilder(),
recording: new V2021_10_20.RecordingPayloadBuilder(),
meeting: new V2021_10_20.MeetingPayloadBuilder(),
instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
};
factory.registerVersion("2024-12-01", newVersionBuilders);
expect(factory.getRegisteredVersions()).toContain("2024-12-01");
expect(factory.getRegisteredVersions()).toHaveLength(2);
});
it("should allow overwriting existing version", () => {
const newBuilders: PayloadBuilderSet = {
booking: new V2021_10_20.BookingPayloadBuilder(),
form: new V2021_10_20.FormPayloadBuilder(),
ooo: new V2021_10_20.OOOPayloadBuilder(),
recording: new V2021_10_20.RecordingPayloadBuilder(),
meeting: new V2021_10_20.MeetingPayloadBuilder(),
instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
};
factory.registerVersion(WebhookVersionEnum.V_2021_10_20, newBuilders);
expect(factory.getRegisteredVersions()).toHaveLength(1);
});
});
describe("Builder Routing", () => {
it("should route booking events to booking builder", () => {
const bookingTriggers = [
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_REQUESTED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.BOOKING_REJECTED,
WebhookTriggerEvents.BOOKING_PAID,
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
];
bookingTriggers.forEach((trigger) => {
const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, trigger);
expect(builder).toBe(defaultBuilders.booking);
});
});
it("should route form events to form builder", () => {
const formTriggers = [
WebhookTriggerEvents.FORM_SUBMITTED,
WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
];
formTriggers.forEach((trigger) => {
const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, trigger);
expect(builder).toBe(defaultBuilders.form);
});
});
it("should route OOO events to OOO builder", () => {
const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.OOO_CREATED);
expect(builder).toBe(defaultBuilders.ooo);
});
it("should route recording events to recording builder", () => {
const recordingTriggers = [
WebhookTriggerEvents.RECORDING_READY,
WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
];
recordingTriggers.forEach((trigger) => {
const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, trigger);
expect(builder).toBe(defaultBuilders.recording);
});
});
it("should route meeting events to meeting builder", () => {
const meetingTriggers = [WebhookTriggerEvents.MEETING_STARTED, WebhookTriggerEvents.MEETING_ENDED];
meetingTriggers.forEach((trigger) => {
const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, trigger);
expect(builder).toBe(defaultBuilders.meeting);
});
});
it("should route instant meeting events to instant meeting builder", () => {
const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.INSTANT_MEETING);
expect(builder).toBe(defaultBuilders.instantMeeting);
});
});
describe("Fallback Behavior", () => {
it("should fallback to default version when requested version not found", () => {
const builder = factory.getBuilder(WebhookVersionEnum.V_2099_99_99, WebhookTriggerEvents.BOOKING_CREATED);
// Should get default builder, not throw
expect(builder).toBe(defaultBuilders.booking);
});
it("should log warning when falling back to default", () => {
// Just verify it doesn't throw - logging is tested elsewhere
expect(() => {
factory.getBuilder("invalid-version", WebhookTriggerEvents.BOOKING_CREATED);
}).not.toThrow();
});
it("should never return undefined builder", () => {
const unknownVersion = WebhookVersionEnum.V_2099_99_99;
const builder = factory.getBuilder(unknownVersion, WebhookTriggerEvents.BOOKING_CREATED);
expect(builder).toBeDefined();
expect(builder).toBe(defaultBuilders.booking);
});
});
describe("Type Safety", () => {
it("should return correctly typed builder for each trigger", () => {
// These type assertions verify compile-time type safety
const bookingBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
const formBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.FORM_SUBMITTED);
const oooBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.OOO_CREATED);
const recordingBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.RECORDING_READY);
const meetingBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.MEETING_STARTED);
const instantBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.INSTANT_MEETING);
// Runtime verification
expect(bookingBuilder).toBeDefined();
expect(formBuilder).toBeDefined();
expect(oooBuilder).toBeDefined();
expect(recordingBuilder).toBeDefined();
expect(meetingBuilder).toBeDefined();
expect(instantBuilder).toBeDefined();
});
it("should build valid payload with correctly typed DTO", () => {
const mockDTO: BookingWebhookEventDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
createdAt: "2024-01-15T10:00:00Z",
booking: {
id: 1,
eventTypeId: 1,
userId: 1,
smsReminderNumber: null,
},
eventType: {
eventTitle: "Test Event",
eventDescription: "Test Description",
requiresConfirmation: false,
price: 0,
currency: "USD",
length: 30,
},
evt: {
type: "test-event",
title: "Test Meeting",
description: "Meeting description",
startTime: "2024-01-15T10:00:00Z",
endTime: "2024-01-15T10:30:00Z",
organizer: {
id: 1,
email: "organizer@test.com",
name: "Test Organizer",
timeZone: "UTC",
language: { locale: "en" },
},
attendees: [
{
email: "attendee@test.com",
name: "Test Attendee",
timeZone: "UTC",
language: { locale: "en" },
},
],
uid: "booking-uid-123",
customInputs: {},
responses: {},
userFieldsResponses: {},
},
};
const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
const payload = builder.build(mockDTO);
expect(payload).toBeDefined();
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CREATED);
expect(payload.payload).toBeDefined();
});
});
describe("Multiple Versions", () => {
it("should support multiple versions simultaneously", () => {
const v2Builders: PayloadBuilderSet = {
booking: new V2021_10_20.BookingPayloadBuilder(),
form: new V2021_10_20.FormPayloadBuilder(),
ooo: new V2021_10_20.OOOPayloadBuilder(),
recording: new V2021_10_20.RecordingPayloadBuilder(),
meeting: new V2021_10_20.MeetingPayloadBuilder(),
instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
};
factory.registerVersion(WebhookVersionEnum.V_2024_12_01, v2Builders);
// Both versions should work
const v1Builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
const v2Builder = factory.getBuilder(WebhookVersionEnum.V_2024_12_01, WebhookTriggerEvents.BOOKING_CREATED);
expect(v1Builder).toBe(defaultBuilders.booking);
expect(v2Builder).toBe(v2Builders.booking);
expect(v1Builder).not.toBe(v2Builder); // Different instances
});
});
});
@@ -0,0 +1,257 @@
import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookVersion } from "../../interface/IWebhookRepository";
import type {
AfterGuestsNoShowDTO,
AfterHostsNoShowDTO,
BookingWebhookEventDTO,
DelegationCredentialErrorDTO,
FormSubmittedDTO,
FormSubmittedNoEventDTO,
InstantMeetingDTO,
MeetingEndedDTO,
MeetingStartedDTO,
OOOCreatedDTO,
RecordingReadyDTO,
TranscriptionGeneratedDTO,
WebhookEventDTO,
} from "../../dto/types";
import type { WebhookPayload } from "../types";
const log = logger.getSubLogger({ prefix: ["WebhookPayloadBuilderFactory"] });
/**
* Generic base interface for all payload builders
* Ensures type-safe input DTOs and output payloads
*/
export interface IPayloadBuilder<TInput extends WebhookEventDTO = WebhookEventDTO> {
build(dto: TInput): WebhookPayload;
}
/**
* Type-safe interfaces for specific payload builders
*/
export interface IBookingPayloadBuilder extends IPayloadBuilder<BookingWebhookEventDTO> {
build(dto: BookingWebhookEventDTO): WebhookPayload;
}
export interface IFormPayloadBuilder extends IPayloadBuilder<FormSubmittedDTO | FormSubmittedNoEventDTO> {
build(dto: FormSubmittedDTO | FormSubmittedNoEventDTO): WebhookPayload;
}
export interface IOOOPayloadBuilder extends IPayloadBuilder<OOOCreatedDTO> {
build(dto: OOOCreatedDTO): WebhookPayload;
}
export interface IRecordingPayloadBuilder
extends IPayloadBuilder<RecordingReadyDTO | TranscriptionGeneratedDTO> {
build(dto: RecordingReadyDTO | TranscriptionGeneratedDTO): WebhookPayload;
}
export interface IMeetingPayloadBuilder
extends IPayloadBuilder<MeetingStartedDTO | MeetingEndedDTO | AfterHostsNoShowDTO | AfterGuestsNoShowDTO> {
build(
dto: MeetingStartedDTO | MeetingEndedDTO | AfterHostsNoShowDTO | AfterGuestsNoShowDTO
): WebhookPayload;
}
export interface IInstantMeetingBuilder extends IPayloadBuilder<InstantMeetingDTO> {
build(dto: InstantMeetingDTO): WebhookPayload;
}
export interface IDelegationPayloadBuilder extends IPayloadBuilder<DelegationCredentialErrorDTO> {
build(dto: DelegationCredentialErrorDTO): WebhookPayload;
}
/**
* Set of all payload builders for a specific webhook version
* Each builder is properly typed for its respective event DTOs
*/
export interface PayloadBuilderSet {
booking: IBookingPayloadBuilder;
form: IFormPayloadBuilder;
ooo: IOOOPayloadBuilder;
recording: IRecordingPayloadBuilder;
meeting: IMeetingPayloadBuilder;
instantMeeting: IInstantMeetingBuilder;
delegation: IDelegationPayloadBuilder;
}
/**
* Builder categories - used for explicit routing
*/
type BuilderCategory = keyof PayloadBuilderSet;
/**
* Explicit mapping of trigger events to builder categories.
* This prevents ambiguous routing - each event has exactly one handler.
*
* Note: Record<WebhookTriggerEvents, BuilderCategory> ensures all events are mapped.
*/
const TRIGGER_TO_BUILDER_CATEGORY: Record<WebhookTriggerEvents, BuilderCategory> = {
// Booking events
[WebhookTriggerEvents.BOOKING_CREATED]: "booking",
[WebhookTriggerEvents.BOOKING_RESCHEDULED]: "booking",
[WebhookTriggerEvents.BOOKING_CANCELLED]: "booking",
[WebhookTriggerEvents.BOOKING_REJECTED]: "booking",
[WebhookTriggerEvents.BOOKING_REQUESTED]: "booking",
[WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED]: "booking",
[WebhookTriggerEvents.BOOKING_PAID]: "booking",
[WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED]: "booking",
// Form events
[WebhookTriggerEvents.FORM_SUBMITTED]: "form",
[WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT]: "form",
// OOO events
[WebhookTriggerEvents.OOO_CREATED]: "ooo",
// Recording events
[WebhookTriggerEvents.RECORDING_READY]: "recording",
[WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED]: "recording",
// Meeting events
[WebhookTriggerEvents.MEETING_STARTED]: "meeting",
[WebhookTriggerEvents.MEETING_ENDED]: "meeting",
[WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: "meeting",
[WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: "meeting",
// Instant meeting events
[WebhookTriggerEvents.INSTANT_MEETING]: "instantMeeting",
// Delegation events
[WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR]: "delegation",
};
/**
* Type mapping: TriggerEvent DTO type
* Used for compile-time type safety
*
* Note: WebhookTriggerEvents is a const object, so we use typeof to extract literal types
*/
type BookingTriggerEvents =
| typeof WebhookTriggerEvents.BOOKING_CREATED
| typeof WebhookTriggerEvents.BOOKING_RESCHEDULED
| typeof WebhookTriggerEvents.BOOKING_CANCELLED
| typeof WebhookTriggerEvents.BOOKING_REJECTED
| typeof WebhookTriggerEvents.BOOKING_REQUESTED
| typeof WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED
| typeof WebhookTriggerEvents.BOOKING_PAID
| typeof WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED;
type DelegationTriggerEvents = typeof WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR;
type FormTriggerEvents =
| typeof WebhookTriggerEvents.FORM_SUBMITTED
| typeof WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT;
type OOOTriggerEvents = typeof WebhookTriggerEvents.OOO_CREATED;
type RecordingTriggerEvents =
| typeof WebhookTriggerEvents.RECORDING_READY
| typeof WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED;
type MeetingTriggerEvents =
| typeof WebhookTriggerEvents.MEETING_STARTED
| typeof WebhookTriggerEvents.MEETING_ENDED
| typeof WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW
| typeof WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW;
type InstantMeetingTriggerEvents = typeof WebhookTriggerEvents.INSTANT_MEETING;
/**
* Factory that routes to version-specific payload builders
*
* Uses explicit triggerbuilder mapping for:
* - No ambiguous routing (each event has exactly one handler)
* - Compile-time validation (all events must be mapped)
* - Type-safe builder selection via overloads
*
* @example
* ```ts
* const factory = createPayloadBuilderFactory();
* const builder = factory.getBuilder(version, WebhookTriggerEvents.BOOKING_CREATED);
* const payload = builder.build(bookingDTO); // Type-safe: expects BookingWebhookEventDTO
* ```
*/
export class PayloadBuilderFactory {
private builders: Map<WebhookVersion, PayloadBuilderSet>;
private readonly defaultBuilderSet: PayloadBuilderSet;
private readonly defaultVersion: WebhookVersion;
/**
* @param defaultVersion - The version to use as fallback when requested version is not registered
* @param defaultBuilderSet - Required builders for the default version. Guarantees fallback always works.
*/
constructor(defaultVersion: WebhookVersion, defaultBuilderSet: PayloadBuilderSet) {
this.defaultVersion = defaultVersion;
this.defaultBuilderSet = defaultBuilderSet;
this.builders = new Map();
this.builders.set(defaultVersion, defaultBuilderSet);
}
/**
* Register a complete set of payload builders for a specific version
*/
registerVersion(version: WebhookVersion, builderSet: PayloadBuilderSet): void {
this.builders.set(version, builderSet);
}
/**
* Get the builder set for a version, falling back to default if not registered
*/
private getBuilderSet(version: WebhookVersion): PayloadBuilderSet {
const requestedBuilderSet = this.builders.get(version);
if (!requestedBuilderSet) {
log.warn(
`Webhook version "${version}" not registered, falling back to default version "${this.defaultVersion}". ` +
`Available versions: ${Array.from(this.builders.keys()).join(", ")}`
);
return this.defaultBuilderSet;
}
return requestedBuilderSet;
}
/**
* Type-safe builder getters - overloaded for compile-time DTO type safety
*/
getBuilder(version: WebhookVersion, triggerEvent: BookingTriggerEvents): IBookingPayloadBuilder;
getBuilder(version: WebhookVersion, triggerEvent: FormTriggerEvents): IFormPayloadBuilder;
getBuilder(version: WebhookVersion, triggerEvent: OOOTriggerEvents): IOOOPayloadBuilder;
getBuilder(version: WebhookVersion, triggerEvent: RecordingTriggerEvents): IRecordingPayloadBuilder;
getBuilder(version: WebhookVersion, triggerEvent: MeetingTriggerEvents): IMeetingPayloadBuilder;
getBuilder(version: WebhookVersion, triggerEvent: InstantMeetingTriggerEvents): IInstantMeetingBuilder;
getBuilder(version: WebhookVersion, triggerEvent: DelegationTriggerEvents): IDelegationPayloadBuilder;
getBuilder(version: WebhookVersion, triggerEvent: WebhookTriggerEvents): IPayloadBuilder<WebhookEventDTO>;
getBuilder(version: WebhookVersion, triggerEvent: WebhookTriggerEvents): IPayloadBuilder<WebhookEventDTO> {
const builderSet = this.getBuilderSet(version);
const category = TRIGGER_TO_BUILDER_CATEGORY[triggerEvent];
return builderSet[category];
}
/**
* Get all registered versions
*/
getRegisteredVersions(): WebhookVersion[] {
return Array.from(this.builders.keys());
}
/**
* Check if a version is supported
*/
isVersionSupported(version: WebhookVersion): boolean {
return this.builders.has(version);
}
/**
* Get the default/fallback version
*/
getDefaultVersion(): WebhookVersion {
return this.defaultVersion;
}
}
@@ -0,0 +1,147 @@
import { describe, it, expect } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { DEFAULT_WEBHOOK_VERSION, WebhookVersion } from "../../interface/IWebhookRepository";
import { createPayloadBuilderFactory } from "./registry";
import * as V2021_10_20 from "./v2021-10-20";
describe("Payload Builder Registry", () => {
describe("createPayloadBuilderFactory", () => {
it("should create factory with default version registered", () => {
const factory = createPayloadBuilderFactory();
expect(factory).toBeDefined();
expect(factory.getRegisteredVersions()).toContain(DEFAULT_WEBHOOK_VERSION);
});
it("should register all required builders for default version", () => {
const factory = createPayloadBuilderFactory();
// Test each builder category by trigger event
const bookingBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.BOOKING_CREATED);
const formBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.FORM_SUBMITTED);
const oooBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.OOO_CREATED);
const recordingBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.RECORDING_READY);
const meetingBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.MEETING_STARTED);
const instantBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.INSTANT_MEETING);
expect(bookingBuilder).toBeDefined();
expect(formBuilder).toBeDefined();
expect(oooBuilder).toBeDefined();
expect(recordingBuilder).toBeDefined();
expect(meetingBuilder).toBeDefined();
expect(instantBuilder).toBeDefined();
});
it("should register v2021-10-20 builders", () => {
const factory = createPayloadBuilderFactory();
const builder = factory.getBuilder(WebhookVersion.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
expect(builder).toBeInstanceOf(V2021_10_20.BookingPayloadBuilder);
});
it("should return same factory instance characteristics each time", () => {
const factory1 = createPayloadBuilderFactory();
const factory2 = createPayloadBuilderFactory();
// Both should have same registered versions
expect(factory1.getRegisteredVersions()).toEqual(factory2.getRegisteredVersions());
});
it("should support fallback to default version", () => {
const factory = createPayloadBuilderFactory();
// Request non-existent version (cast to WebhookVersion for testing fallback)
const builder = factory.getBuilder("9999-99-99" as WebhookVersion, WebhookTriggerEvents.BOOKING_CREATED);
// Should get default builder, not throw
expect(builder).toBeDefined();
expect(builder).toBeInstanceOf(V2021_10_20.BookingPayloadBuilder);
});
});
describe("Factory Composition Root", () => {
it("should be the single source of truth for version registration", () => {
const factory = createPayloadBuilderFactory();
// Should only have default version initially
expect(factory.getRegisteredVersions()).toHaveLength(1);
expect(factory.getRegisteredVersions()[0]).toBe(DEFAULT_WEBHOOK_VERSION);
});
it("should allow extending with new versions", () => {
const factory = createPayloadBuilderFactory();
const NEW_VERSION = "2024-12-01" as WebhookVersion;
// Register new version
const newVersionBuilders = {
booking: new V2021_10_20.BookingPayloadBuilder(),
form: new V2021_10_20.FormPayloadBuilder(),
ooo: new V2021_10_20.OOOPayloadBuilder(),
recording: new V2021_10_20.RecordingPayloadBuilder(),
meeting: new V2021_10_20.MeetingPayloadBuilder(),
instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
delegation: new V2021_10_20.DelegationPayloadBuilder(),
};
factory.registerVersion(NEW_VERSION, newVersionBuilders);
expect(factory.getRegisteredVersions()).toContain(NEW_VERSION);
expect(factory.getRegisteredVersions()).toHaveLength(2);
});
it("should maintain independence of builder instances per version", () => {
const factory = createPayloadBuilderFactory();
const NEW_VERSION = "2024-12-01" as WebhookVersion;
// Register second version with new instances
const v2Builders = {
booking: new V2021_10_20.BookingPayloadBuilder(),
form: new V2021_10_20.FormPayloadBuilder(),
ooo: new V2021_10_20.OOOPayloadBuilder(),
recording: new V2021_10_20.RecordingPayloadBuilder(),
meeting: new V2021_10_20.MeetingPayloadBuilder(),
instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
delegation: new V2021_10_20.DelegationPayloadBuilder(),
};
factory.registerVersion(NEW_VERSION, v2Builders);
const v1Builder = factory.getBuilder(WebhookVersion.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
const v2Builder = factory.getBuilder(NEW_VERSION, WebhookTriggerEvents.BOOKING_CREATED);
// Should be different instances
expect(v1Builder).not.toBe(v2Builder);
});
});
describe("DI Integration", () => {
it("should be suitable for DI container registration", () => {
// Factory function is pure and stateless - perfect for DI
const factory1 = createPayloadBuilderFactory();
const factory2 = createPayloadBuilderFactory();
// Both should work independently
expect(factory1).toBeDefined();
expect(factory2).toBeDefined();
// And not interfere with each other
factory1.registerVersion("test-1", {
booking: new V2021_10_20.BookingPayloadBuilder(),
form: new V2021_10_20.FormPayloadBuilder(),
ooo: new V2021_10_20.OOOPayloadBuilder(),
recording: new V2021_10_20.RecordingPayloadBuilder(),
meeting: new V2021_10_20.MeetingPayloadBuilder(),
instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
});
expect(factory1.getRegisteredVersions()).not.toEqual(factory2.getRegisteredVersions());
});
it("should initialize without external dependencies", () => {
// Factory creation should not throw or require external setup
expect(() => createPayloadBuilderFactory()).not.toThrow();
});
});
});
@@ -0,0 +1,49 @@
import { DEFAULT_WEBHOOK_VERSION } from "../../interface/IWebhookRepository";
import { PayloadBuilderFactory } from "./PayloadBuilderFactory";
import * as V2021_10_20 from "./v2021-10-20";
// Re-export for consumers
export { DEFAULT_WEBHOOK_VERSION } from "../../interface/IWebhookRepository";
/**
* Create and initialize a PayloadBuilderFactory with all registered versions.
*
* This is the central registry (composition root) for all webhook versions.
* The factory is fully configured here - consumers just use it.
*
* @returns Fully configured PayloadBuilderFactory instance
*
* @example Adding a new webhook version
* 1. Create directory: versioned/v2024-12-01/
* 2. Implement builders in that directory
* 3. Import and register here:
* ```ts
*
* import * as V2024_12_01 from "./v2024-12-01";
*
* factory.registerVersion(WebhookVersion.V_2024_12_01, {
* booking: new V2024_12_01.BookingPayloadBuilder(),
* form: new V2024_12_01.FormPayloadBuilder(),
* ooo: new V2024_12_01.OOOPayloadBuilder(),
* recording: new V2024_12_01.RecordingPayloadBuilder(),
* meeting: new V2024_12_01.MeetingPayloadBuilder(),
* instantMeeting: new V2024_12_01.InstantMeetingBuilder(),
* delegation: new V2024_12_01.DelegationPayloadBuilder(),
* });
* ```
*/
export function createPayloadBuilderFactory(): PayloadBuilderFactory {
const defaultBuilders = {
booking: new V2021_10_20.BookingPayloadBuilder(),
form: new V2021_10_20.FormPayloadBuilder(),
ooo: new V2021_10_20.OOOPayloadBuilder(),
recording: new V2021_10_20.RecordingPayloadBuilder(),
meeting: new V2021_10_20.MeetingPayloadBuilder(),
instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
delegation: new V2021_10_20.DelegationPayloadBuilder(),
};
const factory = new PayloadBuilderFactory(DEFAULT_WEBHOOK_VERSION, defaultBuilders);
return factory;
}
@@ -0,0 +1,153 @@
import { describe, it, expect, vi } from "vitest";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { BookingWebhookEventDTO, EventTypeInfo } from "../../../dto/types";
import { BookingPayloadBuilder } from "./BookingPayloadBuilder";
vi.mock("@calcom/lib/dayjs", () => ({
getUTCOffsetByTimezone: vi.fn(() => 0),
}));
describe("v2021-10-20/BookingPayloadBuilder", () => {
const builder = new BookingPayloadBuilder();
const mockEventType: EventTypeInfo = {
eventTitle: "Test Event",
eventDescription: "Test Description",
requiresConfirmation: false,
price: 0,
currency: "USD",
length: 30,
};
const mockCalendarEvent: CalendarEvent = {
type: "test-event",
title: "Test Meeting",
description: "Meeting description",
additionalNotes: "Additional notes",
startTime: "2024-01-15T10:00:00Z",
endTime: "2024-01-15T10:30:00Z",
organizer: {
id: 1,
email: "organizer@test.com",
name: "Test Organizer",
timeZone: "UTC",
language: { locale: "en" },
},
attendees: [
{
email: "attendee@test.com",
name: "Test Attendee",
timeZone: "UTC",
language: { locale: "en" },
},
],
location: "https://cal.com/video/123",
uid: "booking-uid-123",
customInputs: {},
responses: {},
userFieldsResponses: {},
};
const createMockDTO = (
triggerEvent: WebhookTriggerEvents,
extra: Partial<BookingWebhookEventDTO> = {}
): BookingWebhookEventDTO => ({
triggerEvent,
createdAt: "2024-01-15T10:00:00Z",
booking: {
id: 1,
eventTypeId: 1,
userId: 1,
smsReminderNumber: null,
},
eventType: mockEventType,
evt: mockCalendarEvent,
...extra,
});
it("should be instance of correct class", () => {
expect(builder).toBeInstanceOf(BookingPayloadBuilder);
});
it("should correctly build BOOKING_CREATED payload", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CREATED);
expect(payload.payload.status).toBe(BookingStatus.ACCEPTED);
expect(payload.payload.bookingId).toBe(1);
expect(payload.payload.title).toBe("Test Meeting");
});
it("should correctly build BOOKING_CANCELLED payload", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CANCELLED, {
cancelledBy: "user@test.com",
cancellationReason: "Schedule conflict",
});
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CANCELLED);
expect(payload.payload.status).toBe(BookingStatus.CANCELLED);
expect(payload.payload.cancelledBy).toBe("user@test.com");
expect(payload.payload.cancellationReason).toBe("Schedule conflict");
});
it("should handle all booking trigger events", () => {
const triggers = [
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_REQUESTED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.BOOKING_REJECTED,
WebhookTriggerEvents.BOOKING_PAID,
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
];
triggers.forEach((trigger) => {
let dto: BookingWebhookEventDTO;
if (trigger === WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED) {
dto = {
triggerEvent: trigger,
createdAt: "2024-01-15T10:00:00Z",
bookingUid: "booking-uid-123",
bookingId: 1,
attendees: [{ email: "attendee@test.com", noShow: true }],
message: "No-show",
} as BookingWebhookEventDTO;
} else {
dto = createMockDTO(trigger);
}
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(trigger);
expect(payload.payload).toBeDefined();
});
});
it("should never throw on missing optional fields", () => {
// Test with minimal DTO
const minimalDTO: BookingWebhookEventDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
createdAt: "2024-01-15T10:00:00Z",
booking: {
id: 1,
eventTypeId: 1,
userId: 1,
smsReminderNumber: null,
},
eventType: mockEventType,
evt: {
...mockCalendarEvent,
organizer: null,
attendees: [],
},
};
expect(() => builder.build(minimalDTO)).not.toThrow();
});
});
@@ -0,0 +1,188 @@
import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { BookingWebhookEventDTO } from "../../../dto/types";
import type { WebhookPayload } from "../../types";
import {
BaseBookingPayloadBuilder,
type BookingExtraDataMap,
type BookingPayloadParams,
} from "../../base/BaseBookingPayloadBuilder";
/**
* Booking payload builder for webhook version 2021-10-20.
*
* This is the initial webhook payload format. It includes:
* - Full CalendarEvent data spread into payload
* - UTC offset calculations for organizer and attendees
* - Event type metadata (title, description, price, etc.)
* - Trigger-specific extra fields (cancellation reason, reschedule info, etc.)
*/
export class BookingPayloadBuilder extends BaseBookingPayloadBuilder {
/**
* Build the complete booking webhook payload for v2021-10-20.
*/
build(dto: BookingWebhookEventDTO): WebhookPayload {
switch (dto.triggerEvent) {
case WebhookTriggerEvents.BOOKING_CREATED:
return this.buildBookingPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
});
case WebhookTriggerEvents.BOOKING_CANCELLED:
return this.buildBookingPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.CANCELLED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
cancelledBy: dto.cancelledBy,
cancellationReason: dto.cancellationReason,
},
});
case WebhookTriggerEvents.BOOKING_REQUESTED:
return this.buildBookingPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.PENDING,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
});
case WebhookTriggerEvents.BOOKING_REJECTED:
return this.buildBookingPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.REJECTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
});
case WebhookTriggerEvents.BOOKING_RESCHEDULED:
return this.buildBookingPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
rescheduleId: dto.rescheduleId,
rescheduleUid: dto.rescheduleUid,
rescheduleStartTime: dto.rescheduleStartTime,
rescheduleEndTime: dto.rescheduleEndTime,
rescheduledBy: dto.rescheduledBy,
},
});
case WebhookTriggerEvents.BOOKING_PAID:
return this.buildBookingPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
paymentId: dto.paymentId,
paymentData: dto.paymentData,
},
});
case WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED:
return this.buildBookingPayload({
booking: dto.booking,
eventType: dto.eventType,
evt: dto.evt,
status: BookingStatus.ACCEPTED,
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
extra: {
paymentId: dto.paymentId,
paymentData: dto.paymentData,
},
});
case WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED:
return this.buildNoShowPayload(dto);
default: {
const _exhaustiveCheck: never = dto;
throw new Error(`Unsupported booking trigger: ${JSON.stringify(_exhaustiveCheck)}`);
}
}
}
/**
* Build the standard booking payload structure for v2021-10-20.
*/
private buildBookingPayload<T extends keyof BookingExtraDataMap>(
params: BookingPayloadParams<T>
): WebhookPayload {
const utcOffsetOrganizer = getUTCOffsetByTimezone(params.evt.organizer?.timeZone, params.evt.startTime);
const organizer = { ...params.evt.organizer, utcOffset: utcOffsetOrganizer };
return {
triggerEvent: params.triggerEvent,
createdAt: params.createdAt,
payload: {
...params.evt,
bookingId: params.booking.id,
startTime: params.evt.startTime,
endTime: params.evt.endTime,
title: params.evt.title,
type: params.evt.type,
organizer,
attendees:
params.evt.attendees?.map((a) => ({
...a,
utcOffset: getUTCOffsetByTimezone(a.timeZone, params.evt.startTime),
})) ?? [],
location: params.evt.location,
uid: params.evt.uid,
customInputs: params.evt.customInputs,
responses: params.evt.responses,
userFieldsResponses: params.evt.userFieldsResponses,
status: params.status,
eventTitle: params.eventType?.eventTitle,
eventDescription: params.eventType?.eventDescription,
requiresConfirmation: params.eventType?.requiresConfirmation,
price: params.eventType?.price,
currency: params.eventType?.currency,
length: params.eventType?.length,
smsReminderNumber: params.booking.smsReminderNumber || undefined,
description: params.evt.description || params.evt.additionalNotes,
...(params.extra || {}),
},
};
}
/**
* Build the no-show updated payload for v2021-10-20.
*/
private buildNoShowPayload(dto: BookingWebhookEventDTO): WebhookPayload {
if (dto.triggerEvent !== WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED) {
throw new Error("Invalid trigger event for no-show payload");
}
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
bookingUid: dto.bookingUid,
bookingId: dto.bookingId,
attendees: dto.attendees,
message: dto.message,
},
};
}
}
@@ -0,0 +1,31 @@
import type { DelegationCredentialErrorDTO, DelegationCredentialErrorPayloadType } from "../../../dto/types";
import type { WebhookPayload } from "../../types";
import { BaseDelegationPayloadBuilder } from "../../base/BaseDelegationPayloadBuilder";
/**
* Delegation payload builder for webhook version 2021-10-20.
*
* Handles DELEGATION_CREDENTIAL_ERROR events with:
* - Error details
* - Credential information
* - User information
*/
export class DelegationPayloadBuilder extends BaseDelegationPayloadBuilder {
/**
* Build the delegation credential error webhook payload for v2021-10-20.
*/
build(dto: DelegationCredentialErrorDTO): WebhookPayload {
const payload: DelegationCredentialErrorPayloadType = {
error: dto.error,
credential: dto.credential,
user: dto.user,
};
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload,
};
}
}
@@ -1,28 +1,30 @@
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../dto/types";
import type { WebhookPayload } from "./types";
export class FormPayloadBuilder {
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
return (
triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED ||
triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT
);
}
import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../../../dto/types";
import type { WebhookPayload } from "../../types";
import { BaseFormPayloadBuilder } from "../../base/BaseFormPayloadBuilder";
/**
* Form payload builder for webhook version 2021-10-20.
*
* This is the initial form webhook payload format. It includes:
* - Form ID, name, and team ID
* - Structured responses object
* - Backwards compatibility: response values also at root level
*/
export class FormPayloadBuilder extends BaseFormPayloadBuilder {
/**
* Build the form webhook payload for v2021-10-20.
*/
build(dto: FormSubmittedDTO | FormSubmittedNoEventDTO): WebhookPayload {
// Type the responses properly using the routing-forms type
const responses = dto.response.data;
// Create properly typed payload with primitive types
const payload: {
formId: string;
formName: string;
teamId: number | null;
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
[key: string]: unknown; // For backward compatibility fields
[key: string]: unknown;
} = {
formId: dto.form.id,
formName: dto.form.name,
@@ -31,25 +33,7 @@ export class FormPayloadBuilder {
};
// Add unwrapped response fields at root level for backwards compatibility
// This ensures both `value` (deprecated) and `response` (new) are available
Object.entries(responses).forEach(([fieldKey, fieldValue]) => {
if (fieldValue && typeof fieldValue === "object") {
// Each field should have both `value` (deprecated) and `response` (new)
const responseField = fieldValue as {
value?: unknown;
response?: unknown;
};
// Add the field value directly to payload root for backward compatibility
// This preserves the legacy behavior where field values were at root level
if (responseField.value !== undefined) {
payload[fieldKey] = responseField.value;
} else if (responseField.response !== undefined) {
// Fallback to response if value is not present
payload[fieldKey] = responseField.response;
}
}
});
this.addBackwardsCompatibilityFields(payload, responses);
return {
triggerEvent: dto.triggerEvent,
@@ -57,4 +41,28 @@ export class FormPayloadBuilder {
payload,
};
}
/**
* Add backwards compatibility fields to payload.
* This spreads individual response values at the root level of the payload.
*/
private addBackwardsCompatibilityFields(
payload: Record<string, unknown>,
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES
): void {
Object.entries(responses).forEach(([fieldKey, fieldValue]) => {
if (fieldValue && typeof fieldValue === "object") {
const responseField = fieldValue as {
value?: unknown;
response?: unknown;
};
if (responseField.value !== undefined) {
payload[fieldKey] = responseField.value;
} else if (responseField.response !== undefined) {
payload[fieldKey] = responseField.response;
}
}
});
}
}
@@ -0,0 +1,30 @@
import type { InstantMeetingDTO } from "../../../dto/types";
import type { WebhookPayload } from "../../types";
import { BaseInstantMeetingBuilder } from "../../base/BaseInstantMeetingBuilder";
/**
* Instant meeting payload builder for webhook version v2021-10-20.
*
* This is the initial instant meeting webhook payload format.
* It includes notification-style data (title, body, icon, url, actions).
*/
export class InstantMeetingBuilder extends BaseInstantMeetingBuilder {
/**
* Build the instant meeting webhook payload for v2021-10-20.
*/
build(dto: InstantMeetingDTO): WebhookPayload {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
title: dto.title,
body: dto.body,
icon: dto.icon,
url: dto.url,
actions: dto.actions,
requireInteraction: dto.requireInteraction,
type: dto.type,
},
};
}
}
@@ -0,0 +1,50 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type {
MeetingStartedDTO,
MeetingEndedDTO,
AfterHostsNoShowDTO,
AfterGuestsNoShowDTO,
} from "../../../dto/types";
import type { WebhookPayload } from "../../types";
import { BaseMeetingPayloadBuilder } from "../../base/BaseMeetingPayloadBuilder";
/**
* Meeting payload builder for webhook version v2021-10-20.
*
* Handles:
* - MEETING_STARTED / MEETING_ENDED: Full booking data in payload
* - AFTER_HOSTS_CAL_VIDEO_NO_SHOW / AFTER_GUESTS_CAL_VIDEO_NO_SHOW: bookingId and webhook info
*/
export class MeetingPayloadBuilder extends BaseMeetingPayloadBuilder {
/**
* Build the meeting webhook payload for v2021-10-20.
*/
build(
dto: MeetingStartedDTO | MeetingEndedDTO | AfterHostsNoShowDTO | AfterGuestsNoShowDTO
): WebhookPayload {
// Handle no-show events (different payload structure)
if (
dto.triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW ||
dto.triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
) {
const noShowDto = dto as AfterHostsNoShowDTO | AfterGuestsNoShowDTO;
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
bookingId: noShowDto.bookingId,
webhook: noShowDto.webhook,
},
};
}
// Handle meeting started/ended events
const meetingDto = dto as MeetingStartedDTO | MeetingEndedDTO;
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: { ...meetingDto.booking },
};
}
}
@@ -0,0 +1,22 @@
import type { OOOCreatedDTO } from "../../../dto/types";
import type { WebhookPayload } from "../../types";
import { BaseOOOPayloadBuilder } from "../../base/BaseOOOPayloadBuilder";
/**
* OOO (Out of Office) payload builder for webhook version 2021-10-20.
*
* This is the initial OOO webhook payload format.
* It includes the OOO entry data in the payload.
*/
export class OOOPayloadBuilder extends BaseOOOPayloadBuilder {
/**
* Build the OOO webhook payload for v2021-10-20.
*/
build(dto: OOOCreatedDTO): WebhookPayload {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: { oooEntry: dto.oooEntry },
};
}
}
@@ -0,0 +1,46 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../../../dto/types";
import type { WebhookPayload } from "../../types";
import { BaseRecordingPayloadBuilder } from "../../base/BaseRecordingPayloadBuilder";
/**
* Recording payload builder for webhook version 2021-10-20.
*
* This is the initial recording webhook payload format.
* It handles both RECORDING_READY and RECORDING_TRANSCRIPTION_GENERATED events.
*/
export class RecordingPayloadBuilder extends BaseRecordingPayloadBuilder {
/**
* Build the recording webhook payload for v2021-10-20.
*/
build(dto: RecordingReadyDTO | TranscriptionGeneratedDTO): WebhookPayload {
if (dto.triggerEvent === WebhookTriggerEvents.RECORDING_READY) {
return this.buildRecordingReadyPayload(dto as RecordingReadyDTO);
}
return this.buildTranscriptionPayload(dto as TranscriptionGeneratedDTO);
}
/**
* Build recording ready payload for v2021-10-20.
*/
private buildRecordingReadyPayload(dto: RecordingReadyDTO): WebhookPayload {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: { downloadLink: dto.downloadLink },
};
}
/**
* Build transcription generated payload for v2021-10-20.
*/
private buildTranscriptionPayload(dto: TranscriptionGeneratedDTO): WebhookPayload {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: { downloadLinks: dto.downloadLinks },
};
}
}
@@ -0,0 +1,15 @@
/**
* Webhook Payload Builders for Version v2021-10-20
*
* This is the initial webhook version. All builders extend base classes
* and use default implementations.
*
*/
export { BookingPayloadBuilder } from "./BookingPayloadBuilder";
export { FormPayloadBuilder } from "./FormPayloadBuilder";
export { MeetingPayloadBuilder } from "./MeetingPayloadBuilder";
export { RecordingPayloadBuilder } from "./RecordingPayloadBuilder";
export { OOOPayloadBuilder } from "./OOOPayloadBuilder";
export { InstantMeetingBuilder } from "./InstantMeetingBuilder";
export { DelegationPayloadBuilder } from "./DelegationPayloadBuilder";
+16 -18
View File
@@ -1,9 +1,11 @@
import { withReporting } from "@calcom/lib/sentryWrapper";
import defaultPrisma from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookSubscriber } from "./dto/types";
import { WebhookOutputMapper } from "./infrastructure/mappers/WebhookOutputMapper";
export type GetSubscriberOptions = {
userId?: number | null;
eventTypeId?: number | null;
@@ -13,25 +15,11 @@ export type GetSubscriberOptions = {
oAuthClientId?: string | null;
};
const webhookSelect = {
id: true,
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
time: true,
timeUnit: true,
eventTriggers: true,
} satisfies Prisma.WebhookSelect;
export type GetWebhooksReturnType = Prisma.WebhookGetPayload<{
select: typeof webhookSelect;
}>[];
const getWebhooks = async (
options: GetSubscriberOptions,
prisma: PrismaClient = defaultPrisma
): Promise<GetWebhooksReturnType> => {
): Promise<WebhookSubscriber[]> => {
const teamId = options.teamId;
const userId = options.userId ?? 0;
const eventTypeId = options.eventTypeId ?? 0;
@@ -85,10 +73,20 @@ const getWebhooks = async (
},
},
},
select: webhookSelect,
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
time: true,
timeUnit: true,
eventTriggers: true,
version: true,
},
});
return allWebhooks;
return allWebhooks.map(WebhookOutputMapper.toSubscriberPartial);
};
export default withReporting(getWebhooks, "getWebhooks");
@@ -0,0 +1,134 @@
import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { Webhook, WebhookSubscriber } from "../../dto/types";
import { parseWebhookVersion } from "../../interface/IWebhookRepository";
/**
* Full webhook from Prisma (for CRUD operations)
*/
type PrismaWebhookFull = {
id: string;
subscriberUrl: string;
payloadTemplate: string | null;
appId: string | null;
secret: string | null;
active: boolean;
eventTriggers: WebhookTriggerEvents[];
eventTypeId: number | null;
teamId: number | null;
userId: number | null;
time: number | null;
timeUnit: TimeUnit | null;
version: string;
createdAt: Date;
platform: boolean;
platformOAuthClientId: string | null;
};
/**
* Minimal webhook from Prisma (for delivery/subscribers)
*/
type PrismaWebhookMinimal = {
id: string;
subscriberUrl: string;
payloadTemplate: string | null;
appId: string | null;
secret: string | null;
eventTriggers: WebhookTriggerEvents[];
time: number | null;
timeUnit: TimeUnit | null;
version: string;
};
export class WebhookOutputMapper {
/**
* Map full Prisma webhook to domain Webhook (for UI/CRUD)
*/
static toWebhook(prismaWebhook: PrismaWebhookFull): Webhook {
return {
id: prismaWebhook.id,
subscriberUrl: prismaWebhook.subscriberUrl,
payloadTemplate: prismaWebhook.payloadTemplate,
appId: prismaWebhook.appId,
secret: prismaWebhook.secret,
active: prismaWebhook.active,
eventTriggers: prismaWebhook.eventTriggers,
eventTypeId: prismaWebhook.eventTypeId,
teamId: prismaWebhook.teamId,
userId: prismaWebhook.userId,
time: prismaWebhook.time,
timeUnit: prismaWebhook.timeUnit,
version: parseWebhookVersion(prismaWebhook.version),
createdAt: prismaWebhook.createdAt,
platform: prismaWebhook.platform,
platformOAuthClientId: prismaWebhook.platformOAuthClientId,
};
}
static toWebhookList(prismaWebhooks: PrismaWebhookFull[]): Webhook[] {
return prismaWebhooks.map(WebhookOutputMapper.toWebhook);
}
/**
* Map minimal Prisma webhook to WebhookSubscriber (for delivery)
*/
static toSubscriber(prismaWebhook: PrismaWebhookMinimal): WebhookSubscriber {
return {
id: prismaWebhook.id,
subscriberUrl: prismaWebhook.subscriberUrl,
payloadTemplate: prismaWebhook.payloadTemplate,
appId: prismaWebhook.appId,
secret: prismaWebhook.secret,
eventTriggers: prismaWebhook.eventTriggers,
time: prismaWebhook.time ?? undefined,
timeUnit: prismaWebhook.timeUnit ?? undefined,
version: parseWebhookVersion(prismaWebhook.version),
};
}
static toSubscriberList(prismaWebhooks: PrismaWebhookMinimal[]): WebhookSubscriber[] {
return prismaWebhooks.map(WebhookOutputMapper.toSubscriber);
}
/**
* Legacy method - kept for backward compatibility
* @deprecated Use toWebhook() or toSubscriber() based on context
*/
static toDomain(prismaWebhook: PrismaWebhookMinimal): WebhookSubscriber {
return WebhookOutputMapper.toSubscriber(prismaWebhook);
}
/**
* Legacy method - kept for backward compatibility
* @deprecated Use toWebhookList() or toSubscriberList() based on context
*/
static toDomainList(prismaWebhooks: PrismaWebhookMinimal[]): WebhookSubscriber[] {
return WebhookOutputMapper.toSubscriberList(prismaWebhooks);
}
static toSubscriberPartial(prismaWebhook: {
id: string;
subscriberUrl: string;
eventTriggers: string[];
version: string;
payloadTemplate?: string | null;
appId?: string | null;
secret?: string | null;
time?: number | null;
timeUnit?: string | null;
}): WebhookSubscriber {
return {
id: prismaWebhook.id,
subscriberUrl: prismaWebhook.subscriberUrl,
payloadTemplate: prismaWebhook.payloadTemplate ?? null,
appId: prismaWebhook.appId ?? null,
secret: prismaWebhook.secret ?? null,
// Enums are just strings at runtime - Prisma validates them
eventTriggers: prismaWebhook.eventTriggers as unknown as WebhookSubscriber["eventTriggers"],
time: prismaWebhook.time ?? undefined,
timeUnit: prismaWebhook.timeUnit as unknown as WebhookSubscriber["timeUnit"],
version: parseWebhookVersion(prismaWebhook.version),
};
}
}
@@ -0,0 +1,87 @@
import type { WebhookTriggerEvents, UserPermissionRole } from "@calcom/prisma/enums";
import type { Webhook, WebhookSubscriber, WebhookGroup } from "../dto/types";
/**
* Webhook Version enum - defines the payload format versions.
*
* This is a TypeScript-only enum (not Prisma).
* DB operations go through the repository which enforces these values.
*/
export const WebhookVersion = {
V_2021_10_20: "2021-10-20",
} as const;
export type WebhookVersion = (typeof WebhookVersion)[keyof typeof WebhookVersion];
/**
* Default webhook version - used for new webhooks and as fallback
*/
export const DEFAULT_WEBHOOK_VERSION = WebhookVersion.V_2021_10_20;
const VALID_WEBHOOK_VERSIONS = new Set<string>(Object.values(WebhookVersion));
export function isValidWebhookVersion(value: string): value is WebhookVersion {
return VALID_WEBHOOK_VERSIONS.has(value);
}
/**
* Parse and validate a webhook version string.
* Throws if the version is invalid.
*/
export function parseWebhookVersion(value: string): WebhookVersion {
if (!isValidWebhookVersion(value)) {
throw new Error(
`Invalid webhook version: "${value}". Valid versions are: ${Object.values(WebhookVersion).join(", ")}`
);
}
return value;
}
export interface GetSubscribersOptions {
userId?: number | null;
eventTypeId?: number | null;
triggerEvent: WebhookTriggerEvents;
teamId?: number | number[] | null;
orgId?: number | null;
oAuthClientId?: string | null;
}
export interface ListWebhooksOptions {
userId: number;
appId?: string | null;
eventTypeId?: number | null;
eventTriggers?: WebhookTriggerEvents[];
}
export interface IWebhookRepository {
getSubscribers(options: GetSubscribersOptions): Promise<WebhookSubscriber[]>;
getWebhookById(id: string): Promise<WebhookSubscriber | null>;
findByWebhookId(webhookId?: string): Promise<{
id: string;
subscriberUrl: string;
payloadTemplate: string | null;
active: boolean;
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
teamId: number | null;
userId: number | null;
platform: boolean;
time: number | null;
timeUnit: string | null;
version: WebhookVersion;
}>;
getFilteredWebhooksForUser(options: { userId: number; userRole?: UserPermissionRole }): Promise<{
webhookGroups: WebhookGroup[];
profiles: {
teamId: number | null | undefined;
slug: string | null;
name: string | null;
image?: string | undefined;
canModify?: boolean;
canDelete?: boolean;
}[];
}>;
listWebhooks(options: ListWebhooksOptions): Promise<Webhook[]>;
}
@@ -1,56 +0,0 @@
import type { Webhook } from "@calcom/prisma/client";
import type { WebhookTriggerEvents, UserPermissionRole } from "@calcom/prisma/enums";
import type { WebhookSubscriber } from "../dto/types";
export interface GetSubscribersOptions {
userId?: number | null;
eventTypeId?: number | null;
triggerEvent: WebhookTriggerEvents;
teamId?: number | number[] | null;
orgId?: number | null;
oAuthClientId?: string | null;
}
type WebhookGroup = {
teamId?: number | null;
profile: {
slug: string | null;
name: string | null;
image?: string;
};
metadata?: {
canModify: boolean;
canDelete: boolean;
};
webhooks: Webhook[];
};
export interface IWebhookRepository {
getSubscribers(options: GetSubscribersOptions): Promise<WebhookSubscriber[]>;
getWebhookById(id: string): Promise<WebhookSubscriber | null>;
findByWebhookId(webhookId?: string): Promise<{
id: string;
subscriberUrl: string;
payloadTemplate: string | null;
active: boolean;
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
teamId: number | null;
userId: number | null;
platform: boolean;
time: number | null;
timeUnit: string | null;
}>;
getFilteredWebhooksForUser(options: { userId: number; userRole?: UserPermissionRole }): Promise<{
webhookGroups: WebhookGroup[];
profiles: {
teamId: number | null | undefined;
slug: string | null;
name: string | null;
image?: string | undefined;
canModify?: boolean;
canDelete?: boolean;
}[];
}>;
}
@@ -4,12 +4,15 @@ import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { prisma as defaultPrisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import type { Webhook } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { UserPermissionRole, MembershipRole } from "@calcom/prisma/enums";
import type { WebhookSubscriber } from "../dto/types";
import type { IWebhookRepository } from "../interface/repository";
import { parseWebhookVersion } from "../interface/IWebhookRepository";
import type { Webhook, WebhookSubscriber, WebhookGroup } from "../dto/types";
import type { IWebhookRepository, WebhookVersion, ListWebhooksOptions } from "../interface/IWebhookRepository";
import { WebhookOutputMapper } from "../infrastructure/mappers/WebhookOutputMapper";
import type { GetSubscribersOptions } from "./types";
// Type for raw query results from the database
@@ -22,24 +25,13 @@ interface WebhookQueryResult {
time: number | null;
timeUnit: TimeUnit | null;
eventTriggers: WebhookTriggerEvents[];
version: WebhookVersion;
priority: number; // This field is added by the query and removed before returning
}
type WebhookGroup = {
teamId?: number | null;
profile: {
slug: string | null;
name: string | null;
image?: string;
};
metadata?: {
canModify: boolean;
canDelete: boolean;
};
webhooks: Webhook[];
};
const filterWebhooks = (webhook: Webhook) => {
const filterWebhooks = (webhook: { appId: string | null }) => {
const appIds = [
"zapier",
"make",
@@ -103,6 +95,7 @@ export class WebhookRepository implements IWebhookRepository {
time: webhook.time,
timeUnit: webhook.timeUnit as TimeUnit | null,
eventTriggers: webhook.eventTriggers as WebhookTriggerEvents[],
version: webhook.version,
}));
}
@@ -124,7 +117,7 @@ export class WebhookRepository implements IWebhookRepository {
const results = await this.prisma.$queryRaw<WebhookQueryResult[]>`
-- Platform webhooks (highest priority)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
1 as priority
FROM "Webhook"
WHERE active = true
@@ -135,7 +128,7 @@ export class WebhookRepository implements IWebhookRepository {
-- User-specific webhooks (only if userId provided)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
2 as priority
FROM "Webhook"
WHERE active = true
@@ -148,7 +141,7 @@ export class WebhookRepository implements IWebhookRepository {
-- Event type webhooks (only if eventTypeId provided)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
3 as priority
FROM "Webhook"
WHERE active = true
@@ -161,7 +154,7 @@ export class WebhookRepository implements IWebhookRepository {
-- Parent event type webhooks (only if managedParentEventTypeId provided)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
4 as priority
FROM "Webhook"
WHERE active = true
@@ -174,7 +167,7 @@ export class WebhookRepository implements IWebhookRepository {
-- Team webhooks (only if teamIds provided and not empty)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
5 as priority
FROM "Webhook"
WHERE active = true
@@ -188,7 +181,7 @@ export class WebhookRepository implements IWebhookRepository {
-- OAuth client webhooks (only if oAuthClientId provided)
SELECT
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
6 as priority
FROM "Webhook"
WHERE active = true
@@ -223,6 +216,7 @@ export class WebhookRepository implements IWebhookRepository {
time: true,
timeUnit: true,
eventTriggers: true,
version: true,
},
});
@@ -237,11 +231,12 @@ export class WebhookRepository implements IWebhookRepository {
time: webhook.time,
timeUnit: webhook.timeUnit as TimeUnit | null,
eventTriggers: webhook.eventTriggers,
version: parseWebhookVersion(webhook.version),
};
}
async findByWebhookId(webhookId?: string) {
return await this.prisma.webhook.findUniqueOrThrow({
const webhook = await this.prisma.webhook.findUniqueOrThrow({
where: {
id: webhookId,
},
@@ -257,8 +252,14 @@ export class WebhookRepository implements IWebhookRepository {
platform: true,
time: true,
timeUnit: true,
version: true,
},
});
return {
...webhook,
version: parseWebhookVersion(webhook.version),
};
}
async findByOrgIdAndTrigger({
@@ -268,7 +269,7 @@ export class WebhookRepository implements IWebhookRepository {
orgId: number;
triggerEvent: WebhookTriggerEvents;
}): Promise<WebhookSubscriber[]> {
return await this.prisma.webhook.findMany({
const webhooks = await this.prisma.webhook.findMany({
where: {
teamId: orgId,
platform: false,
@@ -287,8 +288,14 @@ export class WebhookRepository implements IWebhookRepository {
time: true,
timeUnit: true,
appId: true,
version: true,
},
});
return webhooks.map((webhook) => ({
...webhook,
eventTriggers: webhook.eventTriggers as WebhookTriggerEvents[],
version: parseWebhookVersion(webhook.version),
}));
}
async getFilteredWebhooksForUser({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) {
@@ -299,7 +306,26 @@ export class WebhookRepository implements IWebhookRepository {
username: true,
name: true,
avatarUrl: true,
webhooks: true,
webhooks: {
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
active: true,
eventTriggers: true,
eventTypeId: true,
teamId: true,
userId: true,
time: true,
timeUnit: true,
version: true,
createdAt: true,
platform: true,
platformOAuthClientId: true,
},
},
teams: {
where: {
accepted: true,
@@ -312,7 +338,26 @@ export class WebhookRepository implements IWebhookRepository {
name: true,
slug: true,
logoUrl: true,
webhooks: true,
webhooks: {
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
active: true,
eventTriggers: true,
eventTypeId: true,
teamId: true,
userId: true,
time: true,
timeUnit: true,
version: true,
createdAt: true,
platform: true,
platformOAuthClientId: true,
},
},
},
},
},
@@ -338,7 +383,7 @@ export class WebhookRepository implements IWebhookRepository {
name: user.name,
image: getUserAvatarUrl({ avatarUrl: user.avatarUrl }),
},
webhooks: user.webhooks.filter(filterWebhooks),
webhooks: WebhookOutputMapper.toWebhookList(user.webhooks.filter(filterWebhooks)),
metadata: {
canModify: true,
canDelete: true,
@@ -386,7 +431,7 @@ export class WebhookRepository implements IWebhookRepository {
slug: membership.team.slug || null,
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
webhooks: WebhookOutputMapper.toWebhookList(membership.team.webhooks.filter(filterWebhooks)),
metadata: {
canModify: canUpdate,
canDelete,
@@ -398,6 +443,24 @@ export class WebhookRepository implements IWebhookRepository {
if (userRole === UserPermissionRole.ADMIN) {
const platformWebhooks = await this.prisma.webhook.findMany({
where: { platform: true },
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
active: true,
eventTriggers: true,
eventTypeId: true,
teamId: true,
userId: true,
time: true,
timeUnit: true,
version: true,
createdAt: true,
platform: true,
platformOAuthClientId: true,
},
});
webhookGroups.push({
@@ -407,7 +470,7 @@ export class WebhookRepository implements IWebhookRepository {
name: "Platform",
image: getPlaceholderAvatar(null, "Platform"),
},
webhooks: platformWebhooks,
webhooks: WebhookOutputMapper.toWebhookList(platformWebhooks),
metadata: {
canDelete: true,
canModify: true,
@@ -424,6 +487,101 @@ export class WebhookRepository implements IWebhookRepository {
})),
};
}
/**
* List webhooks for a user with filtering options.
* Handles:
* - App filtering (excludes zapier/make by default unless appId specified)
* - Event type filtering (with managed event type parent handling)
* - Event trigger filtering
* - Permission-based team filtering
*/
async listWebhooks(options: ListWebhooksOptions): Promise<Webhook[]> {
const { userId, appId, eventTypeId, eventTriggers } = options;
// Build WHERE conditions
const whereConditions: NonNullable<Prisma.WebhookWhereInput["AND"]> = [
// AppId filter - null appId by default (excludes zapier/make)
{ appId: appId ?? null },
];
// Get user's teams
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { teams: true },
});
if (eventTypeId) {
// Check for managed event type parent
const managedParentEvt = await this.prisma.eventType.findFirst({
where: {
id: eventTypeId,
parentId: { not: null },
},
select: { parentId: true },
});
if (managedParentEvt?.parentId) {
// Include webhooks from both the event type and its parent (if active)
whereConditions.push({
OR: [{ eventTypeId }, { eventTypeId: managedParentEvt.parentId, active: true }],
});
} else {
whereConditions.push({ eventTypeId });
}
} else {
// No eventTypeId - filter by user and their allowed teams
const permissionService = new PermissionCheckService();
const teamIds = user?.teams?.map((m) => m.teamId) ?? [];
const allowedTeamIds = (
await Promise.all(
teamIds.map(async (teamId) => {
const ok = await permissionService.checkPermission({
userId,
teamId,
permission: "webhook.read",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
return ok ? teamId : null;
})
)
).filter((x): x is number => x !== null);
whereConditions.push({
OR: [{ userId }, ...(allowedTeamIds.length ? [{ teamId: { in: allowedTeamIds } }] : [])],
});
}
// Event triggers filter
if (eventTriggers?.length) {
whereConditions.push({ eventTriggers: { hasEvery: eventTriggers } });
}
const webhooks = await this.prisma.webhook.findMany({
where: { AND: whereConditions },
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
active: true,
eventTriggers: true,
eventTypeId: true,
teamId: true,
userId: true,
time: true,
timeUnit: true,
version: true,
createdAt: true,
platform: true,
platformOAuthClientId: true,
},
});
return WebhookOutputMapper.toWebhookList(webhooks);
}
}
export const webhookRepository = withReporting(
@@ -3,11 +3,14 @@ import { compile } from "handlebars";
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
import { getHumanReadableLocationValue } from "@calcom/app-store/locations";
import type { WebhookSubscriber, PaymentData } from "@calcom/features/webhooks/lib/dto/types";
import { DelegationCredentialErrorPayloadType } from "@calcom/features/webhooks/lib/dto/types";
import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
import type { Payment, Webhook } from "@calcom/prisma/client";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
// Minimal webhook shape for sending payloads (subset of WebhookSubscriber)
type WebhookForPayload = Pick<WebhookSubscriber, "subscriberUrl" | "appId" | "payloadTemplate">;
type ContentType = "application/json" | "application/x-www-form-urlencoded";
export type EventTypeInfo = {
@@ -92,7 +95,7 @@ export type EventPayloadType = CalendarEvent &
paymentId?: number;
rescheduledBy?: string;
cancelledBy?: string;
paymentData?: Payment;
paymentData?: PaymentData;
};
export type WebhookPayloadType =
@@ -217,7 +220,7 @@ const sendPayload = async (
secretKey: string | null,
triggerEvent: string,
createdAt: string,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
webhook: WebhookForPayload,
data: WebhookPayloadType
) => {
const { appId, payloadTemplate: template } = webhook;
@@ -268,7 +271,7 @@ export const sendGenericWebhookPayload = async ({
secretKey: string | null;
triggerEvent: string;
createdAt: string;
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">;
webhook: WebhookForPayload;
data: Record<string, unknown>;
rootData?: Record<string, unknown>;
}) => {
@@ -302,7 +305,7 @@ export const createWebhookSignature = (params: { secret?: string | null; body: s
const _sendPayload = async (
secretKey: string | null,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
webhook: WebhookForPayload,
body: string,
contentType: "application/json" | "application/x-www-form-urlencoded"
) => {
@@ -0,0 +1,309 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { BookingWebhookEventDTO, WebhookEventDTO, WebhookSubscriber } from "../dto/types";
import { WebhookVersion } from "../interface/IWebhookRepository";
import type { PayloadBuilderFactory } from "../factory/versioned/PayloadBuilderFactory";
import type { ILogger } from "../interface/infrastructure";
import type { IWebhookService } from "../interface/services";
import { WebhookNotificationHandler } from "./WebhookNotificationHandler";
describe("WebhookNotificationHandler", () => {
let handler: WebhookNotificationHandler;
let mockWebhookService: IWebhookService;
let mockFactory: PayloadBuilderFactory;
let mockLogger: ILogger;
const mockSubscribers: WebhookSubscriber[] = [
{
id: "webhook-1",
subscriberUrl: "https://example.com/webhook",
payloadTemplate: null,
appId: null,
secret: "secret123",
eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
time: null,
timeUnit: null,
version: WebhookVersion.V_2021_10_20,
},
{
id: "webhook-2",
subscriberUrl: "https://example2.com/webhook",
payloadTemplate: null,
appId: null,
secret: "secret456",
eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
time: null,
timeUnit: null,
version: WebhookVersion.V_2021_10_20,
},
];
beforeEach(() => {
// Mock webhook service
mockWebhookService = {
getSubscribers: vi.fn().mockResolvedValue(mockSubscribers),
processWebhooks: vi.fn().mockResolvedValue(undefined),
sendWebhook: vi.fn(),
scheduleWebhook: vi.fn(),
sendWebhookDirectly: vi.fn(),
};
// Mock factory - returns appropriate payload based on trigger event
mockFactory = {
getBuilder: vi.fn().mockImplementation((_version: string, triggerEvent: WebhookTriggerEvents) => ({
build: vi.fn().mockImplementation((dto: WebhookEventDTO) => ({
triggerEvent,
createdAt: dto.createdAt,
payload: dto,
})),
})),
registerVersion: vi.fn(),
getRegisteredVersions: vi.fn().mockReturnValue([WebhookVersion.V_2021_10_20]),
};
// Mock logger
mockLogger = {
getSubLogger: vi.fn().mockReturnThis(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
handler = new WebhookNotificationHandler(mockWebhookService, mockFactory, mockLogger);
});
describe("Constructor", () => {
it("should initialize with dependencies", () => {
expect(handler).toBeInstanceOf(WebhookNotificationHandler);
});
it("should create sublogger", () => {
expect(mockLogger.getSubLogger).toHaveBeenCalledWith({
prefix: ["[WebhookNotificationHandler]"],
});
});
});
describe("handleNotification", () => {
const mockDTO: BookingWebhookEventDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
createdAt: "2024-01-15T10:00:00Z",
bookingId: 1,
eventTypeId: 1,
userId: 1,
teamId: null,
orgId: null,
platformClientId: null,
booking: {
id: 1,
eventTypeId: 1,
userId: 1,
smsReminderNumber: null,
},
eventType: {
eventTitle: "Test Event",
eventDescription: "Test Description",
requiresConfirmation: false,
price: 0,
currency: "USD",
length: 30,
},
evt: {
type: "test-event",
title: "Test Meeting",
description: "Meeting description",
startTime: "2024-01-15T10:00:00Z",
endTime: "2024-01-15T10:30:00Z",
organizer: {
id: 1,
email: "organizer@test.com",
name: "Test Organizer",
timeZone: "UTC",
language: { locale: "en" },
},
attendees: [
{
email: "attendee@test.com",
name: "Test Attendee",
timeZone: "UTC",
language: { locale: "en" },
},
],
uid: "booking-uid-123",
customInputs: {},
responses: {},
userFieldsResponses: {},
},
};
it("should query for subscribers with correct params", async () => {
await handler.handleNotification(mockDTO);
expect(mockWebhookService.getSubscribers).toHaveBeenCalledWith({
userId: 1,
eventTypeId: 1,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
teamId: null,
orgId: null,
oAuthClientId: null,
});
});
it("should use factory to build payload", async () => {
await handler.handleNotification(mockDTO);
expect(mockFactory.getBuilder).toHaveBeenCalledWith(WebhookVersion.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
});
it("should process webhooks with built payload", async () => {
await handler.handleNotification(mockDTO);
expect(mockWebhookService.processWebhooks).toHaveBeenCalledWith(
WebhookTriggerEvents.BOOKING_CREATED,
expect.objectContaining({
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
payload: expect.any(Object),
}),
mockSubscribers
);
});
it("should log debug messages", async () => {
await handler.handleNotification(mockDTO);
expect(mockLogger.debug).toHaveBeenCalledWith(
"Querying for webhook subscribers with params:",
expect.any(Object)
);
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining("Successfully processed webhook notification"),
expect.any(Object)
);
});
it("should return early when no subscribers found", async () => {
mockWebhookService.getSubscribers.mockResolvedValue([]);
await handler.handleNotification(mockDTO);
expect(mockWebhookService.processWebhooks).not.toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining("No subscribers found"),
expect.any(Object)
);
});
it("should handle errors gracefully", async () => {
const error = new Error("Test error");
mockWebhookService.getSubscribers.mockRejectedValue(error);
await expect(handler.handleNotification(mockDTO)).rejects.toThrow("Test error");
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining("Error handling webhook notification"),
expect.objectContaining({
error: "Test error",
})
);
});
it("should support dry run mode", async () => {
await handler.handleNotification(mockDTO, true);
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining("Dry run mode"));
expect(mockWebhookService.getSubscribers).not.toHaveBeenCalled();
});
});
describe("Special Event Handling", () => {
it("should handle AFTER_HOSTS_CAL_VIDEO_NO_SHOW through factory", async () => {
const noShowDTO: WebhookEventDTO = {
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
createdAt: "2024-01-15T10:00:00Z",
bookingId: 1,
webhook: { id: "webhook-1" },
};
mockWebhookService.getSubscribers.mockResolvedValue(mockSubscribers);
await handler.handleNotification(noShowDTO);
// All events now go through the factory for consistent versioning
expect(mockFactory.getBuilder).toHaveBeenCalledWith(
WebhookVersion.V_2021_10_20,
WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW
);
expect(mockWebhookService.processWebhooks).toHaveBeenCalledWith(
WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
{
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
createdAt: noShowDTO.createdAt,
payload: noShowDTO,
},
mockSubscribers
);
});
it("should handle DELEGATION_CREDENTIAL_ERROR through factory", async () => {
const errorDTO: WebhookEventDTO = {
triggerEvent: WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR,
createdAt: "2024-01-15T10:00:00Z",
error: "Credential error",
credential: { id: 1, type: "test" },
user: { id: 1, email: "user@test.com" },
};
mockWebhookService.getSubscribers.mockResolvedValue(mockSubscribers);
await handler.handleNotification(errorDTO);
// All events now go through the factory for consistent versioning
expect(mockFactory.getBuilder).toHaveBeenCalledWith(
WebhookVersion.V_2021_10_20,
WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR
);
expect(mockWebhookService.processWebhooks).toHaveBeenCalledWith(
WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR,
{
triggerEvent: WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR,
createdAt: errorDTO.createdAt,
payload: errorDTO,
},
mockSubscribers
);
});
});
describe("Version Handling", () => {
it("should use default version (v2021-10-20) for all events currently", async () => {
const dto: BookingWebhookEventDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
createdAt: "2024-01-15T10:00:00Z",
booking: {
id: 1,
eventTypeId: 1,
userId: 1,
smsReminderNumber: null,
},
eventType: {
eventTitle: "Test",
eventDescription: "",
requiresConfirmation: false,
price: 0,
currency: "USD",
length: 30,
},
evt: {},
};
mockWebhookService.getSubscribers.mockResolvedValue(mockSubscribers);
await handler.handleNotification(dto);
expect(mockFactory.getBuilder).toHaveBeenCalledWith(WebhookVersion.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
});
});
});
@@ -1,28 +1,18 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { DEFAULT_WEBHOOK_VERSION } from "../interface/IWebhookRepository";
import type { WebhookEventDTO } from "../dto/types";
import type { BookingPayloadBuilder } from "../factory/BookingPayloadBuilder";
import type { FormPayloadBuilder } from "../factory/FormPayloadBuilder";
import type { InstantMeetingBuilder } from "../factory/InstantMeetingBuilder";
import type { MeetingPayloadBuilder } from "../factory/MeetingPayloadBuilder";
import type { OOOPayloadBuilder } from "../factory/OOOPayloadBuilder";
import type { RecordingPayloadBuilder } from "../factory/RecordingPayloadBuilder";
import type { WebhookPayload } from "../factory/types";
import type { PayloadBuilderFactory } from "../factory/versioned/PayloadBuilderFactory";
import type { ILogger } from "../interface/infrastructure";
import type { IWebhookService } from "../interface/services";
import type { IWebhookNotificationHandler } from "../interface/webhook";
import type { WebhookVersion } from "../interface/IWebhookRepository";
export class WebhookNotificationHandler implements IWebhookNotificationHandler {
private readonly log: ILogger;
constructor(
private readonly webhookService: IWebhookService,
private readonly bookingPayloadBuilder: BookingPayloadBuilder,
private readonly formPayloadBuilder: FormPayloadBuilder,
private readonly oooPayloadBuilder: OOOPayloadBuilder,
private readonly recordingPayloadBuilder: RecordingPayloadBuilder,
private readonly meetingPayloadBuilder: MeetingPayloadBuilder,
private readonly instantMeetingBuilder: InstantMeetingBuilder,
private readonly payloadBuilderFactory: PayloadBuilderFactory,
logger: ILogger
) {
this.log = logger.getSubLogger({ prefix: ["[WebhookNotificationHandler]"] });
@@ -76,72 +66,20 @@ export class WebhookNotificationHandler implements IWebhookNotificationHandler {
}
}
private createPayload(dto: WebhookEventDTO): WebhookPayload {
switch (dto.triggerEvent) {
// Booking events
case WebhookTriggerEvents.BOOKING_CREATED:
case WebhookTriggerEvents.BOOKING_CANCELLED:
case WebhookTriggerEvents.BOOKING_REQUESTED:
case WebhookTriggerEvents.BOOKING_RESCHEDULED:
case WebhookTriggerEvents.BOOKING_REJECTED:
case WebhookTriggerEvents.BOOKING_PAID:
case WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED:
case WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED:
return this.bookingPayloadBuilder.build(dto);
// Form events
case WebhookTriggerEvents.FORM_SUBMITTED:
case WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT:
return this.formPayloadBuilder.build(dto);
// OOO events
case WebhookTriggerEvents.OOO_CREATED:
return this.oooPayloadBuilder.build(dto);
// Recording events
case WebhookTriggerEvents.RECORDING_READY:
case WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED:
return this.recordingPayloadBuilder.build(dto);
// Meeting events
case WebhookTriggerEvents.MEETING_STARTED:
case WebhookTriggerEvents.MEETING_ENDED:
return this.meetingPayloadBuilder.build(dto);
// Instant meeting events
case WebhookTriggerEvents.INSTANT_MEETING:
return this.instantMeetingBuilder.build(dto);
// No-show events (special handling)
case WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW:
case WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW: {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
bookingId: dto.bookingId,
webhook: dto.webhook,
},
};
}
case WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR: {
return {
triggerEvent: dto.triggerEvent,
createdAt: dto.createdAt,
payload: {
error: dto.error,
credential: dto.credential,
user: dto.user,
},
};
}
default: {
// TypeScript exhaustiveness check - this should never happen if all cases are covered
const _exhaustiveCheck: never = dto;
throw new Error(`Unsupported triggerEvent: ${JSON.stringify(_exhaustiveCheck)}`);
}
}
/**
* Create webhook payload using version-specific builder from factory.
*
* All event types now go through the factory for consistent versioning.
*
* Note: Currently uses DEFAULT version for all subscribers.
* In the future, this can be enhanced to:
* 1. Accept subscriber version parameter
* 2. Build version-specific payloads per subscriber
* 3. Group subscribers by version for efficiency
*/
private createPayload(dto: WebhookEventDTO, version: WebhookVersion = DEFAULT_WEBHOOK_VERSION): WebhookPayload {
// Get version-specific builder from factory - handles all event types
const builder = this.payloadBuilderFactory.getBuilder(version, dto.triggerEvent);
return builder.build(dto);
}
}
@@ -1,9 +1,9 @@
import type { Webhook } from "@calcom/prisma/client";
import type { WebhookForReservationCheck } from "./dto/types";
interface Params {
subscriberUrl: string;
id?: string;
webhooks?: Webhook[];
webhooks?: WebhookForReservationCheck[];
teamId?: number;
userId?: number;
eventTypeId?: number;
@@ -23,20 +23,20 @@ export const subscriberUrlReserved = ({
throw new Error("Either teamId, userId, eventTypeId or platform must be provided.");
}
const findMatchingWebhook = (condition: (webhook: Webhook) => void) => {
const findMatchingWebhook = (condition: (webhook: WebhookForReservationCheck) => boolean) => {
return !!webhooks?.find(
(webhook) => webhook.subscriberUrl === subscriberUrl && (!id || webhook.id !== id) && condition(webhook)
);
};
if (teamId) {
return findMatchingWebhook((webhook: Webhook) => webhook.teamId === teamId);
return findMatchingWebhook((webhook) => webhook.teamId === teamId);
}
if (eventTypeId) {
return findMatchingWebhook((webhook: Webhook) => webhook.eventTypeId === eventTypeId);
return findMatchingWebhook((webhook) => webhook.eventTypeId === eventTypeId);
}
if (platform) {
return findMatchingWebhook((webhook: Webhook) => webhook.platform === true);
return findMatchingWebhook((webhook) => webhook.platform === true);
}
return findMatchingWebhook((webhook: Webhook) => webhook.userId === userId);
return findMatchingWebhook((webhook) => webhook.userId === userId);
};
@@ -2,11 +2,18 @@
import { useRouter } from "next/navigation";
import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/SettingsHeaderWithBackButton";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookVersion } from "../lib/interface/IWebhookRepository";
import { WEBHOOK_VERSION_OPTIONS, getWebhookVersionLabel } from "../lib/constants";
import { trpc } from "@calcom/trpc/react";
import { Select } from "@calcom/ui/components/form";
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import type { WebhookFormSubmitData } from "../components/WebhookForm";
@@ -23,6 +30,7 @@ type WebhookProps = {
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
platform: boolean;
version: WebhookVersion;
};
export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
@@ -57,47 +65,73 @@ export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
if (isPending || !webhook) return <SkeletonContainer />;
return (
<>
<WebhookForm
noRoutingFormTriggers={false}
webhook={webhook}
onSubmit={(values: WebhookFormSubmitData) => {
if (
subscriberUrlReserved({
subscriberUrl: values.subscriberUrl,
id: webhook.id,
webhooks,
teamId: webhook.teamId ?? undefined,
userId: webhook.userId ?? undefined,
platform: webhook.platform ?? undefined,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
if (values.changeSecret) {
values.secret = values.newSecret.trim().length ? values.newSecret : null;
}
if (!values.payloadTemplate) {
values.payloadTemplate = null;
}
editWebhookMutation.mutate({
id: webhook.id,
<WebhookForm
noRoutingFormTriggers={false}
webhook={webhook}
headerWrapper={(formMethods, children) => (
<SettingsHeaderWithBackButton
title={t("edit_webhook")}
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}
CTA={
<Tooltip content={t("webhook_version")}>
<div>
<Select
className="min-w-36"
options={WEBHOOK_VERSION_OPTIONS}
value={{
value: formMethods.watch("version"),
label: getWebhookVersionLabel(formMethods.watch("version")),
}}
onChange={(option) => {
if (option) {
formMethods.setValue("version", option.value, { shouldDirty: true });
}
}}
/>
</div>
</Tooltip>
}>
{children}
</SettingsHeaderWithBackButton>
)}
onSubmit={(values: WebhookFormSubmitData) => {
if (
subscriberUrlReserved({
subscriberUrl: values.subscriberUrl,
eventTriggers: values.eventTriggers,
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
time: values.time,
timeUnit: values.timeUnit,
});
}}
apps={installedApps?.items.map((app) => app.slug)}
/>
</>
id: webhook.id,
webhooks,
teamId: webhook.teamId ?? undefined,
userId: webhook.userId ?? undefined,
platform: webhook.platform ?? undefined,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
if (values.changeSecret) {
values.secret = values.newSecret.trim().length ? values.newSecret : null;
}
if (!values.payloadTemplate) {
values.payloadTemplate = null;
}
editWebhookMutation.mutate({
id: webhook.id,
subscriberUrl: values.subscriberUrl,
eventTriggers: values.eventTriggers,
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
time: values.time,
timeUnit: values.timeUnit,
version: values.version,
});
}}
apps={installedApps?.items.map((app) => app.slug)}
/>
);
}
@@ -7,9 +7,12 @@ import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/Setti
import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WEBHOOK_VERSION_OPTIONS, getWebhookVersionLabel } from "../lib/constants";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Select } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import type { WebhookFormSubmitData } from "../components/WebhookForm";
@@ -70,22 +73,45 @@ export const NewWebhookView = ({ webhooks, installedApps }: Props) => {
secret: values.secret,
time: values.time,
timeUnit: values.timeUnit,
version: values.version,
teamId,
platform,
});
};
return (
<SettingsHeaderWithBackButton
title={t("add_webhook")}
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}>
<WebhookForm
noRoutingFormTriggers={false}
onSubmit={onCreateWebhook}
apps={installedApps?.items.map((app) => app.slug)}
/>
</SettingsHeaderWithBackButton>
<WebhookForm
noRoutingFormTriggers={false}
onSubmit={onCreateWebhook}
apps={installedApps?.items.map((app) => app.slug)}
headerWrapper={(formMethods, children) => (
<SettingsHeaderWithBackButton
title={t("add_webhook")}
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}
CTA={
<Tooltip content={t("webhook_version")}>
<div>
<Select
className="min-w-36"
options={WEBHOOK_VERSION_OPTIONS}
value={{
value: formMethods.watch("version"),
label: getWebhookVersionLabel(formMethods.watch("version")),
}}
onChange={(option) => {
if (option) {
formMethods.setValue("version", option.value, { shouldDirty: true });
}
}}
/>
</div>
</Tooltip>
}>
{children}
</SettingsHeaderWithBackButton>
)}
/>
);
};
+3 -2
View File
@@ -3,8 +3,8 @@ import type { TFunction } from "i18next";
import getICalUID from "@calcom/emails/lib/getICalUID";
import type { Booking, EventType, Prisma, Webhook, BookingReference } from "@calcom/prisma/client";
import { CreationSource } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { CreationSource, BookingStatus } from "@calcom/prisma/enums";
import type { CalendarEvent, Person, VideoCallData } from "@calcom/types/Calendar";
export const buildVideoCallData = (callData?: Partial<VideoCallData>): VideoCallData => {
@@ -187,6 +187,7 @@ export const buildWebhook = (webhook?: Partial<Webhook>): Webhook => {
platformOAuthClientId: null,
time: null,
timeUnit: null,
version: WebhookVersion.V_2021_10_20,
...webhook,
platform: false,
};
+5
View File
@@ -38,6 +38,11 @@ export {
WorkflowTemplates,
} from "@calcom/prisma/enums";
export {
WebhookVersion,
DEFAULT_WEBHOOK_VERSION,
} from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
export { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
export { handleMarkNoShow };
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Webhook" ADD COLUMN "version" TEXT NOT NULL DEFAULT '2021-10-20';
+1
View File
@@ -1133,6 +1133,7 @@ model Webhook {
scheduledTriggers WebhookScheduledTriggers[]
time Int?
timeUnit TimeUnit?
version String @default("2021-10-20")
@@unique([userId, subscriberUrl], name: "courseIdentifier")
@@unique([platformOAuthClientId, subscriberUrl], name: "oauthclientwebhook")
@@ -1,3 +1,5 @@
import type { Webhook } from "@calcom/features/webhooks/lib/dto/types";
import { router } from "../../../trpc";
import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
@@ -22,7 +24,7 @@ const UNSTABLE_HANDLER_CACHE: WebhookRouterHandlerCache = {};
export const webhookRouter = router({
list: createWebhookPbacProcedure("webhook.read")
.input(ZListInputSchema)
.query(async ({ ctx, input }) => {
.query(async ({ ctx, input }): Promise<Webhook[]> => {
if (!UNSTABLE_HANDLER_CACHE.list) {
UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler);
}
@@ -131,7 +133,7 @@ export const webhookRouter = router({
}),
getByViewer: createWebhookPbacProcedure("webhook.read", ["ADMIN", "OWNER", "MEMBER"]).query(
async ({ ctx }) => {
async ({ ctx }): Promise<import("./getByViewer.handler").WebhooksByViewer> => {
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
(mod) => mod.getByViewerHandler
@@ -2,6 +2,7 @@ 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 { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { webhookIdAndEventTypeIdSchema } from "./types";
@@ -17,6 +18,7 @@ export const ZCreateInputSchema = webhookIdAndEventTypeIdSchema.extend({
platform: z.boolean().optional(),
time: z.number().nullable().optional(),
timeUnit: z.enum(TIME_UNIT).nullable().optional(),
version: z.nativeEnum(WebhookVersion).optional(),
});
export type TCreateInputSchema = z.infer<typeof ZCreateInputSchema>;
@@ -2,6 +2,7 @@ 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 { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { webhookIdAndEventTypeIdSchema } from "./types";
@@ -16,6 +17,7 @@ export const ZEditInputSchema = webhookIdAndEventTypeIdSchema.extend({
secret: z.string().optional().nullable(),
time: z.number().nullable().optional(),
timeUnit: z.enum(TIME_UNIT).nullable().optional(),
version: z.nativeEnum(WebhookVersion).optional(),
});
export type TEditInputSchema = z.infer<typeof ZEditInputSchema>;
@@ -1,5 +1,5 @@
import type { WebhookGroup } from "@calcom/features/webhooks/lib/dto/types";
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
import type { Webhook } from "@calcom/prisma/client";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
type GetByViewerOptions = {
@@ -8,19 +8,6 @@ type GetByViewerOptions = {
};
};
type WebhookGroup = {
teamId?: number | null;
profile: {
slug: string | null;
name: string | null;
image?: string;
};
metadata?: {
readOnly: boolean;
};
webhooks: Webhook[];
};
export type WebhooksByViewer = {
webhookGroups: WebhookGroup[];
profiles: {
@@ -32,7 +19,7 @@ export type WebhooksByViewer = {
}[];
};
export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
export const getByViewerHandler = async ({ ctx }: GetByViewerOptions): Promise<WebhooksByViewer> => {
// Use the singleton instance to avoid creating new instances repeatedly
const webhookRepository = WebhookRepository.getInstance();
return await webhookRepository.getFilteredWebhooksForUser({
@@ -1,7 +1,5 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import type { Webhook } from "@calcom/features/webhooks/lib/dto/types";
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import type { TListInputSchema } from "./list.schema";
@@ -13,72 +11,13 @@ type ListOptions = {
input: TListInputSchema;
};
export const listHandler = async ({ ctx, input }: ListOptions) => {
const where: Prisma.WebhookWhereInput = {
/* Don't mixup zapier webhooks with normal ones */
AND: [{ appId: !input?.appId ? null : input.appId }],
};
export const listHandler = async ({ ctx, input }: ListOptions): Promise<Webhook[]> => {
const repository = WebhookRepository.getInstance();
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
teams: true,
},
});
if (Array.isArray(where.AND)) {
if (input?.eventTypeId) {
const managedParentEvt = await prisma.eventType.findFirst({
where: {
id: input.eventTypeId,
parentId: {
not: null,
},
},
select: {
parentId: true,
},
});
if (managedParentEvt?.parentId) {
where.AND?.push({
OR: [{ eventTypeId: input.eventTypeId }, { eventTypeId: managedParentEvt.parentId, active: true }],
});
} else {
where.AND?.push({ eventTypeId: input.eventTypeId });
}
} else {
const permissionService = new PermissionCheckService();
const teamIds = user?.teams?.map((m) => m.teamId) ?? [];
const allowedTeamIds = (
await Promise.all(
teamIds.map(async (teamId) => {
const ok = await permissionService.checkPermission({
userId: ctx.user.id,
teamId,
permission: "webhook.read",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
return ok ? teamId : null;
})
)
).filter((x): x is number => x !== null);
console.log("Allowed Team IDs:", allowedTeamIds);
where.AND?.push({
OR: [{ userId: ctx.user.id }, ...(allowedTeamIds.length ? [{ teamId: { in: allowedTeamIds } }] : [])],
});
}
if (input?.eventTriggers) {
where.AND?.push({ eventTriggers: { hasEvery: input.eventTriggers } });
}
}
return await prisma.webhook.findMany({
where,
return repository.listWebhooks({
userId: ctx.user.id,
appId: input?.appId,
eventTypeId: input?.eventTypeId,
eventTriggers: input?.eventTriggers,
});
};