chore: Integrate mark-no-show booking audit (#26570)
* chore: Integrate mark-no-show booking audit Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Simplify host no-show audit and add comment for attendee actor Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Make actionSource required with ValidActionSource type and remove unnecessary comments Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Remove merge conflict markers from bookings.service.ts Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.com> * chore: Add migration for NO_SHOW_UPDATED audit action enum value Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.com> * fix: Update migration to remove old no-show enum values Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Use updated attendees in webhook payload for guest no-show Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Remove duplicate imports and fix actor parameter in bookings.service.ts Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: Move booking query to BookingRepository and remove unused method Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.com> * Fix and add tests for handleMarkBoShow * refactor: Move prisma queries to AttendeeRepository and BookingRepository Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Correct return type for updateNoShow method (noShow can be null) Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Update handleMarkNoShow tests to mock new repository methods Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.com> * refactor: Move triggerHostNoShow queries to repositories and delete unit test file Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: Convert repository methods to use named parameters objects Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.com> * test: Update integration tests to use new schema format with host.userUuid and email keys Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.com> * fix: Update ReassignmentAuditActionService tests to use GetDisplayFieldsParams interface Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.com> * fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.com> * fix: Update test mock to match new AttendeeRepository.updateNoShow signature Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.com> * Fix cubic reported bug --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
hariom@cal.com
Devin AI
parent
08a5edd464
commit
54da93ab02
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, DBBooking>,
|
||||
attendees: {} as Record<string, DBAttendee[]>,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TNoShowInputSchema["attendees"]>,
|
||||
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<TNoShowInputSchema["attendees"]>;
|
||||
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<unknown>[]) => {
|
||||
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<string, { id: number; email: string; noShow: boolean | null }>;
|
||||
|
||||
const getBookingAttendeesFromEmails = async (
|
||||
bookingUid: string,
|
||||
emails: string[]
|
||||
): Promise<EmailToAttendeeMap> => {
|
||||
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<void> {
|
||||
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<ResponsePayloadResult> => {
|
||||
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<TNoShowInputSchema["attendees"]>
|
||||
) => {
|
||||
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<string, { id: number; email: string }>);
|
||||
|
||||
const updateAttendees = async ({
|
||||
attendees,
|
||||
emailToAttendeeMap,
|
||||
}: {
|
||||
attendees: NonNullable<TNoShowInputSchema["attendees"]>;
|
||||
emailToAttendeeMap: EmailToAttendeeMap;
|
||||
}): Promise<NoShowAttendees> => {
|
||||
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<ResponsePayloadResult> => {
|
||||
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<ResponsePayloadResult> => {
|
||||
const actor = makeUserActor(userUuid);
|
||||
|
||||
return handleMarkNoShow({
|
||||
bookingUid,
|
||||
attendees,
|
||||
noShowHost,
|
||||
userId,
|
||||
actor,
|
||||
actionSource,
|
||||
locale,
|
||||
platformClientParams,
|
||||
});
|
||||
};
|
||||
|
||||
export default handleMarkNoShow;
|
||||
|
||||
@@ -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<void> => {
|
||||
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<{
|
||||
|
||||
@@ -51,6 +51,7 @@ export const getBooking = async (bookingId: number) => {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
uuid: true,
|
||||
timeZone: true,
|
||||
email: true,
|
||||
name: true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
const result = await prepareNoShowTrigger(payload);
|
||||
if (!result) return;
|
||||
|
||||
const {
|
||||
webhook,
|
||||
booking,
|
||||
@@ -64,15 +101,23 @@ export async function triggerGuestNoShow(payload: string): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
}
|
||||
} 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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user