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 Shahbaz
2024-12-05 23:20:34 +00:00
committed by GitHub
co-authored by CarinaWolli
parent 4181b5988a
commit 4bf5e3cdf5
8 changed files with 257 additions and 46 deletions
@@ -2040,6 +2040,10 @@
"workflows_unlocked_for_members_description": "Members will be able to add their personal workflows to this event type. Members will be able to see the active team workflows but will not be able to edit any workflow settings.",
"workflows_locked_by_team_admins_description": "You will be able to see the active team workflows but will not be able to edit any workflow settings or add your personal workflows to this event type.",
"workflows_unlocked_by_team_admins_description": "You will be able to enable/disable personal workflows on this event type. You will be able to see the active team workflows but will not be able to edit any team workflow settings.",
"webhooks_locked_for_members_description": "Members can not add their personal webhooks to this event type. Members will be able to see the active webhooks but will not be able to edit any webhook settings.",
"webhooks_unlocked_for_members_description": "Members will be able to add their personal webhooks to this event type. Members will be able to see the active webhooks but will not be able to edit any webhook settings.",
"webhooks_locked_by_team_admins_description": "You are able to see the active managed webhooks but will not be able to edit any webhook settings or add your personal webhooks to this event type.",
"webhooks_unlocked_by_team_admins_description": "You can add webhooks to this event type. You are able to see the active managed webhooks but will not be able to edit them.",
"locked_by_team_admin": "Locked by team admin",
"app_not_connected": "You have not connected a {{appName}} account.",
"connect_now": "Connect now",
@@ -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) } }],