From 54da93ab023665f765e50fe8ff69b44f5130ed8d Mon Sep 17 00:00:00 2001 From: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:57:25 +0530 Subject: [PATCH] chore: Integrate mark-no-show booking audit (#26570) * chore: Integrate mark-no-show booking audit Co-Authored-By: hariom@cal.com * fix: Simplify host no-show audit and add comment for attendee actor Co-Authored-By: hariom@cal.com * fix: Make actionSource required with ValidActionSource type and remove unnecessary comments Co-Authored-By: hariom@cal.com * fix: Remove merge conflict markers from bookings.service.ts Co-Authored-By: hariom@cal.com * feat: implement SYSTEM as a booking audit source for background tasks, including no-show triggers. * refactor: Move prisma query to BookingRepository for audit logging Co-Authored-By: hariom@cal.com * Conflict resolution * refactor: introduce `handleMarkHostNoShow` for public viewer and standardize actor and action source parameters for no-show actions. * refactor: Combine HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService into NoShowUpdatedActionService - Create new NoShowUpdatedAuditActionService with combined schema supporting both noShowHost and noShowAttendee as optional fields - Update BookingAuditActionServiceRegistry to use combined service with NO_SHOW_UPDATED action type - Update BookingAuditTaskerProducerService with single queueNoShowUpdatedAudit method - Update BookingAuditProducerService.interface.ts with combined method - Update BookingEventHandlerService with single onNoShowUpdated method - Update handleMarkNoShow.ts to use combined audit service - Update tasker tasks (triggerGuestNoShow, triggerHostNoShow) to use combined service - Add NO_SHOW_UPDATED to Prisma BookingAuditAction enum - Remove old separate HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService files This allows a single API action (e.g., API V2 markAbsent) that updates both host and attendee no-show status to be logged as a single audit event. Co-Authored-By: hariom@cal.com * chore: Add migration for NO_SHOW_UPDATED audit action enum value Co-Authored-By: hariom@cal.com * refactor: Update no-show audit schema to use host and attendees array - Rename noShowHost to host and noShowAttendee to attendees (remove redundant prefix) - Change attendees from single value to array to support multiple attendees in single audit entry - Update handleMarkNoShow.ts to create single audit entry for all attendees - Update tasker tasks (triggerGuestNoShow, triggerHostNoShow) to use new schema - Update display data types to reflect new schema structure Co-Authored-By: hariom@cal.com * Update schema to be more clear. Call onNoShowUpdated immediately after DB update as audit only cares about DB update, and this would avoid any accidental Audit update on DB change * chore: accommodate schema changes and fix type errors for no-show audit Co-Authored-By: hariom@cal.com * fix: Update migration to remove old no-show enum values Co-Authored-By: hariom@cal.com * fix: Use updated attendees in webhook payload for guest no-show Co-Authored-By: hariom@cal.com * fix: Remove duplicate imports and fix actor parameter in bookings.service.ts Co-Authored-By: hariom@cal.com * refactor: Move booking query to BookingRepository and remove unused method Co-Authored-By: hariom@cal.com * refactor: centralize no-show audit event firing and attendee fetching within `handleMarkNoShow` for improved clarity. * fix: Build attendeesNoShow as Record with attendee IDs as keys Co-Authored-By: hariom@cal.com * Fix and add tests for handleMarkBoShow * refactor: Move prisma queries to AttendeeRepository and BookingRepository Co-Authored-By: hariom@cal.com * fix: Correct return type for updateNoShow method (noShow can be null) Co-Authored-By: hariom@cal.com * fix: Update handleMarkNoShow tests to mock new repository methods Co-Authored-By: hariom@cal.com * fix triggerGuestNoShow * refactor: Move triggerGuestNoShow queries to AttendeeRepository and add unit tests - Add new methods to AttendeeRepository: - findByBookingId: Get attendees with id, email, noShow - findByBookingIdWithDetails: Get full attendee details - updateManyNoShowByBookingIdAndEmails: Update specific attendees - updateManyNoShowByBookingIdExcludingEmails: Update all except specific emails - Refactor triggerGuestNoShow.ts to use AttendeeRepository instead of direct Prisma calls - Add comprehensive unit tests for triggerGuestNoShow following handleMarkNoShow.test.ts pattern: - Mock repositories and external services - In-memory DB simulation - Test core functionality, audit logging, webhook payload, error handling, and edge cases Co-Authored-By: hariom@cal.com * refactor: Move triggerHostNoShow queries to repositories and delete unit test file Co-Authored-By: hariom@cal.com * refactor: Convert repository methods to use named parameters objects Co-Authored-By: hariom@cal.com * refactor: Enhance no-show handling by consolidating audit logging and updating repository interactions - Refactor `buildResultPayload` to accept a structured argument for better clarity. - Update `updateAttendees` to use a named parameter object for improved readability. - Introduce `fireNoShowUpdatedEvent` to centralize no-show audit logging for both hosts and guests. - Remove redundant methods and streamline attendee retrieval in `AttendeeRepository`. - Add comprehensive unit tests for no-show event handling, ensuring accurate audit logging and webhook triggering. * test: Add tests for handleMarkHostNoShow guest actor and unmarking host no-show Co-Authored-By: hariom@cal.com * fix: update attendeesNoShow validation to handle numeric keys - Changed attendeesNoShow schema to use z.coerce.number() for key validation, ensuring string keys are correctly coerced to numbers. - Updated test to validate attendeesNoShow data using numeric key comparison, improving robustness of the audit data checks. * test: Add integration tests for NoShowUpdatedAuditActionService coerce fix Co-Authored-By: hariom@cal.com * feat: add graceful error handling for audit log enrichment - Add hasError field to EnrichedAuditLog type - Create buildFallbackAuditLog() method for failed enrichments - Wrap enrichAuditLog() in try-catch to handle errors gracefully - Add booking_audit_action.error_processing translation key - Update BookingHistory.tsx to show warning icon for error logs - Hide 'Show details' button for logs with hasError - Add comprehensive test cases for error handling scenarios Co-Authored-By: hariom@cal.com * feat: Enhance booking audit action services with new display fields and improved validation - Added "attendee_no_show_updated" and "no_show_updated" translations to common.json. - Updated IAuditActionService to include new methods for handling display fields and migration. - Enhanced NoShowUpdatedAuditActionService to differentiate between host and attendee no-show updates. - Improved ReassignmentAuditActionService to ensure consistent handling of audit data. - Refactored BookingAuditViewerService for better clarity and maintainability. * refactor: Use attendeeEmail instead of attendeeId as key in audit data and store host's userUuid - Updated schema to use attendee email as key instead of attendee ID because attendee records can be reused with different person's data - Store host's userUuid in audit data with format: host: { userUuid, noShow: { old, new } } - Updated fireNoShowUpdated to accept hostUserUuid parameter - Added uuid field to BookingRepository.findByUidIncludeEventTypeAttendeesAndUser - Updated NoShowUpdatedAuditActionService getDisplayFields to work with email-based keys - Added attendeeRepository to DI modules for BookingAuditTaskConsumer - Updated tests to match new schema format Co-Authored-By: hariom@cal.com * test: Update integration tests to use new schema format with host.userUuid and email keys Co-Authored-By: hariom@cal.com * fix: correct audit schema for SYSTEM source and ensure attendee lookup by bookingId - Fix schema inconsistency: use host.userUuid format instead of hostNoShow - Add user.uuid to getBooking select for audit logging - Log warning when hostUserUuid is undefined but host no-show is being updated - Use email as key for attendeesNoShow (not attendee ID) to match schema - Fetch attendees by bookingId before updating to ensure correct scoping - Remove unused safeStringify import * refactor: Change attendeesNoShow schema from Record to Array format - Update NoShowUpdatedAuditActionService schema to use array format: attendeesNoShow: Array<{attendeeEmail: string, noShow: {old, new}}> - Update handleMarkNoShow.ts to build attendeesNoShow as array - Update common.ts fireNoShowUpdatedEvent to convert Map to array - Update all tests to use new array format with attendeeEmail property Co-Authored-By: hariom@cal.com * refactor: Update attendeesNoShow schema to use array format in triggerNoShow tasks - Refactor `triggerGuestNoShow` and `triggerHostNoShow` to replace Map with array for `attendeesNoShowAudit`. - Update `fireNoShowUpdatedEvent` to handle the new array format for attendees. - Ensure consistent data structure across no-show audit logging for better clarity and maintainability. * fix: Update ReassignmentAuditActionService to use GetDisplayFieldsParams interface Co-Authored-By: hariom@cal.com * fix: Update ReassignmentAuditActionService tests to use GetDisplayFieldsParams interface Co-Authored-By: hariom@cal.com * fix: Use handleMarkAttendeeNoShow for API v2 mark absent endpoint - Export handleMarkAttendeeNoShow from platform-libraries - Update API v2 bookings service to use handleMarkAttendeeNoShow with actionSource - Add validation for userUuid before calling handleMarkAttendeeNoShow Co-Authored-By: hariom@cal.com * refactor: Enhance display fields structure in booking audit components - Updated `BookingHistory.tsx` to allow for more flexible display fields, including optional raw values and arrays of values. - Introduced `DisplayFieldValue` component for rendering display fields, improving clarity and maintainability. - Adjusted `IAuditActionService` and `NoShowUpdatedAuditActionService` to align with the new display fields structure. - Ensured consistent handling of display fields across the booking audit service for better data representation. * fix: Remove debug code that duplicated first attendee in display fields Co-Authored-By: hariom@cal.com * refactor: Update attendee repository methods to use structured parameters - Modified `updateNoShow`, `updateManyNoShowByBookingIdAndEmails`, and `updateManyNoShowByBookingIdExcludingEmails` methods to accept structured `where` and `data` parameters for better clarity and maintainability. - Updated calls to these methods throughout the codebase to reflect the new parameter structure. - Removed unused `findByIdWithNoShow` method and adjusted related logic to streamline attendee data retrieval. * feat: Add infrastructure for no-show audit integration - Add Prisma migrations for SYSTEM source and NO_SHOW_UPDATED audit action - Add NoShowUpdatedAuditActionService with array-based attendeesNoShow schema - Update BookingAuditActionServiceRegistry to include NO_SHOW_UPDATED - Update BookingAuditTaskConsumer and BookingAuditViewerService - Add AttendeeRepository methods for no-show queries - Update IAuditActionService interface with values array support - Update locales with no-show audit translation keys Co-Authored-By: hariom@cal.com * fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types Co-Authored-By: hariom@cal.com * fix: Remove HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED from BookingAuditAction type to match Prisma schema Co-Authored-By: hariom@cal.com * fix: Update BookingAuditActionSchema to use NO_SHOW_UPDATED instead of HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED Co-Authored-By: hariom@cal.com * refactor: Remove deprecated no-show audit services and unify to NoShowUpdatedAuditActionService - Delete HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService - Update BookingAuditProducerService.interface.ts to use queueNoShowUpdatedAudit - Update BookingAuditTaskerProducerService.ts to use queueNoShowUpdatedAudit - Update BookingEventHandlerService.ts to use onNoShowUpdated - Add integration tests for NoShowUpdatedAuditActionService Co-Authored-By: hariom@cal.com * fix: Update test mock to match new AttendeeRepository.updateNoShow signature Co-Authored-By: hariom@cal.com * refactor: rename handleMarkAttendeeNoShow to handleMarkNoShow and update related types - Renamed `handleMarkAttendeeNoShow` to `handleMarkNoShow` for clarity. - Updated type definitions to reflect the new naming convention. - Modified the `markNoShow` handler to require `userUuid` and adjusted related logic. - Enhanced the `fireNoShowUpdatedEvent` to accommodate changes in event type structure. - Updated references across various files to ensure consistency with the new function name. * handle null value * fix: add explicit parentheses for operator precedence clarity Co-Authored-By: hariom@cal.com * Fix cubic reported bug --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../controllers/bookings.controller.ts | 5 +- .../2024-08-13/services/bookings.service.ts | 4 +- .../repositories/BookingRepository.ts | 133 ++- packages/features/handleMarkNoShow.test.ts | 828 ++++++++++++++++++ packages/features/handleMarkNoShow.ts | 455 ++++++---- .../tasker/tasks/triggerNoShow/common.ts | 65 +- .../tasker/tasks/triggerNoShow/getBooking.ts | 1 + .../triggerNoShow/triggerGuestNoShow.test.ts | 102 ++- .../tasks/triggerNoShow/triggerGuestNoShow.ts | 90 +- .../triggerNoShow/triggerHostNoShow.test.ts | 258 +++++- .../tasks/triggerNoShow/triggerHostNoShow.ts | 61 +- .../loggedInViewer/markNoShow.handler.ts | 6 +- .../publicViewer/markHostAsNoShow.handler.ts | 10 +- 13 files changed, 1757 insertions(+), 261 deletions(-) create mode 100644 packages/features/handleMarkNoShow.test.ts diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index 7e4b7c2539..694ae06737 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -14,7 +14,7 @@ import { handleCancelBooking, handleMarkNoShow, } from "@calcom/platform-libraries"; -import { type InstantBookingCreateResult } from "@calcom/platform-libraries/bookings"; +import { type InstantBookingCreateResult, makeUserActor } from "@calcom/platform-libraries/bookings"; import { ErrorCode, HttpError } from "@calcom/platform-libraries/errors"; import type { ApiResponse } from "@calcom/platform-types"; import { @@ -314,7 +314,8 @@ export class BookingsController_2024_04_15 { attendees: body.attendees, noShowHost: body.noShowHost, userId: user.id, - userUuid: user.uuid, + actor: makeUserActor(user.uuid), + actionSource: "API_V2", }); return { status: SUCCESS_STATUS, data: markNoShowResponse }; diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index 5b339310c0..c7bf7f40cc 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -1005,7 +1005,7 @@ export class BookingsService_2024_08_13 { bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13, - userUuid?: string + userUuid: string ) { const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body); const bookingBefore = await this.bookingsRepository.getByUid(bookingUid); @@ -1034,6 +1034,8 @@ export class BookingsService_2024_08_13 { userId: bookingOwnerId, userUuid, platformClientParams, + actor: makeUserActor(userUuid), + actionSource: "API_V2", }); const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 041a017486..f3700dee2e 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -1,21 +1,21 @@ import { withReporting } from "@calcom/lib/sentryWrapper"; +import type { + BookingUpdateData, + BookingWhereInput, + BookingWhereUniqueInput, + IBookingRepository, +} from "@calcom/lib/server/repository/dto/IBookingRepository"; import type { PrismaClient } from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; -import type { Booking } from "@calcom/prisma/client"; -import { RRTimestampBasis, BookingStatus } from "@calcom/prisma/enums"; +import type { Booking, Prisma } from "@calcom/prisma/client"; +import { BookingStatus, RRTimestampBasis } from "@calcom/prisma/enums"; import { - bookingMinimalSelect, bookingAuthorizationCheckSelect, bookingDetailsSelect, + bookingMinimalSelect, } from "@calcom/prisma/selects/booking"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; -import type { - BookingWhereInput, - IBookingRepository, - BookingUpdateData, - BookingWhereUniqueInput, -} from "@calcom/lib/server/repository/dto/IBookingRepository"; +import { workflowSelect } from "../../ee/workflows/lib/getAllWorkflows"; const workflowReminderSelect = { id: true, @@ -395,6 +395,103 @@ export class BookingRepository implements IBookingRepository { }); } + async findByUidIncludeEventTypeAttendeesAndUser({ bookingUid }: { bookingUid: string }) { + return await this.prismaClient.booking.findUnique({ + where: { uid: bookingUid }, + select: { + id: true, + startTime: true, + endTime: true, + title: true, + metadata: true, + uid: true, + location: true, + destinationCalendar: true, + smsReminderNumber: true, + userPrimaryEmail: true, + eventType: { + select: { + id: true, + hideOrganizerEmail: true, + customReplyToEmail: true, + schedulingType: true, + slug: true, + title: true, + metadata: true, + parentId: true, + teamId: true, + userId: true, + hosts: { + select: { + user: { + select: { + email: true, + destinationCalendar: { + select: { + primaryEmail: true, + }, + }, + }, + }, + }, + }, + parent: { + select: { + teamId: true, + }, + }, + workflows: { + select: { + workflow: { + select: workflowSelect, + }, + }, + }, + owner: { + select: { + hideBranding: true, + email: true, + name: true, + timeZone: true, + locale: true, + }, + }, + team: { + select: { + parentId: true, + name: true, + id: true, + }, + }, + }, + }, + attendees: { + select: { + email: true, + name: true, + timeZone: true, + locale: true, + phoneNumber: true, + }, + }, + user: { + select: { + id: true, + uuid: true, + email: true, + name: true, + destinationCalendar: true, + timeZone: true, + locale: true, + username: true, + timeFormat: true, + }, + }, + noShowHost: true, + }, + }); + } + async findByUidIncludeEventType({ bookingUid }: { bookingUid: string }) { return await this.prismaClient.booking.findUnique({ where: { @@ -1509,7 +1606,7 @@ export class BookingRepository implements IBookingRepository { }); } -async updateMany({ where, data }: { where: BookingWhereInput; data: BookingUpdateData }) { + async updateMany({ where, data }: { where: BookingWhereInput; data: BookingUpdateData }) { return await this.prismaClient.booking.updateMany({ where: where, data, @@ -1523,6 +1620,20 @@ async updateMany({ where, data }: { where: BookingWhereInput; data: BookingUpdat }); } + async updateNoShowHost({ + bookingUid, + noShowHost, + }: { + bookingUid: string; + noShowHost: boolean; + }): Promise<{ id: number }> { + return await this.prismaClient.booking.update({ + where: { uid: bookingUid }, + data: { noShowHost }, + select: { id: true }, + }); + } + /** * Update a booking and return it with workflow reminders and references * Used during booking cancellation to update status and retrieve related data in one query diff --git a/packages/features/handleMarkNoShow.test.ts b/packages/features/handleMarkNoShow.test.ts new file mode 100644 index 0000000000..3b68211452 --- /dev/null +++ b/packages/features/handleMarkNoShow.test.ts @@ -0,0 +1,828 @@ +import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import handleMarkNoShow, { handleMarkHostNoShow } from "./handleMarkNoShow"; + +const { mockPrismaBookingUpdate, mockPrismaAttendeeUpdate, mockPrismaAttendeeFindMany } = vi.hoisted(() => ({ + mockPrismaBookingUpdate: vi.fn(), + mockPrismaAttendeeUpdate: vi.fn(), + mockPrismaAttendeeFindMany: vi.fn(), +})); + +vi.mock("@calcom/prisma", () => ({ + prisma: { + booking: { update: mockPrismaBookingUpdate }, + attendee: { + findMany: mockPrismaAttendeeFindMany, + update: mockPrismaAttendeeUpdate, + }, + }, +})); + +const { + mockFindByUidIncludeEventTypeAttendeesAndUser, + mockFindByUidIncludeEventTypeAndReferences, + mockUpdateNoShowHost, + MockBookingRepository, +} = vi.hoisted(() => { + const mockFindByUidIncludeEventTypeAttendeesAndUser = vi.fn(); + const mockFindByUidIncludeEventTypeAndReferences = vi.fn(); + const mockUpdateNoShowHost = vi.fn(); + class MockBookingRepository { + findByUidIncludeEventTypeAttendeesAndUser = mockFindByUidIncludeEventTypeAttendeesAndUser; + findByUidIncludeEventTypeAndReferences = mockFindByUidIncludeEventTypeAndReferences; + updateNoShowHost = mockUpdateNoShowHost; + } + return { + mockFindByUidIncludeEventTypeAttendeesAndUser, + mockFindByUidIncludeEventTypeAndReferences, + mockUpdateNoShowHost, + MockBookingRepository, + }; +}); + +vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({ + BookingRepository: MockBookingRepository, +})); + +const { + mockFindByBookingUidAndEmails, + mockFindIdAndEmailByBookingUidAndEmails, + mockUpdateNoShow, + MockAttendeeRepository, +} = vi.hoisted(() => { + const mockFindByBookingUidAndEmails = vi.fn(); + const mockFindIdAndEmailByBookingUidAndEmails = vi.fn(); + const mockUpdateNoShow = vi.fn(); + class MockAttendeeRepository { + findByBookingUidAndEmails = mockFindByBookingUidAndEmails; + findIdAndEmailByBookingUidAndEmails = mockFindIdAndEmailByBookingUidAndEmails; + updateNoShow = mockUpdateNoShow; + } + return { + mockFindByBookingUidAndEmails, + mockFindIdAndEmailByBookingUidAndEmails, + mockUpdateNoShow, + MockAttendeeRepository, + }; +}); + +vi.mock("@calcom/features/bookings/repositories/AttendeeRepository", () => ({ + AttendeeRepository: MockAttendeeRepository, +})); + +const { mockDoesUserIdHaveAccessToBooking, MockBookingAccessService } = vi.hoisted(() => { + const mockDoesUserIdHaveAccessToBooking = vi.fn(); + class MockBookingAccessService { + doesUserIdHaveAccessToBooking = mockDoesUserIdHaveAccessToBooking; + } + return { mockDoesUserIdHaveAccessToBooking, MockBookingAccessService }; +}); + +vi.mock("@calcom/features/bookings/services/BookingAccessService", () => ({ + BookingAccessService: MockBookingAccessService, +})); + +const { mockWebhookServiceInit, mockSendPayload } = vi.hoisted(() => ({ + mockWebhookServiceInit: vi.fn(), + mockSendPayload: vi.fn(), +})); + +vi.mock("@calcom/features/webhooks/lib/WebhookService", () => ({ + WebhookService: { + init: mockWebhookServiceInit, + }, +})); + +const { mockOnNoShowUpdated, mockGetBookingEventHandlerService } = vi.hoisted(() => { + const mockOnNoShowUpdated = vi.fn(); + const mockGetBookingEventHandlerService = vi.fn().mockReturnValue({ + onNoShowUpdated: mockOnNoShowUpdated, + }); + return { mockOnNoShowUpdated, mockGetBookingEventHandlerService }; +}); + +vi.mock("@calcom/features/bookings/di/BookingEventHandlerService.container", () => ({ + getBookingEventHandlerService: mockGetBookingEventHandlerService, +})); + +vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType", () => ({ + getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({ + default: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@calcom/lib/server/i18n", () => ({ + getTranslation: vi + .fn() + .mockResolvedValue((key: string, opts?: { x?: string }) => (opts?.x ? `${opts.x} ${key}` : key)), +})); + +vi.mock("./noShow/handleSendingAttendeeNoShowDataToApps", () => ({ + default: vi.fn().mockResolvedValue(undefined), +})); + +const DB = { + bookings: {} as Record, + attendees: {} as Record, +}; + +type DBBooking = { + uid: string; + id: number; + userId: number; + noShowHost: boolean | null; + startTime: Date; + endTime: Date; + title: string; + location: string | null; + userPrimaryEmail: string | null; + smsReminderNumber: string | null; + destinationCalendar: null; + metadata: null; + eventType: { + id: number; + teamId: number | null; + userId: number | null; + slug: string; + schedulingType: null; + hosts: never[]; + owner: null; + hideOrganizerEmail: boolean; + customReplyToEmail: null; + team: null; + } | null; + user: { + id: number; + uuid: string; + email: string; + name: string; + locale: string; + timeZone: string; + timeFormat: number; + username: string; + destinationCalendar: null; + hideBranding: boolean; + } | null; + attendees: { + id: number; + email: string; + name: string; + noShow: boolean | null; + locale: string; + timeZone: string; + phoneNumber: string | null; + }[]; +}; + +type DBAttendee = { + id: number; + email: string; + noShow: boolean | null; +}; + +const createMockBooking = (overrides: { + uid: string; + userId?: number; + noShowHost?: boolean | null; + startTime?: Date; + endTime?: Date; + hasEventType?: boolean; +}) => { + const booking: DBBooking = { + uid: overrides.uid, + id: 1, + userId: overrides.userId ?? 123, + noShowHost: overrides.noShowHost ?? null, + startTime: overrides.startTime ?? new Date(Date.now() - 3600000), + endTime: overrides.endTime ?? new Date(Date.now() + 3600000), + title: "Test Meeting", + location: null, + userPrimaryEmail: "host@example.com", + smsReminderNumber: null, + destinationCalendar: null, + metadata: null, + eventType: + overrides.hasEventType !== false + ? { + id: 1, + teamId: null, + userId: overrides.userId ?? 123, + slug: "test-event", + schedulingType: null, + hosts: [], + owner: null, + hideOrganizerEmail: false, + customReplyToEmail: null, + team: null, + } + : null, + user: { + id: overrides.userId ?? 123, + uuid: "host-uuid-123", + email: "host@example.com", + name: "Test Host", + locale: "en", + timeZone: "UTC", + timeFormat: 12, + username: "testhost", + destinationCalendar: null, + hideBranding: false, + }, + attendees: [], + }; + DB.bookings[booking.uid] = booking; + DB.attendees[booking.uid] = []; + return booking; +}; + +const createMockAttendee = (overrides: { + bookingUid: string; + email: string; + noShow?: boolean | null; + name?: string; +}) => { + const attendee: DBAttendee = { + id: Date.now() + Math.random(), + email: overrides.email, + noShow: overrides.noShow ?? null, + }; + DB.attendees[overrides.bookingUid] ??= []; + DB.attendees[overrides.bookingUid].push(attendee); + + const booking = DB.bookings[overrides.bookingUid]; + if (booking) { + booking.attendees.push({ + id: attendee.id, + email: attendee.email, + name: overrides.name ?? "Test Attendee", + noShow: attendee.noShow, + locale: "en", + timeZone: "UTC", + phoneNumber: null, + }); + } + return attendee; +}; + +const clearDB = () => { + DB.bookings = {}; + DB.attendees = {}; +}; + +const expectAttendeeNoShowState = (bookingUid: string, email: string, expectedNoShow: boolean) => { + const attendee = DB.attendees[bookingUid]?.find((a) => a.email === email); + expect(attendee).toBeDefined(); + expect(attendee?.noShow).toBe(expectedNoShow); +}; + +const expectBookingNoShowHostState = (bookingUid: string, expectedNoShowHost: boolean) => { + const booking = DB.bookings[bookingUid]; + expect(booking).toBeDefined(); + expect(booking?.noShowHost).toBe(expectedNoShowHost); +}; + +type ExpectedAuditData = { + bookingUid: string; + source: string; + actor: { identifiedBy: string; userUuid?: string; email?: string; name?: null }; + organizationId: number | null; + host?: { userUuid: string; noShow: { old: boolean | null; new: boolean } }; + attendeesNoShow?: Array<{ attendeeEmail: string; noShow: { old: boolean | null; new: boolean } }>; +}; + +const expectNoShowBookingAudit = (expected: ExpectedAuditData) => { + expect(mockOnNoShowUpdated).toHaveBeenCalledTimes(1); + const call = mockOnNoShowUpdated.mock.calls[0][0]; + + expect(call.bookingUid).toBe(expected.bookingUid); + expect(call.source).toBe(expected.source); + expect(call.actor).toMatchObject(expected.actor); + expect(call.organizationId).toBe(expected.organizationId); + + if (expected.host) { + expect(call.auditData.host).toEqual(expected.host); + } + + if (expected.attendeesNoShow) { + expect(call.auditData.attendeesNoShow).toHaveLength(expected.attendeesNoShow.length); + + for (const expectedAttendee of expected.attendeesNoShow) { + const actualAttendee = call.auditData.attendeesNoShow.find( + (a: { attendeeEmail: string }) => a.attendeeEmail === expectedAttendee.attendeeEmail + ); + expect(actualAttendee).toBeDefined(); + expect(actualAttendee.noShow).toEqual(expectedAttendee.noShow); + } + } +}; + +describe("handleMarkNoShow", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearDB(); + + mockFindByUidIncludeEventTypeAttendeesAndUser.mockImplementation(({ bookingUid }) => { + const booking = DB.bookings[bookingUid]; + if (!booking) return Promise.resolve(null); + const attendees = DB.attendees[bookingUid] ?? []; + return Promise.resolve({ + ...booking, + attendees, + }); + }); + + mockFindByUidIncludeEventTypeAndReferences.mockImplementation(({ bookingUid }) => { + const booking = DB.bookings[bookingUid]; + if (!booking) throw new Error("Booking not found"); + const attendees = DB.attendees[bookingUid] ?? []; + return Promise.resolve({ + ...booking, + attendees: attendees.map((a) => ({ ...a })), + }); + }); + + mockPrismaAttendeeFindMany.mockImplementation(({ where }) => { + const bookingUid = where?.AND?.[0]?.booking?.uid ?? where?.booking?.uid; + const emails = where?.AND?.[0]?.email?.in ?? where?.email?.in ?? []; + const attendees = DB.attendees[bookingUid] ?? []; + const filteredAttendees = attendees.filter((a) => emails.includes(a.email)).map((a) => ({ ...a })); + return Promise.resolve(filteredAttendees); + }); + + mockPrismaAttendeeUpdate.mockImplementation(({ where, data }) => { + for (const bookingUid of Object.keys(DB.attendees)) { + const attendee = DB.attendees[bookingUid].find((a) => a.id === where.id); + if (attendee) { + attendee.noShow = data.noShow; + return Promise.resolve({ ...attendee, noShow: data.noShow }); + } + } + return Promise.resolve(null); + }); + + mockPrismaBookingUpdate.mockImplementation(({ where, data }) => { + const booking = DB.bookings[where.uid]; + if (booking && data.noShowHost !== undefined) { + booking.noShowHost = data.noShowHost; + } + return Promise.resolve({ uid: where.uid, noShowHost: data.noShowHost }); + }); + + mockFindByBookingUidAndEmails.mockImplementation( + ({ bookingUid, emails }: { bookingUid: string; emails: string[] }) => { + const attendees = DB.attendees[bookingUid] ?? []; + return Promise.resolve(attendees.filter((a) => emails.includes(a.email)).map((a) => ({ ...a }))); + } + ); + + mockFindIdAndEmailByBookingUidAndEmails.mockImplementation( + ({ bookingUid, emails }: { bookingUid: string; emails: string[] }) => { + const attendees = DB.attendees[bookingUid] ?? []; + return Promise.resolve( + attendees.filter((a) => emails.includes(a.email)).map((a) => ({ id: a.id, email: a.email })) + ); + } + ); + + mockUpdateNoShow.mockImplementation( + ({ + where: { attendeeId }, + data: { noShow }, + }: { + where: { attendeeId: number }; + data: { noShow: boolean }; + }) => { + for (const bookingUid of Object.keys(DB.attendees)) { + const attendee = DB.attendees[bookingUid].find((a) => a.id === attendeeId); + if (attendee) { + attendee.noShow = noShow; + return Promise.resolve({ noShow, email: attendee.email }); + } + } + return Promise.resolve(null); + } + ); + + mockUpdateNoShowHost.mockImplementation( + ({ bookingUid, noShowHost }: { bookingUid: string; noShowHost: boolean }) => { + const booking = DB.bookings[bookingUid]; + if (booking) { + booking.noShowHost = noShowHost; + } + return Promise.resolve({ id: booking?.id ?? 1 }); + } + ); + + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true); + + mockWebhookServiceInit.mockResolvedValue({ sendPayload: mockSendPayload }); + mockSendPayload.mockResolvedValue(undefined); + + mockOnNoShowUpdated.mockResolvedValue(undefined); + }); + + describe("Core Functionality", () => { + it("should mark single attendee as no-show", async () => { + const bookingUid = "test-booking-1"; + createMockBooking({ uid: bookingUid }); + createMockAttendee({ bookingUid, email: "attendee@example.com", noShow: false }); + + const result = await handleMarkNoShow({ + bookingUid, + attendees: [{ email: "attendee@example.com", noShow: true }], + userId: 123, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(result.attendees).toHaveLength(1); + expect(result.attendees[0]).toEqual({ email: "attendee@example.com", noShow: true }); + expectAttendeeNoShowState(bookingUid, "attendee@example.com", true); + }); + + it("should mark multiple attendees as no-show", async () => { + const bookingUid = "test-booking-2"; + createMockBooking({ uid: bookingUid }); + createMockAttendee({ bookingUid, email: "attendee1@example.com", noShow: false }); + createMockAttendee({ bookingUid, email: "attendee2@example.com", noShow: false }); + + const result = await handleMarkNoShow({ + bookingUid, + attendees: [ + { email: "attendee1@example.com", noShow: true }, + { email: "attendee2@example.com", noShow: true }, + ], + userId: 123, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(result.attendees).toHaveLength(2); + expectAttendeeNoShowState(bookingUid, "attendee1@example.com", true); + expectAttendeeNoShowState(bookingUid, "attendee2@example.com", true); + }); + + it("should unmark attendee from no-show", async () => { + const bookingUid = "test-booking-3"; + createMockBooking({ uid: bookingUid }); + createMockAttendee({ bookingUid, email: "attendee@example.com", noShow: true }); + + const result = await handleMarkNoShow({ + bookingUid, + attendees: [{ email: "attendee@example.com", noShow: false }], + userId: 123, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(result.attendees[0]).toEqual({ email: "attendee@example.com", noShow: false }); + }); + + it("should mark host as no-show", async () => { + const bookingUid = "test-booking-4"; + createMockBooking({ uid: bookingUid }); + + const result = await handleMarkNoShow({ + bookingUid, + noShowHost: true, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(result.noShowHost).toBe(true); + expectBookingNoShowHostState(bookingUid, true); + }); + + it("should fire audit event when unmarking host as no-show", async () => { + const bookingUid = "test-booking-unmark-host"; + createMockBooking({ uid: bookingUid, noShowHost: true }); + + const result = await handleMarkNoShow({ + bookingUid, + noShowHost: false, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(result.noShowHost).toBe(false); + expect(mockOnNoShowUpdated).toHaveBeenCalledTimes(1); + const call = mockOnNoShowUpdated.mock.calls[0][0]; + expect(call.auditData.host).toEqual({ + userUuid: "host-uuid-123", + noShow: { old: true, new: false }, + }); + expect(call.source).toBe("WEBAPP"); + expect(call.actor).toEqual({ identifiedBy: "user", userUuid: "user-uuid-123" }); + }); + + it("should mark both host and attendees in single call", async () => { + const bookingUid = "test-booking-5"; + createMockBooking({ uid: bookingUid }); + createMockAttendee({ bookingUid, email: "attendee@example.com", noShow: false }); + + const result = await handleMarkNoShow({ + bookingUid, + attendees: [{ email: "attendee@example.com", noShow: true }], + noShowHost: true, + userId: 123, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(result.noShowHost).toBe(true); + expect(result.attendees).toHaveLength(1); + expect(result.attendees[0].noShow).toBe(true); + expectAttendeeNoShowState(bookingUid, "attendee@example.com", true); + }); + }); + + describe("Error Handling", () => { + it("should throw 404 when booking not found", async () => { + await expect( + handleMarkNoShow({ + bookingUid: "non-existent", + noShowHost: true, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }) + ).rejects.toThrow(/Failed to update no-show status/); + }); + + it("should throw 403 when user lacks access", async () => { + const bookingUid = "test-booking-access"; + createMockBooking({ uid: bookingUid }); + createMockAttendee({ bookingUid, email: "attendee@example.com" }); + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(false); + + await expect( + handleMarkNoShow({ + bookingUid, + attendees: [{ email: "attendee@example.com", noShow: true }], + userId: 999, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }) + ).rejects.toThrow(); + }); + + it("should throw 403 when booking hasn't started yet", async () => { + const bookingUid = "test-future-booking"; + createMockBooking({ + uid: bookingUid, + startTime: new Date(Date.now() + 3600000), + endTime: new Date(Date.now() + 7200000), + }); + createMockAttendee({ bookingUid, email: "attendee@example.com" }); + + await expect( + handleMarkNoShow({ + bookingUid, + attendees: [{ email: "attendee@example.com", noShow: true }], + userId: 123, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }) + ).rejects.toThrow(); + }); + + it("should throw 401 when userId not provided for attendee marking", async () => { + const bookingUid = "test-booking-auth"; + createMockBooking({ uid: bookingUid }); + createMockAttendee({ bookingUid, email: "attendee@example.com" }); + + await expect( + handleMarkNoShow({ + bookingUid, + attendees: [{ email: "attendee@example.com", noShow: true }], + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }) + ).rejects.toThrow(); + }); + }); + + describe("Integrations", () => { + it("should call WebhookService.sendPayload for attendee updates", async () => { + const bookingUid = "test-booking-webhook"; + createMockBooking({ uid: bookingUid }); + createMockAttendee({ bookingUid, email: "attendee@example.com" }); + + await handleMarkNoShow({ + bookingUid, + attendees: [{ email: "attendee@example.com", noShow: true }], + userId: 123, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(mockWebhookServiceInit).toHaveBeenCalled(); + expect(mockSendPayload).toHaveBeenCalledWith(expect.objectContaining({ bookingUid })); + }); + + it("should call BookingEventHandlerService.onNoShowUpdated with correct args", async () => { + const bookingUid = "test-booking-audit"; + createMockBooking({ uid: bookingUid }); + createMockAttendee({ bookingUid, email: "attendee@example.com", noShow: false }); + + await handleMarkNoShow({ + bookingUid, + attendees: [{ email: "attendee@example.com", noShow: true }], + userId: 123, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expectNoShowBookingAudit({ + bookingUid, + source: "WEBAPP", + actor: { identifiedBy: "user", userUuid: "user-uuid-123" }, + organizationId: null, + attendeesNoShow: [{ attendeeEmail: "attendee@example.com", noShow: { old: false, new: true } }], + }); + }); + + it("should include old and new values in audit data for host", async () => { + const bookingUid = "test-booking-audit-host"; + createMockBooking({ uid: bookingUid, noShowHost: false }); + + await handleMarkNoShow({ + bookingUid, + noShowHost: true, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expectNoShowBookingAudit({ + bookingUid, + source: "WEBAPP", + actor: { identifiedBy: "user", userUuid: "user-uuid-123" }, + organizationId: null, + host: { userUuid: "host-uuid-123", noShow: { old: false, new: true } }, + }); + }); + }); + + describe("Public Route (handleMarkHostNoShow)", () => { + it("should create guest actor when marking host as no-show via public route", async () => { + const bookingUid = "test-booking-public-route"; + createMockBooking({ uid: bookingUid }); + + const result = await handleMarkHostNoShow({ + bookingUid, + noShowHost: true, + actionSource: "WEBAPP", + }); + + expect(result.noShowHost).toBe(true); + expectBookingNoShowHostState(bookingUid, true); + + expect(mockOnNoShowUpdated).toHaveBeenCalledTimes(1); + const call = mockOnNoShowUpdated.mock.calls[0][0]; + expect(call.actor.identifiedBy).toBe("guest"); + expect(call.actor.email).toMatch(/@guest\.internal$/); + expect(call.actor.name).toBeNull(); + expect(call.source).toBe("WEBAPP"); + expect(call.auditData.host).toEqual({ + userUuid: "host-uuid-123", + noShow: { old: null, new: true }, + }); + }); + }); + + describe("Edge Cases", () => { + it("should only update attendees found in booking", async () => { + const bookingUid = "test-booking-only-found"; + createMockBooking({ uid: bookingUid }); + createMockAttendee({ bookingUid, email: "attendee1@example.com", noShow: false }); + createMockAttendee({ bookingUid, email: "attendee2@example.com", noShow: false }); + + const result = await handleMarkNoShow({ + bookingUid, + attendees: [{ email: "attendee1@example.com", noShow: true }], + userId: 123, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(result.attendees).toHaveLength(1); + expect(result.attendees[0].email).toBe("attendee1@example.com"); + expect(result.attendees[0].noShow).toBe(true); + expectAttendeeNoShowState(bookingUid, "attendee1@example.com", true); + expectAttendeeNoShowState(bookingUid, "attendee2@example.com", false); + }); + + it("should gracefully handle non-existent attendee emails", async () => { + const bookingUid = "test-booking-non-existent-attendee"; + createMockBooking({ uid: bookingUid }); + createMockAttendee({ bookingUid, email: "attendee1@example.com", noShow: false }); + + const result = await handleMarkNoShow({ + bookingUid, + attendees: [ + { email: "attendee1@example.com", noShow: true }, + { email: "non-existent@example.com", noShow: true }, + ], + userId: 123, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(result.attendees).toHaveLength(1); + expect(result.attendees[0].email).toBe("attendee1@example.com"); + expect(result.attendees[0].noShow).toBe(true); + expectAttendeeNoShowState(bookingUid, "attendee1@example.com", true); + }); + + it("should only audit successfully updated attendees, not failed ones", async () => { + const bookingUid = "test-booking-audit-only-successful"; + createMockBooking({ uid: bookingUid }); + const attendee1 = createMockAttendee({ bookingUid, email: "attendee1@example.com", noShow: false }); + const attendee2 = createMockAttendee({ bookingUid, email: "attendee2@example.com", noShow: false }); + const attendee3 = createMockAttendee({ bookingUid, email: "attendee3@example.com", noShow: false }); + + // Mock updateNoShow to fail for attendee2 + mockUpdateNoShow.mockImplementation( + ({ + where: { attendeeId }, + data: { noShow }, + }: { + where: { attendeeId: number }; + data: { noShow: boolean }; + }) => { + // Simulate database error for attendee2 + if (attendeeId === attendee2.id) { + return Promise.reject(new Error("Database error")); + } + + for (const bookingUid of Object.keys(DB.attendees)) { + const attendee = DB.attendees[bookingUid].find((a) => a.id === attendeeId); + if (attendee) { + attendee.noShow = noShow; + return Promise.resolve({ noShow, email: attendee.email }); + } + } + return Promise.resolve(null); + } + ); + + await handleMarkNoShow({ + bookingUid, + attendees: [ + { email: "attendee1@example.com", noShow: true }, + { email: "attendee2@example.com", noShow: true }, // This will fail + { email: "attendee3@example.com", noShow: true }, + ], + userId: 123, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + // Audit should only contain attendee1 and attendee3, not attendee2 + expectNoShowBookingAudit({ + bookingUid, + source: "WEBAPP", + actor: { identifiedBy: "user", userUuid: "user-uuid-123" }, + organizationId: null, + attendeesNoShow: [ + { attendeeEmail: "attendee1@example.com", noShow: { old: false, new: true } }, + { attendeeEmail: "attendee3@example.com", noShow: { old: false, new: true } }, + ], + }); + + const call = mockOnNoShowUpdated.mock.calls[0][0]; + expect(call.auditData.attendeesNoShow).toHaveLength(2); + expect(call.auditData.attendeesNoShow.map((a: { attendeeEmail: string }) => a.attendeeEmail)).not.toContain( + "attendee2@example.com" + ); + }); + + it("should not fire audit event when nothing changed", async () => { + const bookingUid = "test-booking-no-change"; + createMockBooking({ uid: bookingUid }); + + await handleMarkNoShow({ + bookingUid, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(mockOnNoShowUpdated).not.toHaveBeenCalled(); + }); + + it("should handle booking without eventType", async () => { + const bookingUid = "test-booking-no-event-type"; + createMockBooking({ uid: bookingUid, hasEventType: false }); + + const result = await handleMarkNoShow({ + bookingUid, + noShowHost: true, + actor: makeUserActor("user-uuid-123"), + actionSource: "WEBAPP", + }); + + expect(result.noShowHost).toBe(true); + }); + }); +}); diff --git a/packages/features/handleMarkNoShow.ts b/packages/features/handleMarkNoShow.ts index 09634b07fa..00ff932a57 100644 --- a/packages/features/handleMarkNoShow.ts +++ b/packages/features/handleMarkNoShow.ts @@ -1,10 +1,18 @@ -import { type TFunction } from "i18next"; - +import process from "node:process"; +import type { Actor } from "@calcom/features/booking-audit/lib/dto/types"; +import { + buildActorEmail, + getUniqueIdentifier, + makeGuestActor, + makeUserActor, +} from "@calcom/features/booking-audit/lib/makeActor"; +import type { ValidActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; +import { AttendeeRepository } from "@calcom/features/bookings/repositories/AttendeeRepository"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; -import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows"; import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; import type { ExtendedCalendarEvent } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; @@ -18,21 +26,68 @@ import { prisma } from "@calcom/prisma"; import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { bookingMetadataSchema, type PlatformClientParams } from "@calcom/prisma/zod-utils"; import type { TNoShowInputSchema } from "@calcom/trpc/server/routers/loggedInViewer/markNoShow.schema"; - +import type { TFunction } from "i18next"; import handleSendingAttendeeNoShowDataToApps from "./noShow/handleSendingAttendeeNoShowDataToApps"; export type NoShowAttendees = { email: string; noShow: boolean }[]; -const buildResultPayload = async ( - bookingUid: string, - attendeeEmails: string[], - inputAttendees: NonNullable, - t: TFunction -) => { - const attendees = await updateAttendees(bookingUid, attendeeEmails, inputAttendees); +type GetWebhooksServiceArgs = { + platformClientId?: string; + orgId: number | undefined; + booking: { + id: number; + eventType: { + id: number; + teamId: number | null; + userId: number | null; + } | null; + } | null; +}; - if (attendees.length === 1) { - const [attendee] = attendees; +type HandleMarkHostNoShowArgs = { + bookingUid: string; + noShowHost: boolean; + actionSource: ValidActionSource; + locale?: string; + platformClientParams?: PlatformClientParams; +}; + +type HandleMarkAttendeesAndHostNoShowArgs = { + bookingUid: string; + attendees?: { email: string; noShow: boolean }[]; + noShowHost?: boolean; + userId: number; + userUuid: string; + actionSource: ValidActionSource; + locale?: string; + platformClientParams?: PlatformClientParams; +}; + +type HandleMarkNoShowArgs = { + bookingUid: string; + attendees?: { email: string; noShow: boolean }[]; + noShowHost?: boolean; + userId?: number; + userUuid?: string; + actionSource: ValidActionSource; + locale?: string; + platformClientParams?: PlatformClientParams; + actor: Actor; +}; + +const buildResultPayload = async ({ + attendees, + t, + emailToAttendeeMap, +}: { + attendees: NonNullable; + t: TFunction; + emailToAttendeeMap: EmailToAttendeeMap; +}): Promise<{message: string; attendees: NoShowAttendees}> => { + const updatedAttendees = await updateAttendees({ attendees, emailToAttendeeMap }); + + if (updatedAttendees.length === 1) { + const [attendee] = updatedAttendees; return { message: t(attendee.noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", { x: attendee.email ?? "User", @@ -42,7 +97,7 @@ const buildResultPayload = async ( } return { message: t("no_show_updated"), - attendees: attendees, + attendees: updatedAttendees, }; }; @@ -53,6 +108,12 @@ const logFailedResults = (results: PromiseSettledResult[]) => { console.error("Failed to update no-show status", failedMessage.join(",")); }; +type ResponsePayloadResult = { + attendees: NoShowAttendees; + noShowHost: boolean; + message: string; +}; + class ResponsePayload { attendees: NoShowAttendees; noShowHost: boolean; @@ -76,7 +137,7 @@ class ResponsePayload { this.message = message; } - getPayload() { + getPayload(): ResponsePayloadResult { return { attendees: this.attendees, noShowHost: this.noShowHost, @@ -85,35 +146,131 @@ class ResponsePayload { } } +type EmailToAttendeeMap = Record; + +const getBookingAttendeesFromEmails = async ( + bookingUid: string, + emails: string[] +): Promise => { + const attendeeRepository = new AttendeeRepository(prisma); + const dbAttendees = await attendeeRepository.findByBookingUidAndEmails({ bookingUid, emails }); + const emailToAttendeeMap = dbAttendees.reduce((acc, a) => { + acc[a.email] = a; + return acc; + }, {} as EmailToAttendeeMap); + return emailToAttendeeMap; +}; + +async function fireNoShowUpdated({ + updatedNoShowHost, + hostUserUuid, + booking, + updatedAttendees, + emailToAttendeeMap, + actor, + orgId, + actionSource, +}: { + updatedNoShowHost?: boolean; + hostUserUuid?: string; + booking: { + uid: string; + noShowHost: boolean | null; + }; + updatedAttendees?: NoShowAttendees; + emailToAttendeeMap: EmailToAttendeeMap; + actor: Actor; + orgId: number | null; + actionSource: ValidActionSource; +}): Promise { + const auditData: { + host?: { userUuid: string; noShow: { old: boolean | null; new: boolean } }; + attendeesNoShow?: Array<{ attendeeEmail: string; noShow: { old: boolean | null; new: boolean } }>; + } = {}; + + if (updatedNoShowHost !== undefined && hostUserUuid) { + auditData.host = { + userUuid: hostUserUuid, + noShow: { old: booking.noShowHost, new: updatedNoShowHost }, + }; + } + + if (updatedAttendees) { + auditData.attendeesNoShow = []; + for (const attendee of updatedAttendees) { + const dbAttendee = emailToAttendeeMap[attendee.email]; + if (dbAttendee) { + auditData.attendeesNoShow.push({ + attendeeEmail: attendee.email, + noShow: { old: dbAttendee.noShow ?? null, new: attendee.noShow }, + }); + } + } + } + + const bookingEventHandlerService = getBookingEventHandlerService(); + + const isSomethingChanged = auditData.host || (auditData.attendeesNoShow && auditData.attendeesNoShow.length > 0); + if (isSomethingChanged) { + await bookingEventHandlerService.onNoShowUpdated({ + bookingUid: booking.uid, + actor, + organizationId: orgId ?? null, + source: actionSource, + auditData, + }); + } +} + const handleMarkNoShow = async ({ bookingUid, attendees, noShowHost, userId, - userUuid: _userUuid, + actor, locale, platformClientParams, -}: TNoShowInputSchema & { - userId?: number; - userUuid?: string; - locale?: string; - platformClientParams?: PlatformClientParams; -}) => { + actionSource, +}: HandleMarkNoShowArgs): Promise => { const responsePayload = new ResponsePayload(); const t = await getTranslation(locale ?? "en", "common"); try { const attendeeEmails = attendees?.map((attendee) => attendee.email) || []; + const bookingRepository = new BookingRepository(prisma); + const booking = await bookingRepository.findByUidIncludeEventTypeAttendeesAndUser({ + bookingUid, + }); + + if (!booking) { + throw new HttpError({ statusCode: 404, message: "Booking not found" }); + } + + const [orgId, emailToAttendeeMap] = await Promise.all([ + getOrgIdFromMemberOrTeamId({ + memberId: booking.eventType?.userId, + teamId: booking.eventType?.teamId || booking.eventType?.parent?.teamId, + }), + getBookingAttendeesFromEmails(bookingUid, attendeeEmails), + ]); + + let successfullyUpdatedAttendees: NoShowAttendees | undefined; + if (attendees && attendeeEmails.length > 0) { await assertCanAccessBooking(bookingUid, userId); - const payload = await buildResultPayload(bookingUid, attendeeEmails, attendees, t); - - const { webhooks, bookingId } = await getWebhooksService( - bookingUid, - platformClientParams?.platformClientId - ); + const payload = await buildResultPayload({ + attendees, + t, + emailToAttendeeMap, + }); + successfullyUpdatedAttendees = payload.attendees; + const { webhooks, bookingId } = await getWebhooksService({ + platformClientId: platformClientParams?.platformClientId, + orgId, + booking, + }); await webhooks.sendPayload({ ...payload, @@ -123,98 +280,7 @@ const handleMarkNoShow = async ({ ...(platformClientParams ? platformClientParams : {}), }); - const booking = await prisma.booking.findUnique({ - where: { uid: bookingUid }, - select: { - startTime: true, - endTime: true, - title: true, - metadata: true, - uid: true, - location: true, - destinationCalendar: true, - smsReminderNumber: true, - userPrimaryEmail: true, - eventType: { - select: { - id: true, - hideOrganizerEmail: true, - customReplyToEmail: true, - schedulingType: true, - slug: true, - title: true, - metadata: true, - parentId: true, - teamId: true, - hosts: { - select: { - user: { - select: { - email: true, - destinationCalendar: { - select: { - primaryEmail: true, - }, - }, - }, - }, - }, - }, - parent: { - select: { - teamId: true, - }, - }, - workflows: { - select: { - workflow: { - select: workflowSelect, - }, - }, - }, - owner: { - select: { - hideBranding: true, - email: true, - name: true, - timeZone: true, - locale: true, - }, - }, - team: { - select: { - parentId: true, - name: true, - id: true, - }, - }, - }, - }, - attendees: { - select: { - email: true, - name: true, - timeZone: true, - locale: true, - phoneNumber: true, - }, - }, - user: { - select: { - id: true, - email: true, - name: true, - destinationCalendar: true, - timeZone: true, - locale: true, - username: true, - timeFormat: true, - }, - }, - }, - }); - - if (booking?.eventType) { + if (booking.eventType) { const workflows = await getAllWorkflowsFromEventType(booking.eventType, userId); if (workflows.length > 0) { @@ -251,8 +317,8 @@ const handleMarkNoShow = async ({ const destinationCalendar = booking.destinationCalendar ? [booking.destinationCalendar] : booking.user?.destinationCalendar - ? [booking.user?.destinationCalendar] - : []; + ? [booking.user?.destinationCalendar] + : []; const team = booking.eventType?.team ? { name: booking.eventType.team.name, @@ -268,7 +334,7 @@ const handleMarkNoShow = async ({ endTime: booking.endTime.toISOString(), organizer: { id: booking.user?.id, - email: booking?.userPrimaryEmail || booking.user?.email || "Email-less", + email: booking.userPrimaryEmail || booking.user?.email || "Email-less", name: booking.user?.name || "Nameless", username: booking.user?.username || undefined, timeZone: organizer?.timeZone || "UTC", @@ -320,19 +386,22 @@ const handleMarkNoShow = async ({ } if (noShowHost) { - await prisma.booking.update({ - where: { - uid: bookingUid, - }, - data: { - noShowHost: true, - }, - }); - + await bookingRepository.updateNoShowHost({ bookingUid, noShowHost: true }); responsePayload.setNoShowHost(true); responsePayload.setMessage(t("booking_no_show_updated")); } + await fireNoShowUpdated({ + booking, + updatedNoShowHost: noShowHost, + hostUserUuid: booking.user?.uuid, + updatedAttendees: successfullyUpdatedAttendees, + emailToAttendeeMap, + actor, + orgId: orgId ?? null, + actionSource, + }); + return responsePayload.getPayload(); } catch (error) { if (error instanceof Error) { @@ -342,40 +411,19 @@ const handleMarkNoShow = async ({ } }; -const updateAttendees = async ( - bookingUid: string, - attendeeEmails: string[], - attendees: NonNullable -) => { - const allAttendees = await prisma.attendee.findMany({ - where: { - AND: [ - { - booking: { - uid: bookingUid, - }, - email: { - in: attendeeEmails, - }, - }, - ], - }, - select: { - id: true, - email: true, - }, - }); - - const allAttendeesMap = allAttendees.reduce((acc, attendee) => { - acc[attendee.email] = attendee; - return acc; - }, {} as Record); - +const updateAttendees = async ({ + attendees, + emailToAttendeeMap, +}: { + attendees: NonNullable; + emailToAttendeeMap: EmailToAttendeeMap; +}): Promise => { + const attendeeRepository = new AttendeeRepository(prisma); const updatePromises = attendees.map((attendee) => { - const attendeeToUpdate = allAttendeesMap[attendee.email]; - if (!attendeeToUpdate) return; - return prisma.attendee.update({ - where: { id: attendeeToUpdate.id }, + const attendeeToUpdate = emailToAttendeeMap[attendee.email]; + if (!attendeeToUpdate) return null; + return attendeeRepository.updateNoShow({ + where: { attendeeId: attendeeToUpdate.id }, data: { noShow: attendee.noShow }, }); }); @@ -385,29 +433,12 @@ const updateAttendees = async ( return results .filter((x) => x.status === "fulfilled") - .map((x) => (x as PromiseFulfilledResult<{ noShow: boolean; email: string }>).value) + .map((x) => (x as PromiseFulfilledResult<{ noShow: boolean; email: string } | null>).value) + .filter((x): x is { noShow: boolean; email: string } => x !== null) .map((x) => ({ email: x.email, noShow: x.noShow })); }; -const getWebhooksService = async (bookingUid: string, platformClientId?: string) => { - const booking = await prisma.booking.findUnique({ - where: { uid: bookingUid }, - select: { - id: true, - eventType: { - select: { - id: true, - teamId: true, - userId: true, - }, - }, - }, - }); - - const orgId = await getOrgIdFromMemberOrTeamId({ - memberId: booking?.eventType?.userId, - teamId: booking?.eventType?.teamId, - }); +const getWebhooksService = async ({ platformClientId, orgId, booking }: GetWebhooksServiceArgs) => { const webhooks = await WebhookService.init({ teamId: booking?.eventType?.teamId, userId: booking?.eventType?.userId, @@ -445,4 +476,58 @@ const assertCanAccessBooking = async (bookingUid: string, userId?: number) => { } }; +export const handleMarkHostNoShow = async ({ + bookingUid, + noShowHost, + actionSource, + locale, + platformClientParams, +}: HandleMarkHostNoShowArgs): Promise => { + const actorEmail = buildActorEmail({ + identifier: getUniqueIdentifier({ prefix: "attendee" }), + actorType: "guest", + }); + + // TODO: Accept attendee email/name from the caller to track which attendee triggered this action + const actor = makeGuestActor({ email: actorEmail, name: null }); + + return handleMarkNoShow({ + bookingUid, + noShowHost, + actor, + actionSource, + locale, + platformClientParams, + }); +}; + +/** + * Handle marking attendees as no-show. + * This is called from authenticated endpoints where a logged-in host marks attendees as absent. + * Requires userId and userUuid for proper authorization and audit tracking. + */ +export const handleMarkAttendeesAndHostNoShow = async ({ + bookingUid, + attendees, + noShowHost, + userId, + userUuid, + actionSource, + locale, + platformClientParams, +}: HandleMarkAttendeesAndHostNoShowArgs): Promise => { + const actor = makeUserActor(userUuid); + + return handleMarkNoShow({ + bookingUid, + attendees, + noShowHost, + userId, + actor, + actionSource, + locale, + platformClientParams, + }); +}; + export default handleMarkNoShow; diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts index 31f3608d00..8f8ee30ea8 100644 --- a/packages/features/tasker/tasks/triggerNoShow/common.ts +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -1,16 +1,19 @@ import dayjs from "@calcom/dayjs"; -import { getHostsAndGuests } from "@calcom/features/bookings/lib/getHostsAndGuests"; +import type { NoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService"; +import { makeSystemActor } from "@calcom/features/booking-audit/lib/makeActor"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import type { Host } from "@calcom/features/bookings/lib/getHostsAndGuests"; +import { getHostsAndGuests } from "@calcom/features/bookings/lib/getHostsAndGuests"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; import type { TimeUnit } from "@calcom/prisma/enums"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; - import { getBooking } from "./getBooking"; import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName"; -import type { TWebhook, TTriggerNoShowPayloadSchema } from "./schema"; +import type { TTriggerNoShowPayloadSchema, TWebhook } from "./schema"; import { ZSendNoShowWebhookPayloadSchema } from "./schema"; type OriginalRescheduledBooking = @@ -140,6 +143,62 @@ export async function getParticipantsWithEmail( export const log = logger.getSubLogger({ prefix: ["triggerNoShowTask"] }); +export const fireNoShowUpdatedEvent = async ({ + booking, + noShowHostAudit, + attendeesNoShowAudit, +}: { + booking: { + id: number; + uid: string; + user?: { id: number; uuid: string } | null; + eventType?: { teamId?: number | null; parent?: { teamId?: number | null } | null } | null; + }; + noShowHostAudit?: { old: boolean | null; new: boolean | null }; + attendeesNoShowAudit?: NoShowUpdatedAuditData["attendeesNoShow"]; +}): Promise => { + const hasHostNoShow = noShowHostAudit && noShowHostAudit.new !== null; + const hasAttendeesNoShow = attendeesNoShowAudit && attendeesNoShowAudit.length > 0; + + if (!hasHostNoShow && !hasAttendeesNoShow) { + return; + } + + const hostUserUuid = booking.user?.uuid; + if (hasHostNoShow && !hostUserUuid) { + log.warn("Host no-show audit skipped: booking.user.uuid is undefined", { bookingUid: booking.uid }); + } + + try { + const orgId = await getOrgIdFromMemberOrTeamId({ + memberId: booking.user?.id, + teamId: booking.eventType?.teamId || booking.eventType?.parent?.teamId, + }); + + const bookingEventHandlerService = getBookingEventHandlerService(); + await bookingEventHandlerService.onNoShowUpdated({ + bookingUid: booking.uid, + // This action is taken by the scheduled tasker job, so we use the system actor + actor: makeSystemActor(), + organizationId: orgId ?? null, + source: "SYSTEM", + auditData: { + ...(hasHostNoShow && noShowHostAudit.new !== null && hostUserUuid + ? { + host: { + userUuid: hostUserUuid, + noShow: { old: noShowHostAudit.old, new: noShowHostAudit.new }, + }, + } + : {}), + ...(hasAttendeesNoShow ? { attendeesNoShow: attendeesNoShowAudit } : {}), + }, + }); + } catch (error) { + log.error("Error logging audit for automatic no-show", error); + } +}; + export const prepareNoShowTrigger = async ( payload: string ): Promise<{ diff --git a/packages/features/tasker/tasks/triggerNoShow/getBooking.ts b/packages/features/tasker/tasks/triggerNoShow/getBooking.ts index d4992dd2f7..357543b651 100644 --- a/packages/features/tasker/tasks/triggerNoShow/getBooking.ts +++ b/packages/features/tasker/tasks/triggerNoShow/getBooking.ts @@ -51,6 +51,7 @@ export const getBooking = async (bookingId: number) => { user: { select: { id: true, + uuid: true, timeZone: true, email: true, name: true, diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts index 4bfadc83fa..265f03581b 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts @@ -2,31 +2,37 @@ import { createBookingScenario, getDate, getGoogleCalendarCredential, - TestData, getOrganizer, getScenarioData, + TestData, } from "@calcom/testing/lib/bookingScenario/bookingScenario"; -import { expectWebhookToHaveBeenCalledWith } from "@calcom/testing/lib/bookingScenario/expects"; -import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown"; - -import { describe, vi, test, expect } from "vitest"; - +import process from "node:process"; import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; import dayjs from "@calcom/dayjs"; -import { TimeUnit } from "@calcom/prisma/enums"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; -import { BookingStatus } from "@calcom/prisma/enums"; - +import { BookingStatus, TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { expectWebhookToHaveBeenCalledWith } from "@calcom/testing/lib/bookingScenario/expects"; +import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository"; import { calculateMaxStartTime } from "./common"; import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName"; import type { TSendNoShowWebhookPayloadSchema } from "./schema"; import { triggerGuestNoShow } from "./triggerGuestNoShow"; -import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository"; vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({ getMeetingSessionsFromRoomName: vi.fn(), })); +const { mockOnNoShowUpdated } = vi.hoisted(() => ({ + mockOnNoShowUpdated: vi.fn(), +})); + +vi.mock("@calcom/features/bookings/di/BookingEventHandlerService.container", () => ({ + getBookingEventHandlerService: vi.fn().mockReturnValue({ + onNoShowUpdated: mockOnNoShowUpdated, + }), +})); + const timeout = process.env.CI ? 5000 : 20000; const EMPTY_MEETING_SESSIONS = { @@ -34,9 +40,37 @@ const EMPTY_MEETING_SESSIONS = { data: [], }; +type ExpectNoShowAuditParams = { + bookingUid: string; + source: string; + actor: { + identifiedBy: string; + id: string; + }; + organizationId: number | null; + auditData: { + attendeesNoShow: Array<{ + attendeeEmail: string; + noShow: { + new: boolean; + old: boolean | null; + }; + }>; + }; +}; + +function expectNoShowAuditToBeDone(expected: ExpectNoShowAuditParams): void { + expect(mockOnNoShowUpdated).toHaveBeenCalledTimes(1); + expect(mockOnNoShowUpdated).toHaveBeenCalledWith(expected); +} + describe("Trigger Guest No Show:", () => { setupAndTeardown(); + beforeEach(() => { + mockOnNoShowUpdated.mockClear(); + }); + test( `Should trigger guest no show webhook when no one joined the call`, async () => { @@ -55,6 +89,7 @@ describe("Trigger Guest No Show:", () => { const subscriberUrl = "http://my-webhook.example.com"; const bookingStartTime = `${plus1DateString}T05:00:00.000Z`; + const GUEST_ATTENDEE_ID = 101; await createBookingScenario( getScenarioData({ webhooks: [ @@ -93,6 +128,7 @@ describe("Trigger Guest No Show:", () => { user: { id: organizer.id }, attendees: [ { + id: GUEST_ATTENDEE_ID, email: "guest@example.com", name: "Guest User", timeZone: "UTC", @@ -193,6 +229,18 @@ describe("Trigger Guest No Show:", () => { message: `Guest didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, }, }); + + expectNoShowAuditToBeDone({ + bookingUid: uidOfBooking, + source: "SYSTEM", + actor: { identifiedBy: "id", id: "00000000-0000-0000-0000-000000000000" }, + organizationId: null, + auditData: { + attendeesNoShow: [ + { attendeeEmail: "guest@example.com", noShow: { new: true, old: false } }, + ], + }, + }); }, timeout ); @@ -215,6 +263,7 @@ describe("Trigger Guest No Show:", () => { const subscriberUrl = "http://my-webhook.example.com"; const bookingStartTime = `${plus1DateString}T05:00:00.000Z`; + const GUEST_ATTENDEE_ID = 102; await createBookingScenario( getScenarioData({ webhooks: [ @@ -253,6 +302,7 @@ describe("Trigger Guest No Show:", () => { user: { id: organizer.id }, attendees: [ { + id: GUEST_ATTENDEE_ID, email: "guest@example.com", name: "Guest User", timeZone: "UTC", @@ -381,6 +431,18 @@ describe("Trigger Guest No Show:", () => { message: `Guest didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, }, }); + + expectNoShowAuditToBeDone({ + bookingUid: uidOfBooking, + source: "SYSTEM", + actor: { identifiedBy: "id", id: "00000000-0000-0000-0000-000000000000" }, + organizationId: null, + auditData: { + attendeesNoShow: [ + { attendeeEmail: "guest@example.com", noShow: { new: true, old: false } }, + ], + }, + }); }, timeout ); @@ -407,6 +469,7 @@ describe("Trigger Guest No Show:", () => { const newiCalUID = `${newUidOfBooking}@Cal.com`; const newBookingStartTime = `${plus1DateString}T05:15:00.000Z`; + const GUEST_ATTENDEE_ID = 103; await createBookingScenario( getScenarioData({ webhooks: [ @@ -480,6 +543,7 @@ describe("Trigger Guest No Show:", () => { fromReschedule: uidOfBooking, attendees: [ { + id: GUEST_ATTENDEE_ID, email: "guest@example.com", name: "Guest User", timeZone: "UTC", @@ -608,6 +672,18 @@ describe("Trigger Guest No Show:", () => { message: `Guest didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, }, }); + + expectNoShowAuditToBeDone({ + bookingUid: newUidOfBooking, + source: "SYSTEM", + actor: { identifiedBy: "id", id: "00000000-0000-0000-0000-000000000000" }, + organizationId: null, + auditData: { + attendeesNoShow: [ + { attendeeEmail: "guest@example.com", noShow: { new: true, old: false } }, + ], + }, + }); }, timeout ); @@ -759,6 +835,8 @@ describe("Trigger Guest No Show:", () => { payload: expect.any(Object), }) ).toThrow(); + + expect(mockOnNoShowUpdated).not.toHaveBeenCalled(); }, timeout ); @@ -781,6 +859,7 @@ describe("Trigger Guest No Show:", () => { const subscriberUrl = "http://my-webhook.example.com"; const bookingStartTime = `${plus1DateString}T05:00:00.000Z`; + const GUEST_ATTENDEE_ID = 104; await createBookingScenario( getScenarioData({ webhooks: [ @@ -819,6 +898,7 @@ describe("Trigger Guest No Show:", () => { user: { id: organizer.id }, attendees: [ { + id: GUEST_ATTENDEE_ID, email: "guest@example.com", name: "Guest User", timeZone: "UTC", diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts index 88bc900c54..291ca0c276 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts @@ -1,8 +1,25 @@ import type { Host } from "@calcom/features/bookings/lib/getHostsAndGuests"; +import { AttendeeRepository } from "@calcom/features/bookings/repositories/AttendeeRepository"; import { prisma } from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { + calculateMaxStartTime, + fireNoShowUpdatedEvent, + log, + prepareNoShowTrigger, + sendWebhookPayload, +} from "./common"; -import { calculateMaxStartTime, sendWebhookPayload, prepareNoShowTrigger, log } from "./common"; +type UpdatedAttendee = { + id: number; + email: string; + name: string; + locale: string | null; + timeZone: string; + phoneNumber: string | null; + bookingId: number | null; + noShow: boolean | null; +}; const markGuestAsNoshowInBooking = async ({ bookingId, @@ -12,39 +29,59 @@ const markGuestAsNoshowInBooking = async ({ bookingId: number; hostsThatJoinedTheCall: Host[]; guestsThatDidntJoinTheCall?: { email: string; name: string }[]; -}) => { +}): Promise<{ + updatedAttendees: UpdatedAttendee[] | null; + attendeesMarkedNoShow: { email: string; noShow: boolean; previousNoShow: boolean | null }[]; +}> => { + const attendeeRepository = new AttendeeRepository(prisma); + try { + // Get attendees before update to capture previous noShow values + const attendeesBefore = await attendeeRepository.findByBookingId(bookingId); + const attendeesBeforeMap = new Map(attendeesBefore.map((a) => [a.email, a])); + + let updatedAttendeeEmails: string[]; if (guestsThatDidntJoinTheCall && guestsThatDidntJoinTheCall.length > 0) { - const guestEmailsThatDidntJoin = guestsThatDidntJoinTheCall.map((g) => g.email); - await prisma.attendee.updateMany({ - where: { - bookingId, - email: { in: guestEmailsThatDidntJoin }, - }, + const emailsToUpdate = guestsThatDidntJoinTheCall.map((g) => g.email); + await attendeeRepository.updateManyNoShowByBookingIdAndEmails({ + where: { bookingId, emails: emailsToUpdate }, data: { noShow: true }, }); + updatedAttendeeEmails = emailsToUpdate; } else { const hostsThatJoinedTheCallEmails = hostsThatJoinedTheCall.map((h) => h.email); - await prisma.attendee.updateMany({ - where: { - bookingId, - email: { notIn: hostsThatJoinedTheCallEmails }, - }, + await attendeeRepository.updateManyNoShowByBookingIdExcludingEmails({ + where: { bookingId, excludeEmails: hostsThatJoinedTheCallEmails }, data: { noShow: true }, }); + // TODO: It is possible that by the time the updateMany query runs, there were more attendees added, though it would be a rare/unexpected thing because triggerGuestNoShow is called after the meeting has ended, and after that time attendees aren't updated + updatedAttendeeEmails = attendeesBefore + .filter((a) => !hostsThatJoinedTheCallEmails.includes(a.email)) + .map((a) => a.email); } - return await prisma.attendee.findMany({ where: { bookingId } }); + const updatedAttendees = await attendeeRepository.findByBookingId(bookingId); + + const attendeesMarkedNoShow = updatedAttendeeEmails + .map((email) => { + const before = attendeesBeforeMap.get(email); + if (!before) { + return null; + } + return { email, noShow: true, previousNoShow: before.noShow }; + }) + .filter((a): a is { email: string; noShow: boolean; previousNoShow: boolean | null } => a !== null); + + return { updatedAttendees, attendeesMarkedNoShow }; } catch (err) { log.error("Error marking guests as no show in booking", err); - return null; + return { updatedAttendees: null, attendeesMarkedNoShow: [] }; } }; export async function triggerGuestNoShow(payload: string): Promise { const result = await prepareNoShowTrigger(payload); if (!result) return; - const { webhook, booking, @@ -64,15 +101,23 @@ export async function triggerGuestNoShow(payload: string): Promise { if (requireEmailForGuests) { if (guestsThatDidntJoinTheCall.length > 0) { - const updatedAttendees = await markGuestAsNoshowInBooking({ + const { updatedAttendees, attendeesMarkedNoShow } = await markGuestAsNoshowInBooking({ bookingId: booking.id, hostsThatJoinedTheCall, guestsThatDidntJoinTheCall, }); + + const attendeesNoShowAudit = attendeesMarkedNoShow.map((a) => ({ + attendeeEmail: a.email, + noShow: { old: a.previousNoShow, new: a.noShow }, + })); + await fireNoShowUpdatedEvent({ booking, attendeesNoShowAudit }); + 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, @@ -84,14 +129,23 @@ export async function triggerGuestNoShow(payload: string): Promise { } } else { if (!didGuestJoinTheCall) { - const updatedAttendees = await markGuestAsNoshowInBooking({ + const { updatedAttendees, attendeesMarkedNoShow } = await markGuestAsNoshowInBooking({ bookingId: booking.id, hostsThatJoinedTheCall, }); + + const attendeesNoShowAudit = attendeesMarkedNoShow.map((a) => ({ + attendeeEmail: a.email, + noShow: { old: a.previousNoShow, new: a.noShow }, + })); + await fireNoShowUpdatedEvent({ booking, attendeesNoShowAudit }); + 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, diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts index d677f263e3..c738843949 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts @@ -2,31 +2,37 @@ import { createBookingScenario, getDate, getGoogleCalendarCredential, - TestData, getOrganizer, getScenarioData, + TestData, } from "@calcom/testing/lib/bookingScenario/bookingScenario"; -import { expectWebhookToHaveBeenCalledWith } from "@calcom/testing/lib/bookingScenario/expects"; -import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown"; - -import { describe, vi, test, expect } from "vitest"; - +import process from "node:process"; import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; import dayjs from "@calcom/dayjs"; -import { TimeUnit } from "@calcom/prisma/enums"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; -import { BookingStatus } from "@calcom/prisma/enums"; - +import { BookingStatus, TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { expectWebhookToHaveBeenCalledWith } from "@calcom/testing/lib/bookingScenario/expects"; +import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository"; import { calculateMaxStartTime } from "./common"; import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName"; import type { TSendNoShowWebhookPayloadSchema } from "./schema"; import { triggerHostNoShow } from "./triggerHostNoShow"; -import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository"; vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({ getMeetingSessionsFromRoomName: vi.fn(), })); +const { mockOnNoShowUpdated } = vi.hoisted(() => ({ + mockOnNoShowUpdated: vi.fn(), +})); + +vi.mock("@calcom/features/bookings/di/BookingEventHandlerService.container", () => ({ + getBookingEventHandlerService: vi.fn().mockReturnValue({ + onNoShowUpdated: mockOnNoShowUpdated, + }), +})); + const timeout = process.env.CI ? 5000 : 20000; const EMPTY_MEETING_SESSIONS = { @@ -34,9 +40,44 @@ const EMPTY_MEETING_SESSIONS = { data: [], }; +type ExpectNoShowAuditParams = { + bookingUid: string; + source: string; + actor: { + identifiedBy: string; + id: string; + }; + organizationId: number | null; + auditData: { + host?: { + userUuid: string; + noShow: { + old: boolean | null; + new: boolean; + }; + }; + attendeesNoShow?: Array<{ + attendeeEmail: string; + noShow: { + new: boolean; + old: boolean | null; + }; + }>; + }; +}; + +function expectNoShowAuditToBeDone(expected: ExpectNoShowAuditParams): void { + expect(mockOnNoShowUpdated).toHaveBeenCalledTimes(1); + expect(mockOnNoShowUpdated).toHaveBeenCalledWith(expected); +} + describe("Trigger Host No Show:", () => { setupAndTeardown(); + beforeEach(() => { + mockOnNoShowUpdated.mockClear(); + }); + test( `Should trigger host no show webhook when no one joined the call`, async () => { @@ -174,6 +215,16 @@ describe("Trigger Host No Show:", () => { message: `Host with email ${organizer.email} didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, }, }); + + expectNoShowAuditToBeDone({ + bookingUid: uidOfBooking, + source: "SYSTEM", + actor: { identifiedBy: "id", id: "00000000-0000-0000-0000-000000000000" }, + organizationId: null, + auditData: { + host: { userUuid: expect.any(String), noShow: { old: false, new: true } }, + }, + }); }, timeout ); @@ -341,6 +392,16 @@ describe("Trigger Host No Show:", () => { message: `Host with email ${organizer.email} didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, }, }); + + expectNoShowAuditToBeDone({ + bookingUid: uidOfBooking, + source: "SYSTEM", + actor: { identifiedBy: "id", id: "00000000-0000-0000-0000-000000000000" }, + organizationId: null, + auditData: { + host: { userUuid: expect.any(String), noShow: { old: false, new: true } }, + }, + }); }, timeout ); @@ -548,6 +609,16 @@ describe("Trigger Host No Show:", () => { message: `Host with email ${organizer.email} didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, }, }); + + expectNoShowAuditToBeDone({ + bookingUid: newUidOfBooking, + source: "SYSTEM", + actor: { identifiedBy: "id", id: "00000000-0000-0000-0000-000000000000" }, + organizationId: null, + auditData: { + host: { userUuid: expect.any(String), noShow: { old: false, new: true } }, + }, + }); }, timeout ); @@ -702,6 +773,171 @@ describe("Trigger Host No Show:", () => { message: `Host with email ${organizer.email} didn't join the call or didn't join before ${maxStartTimeHumanReadable}`, }, }); + + expectNoShowAuditToBeDone({ + bookingUid: uidOfBooking, + source: "SYSTEM", + actor: { identifiedBy: "id", id: "00000000-0000-0000-0000-000000000000" }, + organizationId: null, + auditData: { + host: { userUuid: expect.any(String), noShow: { old: false, new: true } }, + }, + }); + }, + timeout + ); + + test( + `Should not trigger host no show webhook when host joined the call`, + 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"]], + }) + ); + + 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_HOSTS_CAL_VIDEO_NO_SHOW], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + time: 5, + timeUnit: TimeUnit.MINUTE, + payloadTemplate: null, + secret: null, + version: WebhookVersion.V_2021_10_20, + }; + + const payload = JSON.stringify({ + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + bookingId: 222, + webhook: TEST_WEBHOOK, + } satisfies TSendNoShowWebhookPayloadSchema); + + await triggerHostNoShow(payload); + + // This will throw an error if the webhook was called + expect(() => + expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + payload: expect.any(Object), + }) + ).toThrow(); + + expect(mockOnNoShowUpdated).not.toHaveBeenCalled(); }, timeout ); diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts index 5a3a0f7926..55ca8c6a58 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts @@ -1,37 +1,68 @@ import type { Host } from "@calcom/features/bookings/lib/getHostsAndGuests"; +import { AttendeeRepository } from "@calcom/features/bookings/repositories/AttendeeRepository"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { prisma } from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; - import type { Booking } from "./common"; -import { calculateMaxStartTime, sendWebhookPayload, prepareNoShowTrigger, log } from "./common"; +import { + calculateMaxStartTime, + fireNoShowUpdatedEvent, + log, + prepareNoShowTrigger, + sendWebhookPayload, +} from "./common"; const markHostsAsNoShowInBooking = async (booking: Booking, hostsThatDidntJoinTheCall: Host[]) => { + const bookingRepository = new BookingRepository(prisma); + const attendeeRepository = new AttendeeRepository(prisma); + try { + if (hostsThatDidntJoinTheCall.length === 0) { + return null; + } + + const attendeesBefore = await attendeeRepository.findByBookingId(booking.id); + const attendeesBeforeByEmail = new Map(attendeesBefore.map((a) => [a.email, a])); + let noShowHost = booking.noShowHost; + const noShowHostAudit: { old: boolean | null; new: boolean | null } = { + old: booking.noShowHost, + new: null, + }; + const attendeesNoShowAudit: Array<{ + attendeeEmail: string; + noShow: { old: boolean | null; new: boolean }; + }> = []; await Promise.allSettled( - hostsThatDidntJoinTheCall.map((host) => { + hostsThatDidntJoinTheCall.map(async (host) => { if (booking?.user?.id === host.id) { noShowHost = true; - return prisma.booking.update({ - where: { - uid: booking.uid, - }, - data: { - noShowHost: true, - }, - }); + noShowHostAudit.new = noShowHost; + return bookingRepository.updateNoShowHost({ bookingUid: booking.uid, noShowHost: true }); } // If there are more than one host then it is stored in attendees table - else if (booking.attendees?.some((attendee) => attendee.email === host.email)) { - return prisma.attendee.update({ - where: { id: host.id }, + const attendeeBefore = attendeesBeforeByEmail.get(host.email); + if (attendeeBefore) { + await attendeeRepository.updateNoShow({ + where: { attendeeId: attendeeBefore.id }, data: { noShow: true }, }); + attendeesNoShowAudit.push({ + attendeeEmail: host.email, + noShow: { old: attendeeBefore.noShow ?? null, new: true }, + }); } return Promise.resolve(); }) ); - const updatedAttendees = await prisma.attendee.findMany({ where: { bookingId: booking.id } }); + const updatedAttendees = await attendeeRepository.findByBookingId(booking.id); + + await fireNoShowUpdatedEvent({ + booking, + noShowHostAudit, + attendeesNoShowAudit, + }); + return { noShowHost, attendees: updatedAttendees }; } catch (error) { log.error("Error marking hosts as no show in booking", error); diff --git a/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts b/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts index e2dda27bdc..df8b941972 100644 --- a/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts @@ -1,4 +1,4 @@ -import handleMarkNoShow from "@calcom/features/handleMarkNoShow"; +import { handleMarkAttendeesAndHostNoShow } from "@calcom/features/handleMarkNoShow"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import type { TNoShowInputSchema } from "./markNoShow.schema"; @@ -13,11 +13,13 @@ type NoShowOptions = { export const markNoShow = async ({ ctx, input }: NoShowOptions) => { const { bookingUid, attendees, noShowHost } = input; - return handleMarkNoShow({ + return handleMarkAttendeesAndHostNoShow({ bookingUid, attendees, noShowHost, userId: ctx.user.id, + userUuid: ctx.user.uuid, locale: ctx.user.locale, + actionSource: "WEBAPP", }); }; diff --git a/packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts b/packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts index f6da15cbee..7b58db949d 100644 --- a/packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts +++ b/packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts @@ -1,4 +1,4 @@ -import handleMarkNoShow from "@calcom/features/handleMarkNoShow"; +import { handleMarkHostNoShow } from "@calcom/features/handleMarkNoShow"; import type { TNoShowInputSchema } from "./markHostAsNoShow.schema"; @@ -6,10 +6,16 @@ type NoShowOptions = { input: TNoShowInputSchema; }; +// TODO: Track which attendee actually called this endpoint to mark host as no-show. +// Currently this is completely anonymous and public endpoint. export const noShowHandler = async ({ input }: NoShowOptions) => { const { bookingUid, noShowHost } = input; - return handleMarkNoShow({ bookingUid, noShowHost }); + return handleMarkHostNoShow({ + bookingUid, + noShowHost, + actionSource: "WEBAPP", + }); }; export default noShowHandler;