first commit
This commit is contained in:
302
calcom/packages/features/webhooks/components/WebhookForm.tsx
Normal file
302
calcom/packages/features/webhooks/components/WebhookForm.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Button, Form, Label, Select, Switch, TextArea, TextField, ToggleGroup } from "@calcom/ui";
|
||||
|
||||
import SectionBottomActions from "../../settings/SectionBottomActions";
|
||||
import customTemplate, { hasTemplateIntegration } from "../lib/integrationTemplate";
|
||||
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;
|
||||
};
|
||||
|
||||
export type WebhookFormSubmitData = WebhookFormData & {
|
||||
changeSecret: boolean;
|
||||
newSecret: string;
|
||||
};
|
||||
|
||||
type WebhookTriggerEventOptions = readonly { value: WebhookTriggerEvents; label: string }[];
|
||||
|
||||
const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEventOptions> = {
|
||||
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.RECORDING_TRANSCRIPTION_GENERATED,
|
||||
label: "recording_transcription_generated",
|
||||
},
|
||||
],
|
||||
"routing-forms": [{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" }],
|
||||
} as const;
|
||||
|
||||
const WebhookForm = (props: {
|
||||
webhook?: WebhookFormData;
|
||||
apps?: (keyof typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2)[];
|
||||
onSubmit: (event: WebhookFormSubmitData) => void;
|
||||
onCancel?: () => void;
|
||||
noRoutingFormTriggers: boolean;
|
||||
selectOnlyInstantMeetingOption?: boolean;
|
||||
}) => {
|
||||
const { apps = [], selectOnlyInstantMeetingOption = false } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const triggerOptions = [...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,
|
||||
},
|
||||
});
|
||||
|
||||
const [useCustomTemplate, setUseCustomTemplate] = useState(false);
|
||||
const [newSecret, setNewSecret] = useState("");
|
||||
const [changeSecret, setChangeSecret] = useState<boolean>(false);
|
||||
const hasSecretKey = !!props?.webhook?.secret;
|
||||
// const currentSecret = props?.webhook?.secret;
|
||||
|
||||
useEffect(() => {
|
||||
if (changeSecret) {
|
||||
formMethods.unregister("secret", { keepDefaultValue: false });
|
||||
}
|
||||
}, [changeSecret, formMethods]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={(values) => props.onSubmit({ ...values, changeSecret, newSecret })}>
|
||||
<div className="border-subtle border-x 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
|
||||
options={translatedTriggerOptions}
|
||||
isMulti
|
||||
value={selectValue}
|
||||
onChange={(event) => {
|
||||
onChange(event.map((selection) => selection.value));
|
||||
}}
|
||||
/>
|
||||
</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 space-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);
|
||||
}
|
||||
}}
|
||||
defaultValue={value ? "custom" : "default"}
|
||||
options={[
|
||||
{ value: "default", label: t("default") },
|
||||
{ value: "custom", label: t("custom") },
|
||||
]}
|
||||
isFullWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{useCustomTemplate && (
|
||||
<TextArea
|
||||
name="customPayloadTemplate"
|
||||
rows={3}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("payloadTemplate", e?.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</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"
|
||||
disabled={!formMethods.formState.isDirty && !changeSecret}
|
||||
loading={formMethods.formState.isSubmitting || formMethods.formState.isSubmitted}>
|
||||
{props?.webhook?.id ? t("save") : t("create_webhook")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
|
||||
<div className="mt-6 rounded-md">
|
||||
<WebhookTestDisclosure />
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebhookForm;
|
||||
157
calcom/packages/features/webhooks/components/WebhookListItem.tsx
Normal file
157
calcom/packages/features/webhooks/components/WebhookListItem.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
showToast,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
|
||||
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;
|
||||
canEditWebhook?: boolean;
|
||||
onEditWebhook: () => void;
|
||||
lastItem: boolean;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const { webhook } = props;
|
||||
const canEditWebhook = props.canEditWebhook ?? true;
|
||||
|
||||
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
|
||||
async onSuccess() {
|
||||
showToast(t("webhook_removed_successfully"), "success");
|
||||
await utils.viewer.webhook.getByViewer.invalidate();
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
});
|
||||
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess(data) {
|
||||
// TODO: Better success message
|
||||
showToast(t(data?.active ? "enabled" : "disabled"), "success");
|
||||
await utils.viewer.webhook.getByViewer.invalidate();
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const onDeleteWebhook = () => {
|
||||
// TODO: Confimation dialog before deleting
|
||||
deleteWebhook.mutate({
|
||||
id: webhook.id,
|
||||
eventTypeId: webhook.eventTypeId || undefined,
|
||||
teamId: webhook.teamId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex w-full justify-between p-4",
|
||||
props.lastItem ? "" : "border-subtle border-b"
|
||||
)}>
|
||||
<div className="w-full truncate">
|
||||
<div className="flex">
|
||||
<Tooltip content={webhook.subscriberUrl}>
|
||||
<p className="text-emphasis max-w-[600px] truncate text-sm font-medium">
|
||||
{webhook.subscriberUrl}
|
||||
</p>
|
||||
</Tooltip>
|
||||
{!!props.readOnly && (
|
||||
<Badge variant="gray" className="ml-2 ">
|
||||
{t("readonly")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip content={t("triggers_when")}>
|
||||
<div className="flex w-4/5 flex-wrap">
|
||||
{webhook.eventTriggers.map((trigger) => (
|
||||
<Badge
|
||||
key={trigger}
|
||||
className="mt-2.5 basis-1/5 ltr:mr-2 rtl:ml-2"
|
||||
variant="gray"
|
||||
startIcon="zap">
|
||||
{t(`${trigger.toLowerCase()}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!props.readOnly && (
|
||||
<div className="ml-2 flex items-center space-x-4">
|
||||
<Switch
|
||||
defaultChecked={webhook.active}
|
||||
data-testid="webhook-switch"
|
||||
disabled={!canEditWebhook}
|
||||
onCheckedChange={() =>
|
||||
toggleWebhook.mutate({
|
||||
id: webhook.id,
|
||||
active: !webhook.active,
|
||||
payloadTemplate: webhook.payloadTemplate,
|
||||
eventTypeId: webhook.eventTypeId || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="hidden lg:flex"
|
||||
color="secondary"
|
||||
onClick={props.onEditWebhook}
|
||||
data-testid="webhook-edit-button">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="hidden lg:flex"
|
||||
color="destructive"
|
||||
StartIcon="trash"
|
||||
variant="icon"
|
||||
onClick={onDeleteWebhook}
|
||||
/>
|
||||
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="lg:hidden" StartIcon="ellipsis" variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem StartIcon="pencil" color="secondary" onClick={props.onEditWebhook}>
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem StartIcon="trash" color="destructive" onClick={onDeleteWebhook}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
export default function WebhookListItemSkeleton() {
|
||||
return (
|
||||
<div className="flex w-full justify-between p-4">
|
||||
<div>
|
||||
<p className="text-emphasis text-sm font-medium">
|
||||
<SkeletonText className="h-4 w-56" />
|
||||
</p>
|
||||
<div className="mt-2.5 w-max">
|
||||
<SkeletonText className="h-5 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<SkeletonText className="h-9 w-9" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SkeletonContainer } from "@calcom/ui";
|
||||
|
||||
import WebhookListItemSkeleton from "./WebhookListItemSkeleton";
|
||||
|
||||
export default function WebhookListSkeleton() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="border-subtle divide-subtle mb-8 mt-6 divide-y rounded-md border">
|
||||
<WebhookListItemSkeleton />
|
||||
<WebhookListItemSkeleton />
|
||||
<WebhookListItemSkeleton />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { ZTestTriggerInputSchema } from "@calcom/trpc/server/routers/viewer/webhook/testTrigger.schema";
|
||||
import { Badge, Button, showToast } from "@calcom/ui";
|
||||
|
||||
export default function WebhookTestDisclosure() {
|
||||
const [subscriberUrl, webhookSecret]: [string, string] = useWatch({ name: ["subscriberUrl", "secret"] });
|
||||
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
|
||||
const { t } = useLocale();
|
||||
const mutation = trpc.viewer.webhook.testTrigger.useMutation({
|
||||
onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-subtle flex justify-between rounded-t-lg border p-6">
|
||||
<div>
|
||||
<p className="text-emphasis text-sm font-semibold leading-5">{t("webhook_test")}</p>
|
||||
<p className="text-default text-sm">{t("test_webhook")}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
disabled={mutation.isPending || !subscriberUrl}
|
||||
StartIcon="activity"
|
||||
onClick={() => {
|
||||
try {
|
||||
ZTestTriggerInputSchema.parse({
|
||||
url: subscriberUrl,
|
||||
secret: webhookSecret,
|
||||
type: "PING",
|
||||
payloadTemplate,
|
||||
});
|
||||
mutation.mutate({ url: subscriberUrl, secret: webhookSecret, type: "PING", payloadTemplate });
|
||||
} catch (error) {
|
||||
//this catches invalid subscriberUrl before calling the mutation
|
||||
if (error instanceof ZodError) {
|
||||
const errorMessage = error.errors.map((e) => e.message).join(", ");
|
||||
showToast(errorMessage, "error");
|
||||
} else {
|
||||
showToast(t("unexpected_error_try_again"), "error");
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{t("ping_test")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-subtle space-y-0 rounded-b-lg border border-t-0 px-6 py-8 sm:mx-0">
|
||||
<div className="border-subtle flex justify-between rounded-t-lg border p-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<h3 className="text-emphasis self-center text-sm font-semibold leading-4">
|
||||
{t("webhook_response")}
|
||||
</h3>
|
||||
{mutation.data && (
|
||||
<Badge variant={mutation.data.ok ? "green" : "red"}>
|
||||
{mutation.data.ok ? t("passed") : t("failed")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted border-subtle rounded-b-lg border border-t-0 p-4 font-mono text-[13px] leading-4">
|
||||
{!mutation.data && <p>{t("no_data_yet")}</p>}
|
||||
{mutation.status === "success" && (
|
||||
<div className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
calcom/packages/features/webhooks/components/index.ts
Normal file
5
calcom/packages/features/webhooks/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as WebhookForm } from "./WebhookForm";
|
||||
export { default as WebhookListItem } from "./WebhookListItem";
|
||||
export { default as WebhookListItemSkeleton } from "./WebhookListItemSkeleton";
|
||||
export { default as WebhookListSkeleton } from "./WebhookListSkeleton";
|
||||
export { default as WebhookTestDisclosure } from "./WebhookTestDisclosure";
|
||||
40
calcom/packages/features/webhooks/lib/WebhookService.ts
Normal file
40
calcom/packages/features/webhooks/lib/WebhookService.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import getWebhooks 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 options = {} as Parameters<typeof getWebhooks>[0];
|
||||
private webhooks: Awaited<ReturnType<typeof getWebhooks>> = [];
|
||||
constructor(options: Parameters<typeof getWebhooks>[0]) {
|
||||
return (async (): Promise<WebhookService> => {
|
||||
this.options = options;
|
||||
this.webhooks = await getWebhooks(options);
|
||||
return this;
|
||||
})() as unknown as WebhookService;
|
||||
}
|
||||
async getWebhooks() {
|
||||
return this.webhooks;
|
||||
}
|
||||
async sendPayload(payload: Parameters<typeof sendOrSchedulePayload>[4]) {
|
||||
const promises = this.webhooks.map((sub) =>
|
||||
sendOrSchedulePayload(
|
||||
sub.secret,
|
||||
this.options.triggerEvent,
|
||||
new Date().toISOString(),
|
||||
sub,
|
||||
payload
|
||||
).catch((e) => {
|
||||
log.error(
|
||||
`Error executing webhook for event: ${this.options.triggerEvent}, URL: ${sub.subscriberUrl}`,
|
||||
safeStringify(e)
|
||||
);
|
||||
})
|
||||
);
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
}
|
||||
27
calcom/packages/features/webhooks/lib/constants.ts
Normal file
27
calcom/packages/features/webhooks/lib/constants.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
// this is exported as we can't use `WebhookTriggerEvents` in the frontend straight-off
|
||||
|
||||
export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
|
||||
core: [
|
||||
WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||
WebhookTriggerEvents.BOOKING_CREATED,
|
||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||
WebhookTriggerEvents.BOOKING_PAID,
|
||||
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
|
||||
WebhookTriggerEvents.MEETING_ENDED,
|
||||
WebhookTriggerEvents.MEETING_STARTED,
|
||||
WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
WebhookTriggerEvents.BOOKING_REJECTED,
|
||||
WebhookTriggerEvents.RECORDING_READY,
|
||||
WebhookTriggerEvents.INSTANT_MEETING,
|
||||
WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
|
||||
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
|
||||
] as const,
|
||||
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const,
|
||||
};
|
||||
|
||||
export const WEBHOOK_TRIGGER_EVENTS = [
|
||||
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.core,
|
||||
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["routing-forms"],
|
||||
] as const;
|
||||
23
calcom/packages/features/webhooks/lib/cron.ts
Normal file
23
calcom/packages/features/webhooks/lib/cron.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/* Cron job for scheduled webhook events triggers */
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { handleWebhookScheduledTriggers } from "./handleWebhookScheduledTriggers";
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||
if (process.env.CRON_API_KEY !== apiKey) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
await handleWebhookScheduledTriggers(prisma);
|
||||
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
POST: Promise.resolve({ default: handler }),
|
||||
});
|
||||
59
calcom/packages/features/webhooks/lib/getWebhooks.ts
Normal file
59
calcom/packages/features/webhooks/lib/getWebhooks.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import defaultPrisma from "@calcom/prisma";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
export type GetSubscriberOptions = {
|
||||
userId?: number | null;
|
||||
eventTypeId?: number | null;
|
||||
triggerEvent: WebhookTriggerEvents;
|
||||
teamId?: number | null;
|
||||
orgId?: number | null;
|
||||
};
|
||||
|
||||
const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient = defaultPrisma) => {
|
||||
const userId = options.userId ?? 0;
|
||||
const eventTypeId = options.eventTypeId ?? 0;
|
||||
const teamId = options.teamId ?? 0;
|
||||
const orgId = options.orgId ?? 0;
|
||||
|
||||
// if we have userId and teamId it is a managed event type and should trigger for team and user
|
||||
const allWebhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
platform: true,
|
||||
},
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
eventTypeId,
|
||||
},
|
||||
{
|
||||
teamId: {
|
||||
in: [teamId, orgId],
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: {
|
||||
eventTriggers: {
|
||||
has: options.triggerEvent,
|
||||
},
|
||||
active: {
|
||||
equals: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
subscriberUrl: true,
|
||||
payloadTemplate: true,
|
||||
appId: true,
|
||||
secret: true,
|
||||
},
|
||||
});
|
||||
|
||||
return allWebhooks;
|
||||
};
|
||||
|
||||
export default getWebhooks;
|
||||
@@ -0,0 +1,87 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import { createWebhookSignature, jsonParse } from "./sendPayload";
|
||||
|
||||
export async function handleWebhookScheduledTriggers(prisma: PrismaClient) {
|
||||
await prisma.webhookScheduledTriggers.deleteMany({
|
||||
where: {
|
||||
startAfter: {
|
||||
lte: dayjs().subtract(1, "day").toDate(),
|
||||
},
|
||||
},
|
||||
});
|
||||
// get jobs that should be run
|
||||
const jobsToRun = await prisma.webhookScheduledTriggers.findMany({
|
||||
where: {
|
||||
startAfter: {
|
||||
lte: dayjs().toDate(),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
jobName: true,
|
||||
payload: true,
|
||||
subscriberUrl: true,
|
||||
webhook: {
|
||||
select: {
|
||||
secret: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fetchPromises: Promise<any>[] = [];
|
||||
|
||||
// run jobs
|
||||
for (const job of jobsToRun) {
|
||||
// Fetch the webhook configuration so that we can get the secret.
|
||||
let webhook = job.webhook;
|
||||
|
||||
// only needed to support old jobs that don't have the webhook relationship yet
|
||||
if (!webhook && job.jobName) {
|
||||
const [appId, subscriberId] = job.jobName.split("_");
|
||||
try {
|
||||
webhook = await prisma.webhook.findUniqueOrThrow({
|
||||
where: { id: subscriberId, appId: appId !== "null" ? appId : null },
|
||||
});
|
||||
} catch {
|
||||
logger.error(`Error finding webhook for subscriberId: ${subscriberId}, appId: ${appId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type":
|
||||
!job.payload || jsonParse(job.payload) ? "application/json" : "application/x-www-form-urlencoded",
|
||||
};
|
||||
|
||||
if (webhook) {
|
||||
headers["X-Cal-Signature-256"] = createWebhookSignature({ secret: webhook.secret, body: job.payload });
|
||||
}
|
||||
fetchPromises.push(
|
||||
fetch(job.subscriberUrl, {
|
||||
method: "POST",
|
||||
body: job.payload,
|
||||
headers,
|
||||
}).catch((error) => {
|
||||
console.error(`Webhook trigger for subscriber url ${job.subscriberUrl} failed with error: ${error}`);
|
||||
})
|
||||
);
|
||||
|
||||
const parsedJobPayload = JSON.parse(job.payload) as {
|
||||
id: number; // booking id
|
||||
endTime: string;
|
||||
triggerEvent: string;
|
||||
};
|
||||
|
||||
// clean finished job
|
||||
await prisma.webhookScheduledTriggers.delete({
|
||||
where: {
|
||||
id: job.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Promise.allSettled(fetchPromises);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const supportedWebhookIntegrationList = ["https://discord.com/api/webhooks/"];
|
||||
|
||||
type WebhookIntegrationProps = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const hasTemplateIntegration = (props: WebhookIntegrationProps) => {
|
||||
const ind = supportedWebhookIntegrationList.findIndex((integration) => {
|
||||
return props.url.includes(integration);
|
||||
});
|
||||
return ind > -1;
|
||||
};
|
||||
|
||||
const customTemplate = (props: WebhookIntegrationProps) => {
|
||||
const ind = supportedWebhookIntegrationList.findIndex((integration) => {
|
||||
return props.url.includes(integration);
|
||||
});
|
||||
return integrationTemplate(supportedWebhookIntegrationList[ind]) || "";
|
||||
};
|
||||
|
||||
const integrationTemplate = (webhookIntegration: string) => {
|
||||
switch (webhookIntegration) {
|
||||
case "https://discord.com/api/webhooks/":
|
||||
return '{"content": "An event has been scheduled/updated","embeds": [{"color": 2697513,"fields": [{"name": "Event Trigger","value": "{{triggerEvent}}"}, {"name": "What","value": "{{title}} ({{type}})"},{"name": "When","value": "Start: {{startTime}} \\n End: {{endTime}} \\n Timezone: ({{organizer.timeZone}})"},{"name": "Who","value": "Organizer: {{organizer.name}} ({{organizer.email}}) \\n Booker: {{attendees.0.name}} ({{attendees.0.email}})" },{"name":"Description", "value":": {{description}}"},{"name":"Where","value":": {{location}} "}]}]}';
|
||||
}
|
||||
};
|
||||
|
||||
export default customTemplate;
|
||||
16
calcom/packages/features/webhooks/lib/schedulePayload.ts
Normal file
16
calcom/packages/features/webhooks/lib/schedulePayload.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import tasker from "@calcom/features/tasker";
|
||||
|
||||
import type sendPayload from "./sendPayload";
|
||||
|
||||
type SchedulePayload = typeof sendPayload;
|
||||
|
||||
const schedulePayload: SchedulePayload = async (secretKey, triggerEvent, createdAt, webhook, data) => {
|
||||
await tasker.create("sendWebhook", JSON.stringify({ secretKey, triggerEvent, createdAt, webhook, data }));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
message: "Webhook scheduled successfully",
|
||||
};
|
||||
};
|
||||
|
||||
export default schedulePayload;
|
||||
453
calcom/packages/features/webhooks/lib/scheduleTrigger.ts
Normal file
453
calcom/packages/features/webhooks/lib/scheduleTrigger.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import type { Prisma, Webhook, Booking } from "@prisma/client";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
import { getHumanReadableLocationValue } from "@calcom/core/location";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { ApiKey } from "@calcom/prisma/client";
|
||||
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
const SCHEDULING_TRIGGER: WebhookTriggerEvents[] = [
|
||||
WebhookTriggerEvents.MEETING_ENDED,
|
||||
WebhookTriggerEvents.MEETING_STARTED,
|
||||
];
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[node-scheduler]"] });
|
||||
|
||||
export async function addSubscription({
|
||||
appApiKey,
|
||||
triggerEvent,
|
||||
subscriberUrl,
|
||||
appId,
|
||||
account,
|
||||
}: {
|
||||
appApiKey?: ApiKey;
|
||||
triggerEvent: WebhookTriggerEvents;
|
||||
subscriberUrl: string;
|
||||
appId: string;
|
||||
account?: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
isTeam: boolean;
|
||||
} | null;
|
||||
}) {
|
||||
try {
|
||||
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
|
||||
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
|
||||
|
||||
const createSubscription = await prisma.webhook.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
userId,
|
||||
teamId,
|
||||
eventTriggers: [triggerEvent],
|
||||
subscriberUrl,
|
||||
active: true,
|
||||
appId: appId,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
triggerEvent === WebhookTriggerEvents.MEETING_ENDED ||
|
||||
triggerEvent === WebhookTriggerEvents.MEETING_STARTED
|
||||
) {
|
||||
//schedule job for already existing bookings
|
||||
const where: Prisma.BookingWhereInput = {};
|
||||
if (teamId) {
|
||||
where.eventType = { teamId };
|
||||
} else {
|
||||
where.eventType = { userId };
|
||||
}
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
...where,
|
||||
startTime: {
|
||||
gte: new Date(),
|
||||
},
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
for (const booking of bookings) {
|
||||
scheduleTrigger({
|
||||
booking,
|
||||
subscriberUrl: createSubscription.subscriberUrl,
|
||||
subscriber: {
|
||||
id: createSubscription.id,
|
||||
appId: createSubscription.appId,
|
||||
},
|
||||
triggerEvent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return createSubscription;
|
||||
} catch (error) {
|
||||
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
|
||||
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
|
||||
|
||||
log.error(
|
||||
`Error creating subscription for ${teamId ? `team ${teamId}` : `user ${userId}`}.`,
|
||||
safeStringify(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSubscription({
|
||||
appApiKey,
|
||||
webhookId,
|
||||
appId,
|
||||
account,
|
||||
}: {
|
||||
appApiKey?: ApiKey;
|
||||
webhookId: string;
|
||||
appId: string;
|
||||
account?: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
isTeam: boolean;
|
||||
} | null;
|
||||
}) {
|
||||
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
|
||||
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
|
||||
try {
|
||||
let where: Prisma.WebhookWhereInput = {};
|
||||
if (teamId) {
|
||||
where = { teamId };
|
||||
} else {
|
||||
where = { userId };
|
||||
}
|
||||
|
||||
const deleteWebhook = await prisma.webhook.delete({
|
||||
where: {
|
||||
...where,
|
||||
appId: appId,
|
||||
id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!deleteWebhook) {
|
||||
throw new Error(`Unable to delete webhook ${webhookId}`);
|
||||
}
|
||||
return deleteWebhook;
|
||||
} catch (err) {
|
||||
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
|
||||
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
|
||||
|
||||
log.error(
|
||||
`Error deleting subscription for user ${
|
||||
teamId ? `team ${teamId}` : `userId ${userId}`
|
||||
}, webhookId ${webhookId}`,
|
||||
safeStringify(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listBookings(
|
||||
appApiKey?: ApiKey,
|
||||
account?: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
isTeam: boolean;
|
||||
} | null
|
||||
) {
|
||||
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
|
||||
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
|
||||
try {
|
||||
const where: Prisma.BookingWhereInput = {};
|
||||
if (teamId) {
|
||||
where.eventType = {
|
||||
OR: [{ teamId }, { parent: { teamId } }],
|
||||
};
|
||||
} else {
|
||||
where.eventType = { userId };
|
||||
}
|
||||
|
||||
const bookings = await prisma.booking.findMany({
|
||||
take: 3,
|
||||
where: where,
|
||||
orderBy: {
|
||||
id: "desc",
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
responses: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
location: true,
|
||||
cancellationReason: true,
|
||||
status: true,
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
locale: true,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
select: {
|
||||
title: true,
|
||||
description: true,
|
||||
requiresConfirmation: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
length: true,
|
||||
bookingFields: true,
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (bookings.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const t = await getTranslation(bookings[0].user?.locale ?? "en", "common");
|
||||
|
||||
const updatedBookings = bookings.map((booking) => {
|
||||
return {
|
||||
...booking,
|
||||
...getCalEventResponses({
|
||||
bookingFields: booking.eventType?.bookingFields ?? null,
|
||||
booking,
|
||||
}),
|
||||
location: getHumanReadableLocationValue(booking.location || "", t),
|
||||
};
|
||||
});
|
||||
|
||||
return updatedBookings;
|
||||
} catch (err) {
|
||||
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
|
||||
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
|
||||
|
||||
log.error(
|
||||
`Error retrieving list of bookings for ${teamId ? `team ${teamId}` : `user ${userId}`}.`,
|
||||
safeStringify(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleTrigger({
|
||||
booking,
|
||||
subscriberUrl,
|
||||
subscriber,
|
||||
triggerEvent,
|
||||
}: {
|
||||
booking: { id: number; endTime: Date; startTime: Date };
|
||||
subscriberUrl: string;
|
||||
subscriber: { id: string; appId: string | null };
|
||||
triggerEvent: WebhookTriggerEvents;
|
||||
}) {
|
||||
try {
|
||||
const payload = JSON.stringify({ triggerEvent, ...booking });
|
||||
|
||||
await prisma.webhookScheduledTriggers.create({
|
||||
data: {
|
||||
payload,
|
||||
appId: subscriber.appId,
|
||||
startAfter: triggerEvent === WebhookTriggerEvents.MEETING_ENDED ? booking.endTime : booking.startTime,
|
||||
subscriberUrl,
|
||||
webhook: {
|
||||
connect: {
|
||||
id: subscriber.id,
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: booking.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error cancelling scheduled jobs", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWebhookScheduledTriggers({
|
||||
booking,
|
||||
appId,
|
||||
triggerEvent,
|
||||
webhookId,
|
||||
userId,
|
||||
teamId,
|
||||
}: {
|
||||
booking?: { id: number; uid: string };
|
||||
appId?: string | null;
|
||||
triggerEvent?: WebhookTriggerEvents;
|
||||
webhookId?: string;
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
}) {
|
||||
try {
|
||||
if (appId && (userId || teamId)) {
|
||||
const where: Prisma.BookingWhereInput = {};
|
||||
if (userId) {
|
||||
where.eventType = { userId };
|
||||
} else {
|
||||
where.eventType = { teamId };
|
||||
}
|
||||
await prisma.webhookScheduledTriggers.deleteMany({
|
||||
where: {
|
||||
appId: appId,
|
||||
booking: where,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (booking) {
|
||||
await prisma.webhookScheduledTriggers.deleteMany({
|
||||
where: {
|
||||
bookingId: booking.id,
|
||||
},
|
||||
});
|
||||
} else if (webhookId) {
|
||||
const where: Prisma.WebhookScheduledTriggersWhereInput = { webhookId: webhookId };
|
||||
|
||||
if (triggerEvent) {
|
||||
const shouldContain = `"triggerEvent":"${triggerEvent}"`;
|
||||
where.payload = { contains: shouldContain };
|
||||
}
|
||||
|
||||
await prisma.webhookScheduledTriggers.deleteMany({
|
||||
where,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting webhookScheduledTriggers ", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTriggerForExistingBookings(
|
||||
webhook: Webhook,
|
||||
existingEventTriggers: WebhookTriggerEvents[],
|
||||
updatedEventTriggers: WebhookTriggerEvents[]
|
||||
) {
|
||||
const addedEventTriggers = updatedEventTriggers.filter(
|
||||
(trigger) => !existingEventTriggers.includes(trigger) && SCHEDULING_TRIGGER.includes(trigger)
|
||||
);
|
||||
const removedEventTriggers = existingEventTriggers.filter(
|
||||
(trigger) => !updatedEventTriggers.includes(trigger) && SCHEDULING_TRIGGER.includes(trigger)
|
||||
);
|
||||
|
||||
if (addedEventTriggers.length === 0 && removedEventTriggers.length === 0) return;
|
||||
|
||||
const currentTime = new Date();
|
||||
const where: Prisma.BookingWhereInput = {
|
||||
AND: [{ status: BookingStatus.ACCEPTED }],
|
||||
OR: [{ startTime: { gt: currentTime }, endTime: { gt: currentTime } }],
|
||||
};
|
||||
|
||||
let bookings: Booking[] = [];
|
||||
|
||||
if (Array.isArray(where.AND)) {
|
||||
if (webhook.teamId) {
|
||||
const org = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: webhook.teamId,
|
||||
isOrganization: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
children: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// checking if teamId is an org id
|
||||
if (org) {
|
||||
const teamEvents = await prisma.eventType.findMany({
|
||||
where: {
|
||||
teamId: {
|
||||
in: org.children.map((team) => team.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
bookings: {
|
||||
where,
|
||||
},
|
||||
},
|
||||
});
|
||||
const teamEventBookings = teamEvents.flatMap((event) => event.bookings);
|
||||
const teamBookingsId = teamEventBookings.map((booking) => booking.id);
|
||||
const orgMemberIds = org.members.map((member) => member.userId);
|
||||
where.AND.push({
|
||||
userId: {
|
||||
in: orgMemberIds,
|
||||
},
|
||||
});
|
||||
// don't want to get the team bookings again
|
||||
where.AND.push({
|
||||
id: {
|
||||
notIn: teamBookingsId,
|
||||
},
|
||||
});
|
||||
const userBookings = await prisma.booking.findMany({
|
||||
where,
|
||||
});
|
||||
// add teams bookings and users bookings to get total org bookings
|
||||
bookings = teamEventBookings.concat(userBookings);
|
||||
} else {
|
||||
const teamEvents = await prisma.eventType.findMany({
|
||||
where: {
|
||||
teamId: webhook.teamId,
|
||||
},
|
||||
select: {
|
||||
bookings: {
|
||||
where,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
bookings = teamEvents.flatMap((event) => event.bookings);
|
||||
}
|
||||
} else {
|
||||
if (webhook.eventTypeId) {
|
||||
where.AND.push({ eventTypeId: webhook.eventTypeId });
|
||||
} else if (webhook.userId) {
|
||||
where.AND.push({ userId: webhook.userId });
|
||||
}
|
||||
|
||||
bookings = await prisma.booking.findMany({
|
||||
where,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
if (addedEventTriggers.length > 0) {
|
||||
const promise = bookings.map((booking) => {
|
||||
return addedEventTriggers.map((triggerEvent) => {
|
||||
scheduleTrigger({ booking, subscriberUrl: webhook.subscriberUrl, subscriber: webhook, triggerEvent });
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promise);
|
||||
}
|
||||
|
||||
const promise = removedEventTriggers.map((triggerEvent) =>
|
||||
deleteWebhookScheduledTriggers({ triggerEvent, webhookId: webhook.id })
|
||||
);
|
||||
await Promise.all(promise);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import schedulePayload from "./schedulePayload";
|
||||
import sendPayload from "./sendPayload";
|
||||
|
||||
type SendOrSchedulePayload = typeof sendPayload;
|
||||
|
||||
const sendOrSchedulePayload: SendOrSchedulePayload = async (...args) => {
|
||||
// If Tasker is enabled, schedule the payload instead of sending it immediately
|
||||
if (process.env.TASKER_ENABLE_WEBHOOKS === "1") return schedulePayload(...args);
|
||||
return sendPayload(...args);
|
||||
};
|
||||
|
||||
export default sendOrSchedulePayload;
|
||||
248
calcom/packages/features/webhooks/lib/sendPayload.ts
Normal file
248
calcom/packages/features/webhooks/lib/sendPayload.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { Webhook } from "@prisma/client";
|
||||
import { createHmac } from "crypto";
|
||||
import { compile } from "handlebars";
|
||||
|
||||
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
|
||||
import { getHumanReadableLocationValue } from "@calcom/app-store/locations";
|
||||
import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
type ContentType = "application/json" | "application/x-www-form-urlencoded";
|
||||
|
||||
export type EventTypeInfo = {
|
||||
eventTitle?: string | null;
|
||||
eventDescription?: string | null;
|
||||
requiresConfirmation?: boolean | null;
|
||||
price?: number | null;
|
||||
currency?: string | null;
|
||||
length?: number | null;
|
||||
};
|
||||
|
||||
export type UTCOffset = {
|
||||
utcOffset?: number | null;
|
||||
};
|
||||
|
||||
export type WithUTCOffsetType<T> = T & {
|
||||
user?: Person & UTCOffset;
|
||||
} & {
|
||||
organizer?: Person & UTCOffset;
|
||||
} & {
|
||||
attendees?: (Person & UTCOffset)[];
|
||||
};
|
||||
|
||||
export type BookingNoShowUpdatedPayload = {
|
||||
message: string;
|
||||
bookingUid: string;
|
||||
bookingId: number;
|
||||
attendees: { email: string; noShow: boolean }[];
|
||||
};
|
||||
|
||||
export type TranscriptionGeneratedPayload = {
|
||||
downloadLinks?: {
|
||||
transcription: TGetTranscriptAccessLink["transcription"];
|
||||
recording: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebhookDataType = CalendarEvent &
|
||||
TranscriptionGeneratedPayload &
|
||||
// BookingNoShowUpdatedPayload & // This breaks all other webhooks
|
||||
EventTypeInfo & {
|
||||
metadata?: { [key: string]: string | number | boolean | null };
|
||||
bookingId?: number;
|
||||
status?: string;
|
||||
smsReminderNumber?: string;
|
||||
rescheduleId?: number;
|
||||
rescheduleUid?: string;
|
||||
rescheduleStartTime?: string;
|
||||
rescheduleEndTime?: string;
|
||||
triggerEvent: string;
|
||||
createdAt: string;
|
||||
downloadLink?: string;
|
||||
paymentId?: number;
|
||||
};
|
||||
|
||||
function addUTCOffset(
|
||||
data: Omit<WebhookDataType, "createdAt" | "triggerEvent">
|
||||
): WithUTCOffsetType<WebhookDataType> {
|
||||
if (data.organizer?.timeZone) {
|
||||
(data.organizer as Person & UTCOffset).utcOffset = getUTCOffsetByTimezone(
|
||||
data.organizer.timeZone,
|
||||
data.startTime
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.attendees?.length) {
|
||||
(data.attendees as (Person & UTCOffset)[]).forEach((attendee) => {
|
||||
attendee.utcOffset = getUTCOffsetByTimezone(attendee.timeZone, data.startTime);
|
||||
});
|
||||
}
|
||||
|
||||
return data as WithUTCOffsetType<WebhookDataType>;
|
||||
}
|
||||
|
||||
function getZapierPayload(
|
||||
data: WithUTCOffsetType<CalendarEvent & EventTypeInfo & { status?: string; createdAt: string }>
|
||||
): string {
|
||||
const attendees = (data.attendees as (Person & UTCOffset)[]).map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
utcOffset: attendee.utcOffset,
|
||||
};
|
||||
});
|
||||
|
||||
const t = data.organizer.language.translate;
|
||||
const location = getHumanReadableLocationValue(data.location || "", t);
|
||||
|
||||
const body = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
customInputs: data.customInputs,
|
||||
responses: data.responses,
|
||||
userFieldsResponses: data.userFieldsResponses,
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
location: location,
|
||||
status: data.status,
|
||||
cancellationReason: data.cancellationReason,
|
||||
user: {
|
||||
username: data.organizer.username,
|
||||
name: data.organizer.name,
|
||||
email: data.organizer.email,
|
||||
timeZone: data.organizer.timeZone,
|
||||
utcOffset: data.organizer.utcOffset,
|
||||
locale: data.organizer.locale,
|
||||
},
|
||||
eventType: {
|
||||
title: data.eventTitle,
|
||||
description: data.eventDescription,
|
||||
requiresConfirmation: data.requiresConfirmation,
|
||||
price: data.price,
|
||||
currency: data.currency,
|
||||
length: data.length,
|
||||
},
|
||||
attendees: attendees,
|
||||
createdAt: data.createdAt,
|
||||
};
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
function applyTemplate(template: string, data: WebhookDataType, contentType: ContentType) {
|
||||
const compiled = compile(template)(data).replace(/"/g, '"');
|
||||
|
||||
if (contentType === "application/json") {
|
||||
return JSON.stringify(jsonParse(compiled));
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
export function jsonParse(jsonString: string) {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
// don't do anything.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const sendPayload = async (
|
||||
secretKey: string | null,
|
||||
triggerEvent: string,
|
||||
createdAt: string,
|
||||
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
|
||||
data: Omit<WebhookDataType, "createdAt" | "triggerEvent">
|
||||
) => {
|
||||
const { appId, payloadTemplate: template } = webhook;
|
||||
|
||||
const contentType =
|
||||
!template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded";
|
||||
|
||||
data.description = data.description || data.additionalNotes;
|
||||
data = addUTCOffset(data);
|
||||
|
||||
let body;
|
||||
|
||||
/* Zapier id is hardcoded in the DB, we send the raw data for this case */
|
||||
if (appId === "zapier") {
|
||||
body = getZapierPayload({ ...data, createdAt });
|
||||
} else if (template) {
|
||||
body = applyTemplate(template, { ...data, triggerEvent, createdAt }, contentType);
|
||||
} else {
|
||||
body = JSON.stringify({
|
||||
triggerEvent: triggerEvent,
|
||||
createdAt: createdAt,
|
||||
payload: data,
|
||||
});
|
||||
}
|
||||
|
||||
return _sendPayload(secretKey, webhook, body, contentType);
|
||||
};
|
||||
|
||||
export const sendGenericWebhookPayload = async ({
|
||||
secretKey,
|
||||
triggerEvent,
|
||||
createdAt,
|
||||
webhook,
|
||||
data,
|
||||
rootData,
|
||||
}: {
|
||||
secretKey: string | null;
|
||||
triggerEvent: string;
|
||||
createdAt: string;
|
||||
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">;
|
||||
data: Record<string, unknown>;
|
||||
rootData?: Record<string, unknown>;
|
||||
}) => {
|
||||
const body = JSON.stringify({
|
||||
// Added rootData props first so that using the known(i.e. triggerEvent, createdAt, payload) properties in rootData doesn't override the known properties
|
||||
...rootData,
|
||||
triggerEvent: triggerEvent,
|
||||
createdAt: createdAt,
|
||||
payload: data,
|
||||
});
|
||||
|
||||
return _sendPayload(secretKey, webhook, body, "application/json");
|
||||
};
|
||||
|
||||
export const createWebhookSignature = (params: { secret?: string | null; body: string }) =>
|
||||
params.secret
|
||||
? createHmac("sha256", params.secret).update(`${params.body}`).digest("hex")
|
||||
: "no-secret-provided";
|
||||
|
||||
const _sendPayload = async (
|
||||
secretKey: string | null,
|
||||
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
|
||||
body: string,
|
||||
contentType: "application/json" | "application/x-www-form-urlencoded"
|
||||
) => {
|
||||
const { subscriberUrl } = webhook;
|
||||
if (!subscriberUrl || !body) {
|
||||
throw new Error("Missing required elements to send webhook payload.");
|
||||
}
|
||||
|
||||
const response = await fetch(subscriberUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"X-Cal-Signature-256": createWebhookSignature({ secret: secretKey, body }),
|
||||
},
|
||||
redirect: "manual",
|
||||
body,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
...(text
|
||||
? {
|
||||
message: text,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
export default sendPayload;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Webhook } from "@calcom/prisma/client";
|
||||
|
||||
interface Params {
|
||||
subscriberUrl: string;
|
||||
id?: string;
|
||||
webhooks?: Webhook[];
|
||||
teamId?: number;
|
||||
userId?: number;
|
||||
eventTypeId?: number;
|
||||
platform?: boolean;
|
||||
}
|
||||
|
||||
export const subscriberUrlReserved = ({
|
||||
subscriberUrl,
|
||||
id,
|
||||
webhooks,
|
||||
teamId,
|
||||
userId,
|
||||
eventTypeId,
|
||||
platform,
|
||||
}: Params): boolean => {
|
||||
if (!teamId && !userId && !eventTypeId && !platform) {
|
||||
throw new Error("Either teamId, userId, eventTypeId or platform must be provided.");
|
||||
}
|
||||
|
||||
const findMatchingWebhook = (condition: (webhook: Webhook) => void) => {
|
||||
return !!webhooks?.find(
|
||||
(webhook) => webhook.subscriberUrl === subscriberUrl && (!id || webhook.id !== id) && condition(webhook)
|
||||
);
|
||||
};
|
||||
|
||||
if (teamId) {
|
||||
return findMatchingWebhook((webhook: Webhook) => webhook.teamId === teamId);
|
||||
}
|
||||
if (eventTypeId) {
|
||||
return findMatchingWebhook((webhook: Webhook) => webhook.eventTypeId === eventTypeId);
|
||||
}
|
||||
if (platform) {
|
||||
return findMatchingWebhook((webhook: Webhook) => webhook.platform === true);
|
||||
}
|
||||
return findMatchingWebhook((webhook: Webhook) => webhook.userId === userId);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
import { WebhookService } from "../WebhookService";
|
||||
import getWebhooks from "../getWebhooks";
|
||||
|
||||
vi.mock("../getWebhooks");
|
||||
vi.mock("../sendOrSchedulePayload");
|
||||
|
||||
vi.mock("@calcom/lib/logger", async () => {
|
||||
const actual = await vi.importActual<typeof import("@calcom/lib/logger")>("@calcom/lib/logger");
|
||||
return {
|
||||
...actual,
|
||||
getSubLogger: vi.fn(() => ({
|
||||
error: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/safeStringify", () => ({
|
||||
safeStringify: JSON.stringify,
|
||||
}));
|
||||
|
||||
describe("WebhookService", () => {
|
||||
const mockOptions = {
|
||||
id: "mockOptionsId",
|
||||
subscriberUrl: "subUrl",
|
||||
payloadTemplate: "PayloadTemplate",
|
||||
appId: "AppId",
|
||||
secret: "WhSecret",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should initialize with options and webhooks", async () => {
|
||||
const mockWebhooks = [
|
||||
{
|
||||
id: "webhookId",
|
||||
subscriberUrl: "url",
|
||||
secret: "secret",
|
||||
appId: "appId",
|
||||
payloadTemplate: "payloadTemplate",
|
||||
},
|
||||
];
|
||||
vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks);
|
||||
|
||||
// Has to be called with await due to the iffi being async
|
||||
const service = await new WebhookService(mockOptions);
|
||||
|
||||
expect(service).toBeInstanceOf(WebhookService);
|
||||
expect(await service.getWebhooks()).toEqual(mockWebhooks);
|
||||
expect(getWebhooks).toHaveBeenCalledWith(mockOptions);
|
||||
});
|
||||
|
||||
// it("should send payload to all webhooks", async () => {
|
||||
// const mockWebhooks = [
|
||||
// {
|
||||
// id: "webhookId",
|
||||
// subscriberUrl: "url",
|
||||
// secret: "secret",
|
||||
// appId: "appId",
|
||||
// payloadTemplate: "payloadTemplate",
|
||||
// },
|
||||
// {
|
||||
// id: "webhookId2",
|
||||
// subscriberUrl: "url",
|
||||
// secret: "secret2",
|
||||
// appId: "appId2",
|
||||
// payloadTemplate: "payloadTemplate",
|
||||
// },
|
||||
// ];
|
||||
// vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks);
|
||||
// const service = await new WebhookService(mockOptions);
|
||||
//
|
||||
// const payload = {
|
||||
// secretKey: "secret",
|
||||
// triggerEvent: "triggerEvent",
|
||||
// createdAt: "now",
|
||||
// webhook: {
|
||||
// subscriberUrl: "url",
|
||||
// appId: "appId",
|
||||
// payloadTemplate: "payloadTemplate",
|
||||
// },
|
||||
// data: "test",
|
||||
// };
|
||||
//
|
||||
// await service.sendPayload(payload as any);
|
||||
//
|
||||
// expect(sendOrSchedulePayload).toHaveBeenCalledTimes(mockWebhooks.length);
|
||||
//
|
||||
// mockWebhooks.forEach((webhook) => {
|
||||
// expect(sendOrSchedulePayload).toHaveBeenCalledWith(
|
||||
// webhook.secret,
|
||||
// mockOptions.triggerEvent,
|
||||
// expect.any(String),
|
||||
// webhook,
|
||||
// payload
|
||||
// );
|
||||
// });
|
||||
// });
|
||||
//
|
||||
// it("should log error when sending payload fails", async () => {
|
||||
// const mockWebhooks = [
|
||||
// {
|
||||
// id: "webhookId",
|
||||
// subscriberUrl: "url",
|
||||
// secret: "secret",
|
||||
// appId: "appId",
|
||||
// payloadTemplate: "payloadTemplate",
|
||||
// },
|
||||
// ];
|
||||
// vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks);
|
||||
//
|
||||
// const logError = vi.fn();
|
||||
//
|
||||
// (sendOrSchedulePayload as any).mockImplementation(() => {
|
||||
// throw new Error("Failure");
|
||||
// });
|
||||
//
|
||||
// const service = new WebhookService(mockOptions);
|
||||
//
|
||||
// const payload = {
|
||||
// secretKey: "secret", triggerEvent: "triggerEvent", createdAt: "now", webhook: {
|
||||
// subscriberUrl: "url",
|
||||
// appId: "appId",
|
||||
// payloadTemplate: "payloadTemplate"
|
||||
// },
|
||||
// data: "test"
|
||||
// };
|
||||
//
|
||||
// await service.sendPayload(payload as any);
|
||||
//
|
||||
// expect(logError).toHaveBeenCalledWith(
|
||||
// `Error executing webhook for event: ${mockOptions.triggerEvent}, URL: ${mockWebhooks[0].subscriberUrl}`,
|
||||
// JSON.stringify(new Error("Failure"))
|
||||
// );
|
||||
// });
|
||||
});
|
||||
73
calcom/packages/features/webhooks/lib/test/webhooks.test.ts
Normal file
73
calcom/packages/features/webhooks/lib/test/webhooks.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { expectWebhookToHaveBeenCalledWith } from "@calcom/web/test/utils/bookingScenario/expects";
|
||||
|
||||
import { describe, expect, beforeEach } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
import { handleWebhookScheduledTriggers } from "../handleWebhookScheduledTriggers";
|
||||
|
||||
describe("Cron job handler", () => {
|
||||
beforeEach(async () => {
|
||||
await prismock.webhookScheduledTriggers.deleteMany();
|
||||
});
|
||||
test(`should delete old webhook scheduled triggers`, async () => {
|
||||
const now = dayjs();
|
||||
await prismock.webhookScheduledTriggers.createMany({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
subscriberUrl: "https://example.com",
|
||||
startAfter: now.subtract(2, "day").toDate(),
|
||||
payload: "",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
subscriberUrl: "https://example.com",
|
||||
startAfter: now.subtract(1, "day").subtract(1, "hour").toDate(),
|
||||
payload: "",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
subscriberUrl: "https://example.com",
|
||||
startAfter: now.add(1, "day").toDate(),
|
||||
payload: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await handleWebhookScheduledTriggers(prismock);
|
||||
|
||||
const scheduledTriggers = await prismock.webhookScheduledTriggers.findMany();
|
||||
expect(scheduledTriggers.length).toBe(1);
|
||||
expect(scheduledTriggers[0].startAfter).toStrictEqual(now.add(1, "day").toDate());
|
||||
});
|
||||
test(`should trigger if current date is after startAfter`, async () => {
|
||||
const now = dayjs();
|
||||
const payload = `{"triggerEvent":"MEETING_ENDED"}`;
|
||||
await prismock.webhookScheduledTriggers.createMany({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
subscriberUrl: "https://example.com",
|
||||
startAfter: now.add(5, "minute").toDate(),
|
||||
payload,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
subscriberUrl: "https://example.com/test",
|
||||
startAfter: now.subtract(5, "minute").toDate(),
|
||||
payload,
|
||||
},
|
||||
],
|
||||
});
|
||||
await handleWebhookScheduledTriggers(prismock);
|
||||
|
||||
expectWebhookToHaveBeenCalledWith("https://example.com/test", { triggerEvent: "MEETING_ENDED", payload });
|
||||
expect(() =>
|
||||
expectWebhookToHaveBeenCalledWith("https://example.com", { triggerEvent: "MEETING_ENDED", payload })
|
||||
).toThrow("Webhook not sent to https://example.com for MEETING_ENDED. All webhooks: []");
|
||||
});
|
||||
});
|
||||
111
calcom/packages/features/webhooks/pages/webhook-edit-view.tsx
Normal file
111
calcom/packages/features/webhooks/pages/webhook-edit-view.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Meta, showToast, SkeletonContainer } from "@calcom/ui";
|
||||
|
||||
import { getLayout } from "../../settings/layouts/SettingsLayout";
|
||||
import type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||
import WebhookForm from "../components/WebhookForm";
|
||||
import { subscriberUrlReserved } from "../lib/subscriberUrlReserved";
|
||||
|
||||
const EditWebhook = () => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const id = searchParams?.get("id");
|
||||
|
||||
if (!id) return <SkeletonContainer />;
|
||||
|
||||
// I think we should do SSR for this page
|
||||
return <Component webhookId={id} />;
|
||||
};
|
||||
|
||||
function Component({ webhookId }: { webhookId: string }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const router = useRouter();
|
||||
const { data: installedApps, isPending } = trpc.viewer.integrations.useQuery(
|
||||
{ variant: "other", onlyInstalled: true },
|
||||
{
|
||||
suspense: true,
|
||||
enabled: !!webhookId,
|
||||
}
|
||||
);
|
||||
const { data: webhook } = trpc.viewer.webhook.get.useQuery(
|
||||
{ webhookId },
|
||||
{
|
||||
suspense: true,
|
||||
enabled: !!webhookId,
|
||||
}
|
||||
);
|
||||
const { data: webhooks } = trpc.viewer.webhook.list.useQuery(undefined, {
|
||||
suspense: true,
|
||||
enabled: !!webhookId,
|
||||
});
|
||||
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.webhook.get.invalidate({ webhookId });
|
||||
showToast(t("webhook_updated_successfully"), "success");
|
||||
router.back();
|
||||
},
|
||||
onError(error) {
|
||||
showToast(`${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending || !webhook) return <SkeletonContainer />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={t("edit_webhook")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
borderInShellHeader={true}
|
||||
backButton
|
||||
/>
|
||||
<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,
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
eventTriggers: values.eventTriggers,
|
||||
active: values.active,
|
||||
payloadTemplate: values.payloadTemplate,
|
||||
secret: values.secret,
|
||||
});
|
||||
}}
|
||||
apps={installedApps?.items.map((app) => app.slug)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
EditWebhook.getLayout = getLayout;
|
||||
|
||||
export default EditWebhook;
|
||||
116
calcom/packages/features/webhooks/pages/webhook-new-view.tsx
Normal file
116
calcom/packages/features/webhooks/pages/webhook-new-view.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Meta, showToast, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { getLayout } from "../../settings/layouts/SettingsLayout";
|
||||
import type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||
import WebhookForm from "../components/WebhookForm";
|
||||
import { subscriberUrlReserved } from "../lib/subscriberUrlReserved";
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
const NewWebhookView = () => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
|
||||
const teamId = searchParams?.get("teamId") ? Number(searchParams.get("teamId")) : undefined;
|
||||
const platform = searchParams?.get("platform") ? Boolean(searchParams.get("platform")) : false;
|
||||
|
||||
const { data: installedApps, isPending } = trpc.viewer.integrations.useQuery(
|
||||
{ variant: "other", onlyInstalled: true },
|
||||
{
|
||||
suspense: true,
|
||||
enabled: session.status === "authenticated",
|
||||
}
|
||||
);
|
||||
const { data: webhooks } = trpc.viewer.webhook.list.useQuery(undefined, {
|
||||
suspense: true,
|
||||
enabled: session.status === "authenticated",
|
||||
});
|
||||
|
||||
const createWebhookMutation = trpc.viewer.webhook.create.useMutation({
|
||||
async onSuccess() {
|
||||
showToast(t("webhook_created_successfully"), "success");
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
router.back();
|
||||
},
|
||||
onError(error) {
|
||||
showToast(`${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const onCreateWebhook = async (values: WebhookFormSubmitData) => {
|
||||
if (
|
||||
subscriberUrlReserved({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
id: values.id,
|
||||
webhooks,
|
||||
teamId,
|
||||
userId: session.data?.user.id,
|
||||
platform,
|
||||
})
|
||||
) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!values.payloadTemplate) {
|
||||
values.payloadTemplate = null;
|
||||
}
|
||||
|
||||
createWebhookMutation.mutate({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
eventTriggers: values.eventTriggers,
|
||||
active: values.active,
|
||||
payloadTemplate: values.payloadTemplate,
|
||||
secret: values.secret,
|
||||
teamId,
|
||||
platform,
|
||||
});
|
||||
};
|
||||
|
||||
if (isPending)
|
||||
return (
|
||||
<SkeletonLoader
|
||||
title={t("add_webhook")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={t("add_webhook")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
backButton
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<WebhookForm
|
||||
noRoutingFormTriggers={false}
|
||||
onSubmit={onCreateWebhook}
|
||||
apps={installedApps?.items.map((app) => app.slug)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
NewWebhookView.getLayout = getLayout;
|
||||
|
||||
export default NewWebhookView;
|
||||
184
calcom/packages/features/webhooks/pages/webhooks-view.tsx
Normal file
184
calcom/packages/features/webhooks/pages/webhooks-view.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { WebhooksByViewer } from "@calcom/trpc/server/routers/viewer/webhook/getByViewer.handler";
|
||||
import {
|
||||
Avatar,
|
||||
CreateButtonWithTeamsList,
|
||||
EmptyScreen,
|
||||
Meta,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { getLayout } from "../../settings/layouts/SettingsLayout";
|
||||
import { WebhookListItem } from "../components";
|
||||
|
||||
const SkeletonLoader = ({
|
||||
title,
|
||||
description,
|
||||
borderInShellHeader,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
borderInShellHeader: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={borderInShellHeader} />
|
||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const WebhooksView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;
|
||||
|
||||
const { data, isPending } = trpc.viewer.webhook.getByViewer.useQuery(undefined, {
|
||||
enabled: session.status === "authenticated",
|
||||
});
|
||||
|
||||
const createFunction = (teamId?: number, platform?: boolean) => {
|
||||
if (platform) {
|
||||
router.push(`webhooks/new${platform ? `?platform=${platform}` : ""}`);
|
||||
} else {
|
||||
router.push(`webhooks/new${teamId ? `?teamId=${teamId}` : ""}`);
|
||||
}
|
||||
};
|
||||
if (isPending || !data) {
|
||||
return (
|
||||
<SkeletonLoader
|
||||
title={t("webhooks")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={t("webhooks")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
CTA={
|
||||
data && data.webhookGroups.length > 0 ? (
|
||||
<CreateButtonWithTeamsList
|
||||
color="secondary"
|
||||
subtitle={t("create_for").toUpperCase()}
|
||||
isAdmin={isAdmin}
|
||||
createFunction={createFunction}
|
||||
data-testid="new_webhook"
|
||||
includeOrg={true}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
borderInShellHeader={(data && data.profiles.length === 1) || !data?.webhookGroups?.length}
|
||||
/>
|
||||
<div>
|
||||
<WebhooksList webhooksByViewer={data} isAdmin={isAdmin} createFunction={createFunction} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const WebhooksList = ({
|
||||
webhooksByViewer,
|
||||
isAdmin,
|
||||
createFunction,
|
||||
}: {
|
||||
webhooksByViewer: WebhooksByViewer;
|
||||
isAdmin: boolean;
|
||||
createFunction: (teamId?: number, platform?: boolean) => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const { profiles, webhookGroups } = webhooksByViewer;
|
||||
const bookerUrl = useBookerUrl();
|
||||
|
||||
const hasTeams = profiles && profiles.length > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
{webhookGroups && (
|
||||
<>
|
||||
{!!webhookGroups.length && (
|
||||
<div className={classNames("mt-0", hasTeams && "mt-6")}>
|
||||
{webhookGroups.map((group) => (
|
||||
<div key={group.teamId}>
|
||||
{hasTeams && (
|
||||
<div className="items-centers flex">
|
||||
<Avatar
|
||||
alt={group.profile.image || ""}
|
||||
imageSrc={group.profile.image || `${bookerUrl}/${group.profile.name}/avatar.png`}
|
||||
size="md"
|
||||
className="inline-flex justify-center"
|
||||
/>
|
||||
<div className="text-emphasis ml-2 flex flex-grow items-center font-bold">
|
||||
{group.profile.name || ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col" key={group.profile.slug}>
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle rounded-lg rounded-t-none border border-t-0",
|
||||
hasTeams && "mb-8 mt-3 rounded-t-lg border-t"
|
||||
)}>
|
||||
{group.webhooks.map((webhook, index) => (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
readOnly={group.metadata?.readOnly ?? false}
|
||||
lastItem={group.webhooks.length === index + 1}
|
||||
onEditWebhook={() =>
|
||||
router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id} `)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!webhookGroups.length && (
|
||||
<EmptyScreen
|
||||
Icon="link"
|
||||
headline={t("create_your_first_webhook")}
|
||||
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
|
||||
className="rounded-b-lg rounded-t-none border-t-0"
|
||||
buttonRaw={
|
||||
<CreateButtonWithTeamsList
|
||||
subtitle={t("create_for").toUpperCase()}
|
||||
isAdmin={isAdmin}
|
||||
createFunction={createFunction}
|
||||
data-testid="new_webhook"
|
||||
includeOrg={true}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
WebhooksView.getLayout = getLayout;
|
||||
|
||||
export default WebhooksView;
|
||||
Reference in New Issue
Block a user