From 4bf5e3cdf59cdc783b79c636ff6fafb7c5cd10fc Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Fri, 6 Dec 2024 03:20:34 +0400 Subject: [PATCH] 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 --- apps/web/public/static/locales/en/common.json | 4 + .../lib/handleChildrenEventTypes.ts | 25 --- .../tabs/webhooks/EventWebhooksTab.tsx | 42 +++-- .../components/EventTypeWebhookListItem.tsx | 154 ++++++++++++++++++ packages/features/webhooks/lib/getWebhooks.ts | 17 ++ .../event-types/hooks/useTabsNavigations.tsx | 17 +- .../routers/viewer/webhook/create.handler.ts | 24 +++ .../routers/viewer/webhook/list.handler.ts | 20 ++- 8 files changed, 257 insertions(+), 46 deletions(-) create mode 100644 packages/features/webhooks/components/EventTypeWebhookListItem.tsx diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a8400f7bbc..d5b0bfe5b3 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index 56f5bd9776..66a7f413df 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -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 diff --git a/packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx b/packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx index 0c62c2c09b..c9b11cab04 100644 --- a/packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx +++ b/packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx @@ -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 {webhooks && !isLoading && ( @@ -112,12 +113,31 @@ export const EventWebhooksTab = ({ eventType }: Pick
<> - {isManagedEventType && ( + {(isManagedEventType || isChildrenManagedEventType) && ( + {lockedText[0].toUpperCase()} + {lockedText.slice(1)} {isManagedEventType ? "for members" : "by team admins"} + + } + actions={ +
{webhooksDisableProps.LockedIcon}
+ } + message={ + + {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"} + + } /> )} {webhooks.length ? ( @@ -130,7 +150,7 @@ export const EventWebhooksTab = ({ eventType }: Pick
- {isChildrenManagedEventType && !isManagedEventType ? ( + {cannotEditWebhooks ? ( @@ -142,15 +162,15 @@ export const EventWebhooksTab = ({ eventType }: Pick {webhooks.map((webhook, index) => { return ( - { setEditModalOpen(true); setWebhookToEdit(webhook); }} + readOnly={isChildrenManagedEventType && webhook.eventTypeId !== eventType.id} /> ); })} @@ -174,7 +194,7 @@ export const EventWebhooksTab = ({ eventType }: Pick {t("locked_by_team_admin")} diff --git a/packages/features/webhooks/components/EventTypeWebhookListItem.tsx b/packages/features/webhooks/components/EventTypeWebhookListItem.tsx new file mode 100644 index 0000000000..c34a6e90b3 --- /dev/null +++ b/packages/features/webhooks/components/EventTypeWebhookListItem.tsx @@ -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 ( +
+
+
+ +

+ {webhook.subscriberUrl} +

+
+ {!!props.readOnly && ( + + {t("readonly")} + + )} +
+ +
+ {webhook.eventTriggers.map((trigger) => ( + + {t(`${trigger.toLowerCase()}`)} + + ))} +
+
+
+ {!props.readOnly && ( +
+ + toggleWebhook.mutate({ + id: webhook.id, + active: !webhook.active, + payloadTemplate: webhook.payloadTemplate, + eventTypeId: webhook.eventTypeId || undefined, + }) + } + /> + + + +
+ )} +
+ ); +} diff --git a/packages/features/webhooks/lib/getWebhooks.ts b/packages/features/webhooks/lib/getWebhooks.ts index a31d9ba88c..ca5dc825aa 100644 --- a/packages/features/webhooks/lib/getWebhooks.ts +++ b/packages/features/webhooks/lib/getWebhooks.ts @@ -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], diff --git a/packages/platform/atoms/event-types/hooks/useTabsNavigations.tsx b/packages/platform/atoms/event-types/hooks/useTabsNavigations.tsx index bd7e3d66f9..c70406965e 100644 --- a/packages/platform/atoms/event-types/hooks/useTabsNavigations.tsx +++ b/packages/platform/atoms/event-types/hooks/useTabsNavigations.tsx @@ -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({ diff --git a/packages/trpc/server/routers/viewer/webhook/create.handler.ts b/packages/trpc/server/routers/viewer/webhook/create.handler.ts index 7d410a63bc..f5cddd115e 100644 --- a/packages/trpc/server/routers/viewer/webhook/create.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/create.handler.ts @@ -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({ diff --git a/packages/trpc/server/routers/viewer/webhook/list.handler.ts b/packages/trpc/server/routers/viewer/webhook/list.handler.ts index aec40a20b3..468c19e999 100644 --- a/packages/trpc/server/routers/viewer/webhook/list.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/list.handler.ts @@ -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) } }],