chore: Webhook Scaffolding cleanup (#27159)
* quick cleanup * more fixes
This commit is contained in:
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
-4
@@ -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 };
|
||||
}
|
||||
|
||||
+1
-16
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user