chore: Webhook Scaffolding cleanup (#27159)

* quick cleanup

* more fixes
This commit is contained in:
Syed Ali Shahbaz
2026-01-22 16:11:10 -03:00
committed by GitHub
parent 2542e1e5c4
commit 857b3da875
10 changed files with 126 additions and 150 deletions
@@ -1,28 +1,26 @@
import { getWebhookTaskConsumer } from "@calcom/features/di/webhooks/containers/webhook";
import { webhookTaskPayloadSchema } from "@calcom/features/webhooks/lib/types/webhookTask";
import logger from "@calcom/lib/logger";
import type { ILogger } from "@calcom/lib/tasker/types";
/**
* Webhook Delivery Task Handler
*
*
* This task is queued by WebhookTaskerProducerService and processed here.
* It delegates to WebhookTaskConsumer (via DI) which handles the heavy lifting:
* - Fetching webhook subscribers
* - Fetching event-specific data from database
* - Building versioned webhook payloads
* - Sending HTTP requests to subscriber URLs
*
*
* This handler can be deployed to trigger.dev for scalability.
*/
const log = logger.getSubLogger({ prefix: ["webhookDelivery"] });
const log: ILogger = logger.getSubLogger({ prefix: ["webhookDelivery"] });
export async function webhookDelivery(payload: string, taskId?: string): Promise<void> {
try {
if (!taskId) {
log.error("Task ID is required for webhook delivery consumer", {
taskId,
});
throw new Error("Task ID is required for webhook delivery consumer");
}
@@ -35,7 +35,7 @@ export class WebhookTaskConsumer {
* Main entry point for processing webhook delivery tasks.
*/
async processWebhookTask(payload: WebhookTaskPayload, taskId: string): Promise<void> {
this.log.info("Processing webhook delivery task", {
this.log.debug("Processing webhook delivery task", {
operationId: payload.operationId,
taskId,
triggerEvent: payload.triggerEvent,
@@ -58,7 +58,7 @@ export class WebhookTaskConsumer {
const subscribers = await this.webhookRepository.getSubscribers(subscriberContext);
if (subscribers.length === 0) {
this.log.info("No webhook subscribers found", { operationId: payload.operationId });
this.log.debug("No webhook subscribers found", { operationId: payload.operationId });
return;
}
@@ -80,7 +80,7 @@ export class WebhookTaskConsumer {
// Step 4: Build and send webhooks to each subscriber
await this.sendWebhooksToSubscribers(subscribers, eventData, payload);
this.log.info("Webhook delivery task completed", {
this.log.debug("Webhook delivery task completed", {
operationId: payload.operationId,
subscriberCount: subscribers.length,
});
@@ -106,25 +106,16 @@ export class WebhookTaskConsumer {
/**
* Build webhook payloads and send to each subscriber.
*
* TODO: Implement payload building using PayloadBuilders and HTTP sending.
* For Phase 0, this is a scaffold showing the pattern.
* TODO: Implement with PayloadBuilders and HTTP client (Phase 1+)
*/
private async sendWebhooksToSubscribers(
subscribers: unknown[],
eventData: Record<string, unknown>,
payload: WebhookTaskPayload
): Promise<void> {
// TODO: For each subscriber:
// 1. Build versioned payload using PayloadBuilders
// 2. Send HTTP request to subscriber.subscriberUrl
// 3. Handle retries/failures
// 4. Log delivery status
this.log.debug("Webhook sending not implemented yet (Phase 0 scaffold)", {
subscriberCount: subscribers.length,
triggerEvent: payload.triggerEvent,
});
// This will be implemented when we add PayloadBuilders and HTTP client dependencies
}
}
@@ -70,7 +70,7 @@ export class WebhookTaskerProducerService implements IWebhookProducerService {
async queueFormSubmittedWebhook(params: QueueFormWebhookParams): Promise<void> {
const operationId = params.operationId || uuidv4();
this.log.info("Queueing form webhook task", {
this.log.debug("Queueing form webhook task", {
operationId,
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
formId: params.formId,
@@ -93,7 +93,7 @@ export class WebhookTaskerProducerService implements IWebhookProducerService {
async queueRecordingReadyWebhook(params: QueueRecordingWebhookParams): Promise<void> {
const operationId = params.operationId || uuidv4();
this.log.info("Queueing recording webhook task", {
this.log.debug("Queueing recording webhook task", {
operationId,
triggerEvent: WebhookTriggerEvents.RECORDING_READY,
recordingId: params.recordingId,
@@ -119,7 +119,7 @@ export class WebhookTaskerProducerService implements IWebhookProducerService {
async queueOOOCreatedWebhook(params: QueueOOOWebhookParams): Promise<void> {
const operationId = params.operationId || uuidv4();
this.log.info("Queueing OOO webhook task", {
this.log.debug("Queueing OOO webhook task", {
operationId,
triggerEvent: WebhookTriggerEvents.OOO_CREATED,
oooEntryId: params.oooEntryId,
@@ -152,7 +152,7 @@ export class WebhookTaskerProducerService implements IWebhookProducerService {
): Promise<void> {
const operationId = params.operationId || uuidv4();
this.log.info("Queueing booking webhook task", {
this.log.debug("Queueing booking webhook task", {
operationId,
triggerEvent,
bookingUid: params.bookingUid,
@@ -184,7 +184,7 @@ export class WebhookTaskerProducerService implements IWebhookProducerService {
): Promise<void> {
const operationId = params.operationId || uuidv4();
this.log.info("Queueing payment webhook task", {
this.log.debug("Queueing payment webhook task", {
operationId,
triggerEvent,
bookingUid: params.bookingUid,
@@ -212,7 +212,7 @@ export class WebhookTaskerProducerService implements IWebhookProducerService {
private async queueTask(operationId: string, taskPayload: WebhookTaskPayload): Promise<void> {
try {
await this.tasker.create("webhookDelivery", taskPayload);
this.log.info("Webhook delivery task queued successfully", { operationId });
this.log.debug("Webhook delivery task queued", { operationId });
} catch (error) {
this.log.error("Failed to queue webhook delivery task", {
operationId,
@@ -98,7 +98,7 @@ describe("WebhookTaskConsumer", () => {
await consumer.processWebhookTask(payload, "task-123");
expect(mockLogger.info).toHaveBeenCalledWith(
expect(mockLogger.debug).toHaveBeenCalledWith(
"Processing webhook delivery task",
expect.objectContaining({
operationId: "op-123",
@@ -116,7 +116,7 @@ describe("WebhookTaskConsumer", () => {
oAuthClientId: undefined,
});
expect(mockLogger.info).toHaveBeenCalledWith(
expect(mockLogger.debug).toHaveBeenCalledWith(
"No webhook subscribers found",
expect.objectContaining({
operationId: "op-123",
@@ -171,55 +171,110 @@ describe("WebhookTaskConsumer", () => {
});
describe("processWebhookTask - Event Type Routing", () => {
const testCases = [
{
trigger: WebhookTriggerEvents.BOOKING_CREATED,
requiredField: "bookingUid",
},
{
trigger: WebhookTriggerEvents.FORM_SUBMITTED,
requiredField: "formId",
},
{
trigger: WebhookTriggerEvents.RECORDING_READY,
requiredField: "recordingId",
},
{
trigger: WebhookTriggerEvents.OOO_CREATED,
requiredField: "oooEntryId",
},
];
it("should process BOOKING_CREATED event type (scaffold)", async () => {
const payload: WebhookTaskPayload = {
operationId: "op-test",
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
bookingUid: "test-booking-uid",
timestamp: new Date().toISOString(),
};
testCases.forEach(({ trigger, requiredField }) => {
it(`should process ${trigger} event type (scaffold)`, async () => {
const payload: WebhookTaskPayload = {
operationId: "op-test",
triggerEvent: trigger,
[requiredField]: "test-id",
timestamp: new Date().toISOString(),
};
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
{
id: "sub-1",
subscriberUrl: "https://example.com/webhook",
payloadTemplate: null,
appId: null,
secret: null,
time: null,
timeUnit: null,
eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
version: WebhookVersion.V_2021_10_20,
},
]);
// Mock subscriber so we reach data fetching
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
{
id: "sub-1",
subscriberUrl: "https://example.com/webhook",
payloadTemplate: null,
appId: null,
secret: null,
time: null,
timeUnit: null,
eventTriggers: [trigger],
version: WebhookVersion.V_2021_10_20,
},
]);
await expect(consumer.processWebhookTask(payload, "task-test")).resolves.not.toThrow();
expect(mockWebhookRepository.getSubscribers).toHaveBeenCalled();
});
// Should not throw - scaffold implementation logs debug messages
await expect(consumer.processWebhookTask(payload, "task-test")).resolves.not.toThrow();
it("should process FORM_SUBMITTED event type (scaffold)", async () => {
const payload: WebhookTaskPayload = {
operationId: "op-test",
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
formId: "test-form-id",
timestamp: new Date().toISOString(),
};
// Verify subscriber fetch was called
expect(mockWebhookRepository.getSubscribers).toHaveBeenCalled();
});
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
{
id: "sub-1",
subscriberUrl: "https://example.com/webhook",
payloadTemplate: null,
appId: null,
secret: null,
time: null,
timeUnit: null,
eventTriggers: [WebhookTriggerEvents.FORM_SUBMITTED],
version: WebhookVersion.V_2021_10_20,
},
]);
await expect(consumer.processWebhookTask(payload, "task-test")).resolves.not.toThrow();
expect(mockWebhookRepository.getSubscribers).toHaveBeenCalled();
});
it("should process RECORDING_READY event type (scaffold)", async () => {
const payload: WebhookTaskPayload = {
operationId: "op-test",
triggerEvent: WebhookTriggerEvents.RECORDING_READY,
recordingId: "test-recording-id",
bookingUid: "test-booking-uid",
timestamp: new Date().toISOString(),
};
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
{
id: "sub-1",
subscriberUrl: "https://example.com/webhook",
payloadTemplate: null,
appId: null,
secret: null,
time: null,
timeUnit: null,
eventTriggers: [WebhookTriggerEvents.RECORDING_READY],
version: WebhookVersion.V_2021_10_20,
},
]);
await expect(consumer.processWebhookTask(payload, "task-test")).resolves.not.toThrow();
expect(mockWebhookRepository.getSubscribers).toHaveBeenCalled();
});
it("should process OOO_CREATED event type (scaffold)", async () => {
const payload: WebhookTaskPayload = {
userId: 456,
operationId: "op-test",
triggerEvent: WebhookTriggerEvents.OOO_CREATED,
oooEntryId: 123,
timestamp: new Date().toISOString(),
};
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
{
id: "sub-1",
subscriberUrl: "https://example.com/webhook",
payloadTemplate: null,
appId: null,
secret: null,
time: null,
timeUnit: null,
eventTriggers: [WebhookTriggerEvents.OOO_CREATED],
version: WebhookVersion.V_2021_10_20,
},
]);
await expect(consumer.processWebhookTask(payload, "task-test")).resolves.not.toThrow();
expect(mockWebhookRepository.getSubscribers).toHaveBeenCalled();
});
});
@@ -251,7 +306,7 @@ describe("WebhookTaskConsumer", () => {
const payload: WebhookTaskPayload = {
operationId: "op-missing",
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
// Missing bookingUid
bookingUid: "booking-123",
timestamp: new Date().toISOString(),
};
@@ -284,40 +339,4 @@ describe("WebhookTaskConsumer", () => {
);
});
});
describe("Future Implementation Tests", () => {
it("TODO [When WebhookTaskConsumer.fetchBookingData() is implemented]: Test full booking data fetching", () => {
// When: BookingRepository is injected into WebhookTaskConsumer
// When: fetchBookingData() implementation is complete
// Test: Fetch booking, eventType, user, attendees from database
// Test: Verify correct data structure returned
expect(true).toBe(true); // Placeholder
});
it("TODO [When PayloadBuilders are integrated into sendWebhooksToSubscribers()]: Test payload building", () => {
// When: BookingPayloadBuilder is integrated (for booking events)
// When: FormPayloadBuilder is integrated (for form events)
// When: RecordingPayloadBuilder is integrated (for recording events)
// When: OOOPayloadBuilder is integrated (for OOO events)
// Test: Build versioned payloads, apply payload templates
expect(true).toBe(true); // Placeholder
});
it("TODO [When sendWebhooksToSubscribers() makes HTTP calls]: Test HTTP delivery", () => {
// When: HTTP client is integrated (or existing sendPayload is used)
// When: sendWebhooksToSubscribers() sends to subscriber.subscriberUrl
// Test: Mock HTTP calls, verify correct payload sent
// Test: Handle retries, timeouts, errors
expect(true).toBe(true); // Placeholder
});
it("TODO [When all services are wired]: Integration test for full Producer→Consumer flow", () => {
// When: All webhook services use Producer/Consumer pattern
// Test: Full flow - Producer → Tasker → Consumer → HTTP delivery
// Test: Verify webhook received by mock HTTP server
// Test: Retry logic with task processor
// Test: E2E with real database and task queue
expect(true).toBe(true); // Placeholder
});
});
});
@@ -95,12 +95,12 @@ describe("WebhookTaskerProducerService", () => {
expect(payload.operationId).toBe("custom-op-id");
});
it("should log info messages", async () => {
it("should log debug messages", async () => {
await producer.queueBookingCreatedWebhook({
bookingUid: "booking-123",
});
expect(mockLogger.info).toHaveBeenCalledWith(
expect(mockLogger.debug).toHaveBeenCalledWith(
"Queueing booking webhook task",
expect.objectContaining({
operationId: expect.any(String),
@@ -109,8 +109,8 @@ describe("WebhookTaskerProducerService", () => {
})
);
expect(mockLogger.info).toHaveBeenCalledWith(
"Webhook delivery task queued successfully",
expect(mockLogger.debug).toHaveBeenCalledWith(
"Webhook delivery task queued",
expect.objectContaining({
operationId: expect.any(String),
})
@@ -27,12 +27,7 @@ export class BookingWebhookDataFetcher implements IWebhookDataFetcher {
return null;
}
// TODO [Phase 1+]: Implement using BookingRepository (to be injected)
// const booking = await this.bookingRepository.findByUid(bookingUid);
// const eventType = await this.eventTypeRepository.findById(booking.eventTypeId);
// const organizer = await this.userRepository.findById(booking.userId);
// return { booking, eventType, organizer, attendees: booking.attendees };
// TODO: Implement using BookingRepository/EventTypeRepository/UserRepository (Phase 1+)
this.logger.debug("Booking data fetch not implemented yet (Phase 0 scaffold)", { bookingUid });
return { bookingUid, _scaffold: true };
}
@@ -18,11 +18,7 @@ export class FormWebhookDataFetcher implements IWebhookDataFetcher {
return null;
}
// TODO [Phase 1+]: Implement using FormRepository
// const form = await this.formRepository.findById(formId);
// const responses = await this.formRepository.getResponses(formId);
// return { form, responses };
// TODO: Implement using FormRepository (Phase 1+)
this.logger.debug("Form data fetch not implemented yet (Phase 0 scaffold)", { formId });
return { formId, _scaffold: true };
}
@@ -18,11 +18,7 @@ export class OOOWebhookDataFetcher implements IWebhookDataFetcher {
return null;
}
// TODO [Phase 1+]: Implement using OOORepository
// const oooEntry = await this.oooRepository.findById(oooEntryId);
// const user = await this.userRepository.findById(oooEntry.userId);
// return { oooEntry, user };
// TODO: Implement using OOORepository (Phase 1+)
this.logger.debug("OOO data fetch not implemented yet (Phase 0 scaffold)", { oooEntryId });
return { oooEntryId, _scaffold: true };
}
@@ -23,11 +23,7 @@ export class PaymentWebhookDataFetcher implements IWebhookDataFetcher {
return null;
}
// TODO [Phase 1+]: Implement using BookingRepository and PaymentRepository
// const booking = await this.bookingRepository.findByUid(bookingUid);
// const payment = await this.paymentRepository.findByBookingId(booking.id);
// return { booking, payment, eventType, organizer, attendees };
// TODO: Implement using BookingRepository/PaymentRepository (Phase 1+)
this.logger.debug("Payment data fetch not implemented yet (Phase 0 scaffold)", { bookingUid });
return { bookingUid, _scaffold: true };
}
@@ -23,22 +23,7 @@ export class RecordingWebhookDataFetcher implements IWebhookDataFetcher {
return null;
}
// TODO [Phase 1+]: Implement recording data fetching
// Note: Recording files are stored by video providers. We receive recording_id from their webhook
// and generate our own proxy download link (using generateVideoToken + our API endpoint).
// This method fetches booking/event data from DB needed to build the webhook payload.
//
// Pattern: recordingId → booking → eventType → user → attendees
// Then generate downloadLink: `${WEBAPP_URL}/api/video/recording?token=${generateVideoToken(recordingId)}`
//
// const booking = await this.bookingRepository.findByUid(payload.bookingUid);
// const eventType = await this.eventTypeRepository.findById(booking.eventTypeId);
// const user = await this.userRepository.findById(booking.userId);
// const attendees = booking.attendees;
// const token = generateVideoToken(recordingId);
// const downloadLink = `${WEBAPP_URL}/api/video/recording?token=${token}`;
// return { booking, eventType, user, attendees, downloadLink };
// TODO: Fetch booking data and generate download link (Phase 1+)
this.logger.debug("Recording data fetch not implemented yet (Phase 0 scaffold)", { recordingId });
return { recordingId, _scaffold: true };
}