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:
parent
b83ee2d57d
commit
84efda07e9
|
@ -7,16 +7,13 @@ import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hook
|
||||||
import { WebhookForm } from "@calcom/features/webhooks/components";
|
import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||||
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||||
import { Plus, Lock } from "@calcom/ui/components/icon";
|
import { Plus, Lock } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
export const EventTeamWebhooksTab = ({
|
export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||||
eventType,
|
|
||||||
team,
|
|
||||||
}: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
@ -32,12 +29,6 @@ export const EventTeamWebhooksTab = ({
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
const [webhookToEdit, setWebhookToEdit] = useState<Webhook>();
|
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({
|
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
|
@ -61,7 +52,14 @@ export const EventTeamWebhooksTab = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const onCreateWebhook = async (values: WebhookFormSubmitData) => {
|
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");
|
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -102,7 +100,7 @@ export const EventTeamWebhooksTab = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{team && webhooks && !isLoading && (
|
{webhooks && !isLoading && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -139,7 +137,7 @@ export const EventTeamWebhooksTab = ({
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
Icon={TbWebhook}
|
Icon={TbWebhook}
|
||||||
headline={t("create_your_first_webhook")}
|
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={
|
buttonRaw={
|
||||||
isChildrenManagedEventType && !isManagedEventType ? (
|
isChildrenManagedEventType && !isManagedEventType ? (
|
||||||
<Button StartIcon={Lock} color="secondary" disabled>
|
<Button StartIcon={Lock} color="secondary" disabled>
|
||||||
|
@ -176,7 +174,14 @@ export const EventTeamWebhooksTab = ({
|
||||||
apps={installedApps?.items.map((app) => app.slug)}
|
apps={installedApps?.items.map((app) => app.slug)}
|
||||||
onCancel={() => setEditModalOpen(false)}
|
onCancel={() => setEditModalOpen(false)}
|
||||||
onSubmit={(values: WebhookFormSubmitData) => {
|
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");
|
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
|
@ -33,14 +33,16 @@ const triggerWebhook = async ({
|
||||||
booking: {
|
booking: {
|
||||||
userId: number | undefined;
|
userId: number | undefined;
|
||||||
eventTypeId: number | null;
|
eventTypeId: number | null;
|
||||||
|
teamId?: number | null;
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
|
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
|
||||||
// Send Webhook call if hooked to BOOKING.RECORDING_READY
|
// Send Webhook call if hooked to BOOKING.RECORDING_READY
|
||||||
const subscriberOptions = {
|
const subscriberOptions = {
|
||||||
userId: booking.userId ?? 0,
|
userId: booking.userId,
|
||||||
eventTypeId: booking.eventTypeId ?? 0,
|
eventTypeId: booking.eventTypeId,
|
||||||
triggerEvent: eventTrigger,
|
triggerEvent: eventTrigger,
|
||||||
|
teamId: booking.teamId,
|
||||||
};
|
};
|
||||||
const webhooks = await getWebhooks(subscriberOptions);
|
const webhooks = await getWebhooks(subscriberOptions);
|
||||||
|
|
||||||
|
@ -87,6 +89,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
location: true,
|
location: true,
|
||||||
isRecorded: true,
|
isRecorded: true,
|
||||||
eventTypeId: true,
|
eventTypeId: true,
|
||||||
|
eventType: {
|
||||||
|
select: {
|
||||||
|
teamId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -164,7 +171,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
evt,
|
evt,
|
||||||
downloadLink,
|
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;
|
const isSendingEmailsAllowed = IS_SELF_HOSTED || session?.user?.belongsToActiveTeam;
|
||||||
|
|
|
@ -39,8 +39,8 @@ import { EventLimitsTab } from "@components/eventtype/EventLimitsTab";
|
||||||
import { EventRecurringTab } from "@components/eventtype/EventRecurringTab";
|
import { EventRecurringTab } from "@components/eventtype/EventRecurringTab";
|
||||||
import { EventSetupTab } from "@components/eventtype/EventSetupTab";
|
import { EventSetupTab } from "@components/eventtype/EventSetupTab";
|
||||||
import { EventTeamTab } from "@components/eventtype/EventTeamTab";
|
import { EventTeamTab } from "@components/eventtype/EventTeamTab";
|
||||||
import { EventTeamWebhooksTab } from "@components/eventtype/EventTeamWebhooksTab";
|
|
||||||
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
|
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
|
||||||
|
import { EventWebhooksTab } from "@components/eventtype/EventWebhooksTab";
|
||||||
import EventWorkflowsTab from "@components/eventtype/EventWorkfowsTab";
|
import EventWorkflowsTab from "@components/eventtype/EventWorkfowsTab";
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
@ -309,7 +309,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||||
workflows={eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow)}
|
workflows={eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
webhooks: <EventTeamWebhooksTab eventType={eventType} team={team} />,
|
webhooks: <EventWebhooksTab eventType={eventType} />,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const handleSubmit = async (values: FormValues) => {
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
|
|
@ -52,8 +52,21 @@ test.describe("Manage Booking Questions", () => {
|
||||||
// Considering there are many steps in it, it would need more than default test timeout
|
// Considering there are many steps in it, it would need more than default test timeout
|
||||||
test.setTimeout(testInfo.timeout * 3);
|
test.setTimeout(testInfo.timeout * 3);
|
||||||
const user = await createAndLoginUserWithEventTypes({ users });
|
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 () => {
|
await test.step("Go to First Team Event", async () => {
|
||||||
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
|
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
|
||||||
|
@ -79,7 +92,6 @@ async function runTestStepsCommonForTeamAndUserEventType(
|
||||||
bookerVariant: BookerVariants
|
bookerVariant: BookerVariants
|
||||||
) {
|
) {
|
||||||
await page.click('[href$="tabName=advanced"]');
|
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 test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
|
||||||
await addQuestionAndSave({
|
await addQuestionAndSave({
|
||||||
page,
|
page,
|
||||||
|
@ -391,19 +403,35 @@ async function saveEventType(page: Page) {
|
||||||
await page.locator("[data-testid=update-eventtype]").click();
|
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();
|
const webhookReceiver = createHttpServer();
|
||||||
await prisma.webhook.create({
|
|
||||||
data: {
|
const data: {
|
||||||
|
id: string;
|
||||||
|
subscriberUrl: string;
|
||||||
|
eventTriggers: WebhookTriggerEvents[];
|
||||||
|
userId?: number;
|
||||||
|
teamId?: number;
|
||||||
|
} = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
userId: user.id,
|
|
||||||
subscriberUrl: webhookReceiver.url,
|
subscriberUrl: webhookReceiver.url,
|
||||||
eventTriggers: [
|
eventTriggers: [
|
||||||
WebhookTriggerEvents.BOOKING_CREATED,
|
WebhookTriggerEvents.BOOKING_CREATED,
|
||||||
WebhookTriggerEvents.BOOKING_CANCELLED,
|
WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||||
],
|
],
|
||||||
},
|
};
|
||||||
});
|
|
||||||
|
if (teamId) {
|
||||||
|
data.teamId = teamId;
|
||||||
|
} else if (user) {
|
||||||
|
data.userId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.webhook.create({ data });
|
||||||
|
|
||||||
return webhookReceiver;
|
return webhookReceiver;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1824,5 +1824,7 @@
|
||||||
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
|
"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_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": "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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,9 +25,9 @@ export async function onFormSubmission(
|
||||||
|
|
||||||
const subscriberOptions = {
|
const subscriberOptions = {
|
||||||
userId: form.user.id,
|
userId: form.user.id,
|
||||||
// It isn't an eventType webhook
|
|
||||||
eventTypeId: -1,
|
|
||||||
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
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);
|
const webhooks = await getWebhooks(subscriberOptions);
|
||||||
|
|
|
@ -306,8 +306,9 @@ async function handler(req: CustomRequest) {
|
||||||
// Send Webhook call if hooked to BOOKING.CANCELLED
|
// Send Webhook call if hooked to BOOKING.CANCELLED
|
||||||
const subscriberOptions = {
|
const subscriberOptions = {
|
||||||
userId: bookingToDelete.userId,
|
userId: bookingToDelete.userId,
|
||||||
eventTypeId: (bookingToDelete.eventTypeId as number) || 0,
|
eventTypeId: bookingToDelete.eventTypeId as number,
|
||||||
triggerEvent: eventTrigger,
|
triggerEvent: eventTrigger,
|
||||||
|
teamId: bookingToDelete.eventType?.teamId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventTypeInfo: EventTypeInfo = {
|
const eventTypeInfo: EventTypeInfo = {
|
||||||
|
|
|
@ -30,6 +30,7 @@ export async function handleConfirmation(args: {
|
||||||
price: number;
|
price: number;
|
||||||
requiresConfirmation: boolean;
|
requiresConfirmation: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
teamId?: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
eventTypeId: number | null;
|
eventTypeId: number | null;
|
||||||
smsReminderNumber: string | null;
|
smsReminderNumber: string | null;
|
||||||
|
@ -235,16 +236,17 @@ export async function handleConfirmation(args: {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// schedule job for zapier trigger 'when meeting ends'
|
|
||||||
const subscribersBookingCreated = await getWebhooks({
|
const subscribersBookingCreated = await getWebhooks({
|
||||||
userId: booking.userId || 0,
|
userId: booking.userId,
|
||||||
eventTypeId: booking.eventTypeId || 0,
|
eventTypeId: booking.eventTypeId,
|
||||||
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
|
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
|
||||||
|
teamId: booking.eventType?.teamId,
|
||||||
});
|
});
|
||||||
const subscribersMeetingEnded = await getWebhooks({
|
const subscribersMeetingEnded = await getWebhooks({
|
||||||
userId: booking.userId || 0,
|
userId: booking.userId,
|
||||||
eventTypeId: booking.eventTypeId || 0,
|
eventTypeId: booking.eventTypeId,
|
||||||
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
|
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
|
||||||
|
teamId: booking.eventType?.teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribersMeetingEnded.forEach((subscriber) => {
|
subscribersMeetingEnded.forEach((subscriber) => {
|
||||||
|
|
|
@ -2097,12 +2097,14 @@ async function handler(
|
||||||
userId: organizerUser.id,
|
userId: organizerUser.id,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
triggerEvent: eventTrigger,
|
triggerEvent: eventTrigger,
|
||||||
|
teamId: eventType.team?.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscriberOptionsMeetingEnded = {
|
const subscriberOptionsMeetingEnded = {
|
||||||
userId: organizerUser.id,
|
userId: organizerUser.id,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
|
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
|
||||||
|
teamId: eventType.team?.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -25,6 +25,7 @@ type WebhookProps = {
|
||||||
eventTriggers: WebhookTriggerEvents[];
|
eventTriggers: WebhookTriggerEvents[];
|
||||||
secret: string | null;
|
secret: string | null;
|
||||||
eventTypeId: number | null;
|
eventTypeId: number | null;
|
||||||
|
teamId: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function WebhookListItem(props: {
|
export default function WebhookListItem(props: {
|
||||||
|
@ -32,19 +33,23 @@ export default function WebhookListItem(props: {
|
||||||
canEditWebhook?: boolean;
|
canEditWebhook?: boolean;
|
||||||
onEditWebhook: () => void;
|
onEditWebhook: () => void;
|
||||||
lastItem: boolean;
|
lastItem: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { webhook } = props;
|
const { webhook } = props;
|
||||||
|
const canEditWebhook = props.canEditWebhook ?? true;
|
||||||
|
|
||||||
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
|
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
|
await utils.viewer.webhook.getByViewer.invalidate();
|
||||||
await utils.viewer.webhook.list.invalidate();
|
await utils.viewer.webhook.list.invalidate();
|
||||||
showToast(t("webhook_removed_successfully"), "success");
|
showToast(t("webhook_removed_successfully"), "success");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
|
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
|
||||||
async onSuccess(data) {
|
async onSuccess(data) {
|
||||||
console.log("data", data);
|
await utils.viewer.webhook.getByViewer.invalidate();
|
||||||
await utils.viewer.webhook.list.invalidate();
|
await utils.viewer.webhook.list.invalidate();
|
||||||
// TODO: Better success message
|
// TODO: Better success message
|
||||||
showToast(t(data?.active ? "enabled" : "disabled"), "success");
|
showToast(t(data?.active ? "enabled" : "disabled"), "success");
|
||||||
|
@ -53,7 +58,11 @@ export default function WebhookListItem(props: {
|
||||||
|
|
||||||
const onDeleteWebhook = () => {
|
const onDeleteWebhook = () => {
|
||||||
// TODO: Confimation dialog before deleting
|
// 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 (
|
return (
|
||||||
|
@ -63,7 +72,14 @@ export default function WebhookListItem(props: {
|
||||||
props.lastItem ? "" : "border-subtle border-b"
|
props.lastItem ? "" : "border-subtle border-b"
|
||||||
)}>
|
)}>
|
||||||
<div className="w-full truncate">
|
<div className="w-full truncate">
|
||||||
|
<div className="flex">
|
||||||
<p className="text-emphasis truncate text-sm font-medium">{webhook.subscriberUrl}</p>
|
<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")}>
|
<Tooltip content={t("triggers_when")}>
|
||||||
<div className="flex w-4/5 flex-wrap">
|
<div className="flex w-4/5 flex-wrap">
|
||||||
{webhook.eventTriggers.map((trigger) => (
|
{webhook.eventTriggers.map((trigger) => (
|
||||||
|
@ -78,10 +94,11 @@ export default function WebhookListItem(props: {
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
{!props.readOnly && (
|
||||||
<div className="ml-2 flex items-center space-x-4">
|
<div className="ml-2 flex items-center space-x-4">
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={webhook.active}
|
defaultChecked={webhook.active}
|
||||||
disabled={!props.canEditWebhook}
|
disabled={!canEditWebhook}
|
||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
toggleWebhook.mutate({
|
toggleWebhook.mutate({
|
||||||
id: webhook.id,
|
id: webhook.id,
|
||||||
|
@ -91,9 +108,11 @@ export default function WebhookListItem(props: {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button className="hidden lg:flex" color="secondary" onClick={props.onEditWebhook}>
|
<Button className="hidden lg:flex" color="secondary" onClick={props.onEditWebhook}>
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="hidden lg:flex"
|
className="hidden lg:flex"
|
||||||
color="destructive"
|
color="destructive"
|
||||||
|
@ -101,6 +120,7 @@ export default function WebhookListItem(props: {
|
||||||
variant="icon"
|
variant="icon"
|
||||||
onClick={onDeleteWebhook}
|
onClick={onDeleteWebhook}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button className="lg:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
<Button className="lg:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||||
|
@ -121,6 +141,7 @@ export default function WebhookListItem(props: {
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,13 +4,17 @@ import defaultPrisma from "@calcom/prisma";
|
||||||
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
export type GetSubscriberOptions = {
|
export type GetSubscriberOptions = {
|
||||||
userId: number;
|
userId?: number | null;
|
||||||
eventTypeId: number;
|
eventTypeId?: number | null;
|
||||||
triggerEvent: WebhookTriggerEvents;
|
triggerEvent: WebhookTriggerEvents;
|
||||||
|
teamId?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient = defaultPrisma) => {
|
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({
|
const allWebhooks = await prisma.webhook.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
|
@ -20,6 +24,9 @@ const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient =
|
||||||
{
|
{
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
AND: {
|
AND: {
|
||||||
eventTriggers: {
|
eventTriggers: {
|
||||||
|
|
|
@ -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);
|
||||||
|
};
|
|
@ -9,6 +9,7 @@ import { Meta, showToast, SkeletonContainer } from "@calcom/ui";
|
||||||
import { getLayout } from "../../settings/layouts/SettingsLayout";
|
import { getLayout } from "../../settings/layouts/SettingsLayout";
|
||||||
import type { WebhookFormSubmitData } from "../components/WebhookForm";
|
import type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||||
import WebhookForm from "../components/WebhookForm";
|
import WebhookForm from "../components/WebhookForm";
|
||||||
|
import { subscriberUrlReserved } from "../lib/subscriberUrlReserved";
|
||||||
|
|
||||||
const querySchema = z.object({ id: z.string() });
|
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 />;
|
if (isLoading || !webhook) return <SkeletonContainer />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -63,7 +60,15 @@ const EditWebhook = () => {
|
||||||
<WebhookForm
|
<WebhookForm
|
||||||
webhook={webhook}
|
webhook={webhook}
|
||||||
onSubmit={(values: WebhookFormSubmitData) => {
|
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");
|
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
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 { getLayout } from "../../settings/layouts/SettingsLayout";
|
||||||
import type { WebhookFormSubmitData } from "../components/WebhookForm";
|
import type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||||
import WebhookForm from "../components/WebhookForm";
|
import WebhookForm from "../components/WebhookForm";
|
||||||
|
import { subscriberUrlReserved } from "../lib/subscriberUrlReserved";
|
||||||
|
|
||||||
const NewWebhookView = () => {
|
const NewWebhookView = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const session = useSession();
|
||||||
|
|
||||||
|
const teamId = router.query.teamId ? +router.query.teamId : undefined;
|
||||||
|
|
||||||
const { data: installedApps, isLoading } = trpc.viewer.integrations.useQuery(
|
const { data: installedApps, isLoading } = trpc.viewer.integrations.useQuery(
|
||||||
{ variant: "other", onlyInstalled: true },
|
{ 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) => {
|
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");
|
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -58,6 +66,7 @@ const NewWebhookView = () => {
|
||||||
active: values.active,
|
active: values.active,
|
||||||
payloadTemplate: values.payloadTemplate,
|
payloadTemplate: values.payloadTemplate,
|
||||||
secret: values.secret,
|
secret: values.secret,
|
||||||
|
teamId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,24 @@
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
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 { Plus, Link as LinkIcon } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { getLayout } from "../../settings/layouts/SettingsLayout";
|
import { getLayout } from "../../settings/layouts/SettingsLayout";
|
||||||
|
@ -12,65 +26,152 @@ import { WebhookListItem, WebhookListSkeleton } from "../components";
|
||||||
|
|
||||||
const WebhooksView = () => {
|
const WebhooksView = () => {
|
||||||
const { t } = useLocale();
|
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 (
|
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>
|
<div>
|
||||||
<Suspense fallback={<WebhookListSkeleton />}>
|
<Suspense fallback={<WebhookListSkeleton />}>
|
||||||
<WebhooksList />
|
{data && <WebhooksList webhooksByViewer={data} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</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 { 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 (
|
||||||
<Button
|
<Button color="primary" data-testid="new_webhook" StartIcon={Plus} href={href}>
|
||||||
color="secondary"
|
{isLocaleReady ? t("new") : <SkeletonText className="h-4 w-24" />}
|
||||||
data-testid="new_webhook"
|
|
||||||
StartIcon={Plus}
|
|
||||||
href={`${WEBAPP_URL}/settings/developer/webhooks/new`}>
|
|
||||||
{isLocaleReady ? t("new_webhook") : <SkeletonText className="h-4 w-24" />}
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<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 { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { data: webhooks } = trpc.viewer.webhook.list.useQuery(undefined, {
|
const { profiles, webhookGroups } = webhooksByViewer;
|
||||||
suspense: true,
|
|
||||||
enabled: router.isReady,
|
const hasTeams = profiles && profiles.length > 1;
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{webhooks?.length ? (
|
{webhookGroups && (
|
||||||
<>
|
<>
|
||||||
<div className="border-subtle mt-6 mb-8 rounded-md border">
|
{!!webhookGroups.length && (
|
||||||
{webhooks.map((webhook, index) => (
|
<>
|
||||||
|
{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
|
<WebhookListItem
|
||||||
key={webhook.id}
|
key={webhook.id}
|
||||||
webhook={webhook}
|
webhook={webhook}
|
||||||
lastItem={webhooks.length === index + 1}
|
readOnly={group.metadata?.readOnly ?? false}
|
||||||
onEditWebhook={() => router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id} `)}
|
lastItem={group.webhooks.length === index + 1}
|
||||||
|
onEditWebhook={() =>
|
||||||
|
router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id} `)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<NewWebhookButton />
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
) : (
|
)}
|
||||||
|
{!webhookGroups.length && (
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
Icon={LinkIcon}
|
Icon={LinkIcon}
|
||||||
headline={t("create_your_first_webhook")}
|
headline={t("create_your_first_webhook")}
|
||||||
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
|
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
|
||||||
buttonRaw={<NewWebhookButton />}
|
buttonRaw={<NewWebhookButton profiles={profiles} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -118,6 +118,7 @@ export const buildWebhook = (webhook?: Partial<Webhook>): Webhook => {
|
||||||
secret: faker.lorem.slug(),
|
secret: faker.lorem.slug(),
|
||||||
active: true,
|
active: true,
|
||||||
eventTriggers: [],
|
eventTriggers: [],
|
||||||
|
teamId: null,
|
||||||
...webhook,
|
...webhook,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
|
@ -255,6 +255,7 @@ model Team {
|
||||||
brandColor String @default("#292929")
|
brandColor String @default("#292929")
|
||||||
darkBrandColor String @default("#fafafa")
|
darkBrandColor String @default("#fafafa")
|
||||||
verifiedNumbers VerifiedNumber[]
|
verifiedNumbers VerifiedNumber[]
|
||||||
|
webhooks Webhook[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MembershipRole {
|
enum MembershipRole {
|
||||||
|
@ -503,6 +504,7 @@ enum WebhookTriggerEvents {
|
||||||
model Webhook {
|
model Webhook {
|
||||||
id String @id @unique
|
id String @id @unique
|
||||||
userId Int?
|
userId Int?
|
||||||
|
teamId Int?
|
||||||
eventTypeId Int?
|
eventTypeId Int?
|
||||||
/// @zod.url()
|
/// @zod.url()
|
||||||
subscriberUrl String
|
subscriberUrl String
|
||||||
|
@ -511,6 +513,7 @@ model Webhook {
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
eventTriggers WebhookTriggerEvents[]
|
eventTriggers WebhookTriggerEvents[]
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
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)
|
eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||||
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
|
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
|
||||||
appId String?
|
appId String?
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import * as z from "zod"
|
import * as z from "zod"
|
||||||
import * as imports from "../zod-utils"
|
import * as imports from "../zod-utils"
|
||||||
import { WebhookTriggerEvents } from "@prisma/client"
|
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({
|
export const _WebhookModel = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
userId: z.number().int().nullish(),
|
userId: z.number().int().nullish(),
|
||||||
|
teamId: z.number().int().nullish(),
|
||||||
eventTypeId: z.number().int().nullish(),
|
eventTypeId: z.number().int().nullish(),
|
||||||
subscriberUrl: z.string().url(),
|
subscriberUrl: z.string().url(),
|
||||||
payloadTemplate: z.string().nullish(),
|
payloadTemplate: z.string().nullish(),
|
||||||
|
@ -18,6 +19,7 @@ export const _WebhookModel = z.object({
|
||||||
|
|
||||||
export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
|
export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
|
||||||
user?: CompleteUser | null
|
user?: CompleteUser | null
|
||||||
|
team?: CompleteTeam | null
|
||||||
eventType?: CompleteEventType | null
|
eventType?: CompleteEventType | null
|
||||||
app?: CompleteApp | 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({
|
export const WebhookModel: z.ZodSchema<CompleteWebhook> = z.lazy(() => _WebhookModel.extend({
|
||||||
user: UserModel.nullish(),
|
user: UserModel.nullish(),
|
||||||
|
team: TeamModel.nullish(),
|
||||||
eventType: EventTypeModel.nullish(),
|
eventType: EventTypeModel.nullish(),
|
||||||
app: AppModel.nullish(),
|
app: AppModel.nullish(),
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -238,8 +238,9 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
|
||||||
// Send Webhook call if hooked to BOOKING.CANCELLED
|
// Send Webhook call if hooked to BOOKING.CANCELLED
|
||||||
const subscriberOptions = {
|
const subscriberOptions = {
|
||||||
userId: bookingToReschedule.userId,
|
userId: bookingToReschedule.userId,
|
||||||
eventTypeId: (bookingToReschedule.eventTypeId as number) || 0,
|
eventTypeId: bookingToReschedule.eventTypeId as number,
|
||||||
triggerEvent: eventTrigger,
|
triggerEvent: eventTrigger,
|
||||||
|
teamId: bookingToReschedule.eventType?.teamId,
|
||||||
};
|
};
|
||||||
const webhooks = await getWebhooks(subscriberOptions);
|
const webhooks = await getWebhooks(subscriberOptions);
|
||||||
const promises = webhooks.map((webhook) =>
|
const promises = webhooks.map((webhook) =>
|
||||||
|
|
|
@ -14,6 +14,7 @@ type WebhookRouterHandlerCache = {
|
||||||
edit?: typeof import("./edit.handler").editHandler;
|
edit?: typeof import("./edit.handler").editHandler;
|
||||||
delete?: typeof import("./delete.handler").deleteHandler;
|
delete?: typeof import("./delete.handler").deleteHandler;
|
||||||
testTrigger?: typeof import("./testTrigger.handler").testTriggerHandler;
|
testTrigger?: typeof import("./testTrigger.handler").testTriggerHandler;
|
||||||
|
getByViewer?: typeof import("./getByViewer.handler").getByViewerHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UNSTABLE_HANDLER_CACHE: WebhookRouterHandlerCache = {};
|
const UNSTABLE_HANDLER_CACHE: WebhookRouterHandlerCache = {};
|
||||||
|
@ -116,4 +117,21 @@ export const webhookRouter = router({
|
||||||
input,
|
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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,7 +13,7 @@ type CreateOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||||
if (input.eventTypeId) {
|
if (input.eventTypeId || input.teamId) {
|
||||||
return await prisma.webhook.create({
|
return await prisma.webhook.create({
|
||||||
data: {
|
data: {
|
||||||
id: v4(),
|
id: v4(),
|
||||||
|
|
|
@ -12,6 +12,7 @@ export const ZCreateInputSchema = webhookIdAndEventTypeIdSchema.extend({
|
||||||
eventTypeId: z.number().optional(),
|
eventTypeId: z.number().optional(),
|
||||||
appId: z.string().optional().nullable(),
|
appId: z.string().optional().nullable(),
|
||||||
secret: z.string().optional().nullable(),
|
secret: z.string().optional().nullable(),
|
||||||
|
teamId: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCreateInputSchema = z.infer<typeof ZCreateInputSchema>;
|
export type TCreateInputSchema = z.infer<typeof ZCreateInputSchema>;
|
||||||
|
|
|
@ -12,32 +12,33 @@ type DeleteOptions = {
|
||||||
|
|
||||||
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
||||||
const { id } = input;
|
const { id } = input;
|
||||||
input.eventTypeId
|
|
||||||
? await prisma.eventType.update({
|
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: {
|
where: {
|
||||||
id: input.eventTypeId,
|
AND: andCondition,
|
||||||
},
|
|
||||||
data: {
|
|
||||||
webhooks: {
|
|
||||||
delete: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: await prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: ctx.user.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
webhooks: {
|
|
||||||
delete: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (webhookToDelete) {
|
||||||
|
await prisma.webhook.delete({
|
||||||
|
where: {
|
||||||
|
id: webhookToDelete.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { webhookIdAndEventTypeIdSchema } from "./types";
|
||||||
export const ZDeleteInputSchema = webhookIdAndEventTypeIdSchema.extend({
|
export const ZDeleteInputSchema = webhookIdAndEventTypeIdSchema.extend({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
eventTypeId: z.number().optional(),
|
eventTypeId: z.number().optional(),
|
||||||
|
teamId: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TDeleteInputSchema = z.infer<typeof ZDeleteInputSchema>;
|
export type TDeleteInputSchema = z.infer<typeof ZDeleteInputSchema>;
|
||||||
|
|
|
@ -12,22 +12,14 @@ type EditOptions = {
|
||||||
|
|
||||||
export const editHandler = async ({ ctx, input }: EditOptions) => {
|
export const editHandler = async ({ ctx, input }: EditOptions) => {
|
||||||
const { id, ...data } = input;
|
const { id, ...data } = input;
|
||||||
const webhook = input.eventTypeId
|
|
||||||
? await prisma.webhook.findFirst({
|
const webhook = await prisma.webhook.findFirst({
|
||||||
where: {
|
where: {
|
||||||
eventTypeId: input.eventTypeId,
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: await prisma.webhook.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: ctx.user.id,
|
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!webhook) {
|
if (!webhook) {
|
||||||
// user does not own this webhook
|
|
||||||
// team event doesn't own this webhook
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await prisma.webhook.update({
|
return await prisma.webhook.update({
|
||||||
|
|
|
@ -22,6 +22,8 @@ export const getHandler = async ({ ctx: _ctx, input }: GetOptions) => {
|
||||||
active: true,
|
active: true,
|
||||||
eventTriggers: true,
|
eventTriggers: true,
|
||||||
secret: true,
|
secret: true,
|
||||||
|
teamId: true,
|
||||||
|
userId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1 @@
|
||||||
|
export {};
|
|
@ -17,11 +17,23 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
|
||||||
/* Don't mixup zapier webhooks with normal ones */
|
/* Don't mixup zapier webhooks with normal ones */
|
||||||
AND: [{ appId: !input?.appId ? null : input.appId }],
|
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 (Array.isArray(where.AND)) {
|
||||||
if (input?.eventTypeId) {
|
if (input?.eventTypeId) {
|
||||||
where.AND?.push({ eventTypeId: input.eventTypeId });
|
where.AND?.push({ eventTypeId: input.eventTypeId });
|
||||||
} else {
|
} else {
|
||||||
where.AND?.push({ userId: ctx.user.id });
|
where.AND?.push({
|
||||||
|
OR: [{ userId: ctx.user.id }, { teamId: { in: user?.teams.map((membership) => membership.teamId) } }],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,8 @@ import { webhookIdAndEventTypeIdSchema } from "./types";
|
||||||
export const ZListInputSchema = webhookIdAndEventTypeIdSchema
|
export const ZListInputSchema = webhookIdAndEventTypeIdSchema
|
||||||
.extend({
|
.extend({
|
||||||
appId: z.string().optional(),
|
appId: z.string().optional(),
|
||||||
|
teamId: z.number().optional(),
|
||||||
|
eventTypeId: z.number().optional(),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,6 @@ import { z } from "zod";
|
||||||
export const webhookIdAndEventTypeIdSchema = z.object({
|
export const webhookIdAndEventTypeIdSchema = z.object({
|
||||||
// Webhook ID
|
// Webhook ID
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
// Event type ID
|
|
||||||
eventTypeId: z.number().optional(),
|
eventTypeId: z.number().optional(),
|
||||||
|
teamId: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
import type { Membership } from "@prisma/client";
|
||||||
|
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
@ -10,41 +13,128 @@ export const webhookProcedure = authedProcedure
|
||||||
.use(async ({ ctx, input, next }) => {
|
.use(async ({ ctx, input, next }) => {
|
||||||
// Endpoints that just read the logged in user's data - like 'list' don't necessary have any input
|
// Endpoints that just read the logged in user's data - like 'list' don't necessary have any input
|
||||||
if (!input) return next();
|
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.
|
const assertPartOfTeamWithRequiredAccessLevel = (memberships?: Membership[], teamId?: number) => {
|
||||||
if (eventTypeId) {
|
if (!memberships) return false;
|
||||||
const team = await prisma.team.findFirst({
|
if (teamId) {
|
||||||
|
return memberships.some(
|
||||||
|
(membership) =>
|
||||||
|
membership.teamId === teamId &&
|
||||||
|
(membership.role === MembershipRole.ADMIN || membership.role === MembershipRole.OWNER)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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: {
|
where: {
|
||||||
eventTypes: {
|
id: id,
|
||||||
some: {
|
|
||||||
id: eventTypeId,
|
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
team: true,
|
||||||
|
eventType: true,
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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: {
|
include: {
|
||||||
members: true,
|
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (id) {
|
|
||||||
const authorizedHook = await prisma.webhook.findFirst({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!authorizedHook) {
|
|
||||||
|
if (eventType && eventType.userId !== ctx.user.id) {
|
||||||
|
if (!assertPartOfTeamWithRequiredAccessLevel(eventType.team?.members)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
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();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue
Block a user