Skip to content

Commit 110615b

Browse files
chore: add webhook architecture skeleton (calcom#23247)
* add migration guide and dto * add factory * add notifier * add repo * add services * coderabbit review --1 * coderabbit review --2 * coderabbit review --3 * further improvement * -- * fix * bookingWebhookFactory consideration and type fixes * cleanup * fix types * DI part1 * DI --part 2 * remove migrationGuide as we're WIP * using evyweb for DI -- 1 * DI --final * separate func instead of private class * adds a todo migration file * adjust structure * address feedback * remove todo_migrate * --1 * fix type * address feedback * add TODO comment * address requested changes --1 * address feedback --2 * restructure as per feedback * rename camelcase
1 parent 3915348 commit 110615b

30 files changed

Lines changed: 3076 additions & 0 deletions

packages/features/webhooks/lib/dto/types.ts

Lines changed: 513 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
2+
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
3+
import type { CalendarEvent } from "@calcom/types/Calendar";
4+
5+
import type { EventTypeInfo, BookingWebhookEventDTO } from "../dto/types";
6+
import type { WebhookPayload } from "./types";
7+
8+
type BookingExtraDataMap = {
9+
[WebhookTriggerEvents.BOOKING_CREATED]: null;
10+
[WebhookTriggerEvents.BOOKING_CANCELLED]: { cancelledBy?: string; cancellationReason?: string };
11+
[WebhookTriggerEvents.BOOKING_REQUESTED]: null;
12+
[WebhookTriggerEvents.BOOKING_REJECTED]: null;
13+
[WebhookTriggerEvents.BOOKING_RESCHEDULED]: {
14+
rescheduleId?: number;
15+
rescheduleUid?: string;
16+
rescheduleStartTime?: string;
17+
rescheduleEndTime?: string;
18+
rescheduledBy?: string;
19+
};
20+
[WebhookTriggerEvents.BOOKING_PAID]: { paymentId?: number; paymentData?: Record<string, unknown> };
21+
[WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED]: {
22+
paymentId?: number;
23+
paymentData?: Record<string, unknown>;
24+
};
25+
};
26+
27+
interface CreateBookingWebhookPayloadParams<T extends keyof BookingExtraDataMap> {
28+
booking: {
29+
id: number;
30+
eventTypeId: number | null;
31+
userId: number | null;
32+
smsReminderNumber?: string | null;
33+
};
34+
eventType: EventTypeInfo;
35+
evt: CalendarEvent;
36+
status: BookingStatus;
37+
triggerEvent: WebhookTriggerEvents;
38+
createdAt: string;
39+
extra?: BookingExtraDataMap[T];
40+
}
41+
42+
function createBookingWebhookPayload<T extends keyof BookingExtraDataMap>(
43+
params: CreateBookingWebhookPayloadParams<T>
44+
): WebhookPayload {
45+
const utcOffsetOrganizer = getUTCOffsetByTimezone(params.evt.organizer?.timeZone, params.evt.startTime);
46+
const organizer = { ...params.evt.organizer, utcOffset: utcOffsetOrganizer };
47+
48+
return {
49+
triggerEvent: params.triggerEvent,
50+
createdAt: params.createdAt,
51+
payload: {
52+
...params.evt,
53+
bookingId: params.booking.id,
54+
startTime: params.evt.startTime,
55+
endTime: params.evt.endTime,
56+
title: params.evt.title,
57+
type: params.evt.type,
58+
organizer,
59+
attendees:
60+
params.evt.attendees?.map((a) => ({
61+
...a,
62+
utcOffset: getUTCOffsetByTimezone(a.timeZone, params.evt.startTime),
63+
})) ?? [],
64+
location: params.evt.location,
65+
uid: params.evt.uid,
66+
customInputs: params.evt.customInputs,
67+
responses: params.evt.responses,
68+
userFieldsResponses: params.evt.userFieldsResponses,
69+
status: params.status,
70+
eventTitle: params.eventType?.eventTitle,
71+
eventDescription: params.eventType?.eventDescription,
72+
requiresConfirmation: params.eventType?.requiresConfirmation,
73+
price: params.eventType?.price,
74+
currency: params.eventType?.currency,
75+
length: params.eventType?.length,
76+
smsReminderNumber: params.booking.smsReminderNumber || undefined,
77+
description: params.evt.description || params.evt.additionalNotes,
78+
...(params.extra || {}),
79+
},
80+
};
81+
}
82+
83+
const BOOKING_WEBHOOK_EVENTS: WebhookTriggerEvents[] = [
84+
WebhookTriggerEvents.BOOKING_CREATED,
85+
WebhookTriggerEvents.BOOKING_CANCELLED,
86+
WebhookTriggerEvents.BOOKING_REQUESTED,
87+
WebhookTriggerEvents.BOOKING_RESCHEDULED,
88+
WebhookTriggerEvents.BOOKING_REJECTED,
89+
WebhookTriggerEvents.BOOKING_PAID,
90+
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
91+
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
92+
];
93+
94+
export class BookingPayloadBuilder {
95+
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
96+
return BOOKING_WEBHOOK_EVENTS.includes(triggerEvent);
97+
}
98+
99+
build(dto: BookingWebhookEventDTO): WebhookPayload {
100+
switch (dto.triggerEvent) {
101+
case WebhookTriggerEvents.BOOKING_CREATED:
102+
return createBookingWebhookPayload({
103+
booking: dto.booking,
104+
eventType: dto.eventType,
105+
evt: dto.evt,
106+
status: BookingStatus.ACCEPTED,
107+
triggerEvent: dto.triggerEvent,
108+
createdAt: dto.createdAt,
109+
});
110+
111+
case WebhookTriggerEvents.BOOKING_CANCELLED:
112+
return createBookingWebhookPayload({
113+
booking: dto.booking,
114+
eventType: dto.eventType,
115+
evt: dto.evt,
116+
status: BookingStatus.CANCELLED,
117+
triggerEvent: dto.triggerEvent,
118+
createdAt: dto.createdAt,
119+
extra: {
120+
cancelledBy: dto.cancelledBy,
121+
cancellationReason: dto.cancellationReason,
122+
},
123+
});
124+
125+
case WebhookTriggerEvents.BOOKING_REQUESTED:
126+
return createBookingWebhookPayload({
127+
booking: dto.booking,
128+
eventType: dto.eventType,
129+
evt: dto.evt,
130+
status: BookingStatus.PENDING,
131+
triggerEvent: dto.triggerEvent,
132+
createdAt: dto.createdAt,
133+
});
134+
135+
case WebhookTriggerEvents.BOOKING_REJECTED:
136+
return createBookingWebhookPayload({
137+
booking: dto.booking,
138+
eventType: dto.eventType,
139+
evt: dto.evt,
140+
status: BookingStatus.REJECTED,
141+
triggerEvent: dto.triggerEvent,
142+
createdAt: dto.createdAt,
143+
});
144+
145+
case WebhookTriggerEvents.BOOKING_RESCHEDULED:
146+
return createBookingWebhookPayload({
147+
booking: dto.booking,
148+
eventType: dto.eventType,
149+
evt: dto.evt,
150+
status: BookingStatus.ACCEPTED,
151+
triggerEvent: dto.triggerEvent,
152+
createdAt: dto.createdAt,
153+
extra: {
154+
rescheduleId: dto.rescheduleId,
155+
rescheduleUid: dto.rescheduleUid,
156+
rescheduleStartTime: dto.rescheduleStartTime,
157+
rescheduleEndTime: dto.rescheduleEndTime,
158+
rescheduledBy: dto.rescheduledBy,
159+
},
160+
});
161+
162+
case WebhookTriggerEvents.BOOKING_PAID:
163+
return createBookingWebhookPayload({
164+
booking: dto.booking,
165+
eventType: dto.eventType,
166+
evt: dto.evt,
167+
status: BookingStatus.ACCEPTED,
168+
triggerEvent: dto.triggerEvent,
169+
createdAt: dto.createdAt,
170+
extra: {
171+
paymentId: dto.paymentId,
172+
paymentData: dto.paymentData,
173+
},
174+
});
175+
176+
case WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED:
177+
return createBookingWebhookPayload({
178+
booking: dto.booking,
179+
eventType: dto.eventType,
180+
evt: dto.evt,
181+
status: BookingStatus.ACCEPTED,
182+
triggerEvent: dto.triggerEvent,
183+
createdAt: dto.createdAt,
184+
extra: {
185+
paymentId: dto.paymentId,
186+
paymentData: dto.paymentData,
187+
},
188+
});
189+
190+
case WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED:
191+
return {
192+
triggerEvent: dto.triggerEvent,
193+
createdAt: dto.createdAt,
194+
payload: {
195+
bookingUid: dto.bookingUid,
196+
bookingId: dto.bookingId,
197+
attendees: dto.attendees,
198+
message: dto.message,
199+
},
200+
};
201+
202+
default: {
203+
// TypeScript exhaustiveness check - this should never happen if all cases are covered
204+
const _exhaustiveCheck: never = dto;
205+
throw new Error(`Unsupported booking trigger: ${JSON.stringify(_exhaustiveCheck)}`);
206+
}
207+
}
208+
}
209+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/trpc/utils";
2+
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
3+
4+
import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../dto/types";
5+
import type { WebhookPayload } from "./types";
6+
7+
export class FormPayloadBuilder {
8+
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
9+
return (
10+
triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED ||
11+
triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT
12+
);
13+
}
14+
15+
build(dto: FormSubmittedDTO | FormSubmittedNoEventDTO): WebhookPayload {
16+
// Type the responses properly using the routing-forms type
17+
const responses = dto.response.data;
18+
19+
// Create properly typed payload with primitive types
20+
const payload: {
21+
formId: string;
22+
formName: string;
23+
teamId: number | null;
24+
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
25+
[key: string]: unknown; // For backward compatibility fields
26+
} = {
27+
formId: dto.form.id,
28+
formName: dto.form.name,
29+
teamId: dto.teamId ?? null,
30+
responses,
31+
};
32+
33+
// Add unwrapped response fields at root level for backwards compatibility
34+
// This ensures both `value` (deprecated) and `response` (new) are available
35+
Object.entries(responses).forEach(([fieldKey, fieldValue]) => {
36+
if (fieldValue && typeof fieldValue === "object") {
37+
// Each field should have both `value` (deprecated) and `response` (new)
38+
const responseField = fieldValue as {
39+
value?: unknown;
40+
response?: unknown;
41+
};
42+
43+
// Add the field value directly to payload root for backward compatibility
44+
// This preserves the legacy behavior where field values were at root level
45+
if (responseField.value !== undefined) {
46+
payload[fieldKey] = responseField.value;
47+
} else if (responseField.response !== undefined) {
48+
// Fallback to response if value is not present
49+
payload[fieldKey] = responseField.response;
50+
}
51+
}
52+
});
53+
54+
return {
55+
triggerEvent: dto.triggerEvent,
56+
createdAt: dto.createdAt,
57+
payload,
58+
};
59+
}
60+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
2+
3+
import type { InstantMeetingDTO } from "../dto/types";
4+
import type { WebhookPayload } from "./types";
5+
6+
export class InstantMeetingBuilder {
7+
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
8+
return triggerEvent === WebhookTriggerEvents.INSTANT_MEETING;
9+
}
10+
11+
build(dto: InstantMeetingDTO): WebhookPayload {
12+
return {
13+
triggerEvent: dto.triggerEvent,
14+
createdAt: dto.createdAt,
15+
payload: {
16+
title: dto.title,
17+
body: dto.body,
18+
icon: dto.icon,
19+
url: dto.url,
20+
actions: dto.actions,
21+
requireInteraction: dto.requireInteraction,
22+
type: dto.type,
23+
},
24+
};
25+
}
26+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
2+
3+
import type { MeetingStartedDTO, MeetingEndedDTO } from "../dto/types";
4+
import type { WebhookPayload } from "./types";
5+
6+
export class MeetingPayloadBuilder {
7+
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
8+
return (
9+
triggerEvent === WebhookTriggerEvents.MEETING_STARTED ||
10+
triggerEvent === WebhookTriggerEvents.MEETING_ENDED
11+
);
12+
}
13+
14+
build(dto: MeetingStartedDTO | MeetingEndedDTO): WebhookPayload {
15+
return {
16+
triggerEvent: dto.triggerEvent,
17+
createdAt: dto.createdAt,
18+
payload: { ...dto.booking },
19+
};
20+
}
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
2+
3+
import type { OOOCreatedDTO } from "../dto/types";
4+
import type { WebhookPayload } from "./types";
5+
6+
export class OOOPayloadBuilder {
7+
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
8+
return triggerEvent === WebhookTriggerEvents.OOO_CREATED;
9+
}
10+
11+
build(dto: OOOCreatedDTO): WebhookPayload {
12+
return {
13+
triggerEvent: dto.triggerEvent,
14+
createdAt: dto.createdAt,
15+
payload: { oooEntry: dto.oooEntry },
16+
};
17+
}
18+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
2+
3+
import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../dto/types";
4+
import type { WebhookPayload } from "./types";
5+
6+
export class RecordingPayloadBuilder {
7+
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
8+
return (
9+
triggerEvent === WebhookTriggerEvents.RECORDING_READY ||
10+
triggerEvent === WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED
11+
);
12+
}
13+
14+
build(dto: RecordingReadyDTO | TranscriptionGeneratedDTO): WebhookPayload {
15+
if (dto.triggerEvent === WebhookTriggerEvents.RECORDING_READY) {
16+
return {
17+
triggerEvent: dto.triggerEvent,
18+
createdAt: dto.createdAt,
19+
payload: { downloadLink: dto.downloadLink },
20+
};
21+
}
22+
23+
return {
24+
triggerEvent: dto.triggerEvent,
25+
createdAt: dto.createdAt,
26+
payload: {
27+
downloadLinks: dto.downloadLinks,
28+
},
29+
};
30+
}
31+
}

0 commit comments

Comments
 (0)