feat: OOO webhook and zapier (#15434)

* zapier and webhook

* instructions for zapier

* Fix types

* fix types

* revert adding zap template

* Update packages/app-store/zapier/README.md

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Update packages/app-store/zapier/README.md

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Update packages/app-store/zapier/README.md

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Update packages/features/webhooks/lib/scheduleTrigger.ts

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* wip

* Fixes from comments, no more sendPayloadNoBooking

* fix subscriberOptions teamIds param type

* types

* Update packages/features/webhooks/lib/sendPayload.ts

* Re add zapierPayload to don't break old zaps

* instead of metadata use oooEntry inside webhookPayload

* undo comment message

* Types

* reset changes on yarn.lock

* review changes

* fix types

* fix types

* tentative fix for types in webhook

* type improvements

* fix description + clean up

* revert yarn.lock changes

* allow custom template for ooo entry

* type fixes

* type fix

* fix donwloadLinks in payload

* simplify some types

* allow array or number for teamId

* same payload for test trigger

* code clean up

* fix no show e2e test

* translate text for webhook payload and fix conditional

* add test for ooo_created webhooks

* reset files

* merge fix

* fix test data test id

* remove unused variable

* only trigger for accepted memberships

* remove unused variable

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
alannnc
2024-09-06 15:48:05 -04:00
committed by GitHub
co-authored by Carina Wollendorfer CarinaWolli Udit Takkar Keith Williams Omar López
parent 105e7d3e28
commit f5c16f0d6b
27 changed files with 606 additions and 126 deletions
+13 -8
View File
@@ -1,6 +1,7 @@
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventPayloadType } from "@calcom/features/webhooks/lib/sendPayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -53,11 +54,13 @@ export const triggerRecordingReadyWebhook = async ({
})
);
const payload: EventPayloadType = {
...evt,
downloadLink,
};
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
downloadLink,
}).catch((e) => {
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, payload).catch((e) => {
log.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
@@ -91,16 +94,18 @@ export const triggerTranscriptionGeneratedWebhook = async ({
})
);
const payload: EventPayloadType = {
...evt,
downloadLinks,
};
const promises = webhooks.map((webhook) =>
sendPayload(
webhook.secret,
WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
new Date().toISOString(),
webhook,
{
...evt,
downloadLinks,
}
payload
).catch((e) => {
log.error(
`Error executing webhook for event: ${WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
@@ -23,7 +23,7 @@ const OutOfOfficePage = () => {
const { t } = useLocale();
const params = useSearchParams();
const openModalOnStart = !!params?.get("om");
const openModalOnStart = !!params?.get("open");
useEffect(() => {
if (openModalOnStart) {
setOpenModal(true);
+1 -1
View File
@@ -107,7 +107,7 @@ test.describe("Bookings", () => {
createdAt: "[redacted/dynamic]",
payload: {
message: "first@cal.com marked as no-show",
attendees: [{ email: "first@cal.com", noShow: true, utcOffset: null }],
attendees: [{ email: "first@cal.com", noShow: true }],
bookingUid: bookingWhereFirstUserIsOrganizer?.uid,
bookingId: bookingWhereFirstUserIsOrganizer?.id,
},
+136
View File
@@ -741,3 +741,139 @@ test.describe("FORM_SUBMITTED", async () => {
webhookReceiver.close();
});
});
test.describe("OOO_CREATED", async () => {
test("on creating an OOO, triggers OOO webhook", async ({ page, users, webhooks }) => {
const user = await users.create();
await user.apiLogin();
const webhookReceiver = await webhooks.createReceiver();
await page.goto("/settings/my-account/out-of-office");
await page.getByTestId("add_entry_ooo").click();
await page.getByTestId("reason_select").click();
await page.getByTestId("select-option-4").click();
await page.getByTestId("notes_input").click();
await page.getByTestId("notes_input").fill("Demo notes");
await page.getByTestId("create-or-edit-entry-ooo-redirect").click();
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any;
body.createdAt = dynamic;
body.payload.oooEntry.createdAt = dynamic;
body.payload.oooEntry.updatedAt = dynamic;
body.payload.oooEntry.end = dynamic;
body.payload.oooEntry.id = dynamic;
body.payload.oooEntry.start = dynamic;
body.payload.oooEntry.updatedAt = dynamic;
body.payload.oooEntry.user.id = dynamic;
body.payload.oooEntry.user.email = dynamic;
body.payload.oooEntry.user.username = dynamic;
body.payload.oooEntry.uuid = dynamic;
expect(body).toEqual({
createdAt: "[redacted/dynamic]",
payload: {
oooEntry: {
createdAt: "[redacted/dynamic]",
end: "[redacted/dynamic]",
id: "[redacted/dynamic]",
notes: "Demo notes",
reason: {
emoji: "🤒",
reason: "ooo_reasons_sick_leave",
},
reasonId: 4,
start: "[redacted/dynamic]",
toUser: null,
updatedAt: "[redacted/dynamic]",
user: {
id: "[redacted/dynamic]",
email: "[redacted/dynamic]",
name: null,
timeZone: "Europe/London",
username: "[redacted/dynamic]",
},
uuid: "[redacted/dynamic]",
},
},
triggerEvent: "OOO_CREATED",
});
webhookReceiver.close();
});
test("on creating an OOO inside a team, triggers OOO webhook", async ({ page, users, webhooks }) => {
const user = await users.create(null, {
hasTeam: true,
});
await user.apiLogin();
const { webhookReceiver, teamId } = await webhooks.createTeamReceiver();
await page.goto("/settings/my-account/out-of-office");
await page.getByTestId("add_entry_ooo").click();
await page.getByTestId("reason_select").click();
await page.getByTestId("select-option-4").click();
await page.getByTestId("notes_input").click();
await page.getByTestId("notes_input").fill("Demo notes");
await page.getByTestId("create-or-edit-entry-ooo-redirect").click();
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const body = request.body as any;
body.createdAt = dynamic;
body.payload.oooEntry.createdAt = dynamic;
body.payload.oooEntry.updatedAt = dynamic;
body.payload.oooEntry.end = dynamic;
body.payload.oooEntry.id = dynamic;
body.payload.oooEntry.start = dynamic;
body.payload.oooEntry.updatedAt = dynamic;
body.payload.oooEntry.user.id = dynamic;
body.payload.oooEntry.user.email = dynamic;
body.payload.oooEntry.user.username = dynamic;
body.payload.oooEntry.uuid = dynamic;
expect(body).toEqual({
triggerEvent: "OOO_CREATED",
createdAt: "[redacted/dynamic]",
payload: {
oooEntry: {
createdAt: "[redacted/dynamic]",
end: "[redacted/dynamic]",
id: "[redacted/dynamic]",
notes: "Demo notes",
reason: {
emoji: "🤒",
reason: "ooo_reasons_sick_leave",
},
reasonId: 4,
start: "[redacted/dynamic]",
toUser: null,
updatedAt: "[redacted/dynamic]",
user: {
id: "[redacted/dynamic]",
email: "[redacted/dynamic]",
name: null,
timeZone: "Europe/London",
username: "[redacted/dynamic]",
},
uuid: "[redacted/dynamic]",
},
},
});
webhookReceiver.close();
});
});
@@ -487,6 +487,7 @@
"meeting_ended": "Meeting Ended",
"form_submitted": "Form Submitted",
"booking_paid": "Booking Paid",
"ooo_created": "Out of Office Created",
"booking_no_show_updated": "Booking No-Show Updated",
"event_triggers": "Event Triggers",
"subscriber_url": "Subscriber URL",
+24 -3
View File
@@ -33,7 +33,7 @@ If you run it on localhost, check out the [additional information](https://githu
## Triggers
Booking created, Booking rescheduled, Booking cancelled, Meeting ended
Booking created, Booking rescheduled, Booking cancelled, Meeting ended, Out Of Office Created
### Booking created
@@ -56,9 +56,30 @@ Booking created, Booking rescheduled, Booking cancelled, Meeting ended
Create the other triggers (booking rescheduled, booking cancelled and meeting ended) exactly like this one, just use the appropriate naming (e.g. booking_rescheduled instead of booking_created)
### Out Of Office Created
1. Settings
- Key: ooo_created
- Name: Out Of Office Created
- Noun: OOO Entry
- Description: Triggers when a new Out Of Office entry is created.
2. API Configuration (apiKey is set automatically, leave it like it is):
- Trigger Type: REST Hook
- Subscribe: POST `<baseUrl>`/api/integrations/zapier/addSubscription
- Request Body
- subscriberUrl: {{bundle.targetUrl}}
- triggerEvent: OOO_CREATED
- Unsubscribe: DELETE `<baseUrl>`/api/integrations/zapier/deleteSubscription
- URL Params (in addition to apiKey)
- id: {{bundle.subscribeData.id}}
- PerformList: GET `<baseUrl>`/api/integrations/zapier/listOOOEntries
3. Test your API request
4. Note: When creating the ZAP you need to remember that data is stored in the { payload: { oooEntry: { ... } } }
### Set ZAPIER_INVITE_LINK
The invite link can be found under under Manage → Sharing.
The invite link can be found under Manage → Sharing.
## Localhost
@@ -67,5 +88,5 @@ Localhost urls can not be used as the base URL for api endpoints
Possible solution: using [https://ngrok.com/](https://ngrok.com/)
1. Create Account
2. [Download](https://ngrok.com/download) gnork and start a tunnel to your running localhost
2. [Download](https://ngrok.com/download) ngrok and start a tunnel to your running localhost
- Use forwarding url as your baseUrl for the URL endpoints
+1
View File
@@ -1,5 +1,6 @@
export { default as add } from "./add";
export { default as listBookings } from "./subscriptions/listBookings";
export { default as listOOOEntries } from "./subscriptions/listOOOEntries";
export { default as deleteSubscription } from "./subscriptions/deleteSubscription";
export { default as addSubscription } from "./subscriptions/addSubscription";
export { default as me } from "./subscriptions/me";
@@ -0,0 +1,76 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { listOOOEntries } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { validateAccountOrApiKey } from "../../lib/validateAccountOrApiKey";
export const selectOOOEntries = {
id: true,
start: true,
end: true,
createdAt: true,
updatedAt: true,
notes: true,
reason: {
select: {
reason: true,
emoji: true,
},
},
reasonId: true,
user: {
select: {
id: true,
name: true,
email: true,
timeZone: true,
},
},
toUser: {
select: {
id: true,
name: true,
email: true,
timeZone: true,
},
},
uuid: true,
};
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { account: authorizedAccount, appApiKey: validKey } = await validateAccountOrApiKey(req, [
"READ_PROFILE",
]);
if (!authorizedAccount && !validKey) {
res.status(401).json({ message: "Unauthorized" });
return;
}
const oooEntries = await listOOOEntries(validKey, authorizedAccount);
if (!oooEntries) {
res.status(500).json({ message: "Unable to get out of office entries list." });
return;
}
if (oooEntries.length === 0) {
res.status(200).json([]);
return;
}
// Wrap entries in metadata object
const response = oooEntries.map((oooEntry) => {
return {
createdAt: oooEntry.createdAt,
triggerEvent: WebhookTriggerEvents.OOO_CREATED,
payload: {
oooEntry,
},
};
});
res.status(200).json(response);
return;
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
@@ -1,4 +1,4 @@
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import type { CalendarEvent } from "@calcom/types/Calendar";
export const getWebhookPayloadForBooking = ({
@@ -29,9 +29,12 @@ export const getWebhookPayloadForBooking = ({
currency: booking.eventType?.currency,
length: booking.eventType?.length,
};
return {
const payload: EventPayloadType = {
...evt,
...eventTypeInfo,
bookingId: booking.id,
};
return payload;
};
@@ -13,7 +13,7 @@ import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
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 type { EventPayloadType, 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";
@@ -397,16 +397,24 @@ export async function handleConfirmation(args: {
length: eventType?.length,
};
const payload: EventPayloadType = {
...evt,
...eventTypeInfo,
bookingId,
eventTypeId: eventType?.id,
status: "ACCEPTED",
smsReminderNumber: booking.smsReminderNumber || undefined,
metadata: meetingUrl ? { videoCallUrl: meetingUrl } : undefined,
};
const promises = subscribersBookingCreated.map((sub) =>
sendPayload(sub.secret, WebhookTriggerEvents.BOOKING_CREATED, new Date().toISOString(), sub, {
...evt,
...eventTypeInfo,
bookingId,
eventTypeId: eventType?.id,
status: "ACCEPTED",
smsReminderNumber: booking.smsReminderNumber || undefined,
metadata: meetingUrl ? { videoCallUrl: meetingUrl } : undefined,
}).catch((e) => {
sendPayload(
sub.secret,
WebhookTriggerEvents.BOOKING_CREATED,
new Date().toISOString(),
sub,
payload
).catch((e) => {
log.error(
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_CREATED}, URL: ${sub.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
@@ -452,19 +460,20 @@ export async function handleConfirmation(args: {
eventTitle: eventType?.title,
externalId: paymentExternalId,
};
payload.paymentId = bookingWithPayment?.payment?.[0].id;
payload.metadata = {
...(paid ? paymentMetadata : {}),
};
const bookingPaidSubscribers = subscriberMeetingPaid.map((sub) =>
sendPayload(sub.secret, WebhookTriggerEvents.BOOKING_PAID, new Date().toISOString(), sub, {
...evt,
...eventTypeInfo,
bookingId,
eventTypeId: eventType?.id,
status: "ACCEPTED",
smsReminderNumber: booking.smsReminderNumber || undefined,
paymentId: bookingWithPayment?.payment?.[0].id,
metadata: {
...(paid ? paymentMetadata : {}),
},
}).catch((e) => {
sendPayload(
sub.secret,
WebhookTriggerEvents.BOOKING_PAID,
new Date().toISOString(),
sub,
payload
).catch((e) => {
log.error(
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_PAID}, URL: ${sub.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
@@ -84,7 +84,7 @@ import type {
} from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
import type { EventPayloadType, EventTypeInfo } from "../../webhooks/lib/sendPayload";
import { getAllCredentials } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials";
import getBookingDataSchema from "./getBookingDataSchema";
@@ -1547,7 +1547,7 @@ async function handler(
}
: undefined;
const webhookData = {
const webhookData: EventPayloadType = {
...evt,
...eventTypeInfo,
bookingId: booking?.id,
@@ -2,7 +2,7 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { updateMeeting } from "@calcom/core/videoClient";
import { sendCancelledSeatEmails } from "@calcom/emails";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -129,13 +129,21 @@ async function cancelAttendeeSeat(
]
: [];
const payload: EventPayloadType = {
...evt,
...eventTypeInfo,
status: "CANCELLED",
smsReminderNumber: bookingToDelete.smsReminderNumber || undefined,
};
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, WebhookTriggerEvents.BOOKING_CANCELLED, new Date().toISOString(), webhook, {
...evt,
...eventTypeInfo,
status: "CANCELLED",
smsReminderNumber: bookingToDelete.smsReminderNumber || undefined,
}).catch((e) => {
sendPayload(
webhook.secret,
WebhookTriggerEvents.BOOKING_CANCELLED,
new Date().toISOString(),
webhook,
payload
).catch((e) => {
logger.error(
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_CANCELLED}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
@@ -2,6 +2,7 @@
import dayjs from "@calcom/dayjs";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import type { EventPayloadType } from "@calcom/features/webhooks/lib/sendPayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
@@ -125,7 +126,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
}
const webhookData = {
const webhookData: EventPayloadType = {
...evt,
...eventTypeInfo,
uid: resultBooking?.uid || uid,
@@ -1,14 +1,14 @@
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { WebhookDataType } from "@calcom/features/webhooks/lib/sendPayload";
import { isEventPayload, type WebhookPayloadType } from "@calcom/features/webhooks/lib/sendPayload";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
export async function handleWebhookTrigger(args: {
subscriberOptions: GetSubscriberOptions;
eventTrigger: string;
webhookData: Omit<WebhookDataType, "createdAt" | "triggerEvent">;
webhookData: WebhookPayloadType;
}) {
try {
const subscribers = await getWebhooks(args.subscriberOptions);
@@ -16,10 +16,12 @@ export async function handleWebhookTrigger(args: {
const promises = subscribers.map((sub) =>
sendPayload(sub.secret, args.eventTrigger, new Date().toISOString(), sub, args.webhookData).catch(
(e) => {
logger.error(
`Error executing webhook for event: ${args.eventTrigger}, URL: ${sub.subscriberUrl}, bookingId: ${args.webhookData.bookingId}, bookingUid: ${args.webhookData.uid}`,
safeStringify(e)
);
if (isEventPayload(args.webhookData)) {
logger.error(
`Error executing webhook for event: ${args.eventTrigger}, URL: ${sub.subscriberUrl}, booking id: ${args.webhookData.bookingId}, booking uid: ${args.webhookData.uid}`,
safeStringify(e)
);
}
}
)
);
-1
View File
@@ -95,7 +95,6 @@ const handleMarkNoShow = async ({
await webhooks.sendPayload({
...payload,
/** We send webhook message pre-translated, on client we already handle this */
// @ts-expect-error payload is too booking specific, we need to refactor this
bookingUid,
bookingId,
});
@@ -43,6 +43,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
{ value: WebhookTriggerEvents.MEETING_STARTED, label: "meeting_started" },
{ value: WebhookTriggerEvents.RECORDING_READY, label: "recording_ready" },
{ value: WebhookTriggerEvents.INSTANT_MEETING, label: "instant_meeting" },
{ value: WebhookTriggerEvents.OOO_CREATED, label: "ooo_created" },
{
value: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
label: "recording_transcription_generated",
@@ -100,7 +101,6 @@ const WebhookForm = (props: {
const [newSecret, setNewSecret] = useState("");
const [changeSecret, setChangeSecret] = useState<boolean>(false);
const hasSecretKey = !!props?.webhook?.secret;
// const currentSecret = props?.webhook?.secret;
useEffect(() => {
if (changeSecret) {
@@ -290,7 +290,7 @@ const WebhookForm = (props: {
<Button
type="submit"
disabled={!formMethods.formState.isDirty && !changeSecret}
loading={formMethods.formState.isSubmitting || formMethods.formState.isSubmitted}>
loading={formMethods.formState.isSubmitting}>
{props?.webhook?.id ? t("save") : t("create_webhook")}
</Button>
</SectionBottomActions>
@@ -17,6 +17,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.INSTANT_MEETING,
WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
WebhookTriggerEvents.OOO_CREATED,
] as const,
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const,
};
@@ -6,15 +6,16 @@ export type GetSubscriberOptions = {
userId?: number | null;
eventTypeId?: number | null;
triggerEvent: WebhookTriggerEvents;
teamId?: number | null;
teamId?: number | number[] | null;
orgId?: number | null;
oAuthClientId?: string | null;
};
const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient = defaultPrisma) => {
const teamId = options.teamId;
const userId = options.userId ?? 0;
const eventTypeId = options.eventTypeId ?? 0;
const teamId = options.teamId ?? 0;
const teamIds = Array.isArray(teamId) ? teamId : [teamId ?? 0];
const orgId = options.orgId ?? 0;
const oAuthClientId = options.oAuthClientId ?? "";
@@ -33,7 +34,7 @@ const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient =
},
{
teamId: {
in: [teamId, orgId],
in: [...teamIds, orgId],
},
},
{ platformOAuthClientId: oAuthClientId },
@@ -1,6 +1,7 @@
import type { Prisma, Webhook, Booking } from "@prisma/client";
import { v4 } from "uuid";
import { selectOOOEntries } from "@calcom/app-store/zapier/api/subscriptions/listOOOEntries";
import { getHumanReadableLocationValue } from "@calcom/core/location";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import logger from "@calcom/lib/logger";
@@ -451,3 +452,56 @@ export async function updateTriggerForExistingBookings(
);
await Promise.all(promise);
}
export async function listOOOEntries(
appApiKey?: ApiKey,
account?: {
id: number;
name: string | null;
isTeam: boolean;
} | null
) {
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
try {
const where: Prisma.OutOfOfficeEntryWhereInput = {};
if (teamId) {
where.user = {
teams: {
some: {
teamId,
},
},
};
} else if (userId) {
where.userId = userId;
}
// early return
if (!where.userId && !where.user) {
return [];
}
const oooEntries = await prisma.outOfOfficeEntry.findMany({
where: {
...where,
},
take: 3,
orderBy: {
id: "desc",
},
select: selectOOOEntries,
});
if (oooEntries.length === 0) {
return [];
}
return oooEntries;
} catch (err) {
log.error(
`Error retrieving list of ooo entries for user ${userId}. or teamId ${teamId}`,
safeStringify(err)
);
}
}
+83 -36
View File
@@ -33,7 +33,7 @@ export type WithUTCOffsetType<T> = T & {
export type BookingNoShowUpdatedPayload = {
message: string;
bookingUid: string;
bookingId: number;
bookingId?: number;
attendees: { email: string; noShow: boolean }[];
};
@@ -44,9 +44,39 @@ export type TranscriptionGeneratedPayload = {
};
};
export type WebhookDataType = CalendarEvent &
export type OOOEntryPayloadType = {
oooEntry: {
id: number;
start: string;
end: string;
createdAt: string;
updatedAt: string;
notes: string | null;
reason: {
emoji?: string;
reason?: string;
};
reasonId: number;
user: {
id: number;
name: string | null;
username: string | null;
timeZone: string;
email: string;
};
toUser: {
id: number;
name?: string | null;
username?: string | null;
timeZone?: string;
email?: string;
} | null;
uuid: string;
};
};
export type EventPayloadType = CalendarEvent &
TranscriptionGeneratedPayload &
// BookingNoShowUpdatedPayload & // This breaks all other webhooks
EventTypeInfo & {
metadata?: { [key: string]: string | number | boolean | null };
bookingId?: number;
@@ -56,8 +86,6 @@ export type WebhookDataType = CalendarEvent &
rescheduleUid?: string;
rescheduleStartTime?: string;
rescheduleEndTime?: string;
triggerEvent: string;
createdAt: string;
downloadLink?: string;
paymentId?: number;
rescheduledBy?: string;
@@ -65,28 +93,30 @@ export type WebhookDataType = CalendarEvent &
paymentData?: Payment;
};
function addUTCOffset(
data: Omit<WebhookDataType, "createdAt" | "triggerEvent">
): WithUTCOffsetType<WebhookDataType> {
if (data.organizer?.timeZone) {
(data.organizer as Person & UTCOffset).utcOffset = getUTCOffsetByTimezone(
data.organizer.timeZone,
data.startTime
);
export type WebhookPayloadType = EventPayloadType | OOOEntryPayloadType | BookingNoShowUpdatedPayload;
type WebhookDataType = WebhookPayloadType & { triggerEvent: string; createdAt: string };
function addUTCOffset(data: WebhookPayloadType): WithUTCOffsetType<WebhookPayloadType> {
if (isEventPayload(data)) {
if (data.organizer?.timeZone) {
(data.organizer as Person & UTCOffset).utcOffset = getUTCOffsetByTimezone(
data.organizer.timeZone,
data.startTime
);
}
if (data.attendees?.length) {
(data.attendees as (Person & UTCOffset)[]).forEach((attendee) => {
attendee.utcOffset = getUTCOffsetByTimezone(attendee.timeZone, data.startTime);
});
}
}
if (data?.attendees?.length) {
(data.attendees as (Person & UTCOffset)[]).forEach((attendee) => {
attendee.utcOffset = getUTCOffsetByTimezone(attendee.timeZone, data.startTime);
});
}
return data as WithUTCOffsetType<WebhookDataType>;
return data as WithUTCOffsetType<WebhookPayloadType>;
}
function getZapierPayload(
data: WithUTCOffsetType<CalendarEvent & EventTypeInfo & { status?: string; createdAt: string }>
): string {
function getZapierPayload(data: WithUTCOffsetType<EventPayloadType & { createdAt: string }>): string {
const attendees = (data.attendees as (Person & UTCOffset)[]).map((attendee) => {
return {
name: attendee.name,
@@ -150,34 +180,51 @@ export function jsonParse(jsonString: string) {
return false;
}
export function isOOOEntryPayload(data: WebhookPayloadType): data is OOOEntryPayloadType {
return "oooEntry" in data;
}
export function isNoShowPayload(data: WebhookPayloadType): data is BookingNoShowUpdatedPayload {
return "message" in data;
}
export function isEventPayload(data: WebhookPayloadType): data is EventPayloadType {
return !isNoShowPayload(data) && !isOOOEntryPayload(data);
}
const sendPayload = async (
secretKey: string | null,
triggerEvent: string,
createdAt: string,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
data: Omit<WebhookDataType, "createdAt" | "triggerEvent">
data: WebhookPayloadType
) => {
const { appId, payloadTemplate: template } = webhook;
const contentType =
!template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded";
data.description = data.description || data.additionalNotes;
data = addUTCOffset(data);
let body;
/* Zapier id is hardcoded in the DB, we send the raw data for this case */
if (appId === "zapier") {
body = getZapierPayload({ ...data, createdAt });
} else if (template) {
body = applyTemplate(template, { ...data, triggerEvent, createdAt }, contentType);
} else {
body = JSON.stringify({
triggerEvent: triggerEvent,
createdAt: createdAt,
payload: data,
});
if (isEventPayload(data)) {
data.description = data.description || data.additionalNotes;
if (appId === "zapier") {
body = getZapierPayload({ ...data, createdAt });
}
}
if (body === undefined) {
if (template && (isOOOEntryPayload(data) || isEventPayload(data) || isNoShowPayload(data))) {
body = applyTemplate(template, { ...data, triggerEvent, createdAt }, contentType);
} else {
body = JSON.stringify({
triggerEvent: triggerEvent,
createdAt: createdAt,
payload: data,
});
}
}
return _sendPayload(secretKey, webhook, body, contentType);
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'OOO_CREATED';
+1
View File
@@ -754,6 +754,7 @@ enum WebhookTriggerEvents {
RECORDING_READY
INSTANT_MEETING
RECORDING_TRANSCRIPTION_GENERATED
OOO_CREATED
}
model Webhook {
@@ -1,7 +1,13 @@
import type { Prisma } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import { selectOOOEntries } from "@calcom/app-store/zapier/api/subscriptions/listOOOEntries";
import dayjs from "@calcom/dayjs";
import { sendBookingRedirectNotification } from "@calcom/emails";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import type { OOOEntryPayloadType } from "@calcom/features/webhooks/lib/sendPayload";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { getTranslation } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -9,6 +15,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TOutOfOfficeDelete, TOutOfOfficeInputSchema } from "./outOfOffice.schema";
import { WebhookTriggerEvents } from ".prisma/client";
type TBookingRedirect = {
ctx: {
@@ -44,7 +51,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_must_be_in_the_future" });
}
let toUserId;
let toUserId: number | null = null;
if (input.toTeamUserId) {
const user = await prisma.user.findUnique({
@@ -130,7 +137,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
toUserId: true,
},
where: {
userId: toUserId,
...(toUserId && { userId: toUserId }),
toUserId: ctx.user.id,
// Check for time overlap or collision
OR: [
@@ -200,7 +207,43 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
toUserId: toUserId ? toUserId : null,
},
});
let resultRedirect: Prisma.OutOfOfficeEntryGetPayload<{ select: typeof selectOOOEntries }> | null = null;
if (createdOrUpdatedOutOfOffice) {
const findRedirect = await prisma.outOfOfficeEntry.findFirst({
where: {
uuid: createdOrUpdatedOutOfOffice.uuid,
},
select: selectOOOEntries,
});
if (findRedirect) {
resultRedirect = findRedirect;
}
}
if (!resultRedirect) {
return;
}
const toUser = toUserId
? await prisma.user.findFirst({
where: {
id: toUserId,
},
select: {
name: true,
username: true,
timeZone: true,
email: true,
},
})
: null;
const reason = await prisma.outOfOfficeReason.findFirst({
where: {
id: input.reasonId,
},
select: {
reason: true,
emoji: true,
},
});
if (toUserId) {
// await send email to notify user
const userToNotify = await prisma.user.findFirst({
@@ -275,6 +318,76 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
}
}
const memberships = await prisma.membership.findMany({
where: {
userId: ctx.user.id,
accepted: true,
},
});
const teamIds = memberships.map((membership) => membership.teamId);
// Send webhook to notify other services
const subscriberOptions: GetSubscriberOptions = {
userId: ctx.user.id,
teamId: teamIds,
orgId: ctx.user.organizationId,
triggerEvent: WebhookTriggerEvents.OOO_CREATED,
};
const subscribers = await getWebhooks(subscriberOptions);
const payload: OOOEntryPayloadType = {
oooEntry: {
id: createdOrUpdatedOutOfOffice.id,
start: dayjs(createdOrUpdatedOutOfOffice.start)
.tz(ctx.user.timeZone, true)
.format("YYYY-MM-DDTHH:mm:ssZ"),
end: dayjs(createdOrUpdatedOutOfOffice.end).tz(ctx.user.timeZone, true).format("YYYY-MM-DDTHH:mm:ssZ"),
createdAt: createdOrUpdatedOutOfOffice.createdAt.toISOString(),
updatedAt: createdOrUpdatedOutOfOffice.updatedAt.toISOString(),
notes: createdOrUpdatedOutOfOffice.notes,
reason: {
emoji: reason?.emoji,
reason: reason?.reason,
},
reasonId: input.reasonId,
user: {
id: ctx.user.id,
name: ctx.user.name,
username: ctx.user.username,
email: ctx.user.email,
timeZone: ctx.user.timeZone,
},
toUser: toUserId
? {
id: toUserId,
name: toUser?.name,
username: toUser?.username,
email: toUser?.email,
timeZone: toUser?.timeZone,
}
: null,
uuid: createdOrUpdatedOutOfOffice.uuid,
},
};
await Promise.all(
subscribers.map(async (subscriber) => {
sendPayload(
subscriber.secret,
WebhookTriggerEvents.OOO_CREATED,
dayjs().toISOString(),
{
appId: subscriber.appId,
subscriberUrl: subscriber.subscriberUrl,
payloadTemplate: subscriber.payloadTemplate,
},
payload
);
})
);
return {};
};
@@ -8,7 +8,7 @@ import { sendDeclinedEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import type { EventPayloadType, 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";
@@ -399,7 +399,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
currency: booking.eventType?.currency,
length: booking.eventType?.length,
};
const webhookData = {
const webhookData: EventPayloadType = {
...evt,
...eventTypeInfo,
bookingId,
@@ -296,10 +296,11 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
userId,
eventTypeId: bookingToReschedule.eventTypeId as number,
triggerEvent: eventTrigger,
teamId,
teamId: teamId ? [teamId] : null,
orgId,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
@@ -1,4 +1,5 @@
import type { Webhook } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import { v4 } from "uuid";
import { updateTriggerForExistingBookings } from "@calcom/features/webhooks/lib/scheduleTrigger";
@@ -17,33 +18,29 @@ type CreateOptions = {
};
export const createHandler = async ({ ctx, input }: CreateOptions) => {
const { user } = ctx;
const webhookData: Prisma.WebhookCreateInput = {
id: v4(),
...input,
};
if (input.platform && user.role !== "ADMIN") {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// Add userId if platform, eventTypeId, and teamId are not provided
if (!input.platform && !input.eventTypeId && !input.teamId) {
webhookData.user = { connect: { id: user.id } };
}
let newWebhook: Webhook;
if (input.platform) {
const { user } = ctx;
if (user?.role !== "ADMIN") {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
try {
newWebhook = await prisma.webhook.create({
data: {
id: v4(),
...input,
},
});
} else if (input.eventTypeId || input.teamId) {
newWebhook = await prisma.webhook.create({
data: {
id: v4(),
...input,
},
});
} else {
newWebhook = await prisma.webhook.create({
data: {
id: v4(),
userId: ctx.user.id,
...input,
},
data: webhookData,
});
} catch (error) {
// Avoid printing raw prisma error on frontend
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create webhook" });
}
await updateTriggerForExistingBookings(newWebhook, [], newWebhook.eventTriggers);
@@ -1,3 +1,4 @@
import type { EventPayloadType } from "@calcom/features/webhooks/lib/sendPayload";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { getTranslation } from "@calcom/lib/server/i18n";
@@ -17,7 +18,7 @@ export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOption
translate: translation,
};
const data = {
const data: EventPayloadType = {
type: "Test",
title: "Test trigger event",
description: "",