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>pull/9014/head^2
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 type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||
import { Plus, Lock } from "@calcom/ui/components/icon";
|
||||
|
||||
export const EventTeamWebhooksTab = ({
|
||||
eventType,
|
||||
team,
|
||||
}: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
@ -32,12 +29,6 @@ export const EventTeamWebhooksTab = ({
|
|||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [webhookToEdit, setWebhookToEdit] = useState<Webhook>();
|
||||
|
||||
const subscriberUrlReserved = (subscriberUrl: string, id?: string): boolean => {
|
||||
return !!webhooks?.find(
|
||||
(webhook) => webhook.subscriberUrl === subscriberUrl && (!id || webhook.id !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess() {
|
||||
setEditModalOpen(false);
|
||||
|
@ -61,7 +52,14 @@ export const EventTeamWebhooksTab = ({
|
|||
});
|
||||
|
||||
const onCreateWebhook = async (values: WebhookFormSubmitData) => {
|
||||
if (subscriberUrlReserved(values.subscriberUrl, values.id)) {
|
||||
if (
|
||||
subscriberUrlReserved({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
id: values.id,
|
||||
webhooks,
|
||||
eventTypeId: eventType.id,
|
||||
})
|
||||
) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
||||
|
@ -102,7 +100,7 @@ export const EventTeamWebhooksTab = ({
|
|||
|
||||
return (
|
||||
<div>
|
||||
{team && webhooks && !isLoading && (
|
||||
{webhooks && !isLoading && (
|
||||
<>
|
||||
<div>
|
||||
<div>
|
||||
|
@ -139,7 +137,7 @@ export const EventTeamWebhooksTab = ({
|
|||
<EmptyScreen
|
||||
Icon={TbWebhook}
|
||||
headline={t("create_your_first_webhook")}
|
||||
description={t("create_your_first_team_webhook_description", { appName: APP_NAME })}
|
||||
description={t("first_event_type_webhook_description")}
|
||||
buttonRaw={
|
||||
isChildrenManagedEventType && !isManagedEventType ? (
|
||||
<Button StartIcon={Lock} color="secondary" disabled>
|
||||
|
@ -176,7 +174,14 @@ export const EventTeamWebhooksTab = ({
|
|||
apps={installedApps?.items.map((app) => app.slug)}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
onSubmit={(values: WebhookFormSubmitData) => {
|
||||
if (subscriberUrlReserved(values.subscriberUrl, webhookToEdit?.id || "")) {
|
||||
if (
|
||||
subscriberUrlReserved({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
id: values.id,
|
||||
webhooks,
|
||||
eventTypeId: eventType.id,
|
||||
})
|
||||
) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -39,8 +39,8 @@ import { EventLimitsTab } from "@components/eventtype/EventLimitsTab";
|
|||
import { EventRecurringTab } from "@components/eventtype/EventRecurringTab";
|
||||
import { EventSetupTab } from "@components/eventtype/EventSetupTab";
|
||||
import { EventTeamTab } from "@components/eventtype/EventTeamTab";
|
||||
import { EventTeamWebhooksTab } from "@components/eventtype/EventTeamWebhooksTab";
|
||||
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
|
||||
import { EventWebhooksTab } from "@components/eventtype/EventWebhooksTab";
|
||||
import EventWorkflowsTab from "@components/eventtype/EventWorkfowsTab";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
@ -309,7 +309,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||
workflows={eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow)}
|
||||
/>
|
||||
),
|
||||
webhooks: <EventTeamWebhooksTab eventType={eventType} team={team} />,
|
||||
webhooks: <EventWebhooksTab eventType={eventType} />,
|
||||
} as const;
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
|
|
|
@ -52,8 +52,21 @@ test.describe("Manage Booking Questions", () => {
|
|||
// Considering there are many steps in it, it would need more than default test timeout
|
||||
test.setTimeout(testInfo.timeout * 3);
|
||||
const user = await createAndLoginUserWithEventTypes({ users });
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const webhookReceiver = await addWebhook(user);
|
||||
const teamId = team?.id ?? 0;
|
||||
const webhookReceiver = await addWebhook(undefined, teamId);
|
||||
|
||||
await test.step("Go to First Team Event", async () => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
|
||||
|
@ -79,7 +92,6 @@ async function runTestStepsCommonForTeamAndUserEventType(
|
|||
bookerVariant: BookerVariants
|
||||
) {
|
||||
await page.click('[href$="tabName=advanced"]');
|
||||
|
||||
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
|
||||
await addQuestionAndSave({
|
||||
page,
|
||||
|
@ -391,19 +403,35 @@ async function saveEventType(page: Page) {
|
|||
await page.locator("[data-testid=update-eventtype]").click();
|
||||
}
|
||||
|
||||
async function addWebhook(user: Awaited<ReturnType<typeof createAndLoginUserWithEventTypes>>) {
|
||||
async function addWebhook(
|
||||
user?: Awaited<ReturnType<typeof createAndLoginUserWithEventTypes>>,
|
||||
teamId?: number | null
|
||||
) {
|
||||
const webhookReceiver = createHttpServer();
|
||||
await prisma.webhook.create({
|
||||
data: {
|
||||
id: uuid(),
|
||||
userId: user.id,
|
||||
subscriberUrl: webhookReceiver.url,
|
||||
eventTriggers: [
|
||||
WebhookTriggerEvents.BOOKING_CREATED,
|
||||
WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const data: {
|
||||
id: string;
|
||||
subscriberUrl: string;
|
||||
eventTriggers: WebhookTriggerEvents[];
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
} = {
|
||||
id: uuid(),
|
||||
subscriberUrl: webhookReceiver.url,
|
||||
eventTriggers: [
|
||||
WebhookTriggerEvents.BOOKING_CREATED,
|
||||
WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||
],
|
||||
};
|
||||
|
||||
if (teamId) {
|
||||
data.teamId = teamId;
|
||||
} else if (user) {
|
||||
data.userId = user.id;
|
||||
}
|
||||
|
||||
await prisma.webhook.create({ data });
|
||||
|
||||
return webhookReceiver;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -25,6 +25,7 @@ type WebhookProps = {
|
|||
eventTriggers: WebhookTriggerEvents[];
|
||||
secret: string | null;
|
||||
eventTypeId: number | null;
|
||||
teamId: number | null;
|
||||
};
|
||||
|
||||
export default function WebhookListItem(props: {
|
||||
|
@ -32,19 +33,23 @@ export default function WebhookListItem(props: {
|
|||
canEditWebhook?: boolean;
|
||||
onEditWebhook: () => void;
|
||||
lastItem: boolean;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { webhook } = props;
|
||||
const canEditWebhook = props.canEditWebhook ?? true;
|
||||
|
||||
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.viewer.webhook.getByViewer.invalidate();
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
showToast(t("webhook_removed_successfully"), "success");
|
||||
},
|
||||
});
|
||||
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess(data) {
|
||||
console.log("data", data);
|
||||
await utils.viewer.webhook.getByViewer.invalidate();
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
// TODO: Better success message
|
||||
showToast(t(data?.active ? "enabled" : "disabled"), "success");
|
||||
|
@ -53,7 +58,11 @@ export default function WebhookListItem(props: {
|
|||
|
||||
const onDeleteWebhook = () => {
|
||||
// TODO: Confimation dialog before deleting
|
||||
deleteWebhook.mutate({ id: webhook.id, eventTypeId: webhook.eventTypeId || undefined });
|
||||
deleteWebhook.mutate({
|
||||
id: webhook.id,
|
||||
eventTypeId: webhook.eventTypeId || undefined,
|
||||
teamId: webhook.teamId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -63,7 +72,14 @@ export default function WebhookListItem(props: {
|
|||
props.lastItem ? "" : "border-subtle border-b"
|
||||
)}>
|
||||
<div className="w-full truncate">
|
||||
<p className="text-emphasis truncate text-sm font-medium">{webhook.subscriberUrl}</p>
|
||||
<div className="flex">
|
||||
<p className="text-emphasis truncate text-sm font-medium">{webhook.subscriberUrl}</p>
|
||||
{!!props.readOnly && (
|
||||
<Badge variant="gray" className="ml-2 ">
|
||||
{t("readonly")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip content={t("triggers_when")}>
|
||||
<div className="flex w-4/5 flex-wrap">
|
||||
{webhook.eventTriggers.map((trigger) => (
|
||||
|
@ -78,49 +94,54 @@ export default function WebhookListItem(props: {
|
|||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="ml-2 flex items-center space-x-4">
|
||||
<Switch
|
||||
defaultChecked={webhook.active}
|
||||
disabled={!props.canEditWebhook}
|
||||
onCheckedChange={() =>
|
||||
toggleWebhook.mutate({
|
||||
id: webhook.id,
|
||||
active: !webhook.active,
|
||||
payloadTemplate: webhook.payloadTemplate,
|
||||
eventTypeId: webhook.eventTypeId || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button className="hidden lg:flex" color="secondary" onClick={props.onEditWebhook}>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
className="hidden lg:flex"
|
||||
color="destructive"
|
||||
StartIcon={Trash}
|
||||
variant="icon"
|
||||
onClick={onDeleteWebhook}
|
||||
/>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="lg:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem StartIcon={Edit} color="secondary" onClick={props.onEditWebhook}>
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{!props.readOnly && (
|
||||
<div className="ml-2 flex items-center space-x-4">
|
||||
<Switch
|
||||
defaultChecked={webhook.active}
|
||||
disabled={!canEditWebhook}
|
||||
onCheckedChange={() =>
|
||||
toggleWebhook.mutate({
|
||||
id: webhook.id,
|
||||
active: !webhook.active,
|
||||
payloadTemplate: webhook.payloadTemplate,
|
||||
eventTypeId: webhook.eventTypeId || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem StartIcon={Trash} color="destructive" onClick={onDeleteWebhook}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Button className="hidden lg:flex" color="secondary" onClick={props.onEditWebhook}>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="hidden lg:flex"
|
||||
color="destructive"
|
||||
StartIcon={Trash}
|
||||
variant="icon"
|
||||
onClick={onDeleteWebhook}
|
||||
/>
|
||||
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="lg:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem StartIcon={Edit} color="secondary" onClick={props.onEditWebhook}>
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem StartIcon={Trash} color="destructive" onClick={onDeleteWebhook}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||
import WebhookForm from "../components/WebhookForm";
|
||||
import { subscriberUrlReserved } from "../lib/subscriberUrlReserved";
|
||||
|
||||
const querySchema = z.object({ id: z.string() });
|
||||
|
||||
|
@ -47,10 +48,6 @@ const EditWebhook = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const subscriberUrlReserved = (subscriberUrl: string, id: string): boolean => {
|
||||
return !!webhooks?.find((webhook) => webhook.subscriberUrl === subscriberUrl && webhook.id !== id);
|
||||
};
|
||||
|
||||
if (isLoading || !webhook) return <SkeletonContainer />;
|
||||
|
||||
return (
|
||||
|
@ -63,7 +60,15 @@ const EditWebhook = () => {
|
|||
<WebhookForm
|
||||
webhook={webhook}
|
||||
onSubmit={(values: WebhookFormSubmitData) => {
|
||||
if (subscriberUrlReserved(values.subscriberUrl, webhook.id)) {
|
||||
if (
|
||||
subscriberUrlReserved({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
id: webhook.id,
|
||||
webhooks,
|
||||
teamId: webhook.teamId ?? undefined,
|
||||
userId: webhook.userId ?? undefined,
|
||||
})
|
||||
) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -1,10 +1,24 @@
|
|||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, EmptyScreen, Meta, SkeletonText } from "@calcom/ui";
|
||||
import type { WebhooksByViewer } from "@calcom/trpc/server/routers/viewer/webhook/getByViewer.handler";
|
||||
import {
|
||||
Button,
|
||||
Meta,
|
||||
SkeletonText,
|
||||
EmptyScreen,
|
||||
Dropdown,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
DropdownMenuLabel,
|
||||
} from "@calcom/ui";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
import { Plus, Link as LinkIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { getLayout } from "../../settings/layouts/SettingsLayout";
|
||||
|
@ -12,63 +26,150 @@ import { WebhookListItem, WebhookListSkeleton } from "../components";
|
|||
|
||||
const WebhooksView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const { data } = trpc.viewer.webhook.getByViewer.useQuery(undefined, {
|
||||
suspense: true,
|
||||
enabled: router.isReady,
|
||||
});
|
||||
|
||||
const profiles = data?.profiles.filter((profile) => !profile.readOnly);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="Webhooks" description={t("webhooks_description", { appName: APP_NAME })} />
|
||||
<Meta
|
||||
title="Webhooks"
|
||||
description={t("webhooks_description", { appName: APP_NAME })}
|
||||
CTA={data && data.webhookGroups.length > 0 ? <NewWebhookButton profiles={profiles} /> : <></>}
|
||||
/>
|
||||
<div>
|
||||
<Suspense fallback={<WebhookListSkeleton />}>
|
||||
<WebhooksList />
|
||||
{data && <WebhooksList webhooksByViewer={data} />}
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const NewWebhookButton = () => {
|
||||
const NewWebhookButton = ({
|
||||
teamId,
|
||||
profiles,
|
||||
}: {
|
||||
teamId?: number | null;
|
||||
profiles?: {
|
||||
readOnly?: boolean | undefined;
|
||||
slug: string | null;
|
||||
name: string | null;
|
||||
image?: string | undefined;
|
||||
teamId: number | null | undefined;
|
||||
}[];
|
||||
}) => {
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
|
||||
const url = new URL(`${WEBAPP_URL}/settings/developer/webhooks/new`);
|
||||
if (!!teamId) {
|
||||
url.searchParams.set("teamId", `${teamId}`);
|
||||
}
|
||||
const href = url.href;
|
||||
|
||||
if (!profiles || profiles.length < 2) {
|
||||
return (
|
||||
<Button color="primary" data-testid="new_webhook" StartIcon={Plus} href={href}>
|
||||
{isLocaleReady ? t("new") : <SkeletonText className="h-4 w-24" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="new_webhook"
|
||||
StartIcon={Plus}
|
||||
href={`${WEBAPP_URL}/settings/developer/webhooks/new`}>
|
||||
{isLocaleReady ? t("new_webhook") : <SkeletonText className="h-4 w-24" />}
|
||||
</Button>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button color="primary" StartIcon={Plus}>
|
||||
{isLocaleReady ? t("new") : <SkeletonText className="h-4 w-24" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={14} align="end">
|
||||
<DropdownMenuLabel>
|
||||
<div className="text-xs">{t("create_for").toUpperCase()}</div>
|
||||
</DropdownMenuLabel>
|
||||
{profiles.map((profile, idx) => (
|
||||
<DropdownMenuItem key={profile.slug}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={(props) => (
|
||||
<Avatar
|
||||
alt={profile.slug || ""}
|
||||
imageSrc={profile.image || `${WEBAPP_URL}/${profile.name}/avatar.png`}
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
)}>
|
||||
<Link href={`webhooks/new${profile.teamId ? `?teamId=${profile.teamId}` : ""}`}>
|
||||
{profile.name}
|
||||
</Link>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const WebhooksList = () => {
|
||||
const WebhooksList = ({ webhooksByViewer }: { webhooksByViewer: WebhooksByViewer }) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: webhooks } = trpc.viewer.webhook.list.useQuery(undefined, {
|
||||
suspense: true,
|
||||
enabled: router.isReady,
|
||||
});
|
||||
const { profiles, webhookGroups } = webhooksByViewer;
|
||||
|
||||
const hasTeams = profiles && profiles.length > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
{webhooks?.length ? (
|
||||
{webhookGroups && (
|
||||
<>
|
||||
<div className="border-subtle mt-6 mb-8 rounded-md border">
|
||||
{webhooks.map((webhook, index) => (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
onEditWebhook={() => router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id} `)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<NewWebhookButton />
|
||||
{!!webhookGroups.length && (
|
||||
<>
|
||||
{webhookGroups.map((group) => (
|
||||
<div key={group.teamId}>
|
||||
{hasTeams && (
|
||||
<div className="items-centers flex ">
|
||||
<Avatar
|
||||
alt={group.profile.image || ""}
|
||||
imageSrc={group.profile.image || `${WEBAPP_URL}/${group.profile.name}/avatar.png`}
|
||||
size="md"
|
||||
className="inline-flex justify-center"
|
||||
/>
|
||||
<div className="text-emphasis ml-2 flex flex-grow items-center font-bold">
|
||||
{group.profile.name || ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col" key={group.profile.slug}>
|
||||
<div className="border-subtle mt-3 mb-8 rounded-md border">
|
||||
{group.webhooks.map((webhook, index) => (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
readOnly={group.metadata?.readOnly ?? false}
|
||||
lastItem={group.webhooks.length === index + 1}
|
||||
onEditWebhook={() =>
|
||||
router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id} `)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!webhookGroups.length && (
|
||||
<EmptyScreen
|
||||
Icon={LinkIcon}
|
||||
headline={t("create_your_first_webhook")}
|
||||
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
|
||||
buttonRaw={<NewWebhookButton profiles={profiles} />}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={LinkIcon}
|
||||
headline={t("create_your_first_webhook")}
|
||||
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
|
||||
buttonRaw={<NewWebhookButton />}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -118,6 +118,7 @@ export const buildWebhook = (webhook?: Partial<Webhook>): Webhook => {
|
|||
secret: faker.lorem.slug(),
|
||||
active: true,
|
||||
eventTriggers: [],
|
||||
teamId: null,
|
||||
...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")
|
||||
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?
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import * as z from "zod"
|
||||
import * as imports from "../zod-utils"
|
||||
import { WebhookTriggerEvents } from "@prisma/client"
|
||||
import { CompleteUser, UserModel, CompleteEventType, EventTypeModel, CompleteApp, AppModel } from "./index"
|
||||
import { CompleteUser, UserModel, CompleteTeam, TeamModel, CompleteEventType, EventTypeModel, CompleteApp, AppModel } from "./index"
|
||||
|
||||
export const _WebhookModel = z.object({
|
||||
id: z.string(),
|
||||
userId: z.number().int().nullish(),
|
||||
teamId: z.number().int().nullish(),
|
||||
eventTypeId: z.number().int().nullish(),
|
||||
subscriberUrl: z.string().url(),
|
||||
payloadTemplate: z.string().nullish(),
|
||||
|
@ -18,6 +19,7 @@ export const _WebhookModel = z.object({
|
|||
|
||||
export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
|
||||
user?: CompleteUser | null
|
||||
team?: CompleteTeam | null
|
||||
eventType?: CompleteEventType | null
|
||||
app?: CompleteApp | null
|
||||
}
|
||||
|
@ -29,6 +31,7 @@ export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
|
|||
*/
|
||||
export const WebhookModel: z.ZodSchema<CompleteWebhook> = z.lazy(() => _WebhookModel.extend({
|
||||
user: UserModel.nullish(),
|
||||
team: TeamModel.nullish(),
|
||||
eventType: EventTypeModel.nullish(),
|
||||
app: AppModel.nullish(),
|
||||
}))
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -12,6 +12,7 @@ export const ZCreateInputSchema = webhookIdAndEventTypeIdSchema.extend({
|
|||
eventTypeId: z.number().optional(),
|
||||
appId: z.string().optional().nullable(),
|
||||
secret: z.string().optional().nullable(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TCreateInputSchema = z.infer<typeof ZCreateInputSchema>;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -5,6 +5,7 @@ import { webhookIdAndEventTypeIdSchema } from "./types";
|
|||
export const ZDeleteInputSchema = webhookIdAndEventTypeIdSchema.extend({
|
||||
id: z.string(),
|
||||
eventTypeId: z.number().optional(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TDeleteInputSchema = z.infer<typeof ZDeleteInputSchema>;
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -22,6 +22,8 @@ export const getHandler = async ({ ctx: _ctx, input }: GetOptions) => {
|
|||
active: true,
|
||||
eventTriggers: 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 */
|
||||
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) } }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue