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:
Hariom Balhara
2026-02-06 09:27:25 -03:00
committed by GitHub
co-authored by hariom@cal.com Devin AI
parent 08a5edd464
commit 54da93ab02
13 changed files with 1757 additions and 261 deletions
@@ -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
+828
View File
@@ -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);
});
});
});
+270 -185
View File
@@ -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;