Team webhooks (#8917)

* allow event type specific webhooks for all event types

* first version of team webhooks

* add empty view

* design fixes when no teams + invalidate query on delete/update

* linke to new webhooks page with teamId in query

* make one button with dropdown instead of a button for every team

* add subtitle to dropdown

* add avatar fallback

* authorization when editing webhook

* fix event type webhooks

* fix authorization for delete handler

* code clean up

* fix disabled switch

* add migration

* fix subscriberUrlReservered function and fix authorization

* fix type error

* fix type error

* fix switch not updating

* make sure webhooks are triggered for the correct even types

* code clean up

* only show teams were user has write access

* make webhooks read-only for members

* fix comment

* fix type error

* fix webhook tests for team event types

* implement feedback

* code clean up from feedback

* code clean up (feedback)

* throw error if param missing in subscriberUrlReservered

* handle null/undefined values in getWebhooks itself

* better variable naming

* better check if webhook is readonly

* create assertPartOfTeamWithRequiredAccessLevel to remove duplicate code

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
This commit is contained in:
Carina Wollendorfer 2023-05-23 03:15:29 +02:00 committed by GitHub
parent b83ee2d57d
commit 84efda07e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 691 additions and 211 deletions

View File

@ -7,16 +7,13 @@ import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hook
import { WebhookForm } from "@calcom/features/webhooks/components";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
import { APP_NAME } from "@calcom/lib/constants";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
import { Plus, Lock } from "@calcom/ui/components/icon";
export const EventTeamWebhooksTab = ({
eventType,
team,
}: Pick<EventTypeSetupProps, "eventType" | "team">) => {
export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
const { t } = useLocale();
const utils = trpc.useContext();
@ -32,12 +29,6 @@ export const EventTeamWebhooksTab = ({
const [editModalOpen, setEditModalOpen] = useState(false);
const [webhookToEdit, setWebhookToEdit] = useState<Webhook>();
const subscriberUrlReserved = (subscriberUrl: string, id?: string): boolean => {
return !!webhooks?.find(
(webhook) => webhook.subscriberUrl === subscriberUrl && (!id || webhook.id !== id)
);
};
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
async onSuccess() {
setEditModalOpen(false);
@ -61,7 +52,14 @@ export const EventTeamWebhooksTab = ({
});
const onCreateWebhook = async (values: WebhookFormSubmitData) => {
if (subscriberUrlReserved(values.subscriberUrl, values.id)) {
if (
subscriberUrlReserved({
subscriberUrl: values.subscriberUrl,
id: values.id,
webhooks,
eventTypeId: eventType.id,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
@ -102,7 +100,7 @@ export const EventTeamWebhooksTab = ({
return (
<div>
{team && webhooks && !isLoading && (
{webhooks && !isLoading && (
<>
<div>
<div>
@ -139,7 +137,7 @@ export const EventTeamWebhooksTab = ({
<EmptyScreen
Icon={TbWebhook}
headline={t("create_your_first_webhook")}
description={t("create_your_first_team_webhook_description", { appName: APP_NAME })}
description={t("first_event_type_webhook_description")}
buttonRaw={
isChildrenManagedEventType && !isManagedEventType ? (
<Button StartIcon={Lock} color="secondary" disabled>
@ -176,7 +174,14 @@ export const EventTeamWebhooksTab = ({
apps={installedApps?.items.map((app) => app.slug)}
onCancel={() => setEditModalOpen(false)}
onSubmit={(values: WebhookFormSubmitData) => {
if (subscriberUrlReserved(values.subscriberUrl, webhookToEdit?.id || "")) {
if (
subscriberUrlReserved({
subscriberUrl: values.subscriberUrl,
id: values.id,
webhooks,
eventTypeId: eventType.id,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}

View File

@ -33,14 +33,16 @@ const triggerWebhook = async ({
booking: {
userId: number | undefined;
eventTypeId: number | null;
teamId?: number | null;
};
}) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
// Send Webhook call if hooked to BOOKING.RECORDING_READY
const subscriberOptions = {
userId: booking.userId ?? 0,
eventTypeId: booking.eventTypeId ?? 0,
userId: booking.userId,
eventTypeId: booking.eventTypeId,
triggerEvent: eventTrigger,
teamId: booking.teamId,
};
const webhooks = await getWebhooks(subscriberOptions);
@ -87,6 +89,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
location: true,
isRecorded: true,
eventTypeId: true,
eventType: {
select: {
teamId: true,
},
},
user: {
select: {
id: true,
@ -164,7 +171,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
await triggerWebhook({
evt,
downloadLink,
booking: { userId: booking?.user?.id, eventTypeId: booking.eventTypeId },
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
teamId: booking.eventType?.teamId,
},
});
const isSendingEmailsAllowed = IS_SELF_HOSTED || session?.user?.belongsToActiveTeam;

View File

@ -39,8 +39,8 @@ import { EventLimitsTab } from "@components/eventtype/EventLimitsTab";
import { EventRecurringTab } from "@components/eventtype/EventRecurringTab";
import { EventSetupTab } from "@components/eventtype/EventSetupTab";
import { EventTeamTab } from "@components/eventtype/EventTeamTab";
import { EventTeamWebhooksTab } from "@components/eventtype/EventTeamWebhooksTab";
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
import { EventWebhooksTab } from "@components/eventtype/EventWebhooksTab";
import EventWorkflowsTab from "@components/eventtype/EventWorkfowsTab";
import { ssrInit } from "@server/lib/ssr";
@ -309,7 +309,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
workflows={eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow)}
/>
),
webhooks: <EventTeamWebhooksTab eventType={eventType} team={team} />,
webhooks: <EventWebhooksTab eventType={eventType} />,
} as const;
const handleSubmit = async (values: FormValues) => {

View File

@ -52,8 +52,21 @@ test.describe("Manage Booking Questions", () => {
// Considering there are many steps in it, it would need more than default test timeout
test.setTimeout(testInfo.timeout * 3);
const user = await createAndLoginUserWithEventTypes({ users });
const team = await prisma.team.findFirst({
where: {
members: {
some: {
userId: user.id,
},
},
},
select: {
id: true,
},
});
const webhookReceiver = await addWebhook(user);
const teamId = team?.id ?? 0;
const webhookReceiver = await addWebhook(undefined, teamId);
await test.step("Go to First Team Event", async () => {
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
@ -79,7 +92,6 @@ async function runTestStepsCommonForTeamAndUserEventType(
bookerVariant: BookerVariants
) {
await page.click('[href$="tabName=advanced"]');
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
await addQuestionAndSave({
page,
@ -391,19 +403,35 @@ async function saveEventType(page: Page) {
await page.locator("[data-testid=update-eventtype]").click();
}
async function addWebhook(user: Awaited<ReturnType<typeof createAndLoginUserWithEventTypes>>) {
async function addWebhook(
user?: Awaited<ReturnType<typeof createAndLoginUserWithEventTypes>>,
teamId?: number | null
) {
const webhookReceiver = createHttpServer();
await prisma.webhook.create({
data: {
id: uuid(),
userId: user.id,
subscriberUrl: webhookReceiver.url,
eventTriggers: [
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
],
},
});
const data: {
id: string;
subscriberUrl: string;
eventTriggers: WebhookTriggerEvents[];
userId?: number;
teamId?: number;
} = {
id: uuid(),
subscriberUrl: webhookReceiver.url,
eventTriggers: [
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
],
};
if (teamId) {
data.teamId = teamId;
} else if (user) {
data.userId = user.id;
}
await prisma.webhook.create({ data });
return webhookReceiver;
}

View File

@ -1824,5 +1824,7 @@
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
"disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.",
"disable_host_confirmation_emails": "Disable default confirmation emails for host",
"disable_host_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the host when the event is booked."
"disable_host_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the host when the event is booked.",
"first_event_type_webhook_description": "Create your first webhook for this event type",
"create_for": "Create for"
}

View File

@ -25,9 +25,9 @@ export async function onFormSubmission(
const subscriberOptions = {
userId: form.user.id,
// It isn't an eventType webhook
eventTypeId: -1,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
// When team routing forms are implemented, we need to make sure to add the teamId here
teamId: null,
};
const webhooks = await getWebhooks(subscriberOptions);

View File

@ -306,8 +306,9 @@ async function handler(req: CustomRequest) {
// Send Webhook call if hooked to BOOKING.CANCELLED
const subscriberOptions = {
userId: bookingToDelete.userId,
eventTypeId: (bookingToDelete.eventTypeId as number) || 0,
eventTypeId: bookingToDelete.eventTypeId as number,
triggerEvent: eventTrigger,
teamId: bookingToDelete.eventType?.teamId,
};
const eventTypeInfo: EventTypeInfo = {

View File

@ -30,6 +30,7 @@ export async function handleConfirmation(args: {
price: number;
requiresConfirmation: boolean;
title: string;
teamId?: number | null;
} | null;
eventTypeId: number | null;
smsReminderNumber: string | null;
@ -235,16 +236,17 @@ export async function handleConfirmation(args: {
}
try {
// schedule job for zapier trigger 'when meeting ends'
const subscribersBookingCreated = await getWebhooks({
userId: booking.userId || 0,
eventTypeId: booking.eventTypeId || 0,
userId: booking.userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
teamId: booking.eventType?.teamId,
});
const subscribersMeetingEnded = await getWebhooks({
userId: booking.userId || 0,
eventTypeId: booking.eventTypeId || 0,
userId: booking.userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId: booking.eventType?.teamId,
});
subscribersMeetingEnded.forEach((subscriber) => {

View File

@ -2097,12 +2097,14 @@ async function handler(
userId: organizerUser.id,
eventTypeId,
triggerEvent: eventTrigger,
teamId: eventType.team?.id,
};
const subscriberOptionsMeetingEnded = {
userId: organizerUser.id,
eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId: eventType.team?.id,
};
try {

View File

@ -25,6 +25,7 @@ type WebhookProps = {
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
eventTypeId: number | null;
teamId: number | null;
};
export default function WebhookListItem(props: {
@ -32,19 +33,23 @@ export default function WebhookListItem(props: {
canEditWebhook?: boolean;
onEditWebhook: () => void;
lastItem: boolean;
readOnly?: boolean;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const { webhook } = props;
const canEditWebhook = props.canEditWebhook ?? true;
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
async onSuccess() {
await utils.viewer.webhook.getByViewer.invalidate();
await utils.viewer.webhook.list.invalidate();
showToast(t("webhook_removed_successfully"), "success");
},
});
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
async onSuccess(data) {
console.log("data", data);
await utils.viewer.webhook.getByViewer.invalidate();
await utils.viewer.webhook.list.invalidate();
// TODO: Better success message
showToast(t(data?.active ? "enabled" : "disabled"), "success");
@ -53,7 +58,11 @@ export default function WebhookListItem(props: {
const onDeleteWebhook = () => {
// TODO: Confimation dialog before deleting
deleteWebhook.mutate({ id: webhook.id, eventTypeId: webhook.eventTypeId || undefined });
deleteWebhook.mutate({
id: webhook.id,
eventTypeId: webhook.eventTypeId || undefined,
teamId: webhook.teamId || undefined,
});
};
return (
@ -63,7 +72,14 @@ export default function WebhookListItem(props: {
props.lastItem ? "" : "border-subtle border-b"
)}>
<div className="w-full truncate">
<p className="text-emphasis truncate text-sm font-medium">{webhook.subscriberUrl}</p>
<div className="flex">
<p className="text-emphasis truncate text-sm font-medium">{webhook.subscriberUrl}</p>
{!!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) => (
@ -78,49 +94,54 @@ export default function WebhookListItem(props: {
</div>
</Tooltip>
</div>
<div className="ml-2 flex items-center space-x-4">
<Switch
defaultChecked={webhook.active}
disabled={!props.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}>
{t("edit")}
</Button>
<Button
className="hidden lg:flex"
color="destructive"
StartIcon={Trash}
variant="icon"
onClick={onDeleteWebhook}
/>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button className="lg:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem StartIcon={Edit} color="secondary" onClick={props.onEditWebhook}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
{!props.readOnly && (
<div className="ml-2 flex items-center space-x-4">
<Switch
defaultChecked={webhook.active}
disabled={!canEditWebhook}
onCheckedChange={() =>
toggleWebhook.mutate({
id: webhook.id,
active: !webhook.active,
payloadTemplate: webhook.payloadTemplate,
eventTypeId: webhook.eventTypeId || undefined,
})
}
/>
<DropdownMenuItem>
<DropdownItem StartIcon={Trash} color="destructive" onClick={onDeleteWebhook}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
<Button className="hidden lg:flex" color="secondary" onClick={props.onEditWebhook}>
{t("edit")}
</Button>
<Button
className="hidden lg:flex"
color="destructive"
StartIcon={Trash}
variant="icon"
onClick={onDeleteWebhook}
/>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button className="lg:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem StartIcon={Edit} 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

@ -4,13 +4,17 @@ import defaultPrisma from "@calcom/prisma";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
export type GetSubscriberOptions = {
userId: number;
eventTypeId: number;
userId?: number | null;
eventTypeId?: number | null;
triggerEvent: WebhookTriggerEvents;
teamId?: number | null;
};
const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient = defaultPrisma) => {
const { userId, eventTypeId } = options;
const userId = options.teamId ? 0 : options.userId ?? 0;
const eventTypeId = options.eventTypeId ?? 0;
const teamId = options.teamId ?? 0;
const allWebhooks = await prisma.webhook.findMany({
where: {
OR: [
@ -20,6 +24,9 @@ const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient =
{
eventTypeId,
},
{
teamId,
},
],
AND: {
eventTriggers: {

View File

@ -0,0 +1,37 @@
import type { Webhook } from "@calcom/prisma/client";
interface Params {
subscriberUrl: string;
id?: string;
webhooks?: Webhook[];
teamId?: number;
userId?: number;
eventTypeId?: number;
}
export const subscriberUrlReserved = ({
subscriberUrl,
id,
webhooks,
teamId,
userId,
eventTypeId,
}: Params): boolean => {
if (!teamId && !userId && !eventTypeId) {
throw new Error("Either teamId, userId, or eventTypeId 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);
}
return findMatchingWebhook((webhook: Webhook) => webhook.userId === userId);
};

View File

@ -9,6 +9,7 @@ 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 querySchema = z.object({ id: z.string() });
@ -47,10 +48,6 @@ const EditWebhook = () => {
},
});
const subscriberUrlReserved = (subscriberUrl: string, id: string): boolean => {
return !!webhooks?.find((webhook) => webhook.subscriberUrl === subscriberUrl && webhook.id !== id);
};
if (isLoading || !webhook) return <SkeletonContainer />;
return (
@ -63,7 +60,15 @@ const EditWebhook = () => {
<WebhookForm
webhook={webhook}
onSubmit={(values: WebhookFormSubmitData) => {
if (subscriberUrlReserved(values.subscriberUrl, webhook.id)) {
if (
subscriberUrlReserved({
subscriberUrl: values.subscriberUrl,
id: webhook.id,
webhooks,
teamId: webhook.teamId ?? undefined,
userId: webhook.userId ?? undefined,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}

View File

@ -1,3 +1,4 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { APP_NAME } from "@calcom/lib/constants";
@ -8,11 +9,16 @@ 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 NewWebhookView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const router = useRouter();
const session = useSession();
const teamId = router.query.teamId ? +router.query.teamId : undefined;
const { data: installedApps, isLoading } = trpc.viewer.integrations.useQuery(
{ variant: "other", onlyInstalled: true },
{
@ -36,14 +42,16 @@ const NewWebhookView = () => {
},
});
const subscriberUrlReserved = (subscriberUrl: string, id?: string): boolean => {
return !!webhooks?.find(
(webhook) => webhook.subscriberUrl === subscriberUrl && (!id || webhook.id !== id)
);
};
const onCreateWebhook = async (values: WebhookFormSubmitData) => {
if (subscriberUrlReserved(values.subscriberUrl, values.id)) {
if (
subscriberUrlReserved({
subscriberUrl: values.subscriberUrl,
id: values.id,
webhooks,
teamId,
userId: session.data?.user.id,
})
) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
@ -58,6 +66,7 @@ const NewWebhookView = () => {
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
teamId,
});
};

View File

@ -1,10 +1,24 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { Suspense } from "react";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, EmptyScreen, Meta, SkeletonText } from "@calcom/ui";
import type { WebhooksByViewer } from "@calcom/trpc/server/routers/viewer/webhook/getByViewer.handler";
import {
Button,
Meta,
SkeletonText,
EmptyScreen,
Dropdown,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuLabel,
} from "@calcom/ui";
import { Avatar } from "@calcom/ui";
import { Plus, Link as LinkIcon } from "@calcom/ui/components/icon";
import { getLayout } from "../../settings/layouts/SettingsLayout";
@ -12,63 +26,150 @@ import { WebhookListItem, WebhookListSkeleton } from "../components";
const WebhooksView = () => {
const { t } = useLocale();
const router = useRouter();
const { data } = trpc.viewer.webhook.getByViewer.useQuery(undefined, {
suspense: true,
enabled: router.isReady,
});
const profiles = data?.profiles.filter((profile) => !profile.readOnly);
return (
<>
<Meta title="Webhooks" description={t("webhooks_description", { appName: APP_NAME })} />
<Meta
title="Webhooks"
description={t("webhooks_description", { appName: APP_NAME })}
CTA={data && data.webhookGroups.length > 0 ? <NewWebhookButton profiles={profiles} /> : <></>}
/>
<div>
<Suspense fallback={<WebhookListSkeleton />}>
<WebhooksList />
{data && <WebhooksList webhooksByViewer={data} />}
</Suspense>
</div>
</>
);
};
const NewWebhookButton = () => {
const NewWebhookButton = ({
teamId,
profiles,
}: {
teamId?: number | null;
profiles?: {
readOnly?: boolean | undefined;
slug: string | null;
name: string | null;
image?: string | undefined;
teamId: number | null | undefined;
}[];
}) => {
const { t, isLocaleReady } = useLocale();
const url = new URL(`${WEBAPP_URL}/settings/developer/webhooks/new`);
if (!!teamId) {
url.searchParams.set("teamId", `${teamId}`);
}
const href = url.href;
if (!profiles || profiles.length < 2) {
return (
<Button color="primary" data-testid="new_webhook" StartIcon={Plus} href={href}>
{isLocaleReady ? t("new") : <SkeletonText className="h-4 w-24" />}
</Button>
);
}
return (
<Button
color="secondary"
data-testid="new_webhook"
StartIcon={Plus}
href={`${WEBAPP_URL}/settings/developer/webhooks/new`}>
{isLocaleReady ? t("new_webhook") : <SkeletonText className="h-4 w-24" />}
</Button>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button color="primary" StartIcon={Plus}>
{isLocaleReady ? t("new") : <SkeletonText className="h-4 w-24" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={14} align="end">
<DropdownMenuLabel>
<div className="text-xs">{t("create_for").toUpperCase()}</div>
</DropdownMenuLabel>
{profiles.map((profile, idx) => (
<DropdownMenuItem key={profile.slug}>
<DropdownItem
type="button"
StartIcon={(props) => (
<Avatar
alt={profile.slug || ""}
imageSrc={profile.image || `${WEBAPP_URL}/${profile.name}/avatar.png`}
size="sm"
{...props}
/>
)}>
<Link href={`webhooks/new${profile.teamId ? `?teamId=${profile.teamId}` : ""}`}>
{profile.name}
</Link>
</DropdownItem>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
);
};
const WebhooksList = () => {
const WebhooksList = ({ webhooksByViewer }: { webhooksByViewer: WebhooksByViewer }) => {
const { t } = useLocale();
const router = useRouter();
const { data: webhooks } = trpc.viewer.webhook.list.useQuery(undefined, {
suspense: true,
enabled: router.isReady,
});
const { profiles, webhookGroups } = webhooksByViewer;
const hasTeams = profiles && profiles.length > 1;
return (
<>
{webhooks?.length ? (
{webhookGroups && (
<>
<div className="border-subtle mt-6 mb-8 rounded-md border">
{webhooks.map((webhook, index) => (
<WebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
onEditWebhook={() => router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id} `)}
/>
))}
</div>
<NewWebhookButton />
{!!webhookGroups.length && (
<>
{webhookGroups.map((group) => (
<div key={group.teamId}>
{hasTeams && (
<div className="items-centers flex ">
<Avatar
alt={group.profile.image || ""}
imageSrc={group.profile.image || `${WEBAPP_URL}/${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="border-subtle mt-3 mb-8 rounded-md border">
{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>
))}
</>
)}
{!webhookGroups.length && (
<EmptyScreen
Icon={LinkIcon}
headline={t("create_your_first_webhook")}
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
buttonRaw={<NewWebhookButton profiles={profiles} />}
/>
)}
</>
) : (
<EmptyScreen
Icon={LinkIcon}
headline={t("create_your_first_webhook")}
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
buttonRaw={<NewWebhookButton />}
/>
)}
</>
);

View File

@ -118,6 +118,7 @@ export const buildWebhook = (webhook?: Partial<Webhook>): Webhook => {
secret: faker.lorem.slug(),
active: true,
eventTriggers: [],
teamId: null,
...webhook,
};
};

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "teamId" INTEGER;
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -255,6 +255,7 @@ model Team {
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
verifiedNumbers VerifiedNumber[]
webhooks Webhook[]
}
enum MembershipRole {
@ -503,6 +504,7 @@ enum WebhookTriggerEvents {
model Webhook {
id String @id @unique
userId Int?
teamId Int?
eventTypeId Int?
/// @zod.url()
subscriberUrl String
@ -511,6 +513,7 @@ model Webhook {
active Boolean @default(true)
eventTriggers WebhookTriggerEvents[]
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
appId String?

View File

@ -1,11 +1,12 @@
import * as z from "zod"
import * as imports from "../zod-utils"
import { WebhookTriggerEvents } from "@prisma/client"
import { CompleteUser, UserModel, CompleteEventType, EventTypeModel, CompleteApp, AppModel } from "./index"
import { CompleteUser, UserModel, CompleteTeam, TeamModel, CompleteEventType, EventTypeModel, CompleteApp, AppModel } from "./index"
export const _WebhookModel = z.object({
id: z.string(),
userId: z.number().int().nullish(),
teamId: z.number().int().nullish(),
eventTypeId: z.number().int().nullish(),
subscriberUrl: z.string().url(),
payloadTemplate: z.string().nullish(),
@ -18,6 +19,7 @@ export const _WebhookModel = z.object({
export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
user?: CompleteUser | null
team?: CompleteTeam | null
eventType?: CompleteEventType | null
app?: CompleteApp | null
}
@ -29,6 +31,7 @@ export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
*/
export const WebhookModel: z.ZodSchema<CompleteWebhook> = z.lazy(() => _WebhookModel.extend({
user: UserModel.nullish(),
team: TeamModel.nullish(),
eventType: EventTypeModel.nullish(),
app: AppModel.nullish(),
}))

View File

@ -238,8 +238,9 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
// Send Webhook call if hooked to BOOKING.CANCELLED
const subscriberOptions = {
userId: bookingToReschedule.userId,
eventTypeId: (bookingToReschedule.eventTypeId as number) || 0,
eventTypeId: bookingToReschedule.eventTypeId as number,
triggerEvent: eventTrigger,
teamId: bookingToReschedule.eventType?.teamId,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) =>

View File

@ -14,6 +14,7 @@ type WebhookRouterHandlerCache = {
edit?: typeof import("./edit.handler").editHandler;
delete?: typeof import("./delete.handler").deleteHandler;
testTrigger?: typeof import("./testTrigger.handler").testTriggerHandler;
getByViewer?: typeof import("./getByViewer.handler").getByViewerHandler;
};
const UNSTABLE_HANDLER_CACHE: WebhookRouterHandlerCache = {};
@ -116,4 +117,21 @@ export const webhookRouter = router({
input,
});
}),
getByViewer: webhookProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
(mod) => mod.getByViewerHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getByViewer({
ctx,
});
}),
});

View File

@ -13,7 +13,7 @@ type CreateOptions = {
};
export const createHandler = async ({ ctx, input }: CreateOptions) => {
if (input.eventTypeId) {
if (input.eventTypeId || input.teamId) {
return await prisma.webhook.create({
data: {
id: v4(),

View File

@ -12,6 +12,7 @@ export const ZCreateInputSchema = webhookIdAndEventTypeIdSchema.extend({
eventTypeId: z.number().optional(),
appId: z.string().optional().nullable(),
secret: z.string().optional().nullable(),
teamId: z.number().optional(),
});
export type TCreateInputSchema = z.infer<typeof ZCreateInputSchema>;

View File

@ -12,31 +12,32 @@ type DeleteOptions = {
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
const { id } = input;
input.eventTypeId
? await prisma.eventType.update({
where: {
id: input.eventTypeId,
},
data: {
webhooks: {
delete: {
id,
},
},
},
})
: await prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
webhooks: {
delete: {
id,
},
},
},
});
const andCondition: Partial<{ id: string; eventTypeId: number; teamId: number; userId: number }>[] = [
{ id: id },
];
if (input.eventTypeId) {
andCondition.push({ eventTypeId: input.eventTypeId });
} else if (input.teamId) {
andCondition.push({ teamId: input.teamId });
} else {
andCondition.push({ userId: ctx.user.id });
}
const webhookToDelete = await prisma.webhook.findFirst({
where: {
AND: andCondition,
},
});
if (webhookToDelete) {
await prisma.webhook.delete({
where: {
id: webhookToDelete.id,
},
});
}
return {
id,

View File

@ -5,6 +5,7 @@ import { webhookIdAndEventTypeIdSchema } from "./types";
export const ZDeleteInputSchema = webhookIdAndEventTypeIdSchema.extend({
id: z.string(),
eventTypeId: z.number().optional(),
teamId: z.number().optional(),
});
export type TDeleteInputSchema = z.infer<typeof ZDeleteInputSchema>;

View File

@ -12,22 +12,14 @@ type EditOptions = {
export const editHandler = async ({ ctx, input }: EditOptions) => {
const { id, ...data } = input;
const webhook = input.eventTypeId
? await prisma.webhook.findFirst({
where: {
eventTypeId: input.eventTypeId,
id,
},
})
: await prisma.webhook.findFirst({
where: {
userId: ctx.user.id,
id,
},
});
const webhook = await prisma.webhook.findFirst({
where: {
id,
},
});
if (!webhook) {
// user does not own this webhook
// team event doesn't own this webhook
return null;
}
return await prisma.webhook.update({

View File

@ -22,6 +22,8 @@ export const getHandler = async ({ ctx: _ctx, input }: GetOptions) => {
active: true,
eventTriggers: true,
secret: true,
teamId: true,
userId: true,
},
});
};

View File

@ -0,0 +1,116 @@
import { CAL_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import type { Webhook } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
type GetByViewerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
type WebhookGroup = {
teamId?: number | null;
profile: {
slug: string | null;
name: string | null;
image?: string;
};
metadata?: {
readOnly: boolean;
};
webhooks: Webhook[];
};
export type WebhooksByViewer = {
webhookGroups: WebhookGroup[];
profiles: {
readOnly?: boolean | undefined;
slug: string | null;
name: string | null;
image?: string | undefined;
teamId: number | null | undefined;
}[];
};
export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
username: true,
avatar: true,
name: true,
webhooks: true,
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
name: true,
slug: true,
members: {
select: {
userId: true,
},
},
webhooks: true,
},
},
},
},
},
});
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const userWebhooks = user.webhooks;
let webhookGroups: WebhookGroup[] = [];
webhookGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
image: user.avatar || undefined,
},
webhooks: userWebhooks,
metadata: {
readOnly: false,
},
});
const teamWebhookGroups: WebhookGroup[] = user.teams.map((membership) => ({
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: "team/" + membership.team.slug,
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
},
metadata: {
readOnly: membership.role !== MembershipRole.ADMIN && membership.role !== MembershipRole.OWNER,
},
webhooks: membership.team.webhooks,
}));
webhookGroups = webhookGroups.concat(teamWebhookGroups);
return {
webhookGroups: webhookGroups.filter((groupBy) => !!groupBy.webhooks?.length),
profiles: webhookGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,
})),
};
};

View File

@ -0,0 +1 @@
export {};

View File

@ -17,11 +17,23 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
/* Don't mixup zapier webhooks with normal ones */
AND: [{ appId: !input?.appId ? null : input.appId }],
};
const user = await prisma.user.findFirst({
where: {
id: ctx.user.id,
},
select: {
teams: true,
},
});
if (Array.isArray(where.AND)) {
if (input?.eventTypeId) {
where.AND?.push({ eventTypeId: input.eventTypeId });
} else {
where.AND?.push({ userId: ctx.user.id });
where.AND?.push({
OR: [{ userId: ctx.user.id }, { teamId: { in: user?.teams.map((membership) => membership.teamId) } }],
});
}
}

View File

@ -5,6 +5,8 @@ import { webhookIdAndEventTypeIdSchema } from "./types";
export const ZListInputSchema = webhookIdAndEventTypeIdSchema
.extend({
appId: z.string().optional(),
teamId: z.number().optional(),
eventTypeId: z.number().optional(),
})
.optional();

View File

@ -4,6 +4,6 @@ import { z } from "zod";
export const webhookIdAndEventTypeIdSchema = z.object({
// Webhook ID
id: z.string().optional(),
// Event type ID
eventTypeId: z.number().optional(),
teamId: z.number().optional(),
});

View File

@ -1,4 +1,7 @@
import type { Membership } from "@prisma/client";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@ -10,41 +13,128 @@ export const webhookProcedure = authedProcedure
.use(async ({ ctx, input, next }) => {
// Endpoints that just read the logged in user's data - like 'list' don't necessary have any input
if (!input) return next();
const { eventTypeId, id } = input;
const { id, teamId, eventTypeId } = input;
// A webhook is either linked to Event Type or to a user.
if (eventTypeId) {
const team = await prisma.team.findFirst({
where: {
eventTypes: {
some: {
id: eventTypeId,
},
},
},
include: {
members: true,
},
});
// Team should be available and the user should be a member of the team
if (!team?.members.some((membership) => membership.userId === ctx.user.id)) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
const assertPartOfTeamWithRequiredAccessLevel = (memberships?: Membership[], teamId?: number) => {
if (!memberships) return false;
if (teamId) {
return memberships.some(
(membership) =>
membership.teamId === teamId &&
(membership.role === MembershipRole.ADMIN || membership.role === MembershipRole.OWNER)
);
}
} else if (id) {
const authorizedHook = await prisma.webhook.findFirst({
return memberships.some(
(membership) =>
membership.userId === ctx.user.id &&
(membership.role === MembershipRole.ADMIN || membership.role === MembershipRole.OWNER)
);
};
if (id) {
//check if user is authorized to edit webhook
const webhook = await prisma.webhook.findFirst({
where: {
id: id,
userId: ctx.user.id,
},
include: {
user: true,
team: true,
eventType: true,
},
});
if (!authorizedHook) {
throw new TRPCError({
code: "UNAUTHORIZED",
if (webhook) {
if (webhook.teamId) {
const user = await prisma.user.findFirst({
where: {
id: ctx.user.id,
},
include: {
teams: true,
},
});
const userHasAdminOwnerPermissionInTeam =
user &&
user.teams.some(
(membership) =>
membership.teamId === webhook.teamId &&
(membership.role === MembershipRole.ADMIN || membership.role === MembershipRole.OWNER)
);
if (!userHasAdminOwnerPermissionInTeam) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
} else if (webhook.eventTypeId) {
const eventType = await prisma.eventType.findFirst({
where: {
id: webhook.eventTypeId,
},
include: {
team: {
include: {
members: true,
},
},
},
});
if (eventType && eventType.userId !== ctx.user.id) {
if (!assertPartOfTeamWithRequiredAccessLevel(eventType.team?.members)) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
}
} else if (webhook.userId && webhook.userId !== ctx.user.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
}
} else {
//check if user is authorized to create webhook on event type or team
if (teamId) {
const user = await prisma.user.findFirst({
where: {
id: ctx.user.id,
},
include: {
teams: true,
},
});
if (!assertPartOfTeamWithRequiredAccessLevel(user?.teams, teamId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
} else if (eventTypeId) {
const eventType = await prisma.eventType.findFirst({
where: {
id: eventTypeId,
},
include: {
team: {
include: {
members: true,
},
},
},
});
if (eventType && eventType.userId !== ctx.user.id) {
if (!assertPartOfTeamWithRequiredAccessLevel(eventType.team?.members)) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
}
}
}
return next();
});