"use client"; import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import customTemplate, { hasTemplateIntegration } from "@calcom/features/webhooks/lib/integrationTemplate"; import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { Form } from "@calcom/ui/components/form"; import { Button } from "@coss/ui/components/button"; import { Card, CardFrame, CardFrameFooter, CardPanel } from "@coss/ui/components/card"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@coss/ui/components/collapsible"; import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxClear, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxPopup, ComboboxValue, } from "@coss/ui/components/combobox"; import { Field, FieldDescription, FieldError, FieldLabel } from "@coss/ui/components/field"; import { Group, GroupSeparator } from "@coss/ui/components/group"; import { Input } from "@coss/ui/components/input"; import { NumberField, NumberFieldGroup, NumberFieldInput } from "@coss/ui/components/number-field"; import { ScrollArea } from "@coss/ui/components/scroll-area"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@coss/ui/components/select"; import { Spinner } from "@coss/ui/components/spinner"; import { Switch } from "@coss/ui/components/switch"; import { Textarea } from "@coss/ui/components/textarea"; import { ExternalLinkIcon, TrashIcon } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import WebhookTestDisclosure from "./WebhookTestDisclosure"; export type TWebhook = RouterOutputs["viewer"]["webhook"]["list"][number]; export type WebhookFormData = { id?: string; subscriberUrl: string; active: boolean; eventTriggers: WebhookTriggerEvents[]; secret: string | null; payloadTemplate: string | undefined | null; time?: number | null; timeUnit?: TimeUnit | null; version: WebhookVersion; }; export type WebhookFormSubmitData = WebhookFormData & { changeSecret: boolean; newSecret: string; }; type WebhookTriggerEventOptions = readonly { value: WebhookTriggerEvents; label: string }[]; const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record = { core: [ { value: WebhookTriggerEvents.BOOKING_CANCELLED, label: "booking_cancelled" }, { value: WebhookTriggerEvents.BOOKING_CREATED, label: "booking_created" }, { value: WebhookTriggerEvents.BOOKING_REJECTED, label: "booking_rejected" }, { value: WebhookTriggerEvents.BOOKING_REQUESTED, label: "booking_requested" }, { value: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, label: "booking_payment_initiated" }, { value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" }, { value: WebhookTriggerEvents.BOOKING_PAID, label: "booking_paid" }, { value: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED, label: "booking_no_show_updated" }, { value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" }, { value: WebhookTriggerEvents.MEETING_STARTED, label: "meeting_started" }, { value: WebhookTriggerEvents.RECORDING_READY, label: "recording_ready" }, { value: WebhookTriggerEvents.INSTANT_MEETING, label: "instant_meeting" }, { value: WebhookTriggerEvents.OOO_CREATED, label: "ooo_created" }, { value: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED, label: "recording_transcription_generated", }, { value: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, label: "after_hosts_cal_video_no_show" }, { value: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, label: "after_guests_cal_video_no_show", }, { value: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT, label: "wrong_assignment_report" }, ], "routing-forms": [ { value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" }, { value: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT, label: "form_submitted_no_event" }, { value: WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT, label: "routing_form_fallback_hit_trigger" }, ], } as const; function getWebhookVariables(t: (key: string) => string) { return [ { category: t("webhook_event_and_booking"), variables: [ { name: "triggerEvent", variable: "{{triggerEvent}}", type: "String", description: t("webhook_trigger_event"), }, { name: "createdAt", variable: "{{createdAt}}", type: "Datetime", description: t("webhook_created_at"), }, { name: "type", variable: "{{type}}", type: "String", description: t("webhook_type") }, { name: "title", variable: "{{title}}", type: "String", description: t("webhook_title") }, { name: "startTime", variable: "{{startTime}}", type: "Datetime", description: t("webhook_start_time"), }, { name: "endTime", variable: "{{endTime}}", type: "Datetime", description: t("webhook_end_time"), }, { name: "description", variable: "{{description}}", type: "String", description: t("webhook_description"), }, { name: "location", variable: "{{location}}", type: "String", description: t("webhook_location"), }, { name: "uid", variable: "{{uid}}", type: "String", description: t("webhook_uid") }, { name: "rescheduleUid", variable: "{{rescheduleUid}}", type: "String", description: t("webhook_reschedule_uid"), }, { name: "cancellationReason", variable: "{{cancellationReason}}", type: "String", description: t("webhook_cancellation_reason"), }, { name: "rejectionReason", variable: "{{rejectionReason}}", type: "String", description: t("webhook_rejection_reason"), }, ], }, { category: t("webhook_people"), variables: [ { name: "organizer.name", variable: "{{organizer.name}}", type: "String", description: t("webhook_organizer_name"), }, { name: "organizer.email", variable: "{{organizer.email}}", type: "String", description: t("webhook_organizer_email"), }, { name: "organizer.timezone", variable: "{{organizer.timezone}}", type: "String", description: t("webhook_organizer_timezone"), }, { name: "organizer.language.locale", variable: "{{organizer.language.locale}}", type: "String", description: t("webhook_organizer_locale"), }, { name: "organizer.username", variable: "{{organizer.username}}", type: "String", description: t("webhook_organizer_username"), }, { name: "organizer.usernameInOrg", variable: "{{organizer.usernameInOrg}}", type: "String", description: t("webhook_organizer_username_in_org"), }, { name: "attendees.0.name", variable: "{{attendees.0.name}}", type: "String", description: t("webhook_attendee_name"), }, { name: "attendees.0.email", variable: "{{attendees.0.email}}", type: "String", description: t("webhook_attendee_email"), }, { name: "attendees.0.timeZone", variable: "{{attendees.0.timeZone}}", type: "String", description: t("webhook_attendee_timezone"), }, { name: "attendees.0.language.locale", variable: "{{attendees.0.language.locale}}", type: "String", description: t("webhook_attendee_locale"), }, ], }, { category: t("webhook_teams"), variables: [ { name: "team.name", variable: "{{team.name}}", type: "String", description: t("webhook_team_name"), }, { name: "team.members", variable: "{{team.members}}", type: "String[]", description: t("webhook_team_members"), }, ], }, { category: t("webhook_metadata"), variables: [ { name: "metadata.videoCallUrl", variable: "{{metadata.videoCallUrl}}", type: "String", description: t("webhook_video_call_url"), }, ], }, ]; } export type WebhookFormValues = { subscriberUrl: string; active: boolean; eventTriggers: WebhookTriggerEvents[]; secret: string | null; payloadTemplate: string | undefined | null; time?: number | null; timeUnit?: TimeUnit | null; version: WebhookVersion; }; const WebhookForm = (props: { 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>, children: React.ReactNode ) => React.ReactNode; }) => { const { apps = [], selectOnlyInstantMeetingOption = false, overrideTriggerOptions } = props; const { t } = useLocale(); const webhookVariables = getWebhookVariables(t); const triggerOptions = overrideTriggerOptions ? [...overrideTriggerOptions] : [...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2["core"]]; if (apps) { for (const app of apps) { if (app === "routing-forms" && props.noRoutingFormTriggers) continue; if (WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2[app]) { triggerOptions.push(...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2[app]); } } } const translatedTriggerOptions = triggerOptions.map((option) => ({ ...option, label: t(option.label) })); const getEventTriggers = () => { if (props.webhook) return props.webhook.eventTriggers; return ( selectOnlyInstantMeetingOption ? translatedTriggerOptions.filter((option) => option.value === WebhookTriggerEvents.INSTANT_MEETING) : translatedTriggerOptions.filter((option) => option.value !== WebhookTriggerEvents.INSTANT_MEETING) ).map((option) => option.value); }; const formMethods = useForm({ defaultValues: { subscriberUrl: props.webhook?.subscriberUrl || "", active: props.webhook ? props.webhook.active : true, eventTriggers: getEventTriggers(), secret: props?.webhook?.secret || "", payloadTemplate: props?.webhook?.payloadTemplate || undefined, timeUnit: props?.webhook?.timeUnit || undefined, time: props?.webhook?.time || undefined, version: props?.webhook?.version || WebhookVersion.V_2021_10_20, }, }); const triggers = formMethods.watch("eventTriggers") || []; const subscriberUrl = formMethods.watch("subscriberUrl"); const time = formMethods.watch("time"); const timeUnit = formMethods.watch("timeUnit"); const TIME_UNITS = [TimeUnit.MINUTE, TimeUnit.HOUR, TimeUnit.DAY] as const; const timeUnitItems = TIME_UNITS.map((unit) => ({ value: unit, label: t(`${unit.toLowerCase()}_timeUnit`), })); const selectedTimeUnitItem = timeUnitItems.find((item) => item.value === timeUnit) ?? timeUnitItems[2]; const isCreating = !props?.webhook?.id; const needsTime = triggers.some( (t) => t === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW || t === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW ); const hasTime = !!time && !!timeUnit; const hasUrl = !!subscriberUrl; const showTimeSection = needsTime; function insertVariableIntoTemplate(current: string, name: string, value: string): string { try { const parsed = JSON.parse(current || "{}"); parsed[name] = value; return JSON.stringify(parsed, null, 2); } catch { const trimmed = current.trim(); if (trimmed === "{}" || trimmed === "") { return `{\n "${name}": "${value}"\n}`; } if (trimmed.endsWith("}")) { const withoutClosing = trimmed.slice(0, -1); const needsComma = withoutClosing.trim().endsWith('"') || withoutClosing.trim().endsWith("}"); return `${withoutClosing}${needsComma ? "," : ""}\n "${name}": "${value}"\n}`; } return `${current}\n"${name}": "${value}"`; } } const [customPayloadOpen, setCustomPayloadOpen] = useState( props?.webhook?.payloadTemplate !== undefined && props?.webhook?.payloadTemplate !== null ); const [showVariables, setShowVariables] = useState(false); const [newSecret, setNewSecret] = useState(""); const [changeSecret, setChangeSecret] = useState(false); const hasSecretKey = !!props?.webhook?.secret; const canSubmit = isCreating ? hasUrl && triggers.length > 0 && (!needsTime || hasTime) : formMethods.formState.isDirty || changeSecret; useEffect(() => { if (isCreating && needsTime && !time && !timeUnit) { formMethods.setValue("time", 5, { shouldDirty: true }); formMethods.setValue("timeUnit", TimeUnit.MINUTE, { shouldDirty: true }); } }, [isCreating, needsTime, time, timeUnit, formMethods]); useEffect(() => { if (changeSecret) { formMethods.unregister("secret", { keepDefaultValue: false }); } }, [changeSecret, formMethods]); const formFields = (
( {t("subscriber_url")} { onChange(urlValue); if (urlValue && hasTemplateIntegration({ url: urlValue })) { setCustomPayloadOpen(true); formMethods.setValue("payloadTemplate", customTemplate({ url: urlValue }), { shouldDirty: true, }); } }} /> {error?.message} )} /> ( onChange(checked)} /> {t("enable_webhook")} )} /> { const selectedItems = translatedTriggerOptions.filter((option) => value.includes(option.value)); return ( {t("event_triggers")} { onChange(newValue.map((item) => item.value)); const noShowWebhookTriggerExists = !!newValue.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 }); } }} items={translatedTriggerOptions} multiple> {(selectedValues: { value: WebhookTriggerEvents; label: string }[]) => ( <> {selectedValues?.map((item) => ( {item.label} ))} 0 ? undefined : t("select_event_triggers", "Select event triggers…") } /> )} {t("no_options_available")} {(item: { value: WebhookTriggerEvents; label: string }) => ( {item.label} )} }> {t("clear_all_triggers", "Clear all triggers")} ); }} /> {showTimeSection && ( ( ( {t("how_long_after_user_no_show_minutes")} onTimeChange(newValue ?? 5)} render={}> )} /> )} /> )} ( {hasSecretKey ? ( <> {t("secret")}
{ setNewSecret(v); onChange(v); } : undefined } placeholder={changeSecret ? t("leave_blank_to_remove_secret") : undefined} className="flex-1" />
{t("forgotten_secret_description")} ) : ( <> {t("secret")} )}
)} /> { // Flatten webhookVariables into a single array for payloadVariables const payloadVariables = webhookVariables.flatMap(({ variables }) => variables.map(({ name: n, variable, description }) => ({ name: n, variable: `{{${variable}}}`, description, })) ); return ( { setCustomPayloadOpen(checked); if (!checked) onChange(undefined); }} /> } /> {t("custom_payload_template", "Custom Payload Template")}