feat: Webhook support for Managed Events (#17986)

* managed event webhooks

* more fixes

* fix types --WIP

* fix types

* fix test

* add test

* remove remnant comments

* address feedback

* fix test

* fix test

* fix tests

* change of plans \(00)/

* remove console log

* remove unnecessary comments

* revert tests

* list active webhooks in child event types

* add translations

* allow editing own event types

* add locked info for children

* secure create handler

* fix for isLocked check

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Syed Ali ShahbazandCarinaWolli authored and GitHub committed 2024-12-05 23:20:34 +00:00
1 parent 4181b5988a
commit 4bf5e3cdf5
8 files changed
+257 -46

No files matched your search

@@ -185,14 +185,6 @@ export default async function handleChildrenEventTypes({
*/
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
// Reserved for future releases
/*
webhooks: eventType.webhooks && {
createMany: {
data: eventType.webhooks?.map((wh) => ({ ...wh, eventTypeId: undefined })),
},
},*/
},
});
})
@@ -275,23 +267,6 @@ export default async function handleChildrenEventTypes({
})
);
}
// Reserved for future releases
/**
const updatedOldWebhooks = await prisma.webhook.updateMany({
where: {
userId: {
in: oldUserIds,
},
},
data: {
...eventType.webhooks,
},
});
console.log(
"handleChildrenEventTypes:updatedOldWebhooks",
JSON.stringify({ updatedOldWebhooks }, null, 2)
);*/
}
// Old users deleted
@@ -7,8 +7,8 @@ import { useFormContext } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import { WebhookForm } from "@calcom/features/webhooks/components";
import EventTypeWebhookListItem from "@calcom/features/webhooks/components/EventTypeWebhookListItem";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -103,8 +103,9 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
translate: t,
formMethods,
});
const webhookLockedStatus = shouldLockDisableProps("webhooks");
const webhooksDisableProps = shouldLockDisableProps("webhooks", { simple: true });
const lockedText = webhooksDisableProps.isLocked ? "locked" : "unlocked";
const cannotEditWebhooks = isChildrenManagedEventType ? webhooksDisableProps.isLocked : false;
return (
<div>
{webhooks && !isLoading && (
@@ -112,12 +113,31 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
<div>
<div>
<>
{isManagedEventType && (
{(isManagedEventType || isChildrenManagedEventType) && (
<Alert
severity="neutral"
severity={webhooksDisableProps.isLocked ? "neutral" : "green"}
className="mb-2"
title={t("locked_for_members")}
message={t("locked_webhooks_description")}
title={
<Trans
i18nKey={`${lockedText}_${isManagedEventType ? "for_members" : "by_team_admins"}`}>
{lockedText[0].toUpperCase()}
{lockedText.slice(1)} {isManagedEventType ? "for members" : "by team admins"}
</Trans>
}
actions={
<div className="flex h-full items-center">{webhooksDisableProps.LockedIcon}</div>
}
message={
<Trans
i18nKey={`webhooks_${lockedText}_${
isManagedEventType ? "for_members" : "by_team_admins"
}_description`}>
{isManagedEventType ? "Members" : "You"}{" "}
{webhooksDisableProps.isLocked
? "will be able to see the active webhooks but will not be able to edit any webhook settings"
: "will be able to see the active webhooks and will be able to edit any webhook settings"}
</Trans>
}
/>
)}
{webhooks.length ? (
@@ -130,7 +150,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
{t("add_webhook_description", { appName: APP_NAME })}
</p>
</div>
{isChildrenManagedEventType && !isManagedEventType ? (
{cannotEditWebhooks ? (
<Button StartIcon="lock" color="secondary" disabled>
{t("locked_by_team_admin")}
</Button>
@@ -142,15 +162,15 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
<div className="border-subtle my-8 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
<EventTypeWebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
canEditWebhook={!webhookLockedStatus.disabled}
onEditWebhook={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
readOnly={isChildrenManagedEventType && webhook.eventTypeId !== eventType.id}
/>
);
})}
@@ -174,7 +194,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
headline={t("create_your_first_webhook")}
description={t("first_event_type_webhook_description")}
buttonRaw={
isChildrenManagedEventType && !isManagedEventType ? (
cannotEditWebhooks ? (
<Button StartIcon="lock" color="secondary" disabled>
{t("locked_by_team_admin")}
</Button>
@@ -0,0 +1,154 @@
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 EventTypeWebhookListItem(props: {
webhook: WebhookProps;
onEditWebhook: () => void;
lastItem: boolean;
readOnly?: boolean;
}) {
const { t } = useLocale();
const utils = trpc.useUtils();
const { webhook } = props;
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"
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>
);
}
@@ -19,6 +19,20 @@ const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient =
const orgId = options.orgId ?? 0;
const oAuthClientId = options.oAuthClientId ?? "";
const managedChildEventType = await prisma.eventType.findFirst({
where: {
id: eventTypeId,
parentId: {
not: null,
},
},
select: {
parentId: true,
},
});
const managedParentEventTypeId = managedChildEventType?.parentId ?? 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: {
@@ -32,6 +46,9 @@ const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient =
{
eventTypeId,
},
{
eventTypeId: managedParentEventTypeId,
},
{
teamId: {
in: [...teamIds, orgId],
@@ -1,6 +1,5 @@
"use client";
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import type { TFunction } from "next-i18next";
import { useMemo } from "react";
@@ -114,8 +113,8 @@ export const useTabsNavigations = ({
}`,
});
}
const showWebhooks = !(isManagedEventType || isChildrenManagedEventType);
if (showWebhooks) {
const showInstant = !(isManagedEventType || isChildrenManagedEventType);
if (showInstant) {
if (team) {
navigation.push({
name: "instant_tab_title",
@@ -124,13 +123,13 @@ export const useTabsNavigations = ({
info: `instant_event_tab_description`,
});
}
navigation.push({
name: "webhooks",
href: `/event-types/${formMethods.getValues("id")}?tabName=webhooks`,
icon: "webhook",
info: `${activeWebhooksNumber} ${t("active")}`,
});
}
navigation.push({
name: "webhooks",
href: `/event-types/${formMethods.getValues("id")}?tabName=webhooks`,
icon: "webhook",
info: `${activeWebhooksNumber} ${t("active")}`,
});
const hidden = true; // hidden while in alpha trial. you can access it with tabName=ai
if (team && hidden) {
navigation.push({
@@ -4,6 +4,7 @@ import { v4 } from "uuid";
import { updateTriggerForExistingBookings } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { prisma } from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
@@ -33,6 +34,29 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
webhookData.user = { connect: { id: user.id } };
}
if (input.eventTypeId) {
const parentManagedEvt = await prisma.eventType.findFirst({
where: {
id: input.eventTypeId,
parentId: {
not: null,
},
},
select: {
parentId: true,
metadata: true,
},
});
if (parentManagedEvt?.parentId) {
const isLocked = !EventTypeMetaDataSchema.parse(parentManagedEvt.metadata)?.managedEventConfig
?.unlockedFields?.webhooks;
if (isLocked) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
}
let newWebhook: Webhook;
try {
newWebhook = await prisma.webhook.create({
@@ -29,7 +29,25 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
if (Array.isArray(where.AND)) {
if (input?.eventTypeId) {
where.AND?.push({ eventTypeId: input.eventTypeId });
const managedParentEvt = await prisma.eventType.findFirst({
where: {
id: input.eventTypeId,
parentId: {
not: null,
},
},
select: {
parentId: true,
},
});
if (managedParentEvt?.parentId) {
where.AND?.push({
OR: [{ eventTypeId: input.eventTypeId }, { eventTypeId: managedParentEvt.parentId, active: true }],
});
} else {
where.AND?.push({ eventTypeId: input.eventTypeId });
}
} else {
where.AND?.push({
OR: [{ userId: ctx.user.id }, { teamId: { in: user?.teams.map((membership) => membership.teamId) } }],