From 84efda07e9a4c4150ee06878e056323fd9a90c74 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Tue, 23 May 2023 03:15:29 +0200 Subject: [PATCH] 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 Co-authored-by: alannnc --- ...amWebhooksTab.tsx => EventWebhooksTab.tsx} | 35 ++-- apps/web/pages/api/recorded-daily-video.ts | 17 +- apps/web/pages/event-types/[type]/index.tsx | 4 +- .../manage-booking-questions.e2e.ts | 58 ++++-- apps/web/public/static/locales/en/common.json | 4 +- .../app-store/routing-forms/trpc/utils.ts | 4 +- .../bookings/lib/handleCancelBooking.ts | 3 +- .../bookings/lib/handleConfirmation.ts | 12 +- .../features/bookings/lib/handleNewBooking.ts | 2 + .../webhooks/components/WebhookListItem.tsx | 111 +++++++----- packages/features/webhooks/lib/getWebhooks.ts | 13 +- .../webhooks/lib/subscriberUrlReserved.ts | 37 ++++ .../webhooks/pages/webhook-edit-view.tsx | 15 +- .../webhooks/pages/webhook-new-view.tsx | 23 ++- .../features/webhooks/pages/webhooks-view.tsx | 171 ++++++++++++++---- packages/lib/test/builder.ts | 1 + .../migration.sql | 5 + packages/prisma/schema.prisma | 3 + packages/prisma/zod/webhook.ts | 5 +- .../bookings/requestReschedule.handler.ts | 3 +- .../server/routers/viewer/webhook/_router.tsx | 18 ++ .../routers/viewer/webhook/create.handler.ts | 2 +- .../routers/viewer/webhook/create.schema.ts | 1 + .../routers/viewer/webhook/delete.handler.ts | 51 +++--- .../routers/viewer/webhook/delete.schema.ts | 1 + .../routers/viewer/webhook/edit.handler.ts | 22 +-- .../routers/viewer/webhook/get.handler.ts | 2 + .../viewer/webhook/getByViewer.handler.ts | 116 ++++++++++++ .../viewer/webhook/getByViewer.schema.ts | 1 + .../routers/viewer/webhook/list.handler.ts | 14 +- .../routers/viewer/webhook/list.schema.ts | 2 + .../server/routers/viewer/webhook/types.ts | 2 +- .../server/routers/viewer/webhook/util.ts | 144 ++++++++++++--- 33 files changed, 691 insertions(+), 211 deletions(-) rename apps/web/components/eventtype/{EventTeamWebhooksTab.tsx => EventWebhooksTab.tsx} (90%) create mode 100644 packages/features/webhooks/lib/subscriberUrlReserved.ts create mode 100644 packages/prisma/migrations/20230515121841_add_team_webhooks/migration.sql create mode 100644 packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts create mode 100644 packages/trpc/server/routers/viewer/webhook/getByViewer.schema.ts diff --git a/apps/web/components/eventtype/EventTeamWebhooksTab.tsx b/apps/web/components/eventtype/EventWebhooksTab.tsx similarity index 90% rename from apps/web/components/eventtype/EventTeamWebhooksTab.tsx rename to apps/web/components/eventtype/EventWebhooksTab.tsx index ec1a30ad3d..89a6956765 100644 --- a/apps/web/components/eventtype/EventTeamWebhooksTab.tsx +++ b/apps/web/components/eventtype/EventWebhooksTab.tsx @@ -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) => { +export const EventWebhooksTab = ({ eventType }: Pick) => { const { t } = useLocale(); const utils = trpc.useContext(); @@ -32,12 +29,6 @@ export const EventTeamWebhooksTab = ({ const [editModalOpen, setEditModalOpen] = useState(false); const [webhookToEdit, setWebhookToEdit] = useState(); - 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 (
- {team && webhooks && !isLoading && ( + {webhooks && !isLoading && ( <>
@@ -139,7 +137,7 @@ export const EventTeamWebhooksTab = ({ @@ -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; } diff --git a/apps/web/pages/api/recorded-daily-video.ts b/apps/web/pages/api/recorded-daily-video.ts index 1192727d3a..d1c07c3501 100644 --- a/apps/web/pages/api/recorded-daily-video.ts +++ b/apps/web/pages/api/recorded-daily-video.ts @@ -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; diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index f9dff7ea9e..7c098997c0 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -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: , + webhooks: , } as const; const handleSubmit = async (values: FormValues) => { diff --git a/apps/web/playwright/manage-booking-questions.e2e.ts b/apps/web/playwright/manage-booking-questions.e2e.ts index fb5ebde5ad..db70278def 100644 --- a/apps/web/playwright/manage-booking-questions.e2e.ts +++ b/apps/web/playwright/manage-booking-questions.e2e.ts @@ -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>) { +async function addWebhook( + user?: Awaited>, + 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; } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b57f17a687..3c862b1202 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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" } diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts index 1cb4cb65a0..a3899491b2 100644 --- a/packages/app-store/routing-forms/trpc/utils.ts +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -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); diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 77ef18574d..584bcba1e1 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -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 = { diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index f7b3a87cd8..214f3cf11a 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -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) => { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 733fe573f3..7b92d13c97 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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 { diff --git a/packages/features/webhooks/components/WebhookListItem.tsx b/packages/features/webhooks/components/WebhookListItem.tsx index f95cdd277f..3ba075dd68 100644 --- a/packages/features/webhooks/components/WebhookListItem.tsx +++ b/packages/features/webhooks/components/WebhookListItem.tsx @@ -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" )}>
-

{webhook.subscriberUrl}

+
+

{webhook.subscriberUrl}

+ {!!props.readOnly && ( + + {t("readonly")} + + )} +
{webhook.eventTriggers.map((trigger) => ( @@ -78,49 +94,54 @@ export default function WebhookListItem(props: {
-
- - 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 b2fc512df2..0f8f9b27d2 100644 --- a/packages/features/webhooks/lib/getWebhooks.ts +++ b/packages/features/webhooks/lib/getWebhooks.ts @@ -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: { diff --git a/packages/features/webhooks/lib/subscriberUrlReserved.ts b/packages/features/webhooks/lib/subscriberUrlReserved.ts new file mode 100644 index 0000000000..28990ce93b --- /dev/null +++ b/packages/features/webhooks/lib/subscriberUrlReserved.ts @@ -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); +}; diff --git a/packages/features/webhooks/pages/webhook-edit-view.tsx b/packages/features/webhooks/pages/webhook-edit-view.tsx index 511fc8e438..acd949c909 100644 --- a/packages/features/webhooks/pages/webhook-edit-view.tsx +++ b/packages/features/webhooks/pages/webhook-edit-view.tsx @@ -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 ; return ( @@ -63,7 +60,15 @@ const EditWebhook = () => { { - 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; } diff --git a/packages/features/webhooks/pages/webhook-new-view.tsx b/packages/features/webhooks/pages/webhook-new-view.tsx index 1a0e2cc734..a3723a4f19 100644 --- a/packages/features/webhooks/pages/webhook-new-view.tsx +++ b/packages/features/webhooks/pages/webhook-new-view.tsx @@ -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, }); }; diff --git a/packages/features/webhooks/pages/webhooks-view.tsx b/packages/features/webhooks/pages/webhooks-view.tsx index 575c553250..cfae8c4a3e 100644 --- a/packages/features/webhooks/pages/webhooks-view.tsx +++ b/packages/features/webhooks/pages/webhooks-view.tsx @@ -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 ( <> - + 0 ? : <>} + />
}> - + {data && }
); }; -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 ( + + ); + } return ( - + + + + + + +
{t("create_for").toUpperCase()}
+
+ {profiles.map((profile, idx) => ( + + ( + + )}> + + {profile.name} + + + + ))} +
+
); }; -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 && ( <> -
- {webhooks.map((webhook, index) => ( - router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id} `)} - /> - ))} -
- + {!!webhookGroups.length && ( + <> + {webhookGroups.map((group) => ( +
+ {hasTeams && ( +
+ +
+ {group.profile.name || ""} +
+
+ )} +
+
+ {group.webhooks.map((webhook, index) => ( + + router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id} `) + } + /> + ))} +
+
+
+ ))} + + )} + {!webhookGroups.length && ( + } + /> + )} - ) : ( - } - /> )} ); diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 51656d7d0c..10b2e77a50 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -118,6 +118,7 @@ export const buildWebhook = (webhook?: Partial): Webhook => { secret: faker.lorem.slug(), active: true, eventTriggers: [], + teamId: null, ...webhook, }; }; diff --git a/packages/prisma/migrations/20230515121841_add_team_webhooks/migration.sql b/packages/prisma/migrations/20230515121841_add_team_webhooks/migration.sql new file mode 100644 index 0000000000..62e5562281 --- /dev/null +++ b/packages/prisma/migrations/20230515121841_add_team_webhooks/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d000df881a..000263cb42 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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? diff --git a/packages/prisma/zod/webhook.ts b/packages/prisma/zod/webhook.ts index 5341972772..1be40628c0 100755 --- a/packages/prisma/zod/webhook.ts +++ b/packages/prisma/zod/webhook.ts @@ -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 { user?: CompleteUser | null + team?: CompleteTeam | null eventType?: CompleteEventType | null app?: CompleteApp | null } @@ -29,6 +31,7 @@ export interface CompleteWebhook extends z.infer { */ export const WebhookModel: z.ZodSchema = z.lazy(() => _WebhookModel.extend({ user: UserModel.nullish(), + team: TeamModel.nullish(), eventType: EventTypeModel.nullish(), app: AppModel.nullish(), })) diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index 461a0affa6..7bb707a7da 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -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) => diff --git a/packages/trpc/server/routers/viewer/webhook/_router.tsx b/packages/trpc/server/routers/viewer/webhook/_router.tsx index e4f1bc844a..bb728bd81f 100644 --- a/packages/trpc/server/routers/viewer/webhook/_router.tsx +++ b/packages/trpc/server/routers/viewer/webhook/_router.tsx @@ -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, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/webhook/create.handler.ts b/packages/trpc/server/routers/viewer/webhook/create.handler.ts index 498e032218..03478098d6 100644 --- a/packages/trpc/server/routers/viewer/webhook/create.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/create.handler.ts @@ -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(), diff --git a/packages/trpc/server/routers/viewer/webhook/create.schema.ts b/packages/trpc/server/routers/viewer/webhook/create.schema.ts index 2bef2f8ab2..196c388c79 100644 --- a/packages/trpc/server/routers/viewer/webhook/create.schema.ts +++ b/packages/trpc/server/routers/viewer/webhook/create.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/webhook/delete.handler.ts b/packages/trpc/server/routers/viewer/webhook/delete.handler.ts index 601f619a30..2588a8d091 100644 --- a/packages/trpc/server/routers/viewer/webhook/delete.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/delete.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/webhook/delete.schema.ts b/packages/trpc/server/routers/viewer/webhook/delete.schema.ts index f2f04a402f..08409243fa 100644 --- a/packages/trpc/server/routers/viewer/webhook/delete.schema.ts +++ b/packages/trpc/server/routers/viewer/webhook/delete.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/webhook/edit.handler.ts b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts index 9e1f2f447f..783a1d2c80 100644 --- a/packages/trpc/server/routers/viewer/webhook/edit.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts @@ -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({ diff --git a/packages/trpc/server/routers/viewer/webhook/get.handler.ts b/packages/trpc/server/routers/viewer/webhook/get.handler.ts index 3050e94eca..ba65c7c04b 100644 --- a/packages/trpc/server/routers/viewer/webhook/get.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/get.handler.ts @@ -22,6 +22,8 @@ export const getHandler = async ({ ctx: _ctx, input }: GetOptions) => { active: true, eventTriggers: true, secret: true, + teamId: true, + userId: true, }, }); }; diff --git a/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts new file mode 100644 index 0000000000..54436b6b91 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts @@ -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; + }; +}; + +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, + })), + }; +}; diff --git a/packages/trpc/server/routers/viewer/webhook/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/webhook/getByViewer.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/webhook/getByViewer.schema.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/trpc/server/routers/viewer/webhook/list.handler.ts b/packages/trpc/server/routers/viewer/webhook/list.handler.ts index 3eddf5ffec..77c5c8f644 100644 --- a/packages/trpc/server/routers/viewer/webhook/list.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/list.handler.ts @@ -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) } }], + }); } } diff --git a/packages/trpc/server/routers/viewer/webhook/list.schema.ts b/packages/trpc/server/routers/viewer/webhook/list.schema.ts index 7362b4f566..46787f8487 100644 --- a/packages/trpc/server/routers/viewer/webhook/list.schema.ts +++ b/packages/trpc/server/routers/viewer/webhook/list.schema.ts @@ -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(); diff --git a/packages/trpc/server/routers/viewer/webhook/types.ts b/packages/trpc/server/routers/viewer/webhook/types.ts index 8a57b596b0..562f446504 100644 --- a/packages/trpc/server/routers/viewer/webhook/types.ts +++ b/packages/trpc/server/routers/viewer/webhook/types.ts @@ -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(), }); diff --git a/packages/trpc/server/routers/viewer/webhook/util.ts b/packages/trpc/server/routers/viewer/webhook/util.ts index 9157dfeef4..a898a8d273 100644 --- a/packages/trpc/server/routers/viewer/webhook/util.ts +++ b/packages/trpc/server/routers/viewer/webhook/util.ts @@ -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(); });