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:
co-authored by
CarinaWolli
parent
4181b5988a
commit
4bf5e3cdf5
@@ -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) } }],
|
||||
|
||||
Reference in New Issue
Block a user