diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts index 9c9820f931..5fef3c95f0 100644 --- a/packages/features/tasker/tasks/triggerNoShow/common.ts +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -28,7 +28,7 @@ type ParticipantsWithEmail = (Participants[number] & { email?: string; isLoggedI export function sendWebhookPayload( webhook: Webhook, triggerEvent: WebhookTriggerEvents, - booking: Booking, + booking: Booking & { guests?: Booking["attendees"] }, maxStartTime: number, participants: ParticipantsWithEmail, originalRescheduledBooking?: OriginalRescheduledBooking, @@ -50,6 +50,12 @@ export function sendWebhookPayload( endTime: booking.endTime, participants, ...(hostEmail ? { hostEmail } : {}), + ...(triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW + ? { noShowHost: booking.noShowHost } + : {}), + ...(triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW && booking.guests + ? { guests: booking.guests } + : {}), ...(originalRescheduledBooking ? { rescheduledBy: originalRescheduledBooking.rescheduledBy } : {}), eventType: { ...booking.eventType, @@ -134,6 +140,7 @@ export const prepareNoShowTrigger = async ( ): Promise<{ booking: Booking; webhook: TWebhook; + hosts: Host[]; hostsThatDidntJoinTheCall: Host[]; hostsThatJoinedTheCall: Host[]; numberOfHostsThatJoined: number; @@ -228,6 +235,7 @@ export const prepareNoShowTrigger = async ( } return { + hosts, hostsThatDidntJoinTheCall, hostsThatJoinedTheCall, booking, diff --git a/packages/features/tasker/tasks/triggerNoShow/getBooking.ts b/packages/features/tasker/tasks/triggerNoShow/getBooking.ts index 21a13d860b..d4992dd2f7 100644 --- a/packages/features/tasker/tasks/triggerNoShow/getBooking.ts +++ b/packages/features/tasker/tasks/triggerNoShow/getBooking.ts @@ -19,6 +19,7 @@ export const getBooking = async (bookingId: number) => { eventTypeId: true, references: true, fromReschedule: true, + noShowHost: true, eventType: { select: { id: true, diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts index ce7ed867e5..d84f639e81 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts @@ -90,6 +90,14 @@ describe("Trigger Guest No Show:", () => { startTime: bookingStartTime, endTime: `${plus1DateString}T05:15:00.000Z`, user: { id: organizer.id }, + attendees: [ + { + email: "guest@example.com", + name: "Guest User", + timeZone: "UTC", + locale: "en", + }, + ], metadata: { videoCallUrl: "https://existing-daily-video-call-url.example.com", }, @@ -149,7 +157,20 @@ describe("Trigger Guest No Show:", () => { triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, payload: { title: "Test Booking Title", - attendees: [], + attendees: [ + expect.objectContaining({ + email: "guest@example.com", + name: "Guest User", + noShow: true, + }), + ], + guests: [ + expect.objectContaining({ + email: "guest@example.com", + name: "Guest User", + noShow: true, + }), + ], bookingId: 222, bookingUid: uidOfBooking, participants: [], @@ -228,6 +249,14 @@ describe("Trigger Guest No Show:", () => { startTime: bookingStartTime, endTime: `${plus1DateString}T05:15:00.000Z`, user: { id: organizer.id }, + attendees: [ + { + email: "guest@example.com", + name: "Guest User", + timeZone: "UTC", + locale: "en", + }, + ], metadata: { videoCallUrl: "https://existing-daily-video-call-url.example.com", }, @@ -315,7 +344,20 @@ describe("Trigger Guest No Show:", () => { triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, payload: { title: "Test Booking Title", - attendees: [], + attendees: [ + expect.objectContaining({ + email: "guest@example.com", + name: "Guest User", + noShow: true, + }), + ], + guests: [ + expect.objectContaining({ + email: "guest@example.com", + name: "Guest User", + noShow: true, + }), + ], bookingId: 222, bookingUid: uidOfBooking, participants: MOCKED_PARTICIPANTS, @@ -433,6 +475,14 @@ describe("Trigger Guest No Show:", () => { endTime: `${plus1DateString}T05:30:00.000Z`, user: { id: organizer.id }, fromReschedule: uidOfBooking, + attendees: [ + { + email: "guest@example.com", + name: "Guest User", + timeZone: "UTC", + locale: "en", + }, + ], metadata: { videoCallUrl: "https://existing-daily-video-call-url.example.com", }, @@ -519,7 +569,20 @@ describe("Trigger Guest No Show:", () => { triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, payload: { title: "Test Booking Title", - attendees: [], + attendees: [ + expect.objectContaining({ + email: "guest@example.com", + name: "Guest User", + noShow: true, + }), + ], + guests: [ + expect.objectContaining({ + email: "guest@example.com", + name: "Guest User", + noShow: true, + }), + ], bookingId: 224, bookingUid: newUidOfBooking, participants: MOCKED_PARTICIPANTS, @@ -694,4 +757,190 @@ describe("Trigger Guest No Show:", () => { }, timeout ); + + test( + `Should send updated attendees with noShow=true in webhook payload`, + async () => { + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + const uidOfBooking = "n5Wv3eHgconAED2j4gcVhP"; + const iCalUID = `${uidOfBooking}@Cal.com`; + const subscriberUrl = "http://my-webhook.example.com"; + const bookingStartTime = `${plus1DateString}T05:00:00.000Z`; + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + id: "22", + userId: organizer.id, + eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + time: 5, + timeUnit: TimeUnit.MINUTE, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 15, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + id: 222, + uid: uidOfBooking, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: `${plus1DateString}T05:15:00.000Z`, + user: { id: organizer.id }, + attendees: [ + { + email: "guest@example.com", + name: "Guest User", + timeZone: "UTC", + locale: "en", + }, + ], + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: undefined, + }, + ], + iCalUID, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const MOCKED_PARTICIPANTS = [ + { + user_id: "101", + email: "organizer@example.com", + participant_id: "MOCK_PARTICIPANT_ID", + user_name: "Organizer", + join_time: 0, + duration: 15, + isLoggedIn: true, + }, + ]; + + const MOCKED_MEETING_SESSIONS = { + total_count: 1, + data: [ + { + id: "MOCK_ID", + room: "MOCK_ROOM", + start_time: 1234567890, + duration: 15, + max_participants: 1, + participants: MOCKED_PARTICIPANTS, + }, + ], + }; + + vi.mocked(getMeetingSessionsFromRoomName).mockResolvedValue(MOCKED_MEETING_SESSIONS); + + const TEST_WEBHOOK = { + id: "22", + eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + time: 5, + timeUnit: TimeUnit.MINUTE, + payloadTemplate: null, + secret: null, + }; + + const payload = JSON.stringify({ + triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + bookingId: 222, + webhook: TEST_WEBHOOK, + } satisfies TSendNoShowWebhookPayloadSchema); + + await triggerGuestNoShow(payload); + + const maxStartTime = calculateMaxStartTime(bookingStartTime as unknown as Date, 5, TimeUnit.MINUTE); + const maxStartTimeHumanReadable = dayjs.unix(maxStartTime).format("YYYY-MM-DD HH:mm:ss Z"); + + await expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + payload: { + title: "Test Booking Title", + attendees: [ + expect.objectContaining({ + email: "guest@example.com", + name: "Guest User", + noShow: true, + }), + ], + guests: [ + expect.objectContaining({ + email: "guest@example.com", + name: "Guest User", + noShow: true, + }), + ], + bookingId: 222, + bookingUid: uidOfBooking, + participants: MOCKED_PARTICIPANTS, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + eventType: { + id: 1, + teamId: null, + parentId: null, + calVideoSettings: null, + }, + webhook: { + ...TEST_WEBHOOK, + secret: undefined, + active: undefined, + eventTypeId: undefined, + }, + message: `Guest didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, + }, + }); + }, + timeout + ); }); diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts index b560da942f..88bc900c54 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts @@ -33,8 +33,11 @@ const markGuestAsNoshowInBooking = async ({ data: { noShow: true }, }); } + + return await prisma.attendee.findMany({ where: { bookingId } }); } catch (err) { log.error("Error marking guests as no show in booking", err); + return null; } }; @@ -45,6 +48,7 @@ export async function triggerGuestNoShow(payload: string): Promise { const { webhook, booking, + hosts, hostsThatJoinedTheCall, didGuestJoinTheCall, guestsThatDidntJoinTheCall, @@ -52,41 +56,50 @@ export async function triggerGuestNoShow(payload: string): Promise { participants, } = result; + const hostEmails = new Set(hosts.map((h) => h.email)); + const maxStartTime = calculateMaxStartTime(booking.startTime, webhook.time, webhook.timeUnit); const requireEmailForGuests = booking.eventType?.calVideoSettings?.requireEmailForGuests ?? false; if (requireEmailForGuests) { if (guestsThatDidntJoinTheCall.length > 0) { - await Promise.all([ - sendWebhookPayload( - webhook, - WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, - booking, - maxStartTime, - participants, - originalRescheduledBooking - ), - markGuestAsNoshowInBooking({ - bookingId: booking.id, - hostsThatJoinedTheCall, - guestsThatDidntJoinTheCall, - }), - ]); + const updatedAttendees = await markGuestAsNoshowInBooking({ + bookingId: booking.id, + hostsThatJoinedTheCall, + guestsThatDidntJoinTheCall, + }); + const guests = updatedAttendees?.filter((a) => !hostEmails.has(a.email)) ?? []; + const bookingWithUpdatedData = updatedAttendees + ? { ...booking, attendees: updatedAttendees, guests } + : booking; + await sendWebhookPayload( + webhook, + WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + bookingWithUpdatedData, + maxStartTime, + participants, + originalRescheduledBooking + ); } } else { if (!didGuestJoinTheCall) { - await Promise.all([ - sendWebhookPayload( - webhook, - WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, - booking, - maxStartTime, - participants, - originalRescheduledBooking - ), - markGuestAsNoshowInBooking({ bookingId: booking.id, hostsThatJoinedTheCall }), - ]); + const updatedAttendees = await markGuestAsNoshowInBooking({ + bookingId: booking.id, + hostsThatJoinedTheCall, + }); + const guests = updatedAttendees?.filter((a) => !hostEmails.has(a.email)) ?? []; + const bookingWithUpdatedData = updatedAttendees + ? { ...booking, attendees: updatedAttendees, guests } + : booking; + await sendWebhookPayload( + webhook, + WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + bookingWithUpdatedData, + maxStartTime, + participants, + originalRescheduledBooking + ); } } } diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts index c554d821eb..408e19c2c9 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts @@ -9,7 +9,7 @@ import { import { expectWebhookToHaveBeenCalledWith } from "@calcom/web/test/utils/bookingScenario/expects"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; -import { describe, vi, test } from "vitest"; +import { describe, vi, test, expect } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; import dayjs from "@calcom/dayjs"; @@ -154,6 +154,7 @@ describe("Trigger Host No Show:", () => { bookingUid: uidOfBooking, participants: [], hostEmail: "organizer@example.com", + noShowHost: true, startTime: `${plus1DateString}T05:00:00.000Z`, endTime: `${plus1DateString}T05:15:00.000Z`, eventType: { @@ -319,6 +320,7 @@ describe("Trigger Host No Show:", () => { bookingUid: uidOfBooking, participants: MOCKED_PARTICIPANTS, hostEmail: "organizer@example.com", + noShowHost: true, startTime: `${plus1DateString}T05:00:00.000Z`, endTime: `${plus1DateString}T05:15:00.000Z`, eventType: { @@ -523,6 +525,7 @@ describe("Trigger Host No Show:", () => { bookingUid: newUidOfBooking, participants: MOCKED_PARTICIPANTS, hostEmail: "organizer@example.com", + noShowHost: true, startTime: `${plus1DateString}T05:15:00.000Z`, endTime: `${plus1DateString}T05:30:00.000Z`, rescheduledBy: organizer.email, @@ -544,4 +547,157 @@ describe("Trigger Host No Show:", () => { }, timeout ); + + test( + `Should send updated attendees with noShow=true when host in attendees table doesn't join`, + async () => { + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + const uidOfBooking = "n5Wv3eHgconAED2j4gcVhP"; + const iCalUID = `${uidOfBooking}@Cal.com`; + const subscriberUrl = "http://my-webhook.example.com"; + const bookingStartTime = `${plus1DateString}T05:00:00.000Z`; + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + id: "22", + userId: organizer.id, + eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + time: 5, + timeUnit: TimeUnit.MINUTE, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 15, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + id: 222, + uid: uidOfBooking, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: `${plus1DateString}T05:15:00.000Z`, + user: { id: organizer.id }, + attendees: [ + { + email: "guest@example.com", + name: "Guest User", + timeZone: "UTC", + locale: "en", + }, + ], + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: undefined, + }, + ], + iCalUID, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + vi.mocked(getMeetingSessionsFromRoomName).mockResolvedValue(EMPTY_MEETING_SESSIONS); + + const TEST_WEBHOOK = { + id: "22", + eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + time: 5, + timeUnit: TimeUnit.MINUTE, + payloadTemplate: null, + secret: null, + }; + + const payload = JSON.stringify({ + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + bookingId: 222, + webhook: TEST_WEBHOOK, + } satisfies TSendNoShowWebhookPayloadSchema); + + await triggerHostNoShow(payload); + const maxStartTime = calculateMaxStartTime(bookingStartTime as unknown as Date, 5, TimeUnit.MINUTE); + const maxStartTimeHumanReadable = dayjs.unix(maxStartTime).format("YYYY-MM-DD HH:mm:ss Z"); + + await expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + payload: { + title: "Test Booking Title", + attendees: [ + expect.objectContaining({ + email: "guest@example.com", + name: "Guest User", + }), + ], + bookingId: 222, + bookingUid: uidOfBooking, + participants: [], + hostEmail: "organizer@example.com", + noShowHost: true, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + eventType: { + id: 1, + teamId: null, + parentId: null, + calVideoSettings: null, + }, + webhook: { + ...TEST_WEBHOOK, + secret: undefined, + active: undefined, + eventTypeId: undefined, + }, + message: `Host with email ${organizer.email} didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, + }, + }); + }, + timeout + ); }); diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts index c22193ec78..5a3a0f7926 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts @@ -7,9 +7,11 @@ import { calculateMaxStartTime, sendWebhookPayload, prepareNoShowTrigger, log } const markHostsAsNoShowInBooking = async (booking: Booking, hostsThatDidntJoinTheCall: Host[]) => { try { + let noShowHost = booking.noShowHost; await Promise.allSettled( hostsThatDidntJoinTheCall.map((host) => { if (booking?.user?.id === host.id) { + noShowHost = true; return prisma.booking.update({ where: { uid: booking.uid, @@ -29,8 +31,11 @@ const markHostsAsNoShowInBooking = async (booking: Booking, hostsThatDidntJoinTh return Promise.resolve(); }) ); + const updatedAttendees = await prisma.attendee.findMany({ where: { bookingId: booking.id } }); + return { noShowHost, attendees: updatedAttendees }; } catch (error) { log.error("Error marking hosts as no show in booking", error); + return null; } }; @@ -42,11 +47,16 @@ export async function triggerHostNoShow(payload: string): Promise { const maxStartTime = calculateMaxStartTime(booking.startTime, webhook.time, webhook.timeUnit); + const updatedData = await markHostsAsNoShowInBooking(booking, hostsThatDidntJoinTheCall); + const bookingWithUpdatedData = updatedData + ? { ...booking, noShowHost: updatedData.noShowHost, attendees: updatedData.attendees } + : booking; + const hostsNoShowPromises = hostsThatDidntJoinTheCall.map((host) => { return sendWebhookPayload( webhook, WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, - booking, + bookingWithUpdatedData, maxStartTime, participants, originalRescheduledBooking, @@ -55,6 +65,4 @@ export async function triggerHostNoShow(payload: string): Promise { }); await Promise.all(hostsNoShowPromises); - - await markHostsAsNoShowInBooking(booking, hostsThatDidntJoinTheCall); }