diff --git a/apps/api/v2/src/modules/organizations/webhooks/controllers/organizations-webhooks.e2e-spec.ts b/apps/api/v2/src/modules/organizations/webhooks/controllers/organizations-webhooks.e2e-spec.ts index 55d0010f83..ab99ec58cd 100644 --- a/apps/api/v2/src/modules/organizations/webhooks/controllers/organizations-webhooks.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/webhooks/controllers/organizations-webhooks.e2e-spec.ts @@ -209,4 +209,99 @@ describe("WebhooksController (e2e)", () => { .delete(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`) .expect(403); }); + + describe("DELEGATION_CREDENTIAL_ERROR webhook trigger", () => { + let delegationWebhook: TeamWebhookOutputResponseDto["data"]; + + it("/organizations/:orgId/webhooks (POST) should create webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/webhooks`) + .send({ + subscriberUrl: "https://example.com/delegation-errors", + triggers: ["DELEGATION_CREDENTIAL_ERROR"], + active: true, + } satisfies CreateWebhookInputDto) + .expect(201) + .then(async (res) => { + expect(res.body.status).toBe("success"); + expect(res.body.data).toMatchObject({ + id: expect.any(String), + subscriberUrl: "https://example.com/delegation-errors", + triggers: ["DELEGATION_CREDENTIAL_ERROR"], + active: true, + teamId: org.id, + }); + delegationWebhook = res.body.data; + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (GET) should retrieve webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`) + .expect(200) + .then((res) => { + expect(res.body.status).toBe("success"); + expect(res.body.data).toMatchObject({ + id: delegationWebhook.id, + subscriberUrl: "https://example.com/delegation-errors", + triggers: ["DELEGATION_CREDENTIAL_ERROR"], + active: true, + teamId: org.id, + }); + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (PATCH) should update webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`) + .send({ + active: false, + subscriberUrl: "https://example.com/delegation-errors-updated", + } satisfies UpdateWebhookInputDto) + .expect(200) + .then((res) => { + expect(res.body.data.active).toBe(false); + expect(res.body.data.subscriberUrl).toBe("https://example.com/delegation-errors-updated"); + expect(res.body.data.triggers).toEqual(["DELEGATION_CREDENTIAL_ERROR"]); + }); + }); + + it("/organizations/:orgId/webhooks (POST) should allow combining DELEGATION_CREDENTIAL_ERROR with other triggers", () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/webhooks`) + .send({ + subscriberUrl: "https://example.com/combined-webhook", + triggers: ["BOOKING_CREATED", "DELEGATION_CREDENTIAL_ERROR"], + active: true, + } satisfies CreateWebhookInputDto) + .expect(201) + .then(async (res) => { + expect(res.body.status).toBe("success"); + expect(res.body.data).toMatchObject({ + id: expect.any(String), + subscriberUrl: "https://example.com/combined-webhook", + triggers: expect.arrayContaining(["BOOKING_CREATED", "DELEGATION_CREDENTIAL_ERROR"]), + active: true, + teamId: org.id, + }); + await request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/webhooks/${res.body.data.id}`) + .expect(200); + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (DELETE) should delete webhook with DELEGATION_CREDENTIAL_ERROR trigger", () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/webhooks/${delegationWebhook.id}`) + .expect(200) + .then((res) => { + expect(res.body.status).toBe("success"); + expect(res.body.data).toMatchObject({ + id: delegationWebhook.id, + triggers: ["DELEGATION_CREDENTIAL_ERROR"], + teamId: org.id, + }); + }); + }); + }); }); diff --git a/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts index 89014abf55..8ec91644b4 100644 --- a/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts +++ b/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts @@ -1,12 +1,20 @@ import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; -import { ConflictException, Injectable } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable } from "@nestjs/common"; + +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; @Injectable() export class EventTypeWebhooksService { constructor(private readonly webhooksRepository: WebhooksRepository) {} async createEventTypeWebhook(eventTypeId: number, body: PipedInputWebhookType) { + if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) { + throw new BadRequestException( + "DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks" + ); + } + const existingWebhook = await this.webhooksRepository.getEventTypeWebhookByUrl( eventTypeId, body.subscriberUrl diff --git a/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts index cd091ce7b7..dd1616736d 100644 --- a/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts +++ b/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts @@ -1,12 +1,20 @@ import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; -import { ConflictException, Injectable } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable } from "@nestjs/common"; + +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; @Injectable() export class UserWebhooksService { constructor(private readonly webhooksRepository: WebhooksRepository) {} async createUserWebhook(userId: number, body: PipedInputWebhookType) { + if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) { + throw new BadRequestException( + "DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks" + ); + } + const existingWebhook = await this.webhooksRepository.getUserWebhookByUrl(userId, body.subscriberUrl); if (existingWebhook) { throw new ConflictException("Webhook with this subscriber url already exists for this user"); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7c68cfd51c..91aa0c2ffd 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -568,6 +568,7 @@ "booking_paid": "Booking Paid", "ooo_created": "Out of Office Created", "booking_no_show_updated": "Booking No-Show Updated", + "delegation_credential_error": "Delegation Credential Error", "event_triggers": "Event Triggers", "subscriber_url": "Subscriber URL", "create_new_webhook": "Create a new webhook", diff --git a/packages/app-store/googlecalendar/lib/CalendarAuth.ts b/packages/app-store/googlecalendar/lib/CalendarAuth.ts index ae86f39c57..df2d343c4e 100644 --- a/packages/app-store/googlecalendar/lib/CalendarAuth.ts +++ b/packages/app-store/googlecalendar/lib/CalendarAuth.ts @@ -1,7 +1,7 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { calendar_v3 } from "@googleapis/calendar"; import { OAuth2Client, JWT } from "googleapis-common"; +import { triggerDelegationCredentialErrorWebhook } from "@calcom/features/webhooks/lib/triggerDelegationCredentialErrorWebhook"; import { CalendarAppDelegationCredentialClientIdNotAuthorizedError, CalendarAppDelegationCredentialInvalidGrantError, @@ -124,20 +124,39 @@ export class CalendarAuth { } catch (error) { log.error("DelegatedTo: Error authorizing using JWT auth", JSON.stringify(error)); - if ((error as any).response?.data?.error === "unauthorized_client") { - throw new CalendarAppDelegationCredentialClientIdNotAuthorizedError( + let delegationError: CalendarAppDelegationCredentialError; + + const errorCode = (error as { response?: { data?: { error?: string } } }).response?.data?.error; + if (errorCode === "unauthorized_client") { + delegationError = new CalendarAppDelegationCredentialClientIdNotAuthorizedError( "Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console" ); - } - - if ((error as any).response?.data?.error === "invalid_grant") { - throw new CalendarAppDelegationCredentialInvalidGrantError( + } else if (errorCode === "invalid_grant") { + delegationError = new CalendarAppDelegationCredentialInvalidGrantError( `User ${emailToImpersonate} might not exist in Google Workspace` ); + } else { + // Catch all error + delegationError = new CalendarAppDelegationCredentialError("Error authorizing delegation credential"); } - // Catch all error - throw new CalendarAppDelegationCredentialError("Error authorizing delegation credential"); + if (user && user.email && this.credential.appId) { + await triggerDelegationCredentialErrorWebhook({ + error: delegationError, + credential: { + id: this.credential.id, + type: this.credential.type, + appId: this.credential.appId, + }, + user: { + id: this.credential.userId ?? 0, + email: user.email, + }, + orgId: this.credential.teamId, + }); + } + + throw delegationError; } }; diff --git a/packages/app-store/office365calendar/lib/CalendarService.ts b/packages/app-store/office365calendar/lib/CalendarService.ts index ee3f32bdf5..5bc69b34ab 100644 --- a/packages/app-store/office365calendar/lib/CalendarService.ts +++ b/packages/app-store/office365calendar/lib/CalendarService.ts @@ -4,6 +4,7 @@ import { findIana } from "windows-iana"; import { MSTeamsLocationType } from "@calcom/app-store/constants"; import dayjs from "@calcom/dayjs"; +import { triggerDelegationCredentialErrorWebhook } from "@calcom/features/webhooks/lib/triggerDelegationCredentialErrorWebhook"; import { getLocation, getRichDescriptionHTML } from "@calcom/lib/CalEventParser"; import { CalendarAppDelegationCredentialInvalidGrantError, @@ -82,7 +83,7 @@ export default class Office365CalendarService implements Calendar { const { client_id, client_secret } = await this.getAuthCredentials(isDelegated); - const url = this.getAuthUrl(isDelegated, credential?.delegatedTo?.serviceAccountKey?.tenant_id); + const url = await this.getAuthUrl(isDelegated, credential?.delegatedTo?.serviceAccountKey?.tenant_id); const bodyParams = { scope: isDelegated @@ -123,12 +124,30 @@ export default class Office365CalendarService implements Calendar { this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); } - private getAuthUrl(delegatedTo: boolean, tenantId?: string): string { + private async getAuthUrl(delegatedTo: boolean, tenantId?: string): Promise { if (delegatedTo) { if (!tenantId) { - throw new CalendarAppDelegationCredentialInvalidGrantError( + const error = new CalendarAppDelegationCredentialInvalidGrantError( "Invalid DelegationCredential Settings: tenantId is missing" ); + + if (this.credential.userId && this.credential.user && this.credential.appId) { + await triggerDelegationCredentialErrorWebhook({ + error, + credential: { + id: this.credential.id, + type: this.credential.type, + appId: this.credential.appId, + }, + user: { + id: this.credential.userId, + email: this.credential.user.email, + }, + orgId: this.credential.teamId, + }); + } + + throw error; } return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; } @@ -142,9 +161,27 @@ export default class Office365CalendarService implements Calendar { const client_secret = this.credential?.delegatedTo?.serviceAccountKey?.private_key; if (!client_id || !client_secret) { - throw new CalendarAppDelegationCredentialConfigurationError( + const error = new CalendarAppDelegationCredentialConfigurationError( "Delegation credential without clientId or Secret" ); + + if (this.credential.userId && this.credential.user && this.credential.appId) { + await triggerDelegationCredentialErrorWebhook({ + error, + credential: { + id: this.credential.id, + type: this.credential.type, + appId: this.credential.appId, + }, + user: { + id: this.credential.userId, + email: this.credential.user.email, + }, + orgId: this.credential.teamId, + }); + } + + throw error; } return { client_id, client_secret }; @@ -160,15 +197,33 @@ export default class Office365CalendarService implements Calendar { if (!isDelegated) return; - const url = this.getAuthUrl(isDelegated, credential?.delegatedTo?.serviceAccountKey?.tenant_id); + const url = await this.getAuthUrl(isDelegated, credential?.delegatedTo?.serviceAccountKey?.tenant_id); const delegationCredentialClientId = credential.delegatedTo?.serviceAccountKey?.client_id; const delegationCredentialClientSecret = credential.delegatedTo?.serviceAccountKey?.private_key; if (!delegationCredentialClientId || !delegationCredentialClientSecret) { - throw new CalendarAppDelegationCredentialConfigurationError( + const error = new CalendarAppDelegationCredentialConfigurationError( "Delegation credential without clientId or Secret" ); + + if (this.credential.userId && this.credential.user && this.credential.appId) { + await triggerDelegationCredentialErrorWebhook({ + error, + credential: { + id: this.credential.id, + type: this.credential.type, + appId: this.credential.appId, + }, + user: { + id: this.credential.userId, + email: this.credential.user.email, + }, + orgId: this.credential.teamId, + }); + } + + throw error; } const loginResponse = await fetch(url, { method: "POST", @@ -201,9 +256,27 @@ export default class Office365CalendarService implements Calendar { const parsedBody = await response.json(); if (!parsedBody?.value?.[0]?.id) { - throw new CalendarAppDelegationCredentialInvalidGrantError( + const error = new CalendarAppDelegationCredentialInvalidGrantError( "User might not exist in Microsoft Azure Active Directory" ); + + if (this.credential.userId && this.credential.user && this.credential.appId) { + await triggerDelegationCredentialErrorWebhook({ + error, + credential: { + id: this.credential.id, + type: this.credential.type, + appId: this.credential.appId, + }, + user: { + id: this.credential.userId ?? 0, + email: this.credential.user.email, + }, + orgId: this.credential.teamId, + }); + } + + throw error; } this.azureUserId = parsedBody.value[0].id; } @@ -214,7 +287,7 @@ export default class Office365CalendarService implements Calendar { async testDelegationCredentialSetup(): Promise { const delegationCredentialClientId = this.credential.delegatedTo?.serviceAccountKey?.client_id; const delegationCredentialClientSecret = this.credential.delegatedTo?.serviceAccountKey?.private_key; - const url = this.getAuthUrl( + const url = await this.getAuthUrl( Boolean(this.credential?.delegatedTo), this.credential?.delegatedTo?.serviceAccountKey?.tenant_id ); @@ -375,7 +448,7 @@ export default class Office365CalendarService implements Calendar { alreadySuccessResponse = await this.fetchResponsesWithNextLink(responseBatchApi.responses); return alreadySuccessResponse ? this.processBusyTimes(alreadySuccessResponse) : []; - } catch (err) { + } catch { return Promise.reject([]); } } @@ -477,7 +550,7 @@ export default class Office365CalendarService implements Calendar { type: "required" as const, })), ...(event.team?.members - ? event.team?.members + ? event.team.members .filter((member) => member.email !== this.credential.user?.email) .map((member) => { const destinationCalendar = @@ -627,7 +700,7 @@ export default class Office365CalendarService implements Calendar { try { const parsedJson = JSON.parse(response); return parsedJson; - } catch (error) { + } catch { // Looking for html in body const openTag = '"body":<'; const closeTag = ""; @@ -677,7 +750,7 @@ export default class Office365CalendarService implements Calendar { let errorBody: string | object; try { errorBody = await response.json(); - } catch (e) { + } catch { errorBody = await response.text(); } this.log.error( diff --git a/packages/app-store/office365video/lib/VideoApiAdapter.ts b/packages/app-store/office365video/lib/VideoApiAdapter.ts index 33eded8338..f2f7c35b33 100644 --- a/packages/app-store/office365video/lib/VideoApiAdapter.ts +++ b/packages/app-store/office365video/lib/VideoApiAdapter.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { triggerDelegationCredentialErrorWebhook } from "@calcom/features/webhooks/lib/triggerDelegationCredentialErrorWebhook"; import { CalendarAppDelegationCredentialConfigurationError, CalendarAppDelegationCredentialInvalidGrantError, @@ -63,12 +64,30 @@ const TeamsVideoApiAdapter = (credential: CredentialForCalendarServiceWithTenant : await getO365VideoAppKeys(); if (isDelegated && (!credentials.client_id || !credentials.client_secret)) { - throw new CalendarAppDelegationCredentialConfigurationError( + const error = new CalendarAppDelegationCredentialConfigurationError( "Delegation credential without clientId or Secret" ); + + if (credential.userId && credential.user && credential.appId) { + await triggerDelegationCredentialErrorWebhook({ + error, + credential: { + id: credential.id, + type: credential.type, + appId: credential.appId, + }, + user: { + id: credential.userId ?? 0, + email: credential.user.email, + }, + orgId: credential.teamId, + }); + } + + throw error; } - const url = getAuthUrl(isDelegated, credential?.delegatedTo?.serviceAccountKey?.tenant_id); + const url = await getAuthUrl(isDelegated, credential?.delegatedTo?.serviceAccountKey?.tenant_id); const scope = isDelegated ? "https://graph.microsoft.com/.default" : OFFICE365_VIDEO_SCOPES.join(" "); const params: Record = { @@ -97,19 +116,37 @@ const TeamsVideoApiAdapter = (credential: CredentialForCalendarServiceWithTenant invalidateTokenObject: () => oAuthManagerHelper.invalidateCredential(credential.id), expireAccessToken: () => oAuthManagerHelper.markTokenAsExpired(credential), updateTokenObject: (tokenObject) => { - if (!Boolean(credential.delegatedTo)) { + if (!credential.delegatedTo) { return oAuthManagerHelper.updateTokenObject({ tokenObject, credentialId: credential.id }); } return Promise.resolve(); }, }); - function getAuthUrl(delegatedTo: boolean, tenantId?: string): string { + async function getAuthUrl(delegatedTo: boolean, tenantId?: string): Promise { if (delegatedTo) { if (!tenantId) { - throw new CalendarAppDelegationCredentialInvalidGrantError( + const error = new CalendarAppDelegationCredentialInvalidGrantError( "Invalid DelegationCredential Settings: tenantId is missing" ); + + if (credential.userId && credential.user && credential.appId) { + await triggerDelegationCredentialErrorWebhook({ + error, + credential: { + id: credential.id, + type: credential.type, + appId: credential.appId, + }, + user: { + id: credential.userId ?? 0, + email: credential.user.email, + }, + orgId: credential.teamId, + }); + } + + throw error; } return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; } @@ -132,15 +169,33 @@ const TeamsVideoApiAdapter = (credential: CredentialForCalendarServiceWithTenant if (!isDelegated) return null; - const url = getAuthUrl(isDelegated, credential?.delegatedTo?.serviceAccountKey?.tenant_id); + const url = await getAuthUrl(isDelegated, credential?.delegatedTo?.serviceAccountKey?.tenant_id); const delegationCredentialClientId = credential.delegatedTo?.serviceAccountKey?.client_id; const delegationCredentialClientSecret = credential.delegatedTo?.serviceAccountKey?.private_key; if (!delegationCredentialClientId || !delegationCredentialClientSecret) { - throw new CalendarAppDelegationCredentialConfigurationError( + const error = new CalendarAppDelegationCredentialConfigurationError( "Delegation credential without clientId or Secret" ); + + if (credential.userId && credential.user && credential.appId) { + await triggerDelegationCredentialErrorWebhook({ + error, + credential: { + id: credential.id, + type: credential.type, + appId: credential.appId, + }, + user: { + id: credential.userId ?? 0, + email: credential.user.email, + }, + orgId: credential.teamId, + }); + } + + throw error; } const loginResponse = await fetch(url, { method: "POST", @@ -172,9 +227,27 @@ const TeamsVideoApiAdapter = (credential: CredentialForCalendarServiceWithTenant const parsedBody = await response.json(); if (!parsedBody?.value?.[0]?.id) { - throw new CalendarAppDelegationCredentialInvalidGrantError( + const error = new CalendarAppDelegationCredentialInvalidGrantError( "User might not exist in Microsoft Azure Active Directory" ); + + if (credential.userId && credential.user && credential.appId) { + await triggerDelegationCredentialErrorWebhook({ + error, + credential: { + id: credential.id, + type: credential.type, + appId: credential.appId, + }, + user: { + id: credential.userId ?? 0, + email: credential.user.email, + }, + orgId: credential.teamId, + }); + } + + throw error; } azureUserId = parsedBody.value[0].id; return azureUserId; diff --git a/packages/features/webhooks/lib/constants.ts b/packages/features/webhooks/lib/constants.ts index bd9ea21b2b..e8b98e6e34 100644 --- a/packages/features/webhooks/lib/constants.ts +++ b/packages/features/webhooks/lib/constants.ts @@ -20,6 +20,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = { WebhookTriggerEvents.OOO_CREATED, WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR, ] as const, "routing-forms": [ WebhookTriggerEvents.FORM_SUBMITTED, diff --git a/packages/features/webhooks/lib/dto/types.ts b/packages/features/webhooks/lib/dto/types.ts index ba849372c3..c7286ad924 100644 --- a/packages/features/webhooks/lib/dto/types.ts +++ b/packages/features/webhooks/lib/dto/types.ts @@ -331,6 +331,12 @@ export interface AfterGuestsNoShowDTO extends BaseEventDTO { }; } +export interface DelegationCredentialErrorDTO + extends BaseEventDTO, + Pick { + triggerEvent: typeof WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR; +} + export type WebhookEventDTO = | BookingCreatedDTO | BookingCancelledDTO @@ -349,7 +355,8 @@ export type WebhookEventDTO = | MeetingEndedDTO | InstantMeetingDTO | AfterHostsNoShowDTO - | AfterGuestsNoShowDTO; + | AfterGuestsNoShowDTO + | DelegationCredentialErrorDTO; // Service layer interfaces export interface WebhookTriggerArgs { @@ -482,6 +489,22 @@ export type OOOEntryPayloadType = { }; }; +export type DelegationCredentialErrorPayloadType = { + error: { + type: string; + message: string; + }; + credential: { + id: number; + type: string; + appId: string; + }; + user: { + id: number; + email: string; + }; +}; + export type EventPayloadType = CalendarEvent & TranscriptionGeneratedPayload & EventTypeInfo & { diff --git a/packages/features/webhooks/lib/sendPayload.ts b/packages/features/webhooks/lib/sendPayload.ts index f4b78ed349..145fba9bfa 100644 --- a/packages/features/webhooks/lib/sendPayload.ts +++ b/packages/features/webhooks/lib/sendPayload.ts @@ -3,6 +3,7 @@ import { compile } from "handlebars"; import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod"; import { getHumanReadableLocationValue } from "@calcom/app-store/locations"; +import { DelegationCredentialErrorPayloadType } from "@calcom/features/webhooks/lib/dto/types"; import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs"; import type { Payment, Webhook } from "@calcom/prisma/client"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; @@ -94,7 +95,11 @@ export type EventPayloadType = CalendarEvent & paymentData?: Payment; }; -export type WebhookPayloadType = EventPayloadType | OOOEntryPayloadType | BookingNoShowUpdatedPayload; +export type WebhookPayloadType = + | EventPayloadType + | OOOEntryPayloadType + | BookingNoShowUpdatedPayload + | DelegationCredentialErrorPayloadType; type WebhookDataType = WebhookPayloadType & { triggerEvent: string; createdAt: string }; @@ -191,11 +196,17 @@ export function isOOOEntryPayload(data: WebhookPayloadType): data is OOOEntryPay } export function isNoShowPayload(data: WebhookPayloadType): data is BookingNoShowUpdatedPayload { - return "message" in data; + return "message" in data && "bookingUid" in data; +} + +export function isDelegationCredentialErrorPayload( + data: WebhookPayloadType +): data is DelegationCredentialErrorPayloadType { + return "error" in data && "credential" in data && "user" in data; } export function isEventPayload(data: WebhookPayloadType): data is EventPayloadType { - return !isNoShowPayload(data) && !isOOOEntryPayload(data); + return !isNoShowPayload(data) && !isOOOEntryPayload(data) && !isDelegationCredentialErrorPayload(data); } const sendPayload = async ( @@ -222,7 +233,13 @@ const sendPayload = async ( } if (body === undefined) { - if (template && (isOOOEntryPayload(data) || isEventPayload(data) || isNoShowPayload(data))) { + if ( + template && + (isOOOEntryPayload(data) || + isEventPayload(data) || + isNoShowPayload(data) || + isDelegationCredentialErrorPayload(data)) + ) { body = applyTemplate(template, { ...data, triggerEvent, createdAt }, contentType); } else { body = JSON.stringify({ diff --git a/packages/features/webhooks/lib/service/WebhookNotificationHandler.ts b/packages/features/webhooks/lib/service/WebhookNotificationHandler.ts index e2073fadcf..1a042d8eb9 100644 --- a/packages/features/webhooks/lib/service/WebhookNotificationHandler.ts +++ b/packages/features/webhooks/lib/service/WebhookNotificationHandler.ts @@ -125,6 +125,18 @@ export class WebhookNotificationHandler implements IWebhookNotificationHandler { }; } + case WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR: { + return { + triggerEvent: dto.triggerEvent, + createdAt: dto.createdAt, + payload: { + error: dto.error, + credential: dto.credential, + user: dto.user, + }, + }; + } + default: { // TypeScript exhaustiveness check - this should never happen if all cases are covered const _exhaustiveCheck: never = dto; diff --git a/packages/features/webhooks/lib/triggerDelegationCredentialErrorWebhook.ts b/packages/features/webhooks/lib/triggerDelegationCredentialErrorWebhook.ts new file mode 100644 index 0000000000..c42bc4d944 --- /dev/null +++ b/packages/features/webhooks/lib/triggerDelegationCredentialErrorWebhook.ts @@ -0,0 +1,83 @@ +import { DelegationCredentialErrorPayloadType } from "@calcom/features/webhooks/lib/dto/types"; +import type { CalendarAppDelegationCredentialError } from "@calcom/lib/CalendarAppError"; +import logger from "@calcom/lib/logger"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; + +import { WebhookRepository } from "./repository/WebhookRepository"; +import sendPayload from "./sendPayload"; + +const log = logger.getSubLogger({ prefix: ["triggerDelegationCredentialErrorWebhook"] }); + +/** + * Triggers delegation credential error webhooks for organization webhooks subscribed to DELEGATION_CREDENTIAL_ERROR + */ +export async function triggerDelegationCredentialErrorWebhook(params: { + error: CalendarAppDelegationCredentialError; + credential: DelegationCredentialErrorPayloadType["credential"]; + user: DelegationCredentialErrorPayloadType["user"]; + orgId?: number | null; +}): Promise { + try { + const { error, credential, user, orgId } = params; + + if (!orgId) { + log.debug("Skipping webhook emission - no organization context"); + return; + } + + const webhookRepository = WebhookRepository.getInstance(); + const webhooks = await webhookRepository.getSubscribers({ + teamId: orgId, + triggerEvent: WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR, + }); + + if (webhooks.length === 0) { + log.debug("No webhooks subscribed to DELEGATION_CREDENTIAL_ERROR for organization", { orgId }); + return; + } + + const payload = { + error: { + type: error.constructor.name, + message: error.message, + }, + credential: { + id: credential.id, + type: credential.type, + appId: credential.appId || "unknown", + }, + user: { + id: user.id, + email: user.email, + }, + } satisfies DelegationCredentialErrorPayloadType; + + const webhookPromises = webhooks.map((webhook) => + sendPayload( + webhook.secret, + WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR, + new Date().toISOString(), + webhook, + payload + ).catch((err) => { + log.error("Failed to send delegation credential error webhook", { + webhookId: webhook.id, + error: err instanceof Error ? err.message : String(err), + }); + }) + ); + + await Promise.allSettled(webhookPromises); + + log.info("Delegation credential error webhooks triggered", { + webhookCount: webhooks.length, + userId: user.id, + credentialId: credential.id, + errorType: error.constructor.name, + }); + } catch (webhookError) { + log.error("Failed to trigger delegation credential error webhook", { + error: webhookError instanceof Error ? webhookError.message : String(webhookError), + }); + } +} diff --git a/packages/prisma/migrations/20251110081648_add_delegation_credential_error_webhook_trigger/migration.sql b/packages/prisma/migrations/20251110081648_add_delegation_credential_error_webhook_trigger/migration.sql new file mode 100644 index 0000000000..5477249efd --- /dev/null +++ b/packages/prisma/migrations/20251110081648_add_delegation_credential_error_webhook_trigger/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "public"."WebhookTriggerEvents" ADD VALUE 'DELEGATION_CREDENTIAL_ERROR'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index c326d6d3a8..9ff244c329 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1089,6 +1089,7 @@ enum WebhookTriggerEvents { AFTER_HOSTS_CAL_VIDEO_NO_SHOW AFTER_GUESTS_CAL_VIDEO_NO_SHOW FORM_SUBMITTED_NO_EVENT + DELEGATION_CREDENTIAL_ERROR } model Webhook {