feat: org-wide webhooks (#15144)

* feat: include org in teams list

* feat: org-wide webhooks

* Move getOrgIdFromMemberOrTeamId to packages/lib
This commit is contained in:
Kartik Saini
2024-06-04 20:16:46 +00:00
committed by GitHub
parent d72f7f8a42
commit 4017c0e28f
20 changed files with 264 additions and 63 deletions
+7 -1
View File
@@ -7,6 +7,7 @@ import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -60,11 +61,16 @@ const triggerWebhook = async ({
// Send Webhook call if hooked to BOOKING.RECORDING_READY
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
const organizerUserId = triggerForUser ? booking.userId : null;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId: booking.teamId });
const subscriberOptions = {
userId: triggerForUser ? booking.userId : null,
userId: organizerUserId,
eventTypeId: booking.eventTypeId,
triggerEvent: eventTrigger,
teamId: booking.teamId,
orgId,
};
const webhooks = await getWebhooks(subscriberOptions);
@@ -1,9 +1,9 @@
import WeebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = WeebhooksView as CalPageWrapper;
const Page = WebhooksView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -1,9 +1,9 @@
import WeebhookNewView from "@calcom/features/webhooks/pages/webhook-new-view";
import WebhookNewView from "@calcom/features/webhooks/pages/webhook-new-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = WeebhookNewView as CalPageWrapper;
const Page = WebhookNewView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -2,6 +2,7 @@ import type { App_RoutingForms_Form, User } from "@prisma/client";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/client";
import type { Ensure } from "@calcom/types/utils";
@@ -33,9 +34,15 @@ export async function onFormSubmission(
};
}
const { userId, teamId } = getWebhookTargetEntity(form);
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId });
const subscriberOptions = {
userId,
teamId,
orgId,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
...getWebhookTargetEntity(form),
};
const webhooks = await getWebhooks(subscriberOptions);
@@ -2,6 +2,7 @@ import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/ema
import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWebhookPayloadForBooking";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
@@ -39,12 +40,18 @@ export async function handleBookingRequested(args: {
await sendOrganizerRequestEmail({ ...evt });
await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]);
const orgId = await getOrgIdFromMemberOrTeamId({
memberId: booking.userId,
teamId: booking.eventType?.teamId,
});
try {
const subscribersBookingRequested = await getWebhooks({
userId: booking.userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
teamId: booking.eventType?.teamId,
orgId,
});
const webhookPayload = getWebhookPayloadForBooking({
@@ -16,6 +16,7 @@ import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/sc
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -183,12 +184,16 @@ async function handler(req: CustomRequest) {
},
});
const triggerForUser = !teamId || (teamId && bookingToDelete.eventType?.parentId);
const organizerUserId = triggerForUser ? bookingToDelete.userId : null;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId });
const subscriberOptions = {
userId: triggerForUser ? bookingToDelete.userId : null,
userId: organizerUserId,
eventTypeId: bookingToDelete.eventTypeId as number,
triggerEvent: eventTrigger,
teamId,
orgId,
};
const eventTypeInfo: EventTypeInfo = {
eventTitle: bookingToDelete?.eventType?.title || null,
@@ -10,6 +10,7 @@ import { scheduleTrigger } from "@calcom/features/webhooks/lib/scheduleTrigger";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -302,23 +303,30 @@ export async function handleConfirmation(args: {
const triggerForUser = !teamId || (teamId && booking.eventType?.parentId);
const userId = triggerForUser ? booking.userId : null;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId });
const subscribersBookingCreated = await getWebhooks({
userId: triggerForUser ? booking.userId : null,
userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
teamId,
orgId,
});
const subscribersMeetingStarted = await getWebhooks({
userId: triggerForUser ? booking.userId : null,
userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
teamId: booking.eventType?.teamId,
orgId,
});
const subscribersMeetingEnded = await getWebhooks({
userId: triggerForUser ? booking.userId : null,
userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId: booking.eventType?.teamId,
orgId,
});
const scheduleTriggerPromises: Promise<unknown>[] = [];
@@ -381,10 +389,11 @@ export async function handleConfirmation(args: {
if (paid) {
let paymentExternalId: string | undefined;
const subscriberMeetingPaid = await getWebhooks({
userId: triggerForUser ? booking.userId : null,
userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_PAID,
teamId: booking.eventType?.teamId,
orgId,
});
const bookingWithPayment = await prisma.booking.findFirst({
where: {
@@ -70,6 +70,7 @@ import { ErrorCode } from "@calcom/lib/errorCodes";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
@@ -1663,11 +1664,16 @@ async function handler(
const triggerForUser = !teamId || (teamId && eventType.parentId);
const organizerUserId = triggerForUser ? organizerUser.id : null;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId });
const subscriberOptions: GetSubscriberOptions = {
userId: triggerForUser ? organizerUser.id : null,
userId: organizerUserId,
eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
teamId,
orgId,
};
const eventTrigger: WebhookTriggerEvents = rescheduleUid
@@ -1681,6 +1687,7 @@ async function handler(
eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId,
orgId,
};
const subscriberOptionsMeetingStarted = {
@@ -1688,6 +1695,7 @@ async function handler(
eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
teamId,
orgId,
};
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
@@ -2335,6 +2343,7 @@ async function handler(
eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
teamId,
orgId,
};
await handleWebhookTrigger({
subscriberOptions: subscriberOptionsPaymentInitiated,
@@ -17,6 +17,7 @@ import { getFullName } from "@calcom/features/form-builder/utils";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
@@ -25,18 +26,32 @@ import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
const handleInstantMeetingWebhookTrigger = async (args: {
eventTypeId: number;
webhookData: Record<string, unknown>;
teamId: number;
}) => {
const orgId = (await getOrgIdFromMemberOrTeamId({ teamId: args.teamId })) ?? 0;
try {
const eventTrigger = WebhookTriggerEvents.INSTANT_MEETING;
const subscribers = await prisma.webhook.findMany({
where: {
OR: [
{
teamId: {
in: [orgId, args.teamId],
},
},
{
eventTypeId: args.eventTypeId,
},
],
AND: {
eventTypeId: args.eventTypeId,
eventTriggers: {
has: eventTrigger,
},
active: true,
active: {
equals: true,
},
},
},
select: {
@@ -213,6 +228,7 @@ async function handler(req: NextApiRequest) {
await handleInstantMeetingWebhookTrigger({
eventTypeId: eventType.id,
webhookData,
teamId: eventType.team?.id,
});
return {
@@ -7,12 +7,15 @@ export type GetSubscriberOptions = {
eventTypeId?: number | null;
triggerEvent: WebhookTriggerEvents;
teamId?: number | null;
orgId?: number | null;
};
const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient = defaultPrisma) => {
const userId = options.userId ?? 0;
const eventTypeId = options.eventTypeId ?? 0;
const teamId = options.teamId ?? 0;
const orgId = options.orgId ?? 0;
// if we have userId and teamId it is a managed event type and should trigger for team and user
const allWebhooks = await prisma.webhook.findMany({
where: {
@@ -27,7 +30,9 @@ const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient =
eventTypeId,
},
{
teamId,
teamId: {
in: [teamId, orgId],
},
},
],
AND: {
@@ -355,18 +355,72 @@ export async function updateTriggerForExistingBookings(
if (Array.isArray(where.AND)) {
if (webhook.teamId) {
const teamEvents = await prisma.eventType.findMany({
const org = await prisma.team.findFirst({
where: {
teamId: webhook.teamId,
id: webhook.teamId,
isOrganization: true,
},
select: {
bookings: {
where,
id: true,
children: {
select: {
id: true,
},
},
members: {
select: {
userId: true,
},
},
},
});
// checking if teamId is an org id
if (org) {
const teamEvents = await prisma.eventType.findMany({
where: {
teamId: {
in: org.children.map((team) => team.id),
},
},
select: {
bookings: {
where,
},
},
});
const teamEventBookings = teamEvents.flatMap((event) => event.bookings);
const teamBookingsId = teamEventBookings.map((booking) => booking.id);
const orgMemberIds = org.members.map((member) => member.userId);
where.AND.push({
userId: {
in: orgMemberIds,
},
});
// don't want to get the team bookings again
where.AND.push({
id: {
notIn: teamBookingsId,
},
});
const userBookings = await prisma.booking.findMany({
where,
});
// add teams bookings and users bookings to get total org bookings
bookings = teamEventBookings.concat(userBookings);
} else {
const teamEvents = await prisma.eventType.findMany({
where: {
teamId: webhook.teamId,
},
select: {
bookings: {
where,
},
},
});
bookings = teamEvents.flatMap((event) => event.bookings);
bookings = teamEvents.flatMap((event) => event.bookings);
}
} else {
if (webhook.eventTypeId) {
where.AND.push({ eventTypeId: webhook.eventTypeId });
@@ -0,0 +1,49 @@
import prisma from "@calcom/prisma";
export default async function getOrgIdFromMemberOrTeamId(args: {
memberId?: number | null;
teamId?: number | null;
}) {
const userId = args.memberId ?? 0;
const teamId = args.teamId ?? 0;
const orgId = await prisma.team.findFirst({
where: {
OR: [
{
AND: [
{
members: {
some: {
userId,
accepted: true,
},
},
},
{
isOrganization: true,
},
],
},
{
AND: [
{
children: {
some: {
id: teamId,
},
},
},
{
isOrganization: true,
},
],
},
],
},
select: {
id: true,
},
});
return orgId?.id;
}
@@ -1,7 +1,10 @@
import authedProcedure from "../../../procedures/authedProcedure";
import { ZTeamsAndUserProfilesQueryInputSchema } from "../teamsAndUserProfilesQuery.schema";
export const teamsAndUserProfilesQuery = authedProcedure.query(async ({ ctx }) => {
const handler = (await import("../teamsAndUserProfilesQuery.handler")).teamsAndUserProfilesQuery;
export const teamsAndUserProfilesQuery = authedProcedure
.input(ZTeamsAndUserProfilesQueryInputSchema)
.query(async ({ ctx, input }) => {
const handler = (await import("../teamsAndUserProfilesQuery.handler")).teamsAndUserProfilesQuery;
return handler({ ctx });
});
return handler({ ctx, input });
});
@@ -7,14 +7,17 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TTeamsAndUserProfilesQueryInputSchema } from "./teamsAndUserProfilesQuery.schema";
type TeamsAndUserProfileOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TTeamsAndUserProfilesQueryInputSchema;
};
export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOptions) => {
export const teamsAndUserProfilesQuery = async ({ ctx, input }: TeamsAndUserProfileOptions) => {
const { prisma } = ctx;
const user = await prisma.user.findUnique({
@@ -56,15 +59,27 @@ export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOpti
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const nonOrgTeams = user.teams
.filter((membership) => !membership.team.isOrganization)
.map((membership) => ({
let teamsData;
if (input?.includeOrg) {
teamsData = user.teams.map((membership) => ({
...membership,
team: {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
},
}));
} else {
teamsData = user.teams
.filter((membership) => !membership.team.isOrganization)
.map((membership) => ({
...membership,
team: {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
},
}));
}
return [
{
@@ -76,7 +91,7 @@ export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOpti
}),
readOnly: false,
},
...nonOrgTeams.map((membership) => ({
...teamsData.map((membership) => ({
teamId: membership.team.id,
name: membership.team.name,
slug: membership.team.slug ? `team/${membership.team.slug}` : null,
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZTeamsAndUserProfilesQueryInputSchema = z
.object({
includeOrg: z.boolean().optional(),
})
.optional();
export type TTeamsAndUserProfilesQueryInputSchema = z.infer<typeof ZTeamsAndUserProfilesQueryInputSchema>;
@@ -10,6 +10,7 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { getTranslation } from "@calcom/lib/server";
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
@@ -370,12 +371,15 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
},
});
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: booking.userId, teamId });
// send BOOKING_REJECTED webhooks
const subscriberOptions = {
userId: booking.userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_REJECTED,
teamId,
orgId,
};
const eventTrigger: WebhookTriggerEvents = WebhookTriggerEvents.BOOKING_REJECTED;
const eventTypeInfo: EventTypeInfo = {
@@ -16,6 +16,7 @@ import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/sc
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -290,13 +291,16 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
});
const triggerForUser = !teamId || (teamId && bookingToReschedule.eventType?.parentId);
const userId = triggerForUser ? bookingToReschedule.userId : null;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId });
// Send Webhook call if hooked to BOOKING.CANCELLED
const subscriberOptions = {
userId: triggerForUser ? bookingToReschedule.userId : null,
userId,
eventTypeId: bookingToReschedule.eventTypeId as number,
triggerEvent: eventTrigger,
teamId,
orgId,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) =>
@@ -4,6 +4,7 @@ import appStore from "@calcom/app-store";
import dayjs from "@calcom/dayjs";
import { sendNoShowFeeChargedEmail } from "@calcom/emails";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTranslation } from "@calcom/lib/server/i18n";
import sendPayload from "@calcom/lib/server/webhooks/sendPayload";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -124,10 +125,14 @@ export const paymentsRouter = router({
throw new TRPCError({ code: "NOT_FOUND", message: `Could not generate payment data` });
}
const userId = ctx.user.id || 0;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId });
const subscriberOptions = {
userId: ctx.user.id || 0,
userId,
eventTypeId: booking.eventTypeId || 0,
triggerEvent: WebhookTriggerEvents.BOOKING_PAID,
orgId,
};
const subscribers = await getWebhooks(subscriberOptions);
@@ -111,37 +111,29 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
membershipRole: membership.role,
}));
const teamWebhookGroups: WebhookGroup[] = user.teams
.filter((mmship) => {
return !mmship.team.isOrganization;
})
.map((membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
return {
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: membership.team.slug
? !membership.team.parentId
? `/team`
: `${membership.team.slug}`
: null,
image: `${bookerUrl}/team/${membership.team.slug}/avatar.png`,
},
metadata: {
readOnly:
membership.role ===
(membership.team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
};
});
const teamWebhookGroups: WebhookGroup[] = user.teams.map((membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
return {
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: membership.team.slug ? (!membership.team.parentId ? `/team` : `${membership.team.slug}`) : null,
image: `${bookerUrl}/team/${membership.team.slug}/avatar.png`,
},
metadata: {
readOnly:
membership.role ===
(membership.team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
};
});
webhookGroups = webhookGroups.concat(teamWebhookGroups);
@@ -10,7 +10,9 @@ export function CreateButtonWithTeamsList(
isAdmin?: boolean;
}
) {
const query = trpc.viewer.teamsAndUserProfilesQuery.useQuery();
const query = trpc.viewer.teamsAndUserProfilesQuery.useQuery({
includeOrg: true,
});
if (!query.data) return null;
const teamsAndUserProfiles: Option[] = query.data