2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View 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;

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View 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";

View 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);
}
}

View 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;

View 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 }),
});

View 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;

View File

@@ -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);
}

View File

@@ -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;

View 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;

View 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);
}

View File

@@ -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;

View 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(/&quot;/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;

View File

@@ -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);
};

View File

@@ -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"))
// );
// });
});

View 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: []");
});
});

View 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;

View 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;

View 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;