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:
@@ -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) {}
|
||||
|
||||
+2
-11
@@ -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) => {
|
||||
|
||||
+9
-2
@@ -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 trigger→builder 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;
|
||||
}
|
||||
+153
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+188
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+31
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+41
-33
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
+46
@@ -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";
|
||||
@@ -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,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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user