From 95f7f03f0e0431ba2eef4a59201f2c9a9e3cce99 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Fri, 23 Jan 2026 08:13:22 +0530 Subject: [PATCH] chore: Integrate round-robin reassignment booking audit (#26586) ## What does this PR do? Implements booking audit logging for round-robin reassignment events (both manual and automatic). This is part of the booking audit integration plan (PR 7). Changes: - Added audit logging to `roundRobinManualReassignment.ts` for manual host reassignments - Added audit logging to `roundRobinReassignment.ts` for automatic round-robin reassignments - Updated tRPC handlers to pass `actionSource: "WEBAPP"` and `reassignedByUuid` - Updated API v2 bookings service to pass `actionSource: "API_V2"` and `reassignedByUuid` - Updated `ReassignmentAuditActionService.ts` with proper field schemas and translation keys The audit logging uses `BookingEventHandlerService.onReassignment()` with proper actor identification and action source tracking. ## Updates since last revision Addressed review feedback: - Renamed `title` field to `hostName` for semantic clarity (tracks host name changes, not booking titles) - Fixed `assignedById` schema: changed from `NumberChangeSchema` to `z.number()` (no old/new pattern needed - it's always the user who performed the reassignment) - Fixed `reassignmentReason` schema: changed from `StringChangeSchema` to `z.string().nullable()` (no old/new pattern needed) - Added `ValidActionSource` type that excludes `UNKNOWN` - clients must pass explicit action sources - Made `actionSource` required in both reassignment functions (no longer optional) - Added integration tests for `ReassignmentAuditActionService` (15 tests covering all methods) - Updated `roundRobinManualReassign.handler.ts` to pass required `actionSource` and `reassignedByUuid` params **Latest fixes:** - Fixed `hasAttendeeUpdated` check in `ReassignmentAuditActionService.ts`: changed from `!== null` to `!= null` to properly handle undefined values - Updated test expectations in `ReassignmentAuditActionService.test.ts` to match the new display JSON field names (`hostAttendeeUserUuidNew`/`hostAttendeeUserUuidOld` instead of `newAssignedRRHostUuid`/`previousAssignedRRHostUuid`) - Fixed async `getDisplayFields` tests to properly await the Promise and include the `previous_assignee` field - Fixed `hasAttendeeUpdated` to check for `hostAttendeeUpdated` object presence instead of optional `id` field - host changes with only `withUserUuid` populated were being ignored (identified by Cubic AI, confidence 9/10) - Fixed test expectation in `getDisplayJson` test: removed incorrect null expectations for `hostAttendeeIdUpdated`, `hostAttendeeUserUuidNew`, `hostAttendeeUserUuidOld` - the implementation uses conditional spreading to omit these fields when `hostAttendeeUpdated` is not present, rather than setting them to null ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - no documentation changes needed. - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. Trigger a manual round-robin reassignment via the webapp and verify audit logs are created with `actionSource: "WEBAPP"` 2. Trigger an automatic round-robin reassignment and verify audit logs are created 3. Use API v2 to reassign a booking and verify audit logs are created with `actionSource: "API_V2"` 4. Verify the audit data contains correct values for `organizerUuid`, `hostAttendeeUpdated`, `reassignmentReason`, and `reassignmentType` ## Checklist - [x] My code follows the style guidelines of this project - [x] I have checked if my changes generate no new warnings ## Human Review Checklist - [x] Verify the `hasAttendeeUpdated` fix is correct: now checks `fields.hostAttendeeUpdated != null` to detect any host attendee update regardless of whether `id` is populated - [x] Verify `getDisplayJson` test fix: fields are correctly omitted (not set to null) when `hostAttendeeUpdated` is not present, matching the conditional spreading implementation - [ ] Verify all callers of reassignment functions pass required `actionSource` and `reassignedByUuid` - [ ] Confirm `getDisplayFields` is properly awaited in all call sites (it's now async) - [ ] Check that `ValidActionSource` type properly excludes `UNKNOWN` for client-side validation ## Important Notes for Reviewer 1. **Dependency on base PR**: The translation key changes use the format from base PR (#26046). This PR should be merged after the base PR. 2. **Schema changes**: The `organizerUuid` and `hostAttendeeUpdated` fields track both organizer changes and round-robin host attendee changes separately for complete audit trail. --- Link to Devin run: https://app.devin.ai/sessions/e4353e2ec6ea4a51ab33313bdc630aba Requested by: @hariombalhara --- .../2024-08-13/services/bookings.service.ts | 107 +++--- apps/web/public/static/locales/en/common.json | 1 + .../client/components/BookingHistory.tsx | 103 +++--- .../lib/actions/IAuditActionService.ts | 8 +- .../actions/ReassignmentAuditActionService.ts | 215 +++++++----- .../ReassignmentAuditActionService.test.ts | 328 ++++++++++++++++++ .../lib/service/BookingAuditViewerService.ts | 2 +- .../roundRobinManualReassignment.test.ts | 238 ++++++++++++- .../roundRobinManualReassignment.ts | 65 +++- .../roundRobinReassignment.test.ts | 243 ++++++++++++- .../ee/round-robin/roundRobinReassignment.ts | 69 +++- .../ee/round-robin/utils/bookingSelect.ts | 1 + .../roundRobinManualReassign.handler.ts | 2 + .../roundRobin/roundRobinReassign.handler.ts | 4 +- 14 files changed, 1142 insertions(+), 244 deletions(-) create mode 100644 packages/features/booking-audit/lib/actions/__tests__/ReassignmentAuditActionService.test.ts diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index 072ac65ca4..acf2487c30 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -1,3 +1,45 @@ +import { + confirmBookingHandler, + getAllUserBookings, + getCalendarLinks, + getTranslation, + handleCancelBooking, + handleMarkNoShow, + roundRobinManualReassignment, + roundRobinReassignment, +} from "@calcom/platform-libraries"; +import { makeUserActor, PrismaOrgMembershipRepository } from "@calcom/platform-libraries/bookings"; +import type { RescheduleSeatedBookingInput_2024_08_13 } from "@calcom/platform-types"; +import { + BookingOutput_2024_08_13, + CancelBookingInput, + CreateBookingInput, + CreateBookingInput_2024_08_13, + CreateInstantBookingInput_2024_08_13, + CreateRecurringBookingInput_2024_08_13, + GetBookingsInput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + MarkAbsentBookingInput_2024_08_13, + ReassignToUserBookingInput_2024_08_13, + RecurringBookingOutput_2024_08_13, + RescheduleBookingInput, +} from "@calcom/platform-types"; +import type { PrismaClient } from "@calcom/prisma"; +import type { EventType, Team, User } from "@calcom/prisma/client"; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, + UnauthorizedException, + UnprocessableEntityException, +} from "@nestjs/common"; +import { Request } from "express"; +import { DateTime } from "luxon"; +import { z } from "zod"; import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output"; import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository"; import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service"; @@ -24,51 +66,8 @@ import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-eve import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UsersService } from "@/modules/users/services/users.service"; import { UsersRepository } from "@/modules/users/users.repository"; -import { - ConflictException, - ForbiddenException, - Injectable, - Logger, - NotFoundException, - UnauthorizedException, - UnprocessableEntityException, -} from "@nestjs/common"; -import { BadRequestException } from "@nestjs/common"; -import { Request } from "express"; -import { DateTime } from "luxon"; -import { z } from "zod"; -export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking"; -import { - getTranslation, - getAllUserBookings, - handleCancelBooking, - roundRobinReassignment, - roundRobinManualReassignment, - handleMarkNoShow, - confirmBookingHandler, - getCalendarLinks, -} from "@calcom/platform-libraries"; -import { PrismaOrgMembershipRepository } from "@calcom/platform-libraries/bookings"; -import { - CreateBookingInput_2024_08_13, - CreateBookingInput, - CreateRecurringBookingInput_2024_08_13, - GetBookingsInput_2024_08_13, - CreateInstantBookingInput_2024_08_13, - MarkAbsentBookingInput_2024_08_13, - ReassignToUserBookingInput_2024_08_13, - BookingOutput_2024_08_13, - RecurringBookingOutput_2024_08_13, - GetSeatedBookingOutput_2024_08_13, - GetRecurringSeatedBookingOutput_2024_08_13, - RescheduleBookingInput, - CancelBookingInput, -} from "@calcom/platform-types"; -import type { RescheduleSeatedBookingInput_2024_08_13 } from "@calcom/platform-types"; -import type { PrismaClient } from "@calcom/prisma"; -import type { EventType, User, Team } from "@calcom/prisma/client"; -import { makeUserActor } from "@calcom/platform-libraries/bookings"; +export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking"; type CreatedBooking = { hosts: { id: number }[]; @@ -115,7 +114,7 @@ export class BookingsService_2024_08_13 { private readonly recurringBookingService: RecurringBookingService, private readonly instantBookingCreateService: InstantBookingCreateService, private readonly eventTypeAccessService: EventTypeAccessService - ) { } + ) {} async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) { let bookingTeamEventType = false; @@ -321,7 +320,8 @@ export class BookingsService_2024_08_13 { const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value); if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) { throw new BadRequestException( - `Invalid option '${submittedValue}' for booking field '${eventTypeBookingField.name + `Invalid option '${submittedValue}' for booking field '${ + eventTypeBookingField.name }'. Allowed options are: ${allowedOptionValues.join(", ")}.` ); } @@ -335,7 +335,8 @@ export class BookingsService_2024_08_13 { const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value); if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) { throw new BadRequestException( - `One or more invalid options for booking field '${eventTypeBookingField.name + `One or more invalid options for booking field '${ + eventTypeBookingField.name }'. Allowed options are: ${allowedOptionValues.join(", ")}.` ); } @@ -349,7 +350,8 @@ export class BookingsService_2024_08_13 { const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value); if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) { throw new BadRequestException( - `One or more invalid options for booking field '${eventTypeBookingField.name + `One or more invalid options for booking field '${ + eventTypeBookingField.name }'. Allowed options are: ${allowedOptionValues.join(", ")}.` ); } @@ -364,7 +366,8 @@ export class BookingsService_2024_08_13 { const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value); if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) { throw new BadRequestException( - `Invalid option '${submittedValue}' for booking field '${eventTypeBookingField.name + `Invalid option '${submittedValue}' for booking field '${ + eventTypeBookingField.name }'. Allowed options are: ${allowedOptionValues.join(", ")}.` ); } @@ -1036,6 +1039,8 @@ export class BookingsService_2024_08_13 { emailsEnabled, platformClientParams, reassignedById: reassignedByUser.id, + actionSource: "API_V2", + reassignedByUuid: reassignedByUser.uuid, }); } catch (error) { if (error instanceof Error) { @@ -1103,6 +1108,8 @@ export class BookingsService_2024_08_13 { reassignedById: reassignedByUser.id, emailsEnabled, platformClientParams, + actionSource: "API_V2", + reassignedByUuid: reassignedByUser.uuid, }); return this.outputService.getOutputReassignedBooking(reassigned); @@ -1216,4 +1223,4 @@ export class BookingsService_2024_08_13 { t: await getTranslation("en", "common"), }); } -} \ No newline at end of file +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 511447e311..17ebb8f3ee 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4389,6 +4389,7 @@ "seat_booked": "Seat booked", "seat_rescheduled": "Seat rescheduled {{oldDate}} -> <0>{{newDate}}", "assignment_type": "Assignment", + "previous_assignee": "Previous assignee", "assignment_type_manual": "Manual", "assignment_type_round_robin": "Round robin", "actor_impersonated_by": "Impersonator", diff --git a/packages/features/booking-audit/client/components/BookingHistory.tsx b/packages/features/booking-audit/client/components/BookingHistory.tsx index 1ba2a0677e..bc87c0c695 100644 --- a/packages/features/booking-audit/client/components/BookingHistory.tsx +++ b/packages/features/booking-audit/client/components/BookingHistory.tsx @@ -1,17 +1,17 @@ "use client"; -import Link from "next/link"; -import { useState } from "react"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; import dayjs from "@calcom/dayjs"; +import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository"; +import ServerTrans from "@calcom/lib/components/ServerTrans"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; +import { Avatar } from "@calcom/ui/components/avatar"; import { Button } from "@calcom/ui/components/button"; +import { FilterSearchField, Select } from "@calcom/ui/components/form"; import { Icon, type IconName } from "@calcom/ui/components/icon"; import { SkeletonText } from "@calcom/ui/components/skeleton"; -import { FilterSearchField, Select } from "@calcom/ui/components/form"; -import { Avatar } from "@calcom/ui/components/avatar"; -import ServerTrans from "@calcom/lib/components/ServerTrans"; -import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository"; +import Link from "next/link"; +import { useState } from "react"; interface BookingHistoryProps { bookingUid: string; @@ -108,7 +108,11 @@ function BookingLogsFilters({