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
This commit is contained in:
Hariom Balhara
2026-01-23 08:13:22 +05:30
committed by GitHub
parent 45c9f19b70
commit 95f7f03f0e
14 changed files with 1142 additions and 244 deletions
@@ -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"),
});
}
}
}
@@ -4389,6 +4389,7 @@
"seat_booked": "Seat booked",
"seat_rescheduled": "Seat rescheduled {{oldDate}} -> <0>{{newDate}}</0>",
"assignment_type": "Assignment",
"previous_assignee": "Previous assignee",
"assignment_type_manual": "Manual",
"assignment_type_round_robin": "Round robin",
"actor_impersonated_by": "Impersonator",
@@ -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({
<div className="min-w-[140px]">
<Select
size="sm"
value={actorFilter ? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter } : { label: `${t("actor")}: ${t("all")}`, value: "" }}
value={
actorFilter
? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter }
: { label: `${t("actor")}: ${t("all")}`, value: "" }
}
onChange={(option) => {
if (!option) return;
onActorFilterChange(option.value || null);
@@ -135,11 +139,7 @@ function ActionTitle({ actionDisplayTitle }: { actionDisplayTitle: TranslationWi
values={actionDisplayTitle.params}
components={actionDisplayTitle.components.map((comp) =>
comp.type === "link" ? (
<Link
key={comp.href}
href={comp.href}
className="text-emphasis underline hover:no-underline"
/>
<Link key={comp.href} href={comp.href} className="text-emphasis underline hover:no-underline" />
) : (
<span key={comp.href} />
)
@@ -171,11 +171,10 @@ function JsonViewer({ data }: JsonViewerProps) {
<div key={idx} className="flex gap-2">
<span
className="text-subtle select-none text-right shrink-0"
style={{ minWidth: `${lineNumberWidth}ch` }}
>
style={{ minWidth: `${lineNumberWidth}ch` }}>
{idx + 1}
</span>
<span className="text-default whitespace-pre">{line || " "}</span>
<span className="text-default whitespace-pre-wrap break-all">{line || " "}</span>
</div>
))}
</div>
@@ -229,9 +228,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
<Icon name={ACTION_ICON_MAP[log.action] ?? "sparkles"} className="h-3 w-3 text-subtle" />
</div>
</div>
{!isLast && (
<div className="w-px bg-subtle flex-1 min-h-0" />
)}
{!isLast && <div className="w-px bg-subtle flex-1 min-h-0" />}
</div>
<div className="flex-1 rounded-lg py-2">
@@ -276,32 +273,40 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
{isExpanded && (
<div className="bg-default rounded-lg m-0.5 text-xs">
{/* Render displayFields if available, otherwise show type */}
{log.displayFields && log.displayFields.length > 0 ? (
log.displayFields.map((field, idx) => (
<div key={idx} className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">{t(field.labelKey)}</span>
{log.displayFields && log.displayFields.length > 0
? log.displayFields.map((field, idx) => (
<div
key={idx}
className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis w-[140px]">
{t(field.labelKey)}
</span>
<span className="text-[#096638] font-medium">{t(field.valueKey)}</span>
</div>
))
) : null}
: null}
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
<span className="font-medium text-emphasis w-[140px]">{t("actor")}</span>
<span className="text-default">{log.actor.displayName || log.actor.type}</span>
</div>
{log.impersonatedBy && (
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">{t("booking_audit_action.actor_impersonated_by", { actor: log.actor.displayName })}</span>
<span className="font-medium text-emphasis w-[140px]">
{t("booking_audit_action.actor_impersonated_by", {
actor: log.actor.displayName,
})}
</span>
<span className="text-default">{log.impersonatedBy.displayName}</span>
</div>
)}
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">{t("booking_audit_action.source")}</span>
<span className="font-medium text-emphasis w-[140px]">
{t("booking_audit_action.source")}
</span>
<span className="text-default">{log.source}</span>
</div>
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">
{t("timestamp")}
</span>
<span className="font-medium text-emphasis w-[140px]">{t("timestamp")}</span>
<span className="text-default">
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
</span>
@@ -318,9 +323,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
{t("json")}
</Button>
</div>
<div>
{showJson && <JsonViewer data={log.displayJson} />}
</div>
<div>{showJson && <JsonViewer data={log.displayJson} />}</div>
</div>
)}
</div>
@@ -335,16 +338,26 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
);
}
function useBookingLogsFilters(
auditLogs: AuditLog[],
searchTerm: string,
actorFilter: string | null
) {
function useBookingLogsFilters(auditLogs: AuditLog[], searchTerm: string, actorFilter: string | null) {
const filteredLogs = auditLogs.filter((log) => {
const doesMatchDisplayFields = (): boolean => {
return log.displayFields?.some((field) => {
return field.valueKey?.toLowerCase().includes(searchTerm.toLowerCase()) ||
field.labelKey?.toLowerCase().includes(searchTerm.toLowerCase());
}) ?? false;
};
const doesMatchActionDisplayTitle = (): boolean => {
return log.actionDisplayTitle.key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
Object.values(log.actionDisplayTitle.params ?? {}).some((param) => param?.toString().toLowerCase().includes(searchTerm.toLowerCase()));
};
const matchesSearch =
!searchTerm ||
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase());
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
doesMatchDisplayFields() ||
doesMatchActionDisplayTitle();
const matchesActor = !actorFilter || log.actor.displayName === actorFilter;
@@ -352,11 +365,7 @@ function useBookingLogsFilters(
});
const uniqueActorNames = Array.from(
new Set(
auditLogs
.map((log) => log.actor.displayName)
.filter((name): name is string => Boolean(name))
)
new Set(auditLogs.map((log) => log.actor.displayName).filter((name): name is string => Boolean(name)))
);
const actorOptions = uniqueActorNames.map((actorName) => ({
@@ -377,11 +386,7 @@ export function BookingHistory({ bookingUid }: BookingHistoryProps) {
const auditLogs = data?.auditLogs || [];
const { filteredLogs, actorOptions } = useBookingLogsFilters(
auditLogs,
searchTerm,
actorFilter
);
const { filteredLogs, actorOptions } = useBookingLogsFilters(auditLogs, searchTerm, actorFilter);
if (error) {
return (
@@ -99,12 +99,12 @@ export interface IAuditActionService {
* Returns additional display fields with translation keys for frontend rendering
* Optional - implement only if custom display fields are needed
* @param storedData - Parsed stored data { version, fields }
* @returns Array of field objects with label and value translation keys
* @returns Promise of array of field objects with label and value translation keys
*/
getDisplayFields?(storedData: BaseStoredAuditData): Array<{
getDisplayFields?(storedData: BaseStoredAuditData): Promise<Array<{
labelKey: string; // Translation key for field label
valueKey: string; // Translation key for field value
}>;
}>>;
/**
* Migrate old version data to latest version
@@ -126,4 +126,4 @@ export interface IAuditActionService {
*/
latestData: Record<string, unknown>;
};
}
}
@@ -1,9 +1,14 @@
import { z } from "zod";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas";
import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService";
import type {
BaseStoredAuditData,
GetDisplayJsonParams,
GetDisplayTitleParams,
IAuditActionService,
TranslationWithParams,
} from "./IAuditActionService";
/**
* Reassignment Audit Action Service
@@ -12,101 +17,145 @@ import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams,
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
assignedToId: NumberChangeSchema,
assignedById: NumberChangeSchema,
reassignmentReason: StringChangeSchema,
reassignmentType: z.enum(["manual", "roundRobin"]),
userPrimaryEmail: StringChangeSchema.optional(),
title: StringChangeSchema.optional(),
organizerUuid: StringChangeSchema.optional(),
hostAttendeeUpdated: z
.object({
id: z.number().optional(),
withUserUuid: StringChangeSchema.optional(),
})
.optional(),
reassignmentReason: z.string().nullable(),
reassignmentType: z.enum(["manual", "roundRobin"]),
});
export class ReassignmentAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "REASSIGNMENT" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
readonly VERSION = 1;
public static readonly TYPE = "REASSIGNMENT" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = ReassignmentAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = ReassignmentAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof ReassignmentAuditActionService.latestFieldsSchema,
typeof ReassignmentAuditActionService.storedDataSchema
>;
constructor(private userRepository: UserRepository) {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: ReassignmentAuditActionService.latestFieldsSchema,
storedDataSchema: ReassignmentAuditActionService.storedDataSchema,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = ReassignmentAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = ReassignmentAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof ReassignmentAuditActionService.latestFieldsSchema,
typeof ReassignmentAuditActionService.storedDataSchema
>;
}
constructor(private userRepository: UserRepository) {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: ReassignmentAuditActionService.latestFieldsSchema,
storedDataSchema: ReassignmentAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const { newUser } = await this.getPreviousAndNewAssigneeUser(fields);
const reassignedToName = newUser?.name || "Unknown";
return {
key: "booking_audit_action.booking_reassigned_to_host",
params: { host: reassignedToName },
};
}
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const user = await this.userRepository.findById({ id: fields.assignedToId.new });
const reassignedToName = user?.name || "Unknown";
return {
key: "booking_audit_action.booking_reassigned_to_host",
params: { host: reassignedToName },
};
}
getDisplayJson({ storedData }: GetDisplayJsonParams): ReassignmentAuditDisplayData {
const { fields } = this.parseStored(storedData);
return {
...(fields.organizerUuid
? {
previousOrganizerUuid: fields.organizerUuid.old,
newOrganizerUuid: fields.organizerUuid.new,
}
: {}),
getDisplayJson({
storedData,
}: GetDisplayJsonParams): ReassignmentAuditDisplayData {
const { fields } = this.parseStored(storedData);
return {
newAssignedToId: fields.assignedToId.new,
reassignmentReason: fields.reassignmentReason.new ?? null,
};
}
...(fields.hostAttendeeUpdated
? {
hostAttendeeIdUpdated: fields.hostAttendeeUpdated.id,
hostAttendeeUserUuidNew: fields.hostAttendeeUpdated.withUserUuid?.new,
hostAttendeeUserUuidOld: fields.hostAttendeeUpdated.withUserUuid?.old,
}
: {}),
reassignmentReason: fields.reassignmentReason ?? null,
};
}
getDisplayFields(storedData: BaseStoredAuditData): Array<{
labelKey: string;
valueKey: string;
}> {
const { fields } = this.parseStored(storedData);
const map = {
manual: "manual",
roundRobin: "round_robin",
}
const typeTranslationKey = `booking_audit_action.assignment_type_${map[fields.reassignmentType]}`;
private async getPreviousAndNewAssigneeUser(fields: ReassignmentAuditData) {
const hasAttendeeUpdated = fields.hostAttendeeUpdated != null;
const newHostUuid = hasAttendeeUpdated
? fields.hostAttendeeUpdated?.withUserUuid?.new
: fields.organizerUuid?.new;
const previousHostUuid = hasAttendeeUpdated
? fields.hostAttendeeUpdated?.withUserUuid?.old
: fields.organizerUuid?.old;
return [
{
labelKey: "booking_audit_action.assignment_type",
valueKey: typeTranslationKey,
}
];
}
const newUser = newHostUuid ? await this.userRepository.findByUuid({ uuid: newHostUuid }) : null;
const previousUser = previousHostUuid
? await this.userRepository.findByUuid({ uuid: previousHostUuid })
: null;
return {
previousUser: previousUser,
newUser: newUser,
};
}
async getDisplayFields(storedData: BaseStoredAuditData): Promise<
Array<{
labelKey: string;
valueKey: string;
}>
> {
const { fields } = this.parseStored(storedData);
const map = {
manual: "manual",
roundRobin: "round_robin",
};
const typeTranslationKey = `booking_audit_action.assignment_type_${map[fields.reassignmentType]}`;
const { previousUser } = await this.getPreviousAndNewAssigneeUser(fields);
return [
{
labelKey: "booking_audit_action.assignment_type",
valueKey: typeTranslationKey,
},
{
labelKey: "booking_audit_action.previous_assignee",
valueKey: previousUser?.name ?? "Unknown",
},
];
}
}
export type ReassignmentAuditData = z.infer<typeof fieldsSchemaV1>;
export type ReassignmentAuditDisplayData = {
newAssignedToId: number;
reassignmentReason: string | null;
previousOrganizerUuid?: string | null;
newOrganizerUuid?: string | null;
hostAttendeeIdUpdated?: number | null;
hostAttendeeUserUuidNew?: string | null;
hostAttendeeUserUuidOld?: string | null;
reassignmentReason: string | null;
};
@@ -0,0 +1,328 @@
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ReassignmentAuditActionService } from "../ReassignmentAuditActionService";
describe("ReassignmentAuditActionService", () => {
let service: ReassignmentAuditActionService;
let mockUserRepository: {
findByUuid: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockUserRepository = {
findByUuid: vi.fn(),
};
service = new ReassignmentAuditActionService(mockUserRepository as unknown as UserRepository);
});
describe("getVersionedData", () => {
it("should wrap fields with version number", () => {
const fields = {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: "Host unavailable",
reassignmentType: "manual" as const,
};
const result = service.getVersionedData(fields);
expect(result).toEqual({
version: 1,
fields,
});
});
it("should handle roundRobin reassignment type with attendee update", () => {
const fields = {
organizerUuid: { old: "fixed-host", new: "fixed-host" },
hostAttendeeUpdated: {
id: 123,
withUserUuid: { old: "old-rr-host", new: "new-rr-host" },
},
reassignmentReason: null,
reassignmentType: "roundRobin" as const,
};
const result = service.getVersionedData(fields);
expect(result).toEqual({
version: 1,
fields,
});
});
it("should handle null reassignment reason", () => {
const fields = {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: null,
reassignmentType: "manual" as const,
};
const result = service.getVersionedData(fields);
expect(result).toEqual({
version: 1,
fields,
});
});
});
describe("parseStored", () => {
it("should parse valid stored data", () => {
const storedData = {
version: 1,
fields: {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: "Host unavailable",
reassignmentType: "manual",
},
};
const result = service.parseStored(storedData);
expect(result.version).toBe(1);
expect(result.fields).toEqual({
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: "Host unavailable",
reassignmentType: "manual",
});
});
it("should throw error for invalid data", () => {
const invalidData = {
version: 1,
fields: {
reassignmentReason: "Host unavailable",
},
};
expect(() => service.parseStored(invalidData)).toThrow();
});
});
describe("getVersion", () => {
it("should return version from stored data", () => {
const storedData = {
version: 1,
fields: {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: null,
reassignmentType: "manual",
},
};
const result = service.getVersion(storedData);
expect(result).toBe(1);
});
});
describe("migrateToLatest", () => {
it("should return data as-is for V1 (no migration needed)", () => {
const fields = {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: "Host unavailable",
reassignmentType: "manual" as const,
};
const result = service.migrateToLatest(fields);
expect(result.isMigrated).toBe(false);
expect(result.latestData).toEqual(fields);
});
});
describe("getDisplayTitle", () => {
it("should return translation key with host name for manual reassignment (organizer changed)", async () => {
const storedData = {
version: 1,
fields: {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: "Host unavailable",
reassignmentType: "manual",
},
};
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-new", name: "New Host" });
const result = await service.getDisplayTitle({ storedData });
expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "organizer-new" });
expect(result).toEqual({
key: "booking_audit_action.booking_reassigned_to_host",
params: { host: "New Host" },
});
});
it("should return host name when attendee was updated (fixed-host scenario)", async () => {
const storedData = {
version: 1,
fields: {
organizerUuid: { old: "fixed-host", new: "fixed-host" },
hostAttendeeUpdated: {
id: 123,
withUserUuid: { old: "old-rr-host", new: "new-rr-host" },
},
reassignmentReason: null,
reassignmentType: "roundRobin",
},
};
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "new-rr-host", name: "New RR Host" });
const result = await service.getDisplayTitle({ storedData });
expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "new-rr-host" });
expect(result).toEqual({
key: "booking_audit_action.booking_reassigned_to_host",
params: { host: "New RR Host" },
});
});
it("should return 'Unknown' when user not found", async () => {
const storedData = {
version: 1,
fields: {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: null,
reassignmentType: "roundRobin",
},
};
mockUserRepository.findByUuid.mockResolvedValue(null);
const result = await service.getDisplayTitle({ storedData });
expect(result).toEqual({
key: "booking_audit_action.booking_reassigned_to_host",
params: { host: "Unknown" },
});
});
it("should return 'Unknown' when user has no name", async () => {
const storedData = {
version: 1,
fields: {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: null,
reassignmentType: "manual",
},
};
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-new", name: null });
const result = await service.getDisplayTitle({ storedData });
expect(result).toEqual({
key: "booking_audit_action.booking_reassigned_to_host",
params: { host: "Unknown" },
});
});
});
describe("getDisplayJson", () => {
it("should return display data for manual reassignment (organizer changed)", () => {
const storedData = {
version: 1,
fields: {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: "Host unavailable",
reassignmentType: "manual",
},
};
const result = service.getDisplayJson({ storedData, userTimeZone: "UTC" });
expect(result).toEqual({
newOrganizerUuid: "organizer-new",
previousOrganizerUuid: "organizer-old",
reassignmentReason: "Host unavailable",
});
});
it("should return display data for fixed-host reassignment (attendee updated)", () => {
const storedData = {
version: 1,
fields: {
organizerUuid: { old: "fixed-host-uuid", new: "fixed-host-uuid" },
hostAttendeeUpdated: {
id: 123,
withUserUuid: { old: "old-rr-host-uuid", new: "new-rr-host-uuid" },
},
reassignmentReason: null,
reassignmentType: "roundRobin",
},
};
const result = service.getDisplayJson({ storedData, userTimeZone: "UTC" });
expect(result).toEqual({
newOrganizerUuid: "fixed-host-uuid",
previousOrganizerUuid: "fixed-host-uuid",
hostAttendeeIdUpdated: 123,
hostAttendeeUserUuidNew: "new-rr-host-uuid",
hostAttendeeUserUuidOld: "old-rr-host-uuid",
reassignmentReason: null,
});
});
});
describe("getDisplayFields", () => {
it("should return assignment type field for manual reassignment", async () => {
const storedData = {
version: 1,
fields: {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: "Host unavailable",
reassignmentType: "manual",
},
};
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-old", name: "Previous Host" });
const result = await service.getDisplayFields(storedData);
expect(result).toEqual([
{
labelKey: "booking_audit_action.assignment_type",
valueKey: "booking_audit_action.assignment_type_manual",
},
{
labelKey: "booking_audit_action.previous_assignee",
valueKey: "Previous Host",
},
]);
});
it("should return assignment type field for round robin reassignment", async () => {
const storedData = {
version: 1,
fields: {
organizerUuid: { old: "organizer-old", new: "organizer-new" },
reassignmentReason: null,
reassignmentType: "roundRobin",
},
};
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-old", name: "Previous Host" });
const result = await service.getDisplayFields(storedData);
expect(result).toEqual([
{
labelKey: "booking_audit_action.assignment_type",
valueKey: "booking_audit_action.assignment_type_round_robin",
},
{
labelKey: "booking_audit_action.previous_assignee",
valueKey: "Previous Host",
},
]);
});
});
describe("TYPE constant", () => {
it("should have correct type value", () => {
expect(ReassignmentAuditActionService.TYPE).toBe("REASSIGNMENT");
});
});
});
@@ -151,7 +151,7 @@ export class BookingAuditViewerService {
: null;
const displayFields = actionService.getDisplayFields
? actionService.getDisplayFields(parsedData)
? await actionService.getDisplayFields(parsedData)
: null;
const impersonatedBy = await this.enrichImpersonator(log.context);
@@ -1,33 +1,35 @@
import prismaMock from "@calcom/testing/lib/__mocks__/prisma";
import {
getDate,
createBookingScenario,
getScenarioData,
getMockBookingAttendee,
TestData,
addWorkflowReminders,
createBookingScenario,
getDate,
getMockBookingAttendee,
getScenarioData,
TestData,
} from "@calcom/testing/lib/bookingScenario/bookingScenario";
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { BookingStatus, SchedulingType, WorkflowMethods } from "@calcom/prisma/enums";
import {
expectBookingToBeInDatabase,
expectSuccessfulRoundRobinReschedulingEmails,
expectWorkflowToBeTriggered,
} from "@calcom/testing/lib/bookingScenario/expects";
import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
import { describe, vi, expect } from "vitest";
import { v4 as uuidv4 } from "uuid";
import { parse } from "node-html-parser";
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
import { test } from "@calcom/testing/lib/fixtures/fixtures";
import { parse } from "node-html-parser";
import { v4 as uuidv4 } from "uuid";
import { beforeEach, describe, expect, vi } from "vitest";
import type { BookingEventHandlerService } from "../../bookings/lib/onBookingEvents/BookingEventHandlerService";
vi.mock("@calcom/features/bookings/lib/EventManager");
vi.mock("@calcom/app-store/utils", () => ({
getAppFromSlug: vi.fn(),
}));
vi.mock("@calcom/features/bookings/di/BookingEventHandlerService.container", () => ({
getBookingEventHandlerService: vi.fn(),
}));
// Type definitions
type TestUserMetadata = {
@@ -58,6 +60,7 @@ const createTestUser = (overrides?: {
email?: string;
destinationCalendar?: { integration: string; externalId: string };
metadata?: TestUserMetadata;
uuid?: string;
}) => ({
id: overrides?.id ?? 1,
name: overrides?.name ?? `user-${overrides?.id ?? 1}`,
@@ -79,6 +82,7 @@ const createTestUser = (overrides?: {
completedOnboarding: true,
locked: false,
organizationId: null,
uuid: overrides?.uuid ?? `uuid-${overrides?.id ?? 1}`,
});
const createTestDestinationCalendar = (overrides?: { integration?: string; externalId?: string }) => ({
@@ -222,8 +226,8 @@ const mockEventManagerReschedule = async (config?: MockEventManagerConfig) => {
conferenceType === "zoom"
? "https://zoom.us/j/123456789"
: conferenceType === "google"
? "https://meet.google.com/test-meeting"
: `https://${conferenceType}.co/test-room`,
? "https://meet.google.com/test-meeting"
: `https://${conferenceType}.co/test-room`,
},
});
}
@@ -243,6 +247,14 @@ const mockGetAppFromSlug = async () => {
describe("roundRobinManualReassignment test", () => {
setupAndTeardown();
beforeEach(() => {
// Set up default mock for BookingEventHandlerService
const mockOnReassignment = vi.fn().mockResolvedValue(undefined);
vi.mocked(getBookingEventHandlerService).mockReturnValue({
onReassignment: mockOnReassignment,
} as unknown as BookingEventHandlerService);
});
test("should reassign round robin booking to new host and update workflows", async ({ emails }) => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
@@ -302,6 +314,8 @@ describe("roundRobinManualReassignment test", () => {
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
actionSource: "WEBAPP",
reassignedByUuid: originalHost.uuid,
});
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
@@ -370,6 +384,8 @@ describe("roundRobinManualReassignment test", () => {
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
actionSource: "WEBAPP",
reassignedByUuid: originalHost.uuid,
});
// Verify that EventManager.reschedule was called with an event containing conferenceCredentialId
@@ -446,6 +462,8 @@ describe("roundRobinManualReassignment test", () => {
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
actionSource: "WEBAPP",
reassignedByUuid: fixedHost.uuid,
});
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
@@ -526,6 +544,8 @@ describe("roundRobinManualReassignment test", () => {
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
actionSource: "WEBAPP",
reassignedByUuid: originalHost.uuid,
});
expect(sendReassignedEmailsAndSMSSpy).toHaveBeenCalledTimes(1);
@@ -535,6 +555,14 @@ describe("roundRobinManualReassignment test", () => {
describe("roundRobinManualReassignment - Location Changes", () => {
setupAndTeardown();
beforeEach(() => {
// Set up default mock for BookingEventHandlerService
const mockOnReassignment = vi.fn().mockResolvedValue(undefined);
vi.mocked(getBookingEventHandlerService).mockReturnValue({
onReassignment: mockOnReassignment,
} as any);
});
const createConferencingUsers = () => {
return {
googleMeetHost: createTestUser({
@@ -655,6 +683,8 @@ describe("roundRobinManualReassignment - Location Changes", () => {
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
actionSource: "WEBAPP",
reassignedByUuid: "uuid-999",
});
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
@@ -720,6 +750,8 @@ describe("roundRobinManualReassignment - Location Changes", () => {
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
actionSource: "WEBAPP",
reassignedByUuid: "uuid-999",
});
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
@@ -771,6 +803,8 @@ describe("roundRobinManualReassignment - Location Changes", () => {
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
actionSource: "WEBAPP",
reassignedByUuid: "uuid-999",
});
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
@@ -821,6 +855,8 @@ describe("roundRobinManualReassignment - Location Changes", () => {
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
actionSource: "WEBAPP",
reassignedByUuid: "uuid-999",
});
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
@@ -876,6 +912,8 @@ describe("roundRobinManualReassignment - Location Changes", () => {
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
actionSource: "WEBAPP",
reassignedByUuid: "uuid-999",
})
).rejects.toThrow("Failed to set video conferencing link, but the meeting has been rescheduled");
});
@@ -884,6 +922,14 @@ describe("roundRobinManualReassignment - Location Changes", () => {
describe("roundRobinManualReassignment - Seated Events", () => {
setupAndTeardown();
beforeEach(() => {
// Set up default mock for BookingEventHandlerService
const mockOnReassignment = vi.fn().mockResolvedValue(undefined);
vi.mocked(getBookingEventHandlerService).mockReturnValue({
onReassignment: mockOnReassignment,
} as any);
});
test("should not expose other attendees in updated email when seatsShowAttendees is disabled for seated round-robin event", async ({
emails,
}) => {
@@ -975,6 +1021,8 @@ describe("roundRobinManualReassignment - Seated Events", () => {
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
actionSource: "WEBAPP",
reassignedByUuid: originalHost.uuid,
});
// Verify that updated emails were sent to all attendees
@@ -1032,3 +1080,161 @@ describe("roundRobinManualReassignment - Seated Events", () => {
}
});
});
describe("roundRobinManualReassignment - Audit Data Verification", () => {
setupAndTeardown();
test("should call BookingEventHandlerService.onReassignment with correct audit data when organizer changes", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
await mockEventManagerReschedule();
const mockOnReassignment = vi.fn().mockResolvedValue(undefined);
vi.mocked(getBookingEventHandlerService).mockReturnValue({
onReassignment: mockOnReassignment,
} as any);
const testDestinationCalendar = createTestDestinationCalendar();
const originalHost = createTestUser({
id: 1,
uuid: "uuid-1",
destinationCalendar: testDestinationCalendar,
});
const newHost = createTestUser({ id: 2, uuid: "uuid-2" });
const reassigningUser = createTestUser({ id: 3, uuid: "uuid-3" });
const users = [originalHost, newHost, reassigningUser];
const bookingToReassignUid = "booking-audit-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
users,
}),
],
bookings: [
await createTestBooking({
eventTypeId: 1,
userId: originalHost.id,
bookingId: 123,
bookingUid: bookingToReassignUid,
}),
],
organizer: originalHost,
usersApartFromOrganizer: users.slice(1),
})
);
await roundRobinManualReassignment({
bookingId: 123,
newUserId: newHost.id,
orgId: null,
reassignedById: reassigningUser.id,
actionSource: "WEBAPP",
reassignedByUuid: reassigningUser.uuid,
reassignReason: "Test reassignment reason",
});
expect(mockOnReassignment).toHaveBeenCalledTimes(1);
expect(mockOnReassignment).toHaveBeenCalledWith({
bookingUid: bookingToReassignUid,
actor: { identifiedBy: "user", userUuid: reassigningUser.uuid },
organizationId: null,
source: "WEBAPP",
auditData: {
organizerUuid: { old: originalHost.uuid, new: newHost.uuid },
reassignmentReason: "Test reassignment reason",
reassignmentType: "manual",
},
});
});
test("should call BookingEventHandlerService.onReassignment with correct audit data when only attendee changes (fixed host scenario)", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
await mockEventManagerReschedule();
const mockOnReassignment = vi.fn().mockResolvedValue(undefined);
vi.mocked(getBookingEventHandlerService).mockReturnValue({
onReassignment: mockOnReassignment,
} as any);
const testDestinationCalendar = createTestDestinationCalendar();
const fixedHost = createTestUser({ id: 1, uuid: "uuid-1", destinationCalendar: testDestinationCalendar });
const currentRRHost = createTestUser({ id: 2, uuid: "uuid-2" });
const newHost = createTestUser({ id: 3, uuid: "uuid-3" });
const reassigningUser = createTestUser({ id: 4, uuid: "uuid-4" });
const users = [fixedHost, currentRRHost, newHost, reassigningUser];
const bookingToReassignUid = "booking-audit-fixed-host-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
users: [fixedHost, currentRRHost, newHost],
hosts: users.slice(0, 3).map((user) => ({
userId: user.id,
isFixed: user.id === fixedHost.id,
})),
}),
],
bookings: [
await createTestBooking({
eventTypeId: 1,
userId: fixedHost.id,
bookingId: 123,
bookingUid: bookingToReassignUid,
attendees: [
getMockBookingAttendee({
id: 1,
name: "attendee",
email: "attendee@test.com",
locale: "en",
timeZone: "Asia/Kolkata",
}),
getMockBookingAttendee({
id: currentRRHost.id,
name: currentRRHost.name,
email: currentRRHost.email,
locale: "en",
timeZone: currentRRHost.timeZone,
}),
],
}),
],
organizer: fixedHost,
usersApartFromOrganizer: users.slice(1, 3),
})
);
await roundRobinManualReassignment({
bookingId: 123,
newUserId: newHost.id,
orgId: null,
reassignedById: reassigningUser.id,
actionSource: "WEBAPP",
reassignedByUuid: reassigningUser.uuid,
reassignReason: "Test reason",
});
expect(mockOnReassignment).toHaveBeenCalledTimes(1);
const callArgs = mockOnReassignment.mock.calls[0][0];
expect(callArgs.bookingUid).toBe(bookingToReassignUid);
expect(callArgs.actor).toEqual({ identifiedBy: "user", userUuid: reassigningUser.uuid });
expect(callArgs.organizationId).toBe(null);
expect(callArgs.source).toBe("WEBAPP");
// organizerUuid should NOT be included when organizer hasn't changed (fixed host scenario)
expect(callArgs.auditData.organizerUuid).toBeUndefined();
expect(callArgs.auditData.reassignmentType).toBe("manual");
expect(callArgs.auditData.reassignmentReason).toBe("Test reason");
expect(callArgs.auditData.hostAttendeeUpdated).toBeDefined();
expect(callArgs.auditData.hostAttendeeUpdated?.withUserUuid).toEqual({
old: currentRRHost.uuid,
new: newHost.uuid,
});
});
});
@@ -1,5 +1,3 @@
import { cloneDeep } from "lodash";
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
@@ -8,6 +6,9 @@ import {
sendReassignedScheduledEmailsAndSMS,
sendReassignedUpdatedEmailsAndSMS,
} from "@calcom/emails/email-manager";
import { 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 EventManager from "@calcom/features/bookings/lib/EventManager";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
@@ -20,8 +21,8 @@ import AssignmentReasonRecorder, {
} from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService";
import {
scheduleEmailReminder,
deleteScheduledEmailReminder,
scheduleEmailReminder,
} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
@@ -36,7 +37,7 @@ import { prisma } from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { cloneDeep } from "lodash";
import { handleRescheduleEventManager } from "./handleRescheduleEventManager";
import type { BookingSelectResult } from "./utils/bookingSelect";
import { bookingSelect } from "./utils/bookingSelect";
@@ -56,6 +57,8 @@ export const roundRobinManualReassignment = async ({
reassignedById,
emailsEnabled = true,
platformClientParams,
actionSource,
reassignedByUuid,
}: {
bookingId: number;
newUserId: number;
@@ -64,6 +67,8 @@ export const roundRobinManualReassignment = async ({
reassignedById: number;
emailsEnabled?: boolean;
platformClientParams?: PlatformClientParams;
actionSource: ValidActionSource;
reassignedByUuid: string;
}) => {
const roundRobinReassignLogger = logger.getSubLogger({
prefix: ["roundRobinManualReassign", `${bookingId}`],
@@ -113,9 +118,20 @@ export const roundRobinManualReassignment = async ({
}));
const fixedHost = eventTypeHosts.find((host) => host.isFixed);
const currentRRHost = booking.attendees.find((attendee) =>
eventTypeHosts.some((host) => !host.isFixed && host.user.email === attendee.email)
);
let currentRRHostUserUuid = null;
let attendeeUpdatedId = null;
const currentRRHostAttendee = booking.attendees.find((attendee) => {
const matchingEventTypeHost = eventTypeHosts.find(
(host) => !host.isFixed && host.user.email === attendee.email
);
if (matchingEventTypeHost) {
currentRRHostUserUuid = matchingEventTypeHost.user.uuid;
return true;
}
return false;
});
const newUserHost = eventTypeHosts.find((host) => host.user.id === newUserId);
if (!newUserHost) {
@@ -152,7 +168,7 @@ export const roundRobinManualReassignment = async ({
let bookingLocation = booking.location;
let conferenceCredentialId: number | null = null;
const organizer = hasOrganizerChanged ? newUser : booking.user ?? newUser;
const organizer = hasOrganizerChanged ? newUser : (booking.user ?? newUser);
const teamMembers = await getTeamMembers({
eventTypeHosts,
@@ -224,10 +240,10 @@ export const roundRobinManualReassignment = async ({
reassignById: reassignedById,
reassignmentType: RRReassignmentType.MANUAL,
});
} else if (currentRRHost) {
} else if (currentRRHostAttendee) {
// Update the round-robin host attendee
await prisma.attendee.update({
where: { id: currentRRHost.id },
where: { id: currentRRHostAttendee.id },
data: {
name: newUser.name || "",
email: newUser.email,
@@ -235,8 +251,36 @@ export const roundRobinManualReassignment = async ({
locale: newUser.locale,
},
});
attendeeUpdatedId = currentRRHostAttendee.id;
}
const bookingEventHandlerService = getBookingEventHandlerService();
await bookingEventHandlerService.onReassignment({
bookingUid: booking.uid,
actor: makeUserActor(reassignedByUuid),
organizationId: orgId,
source: actionSource,
auditData: {
...(hasOrganizerChanged
? { organizerUuid: { old: originalOrganizer.uuid, new: newUser.uuid } }
: null),
...(attendeeUpdatedId
? {
hostAttendeeUpdated: {
id: attendeeUpdatedId,
withUserUuid: {
old: currentRRHostUserUuid,
new: newUser.uuid,
},
},
}
: null),
reassignmentReason: reassignReason ?? null,
reassignmentType: "manual",
},
});
// When organizer hasn't changed, still extract conferenceCredentialId from event type locations
if (!hasOrganizerChanged && bookingLocation) {
const { conferenceCredentialId: extractedCredentialId } =
@@ -257,7 +301,6 @@ export const roundRobinManualReassignment = async ({
});
const organizerT = await getTranslation(organizer?.locale || "en", "common");
const attendeePromises = [];
for (const attendee of booking.attendees) {
if (
@@ -1,29 +1,31 @@
import prismaMock from "@calcom/testing/lib/__mocks__/prisma";
import {
getDate,
createBookingScenario,
getScenarioData,
getMockBookingAttendee,
TestData,
addWorkflowReminders,
createBookingScenario,
getDate,
getMockBookingAttendee,
getScenarioData,
TestData,
} from "@calcom/testing/lib/bookingScenario/bookingScenario";
import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { BookingStatus, SchedulingType, WorkflowMethods } from "@calcom/prisma/enums";
import {
expectBookingToBeInDatabase,
expectSuccessfulRoundRobinReschedulingEmails,
expectWorkflowToBeTriggered,
} from "@calcom/testing/lib/bookingScenario/expects";
import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
import { describe, vi, expect } from "vitest";
import { v4 as uuidv4 } from "uuid";
import { parse } from "node-html-parser";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
import { test } from "@calcom/testing/lib/fixtures/fixtures";
import { parse } from "node-html-parser";
import { v4 as uuidv4 } from "uuid";
import { beforeEach, describe, expect, vi } from "vitest";
import type { BookingEventHandlerService } from "../../bookings/lib/onBookingEvents/BookingEventHandlerService";
vi.mock("@calcom/features/bookings/lib/EventManager");
vi.mock("@calcom/features/bookings/di/BookingEventHandlerService.container", () => ({
getBookingEventHandlerService: vi.fn(),
}));
const testDestinationCalendar = {
integration: "test-calendar",
@@ -39,6 +41,7 @@ const testUsers = [
email: "host1@test.com",
schedules: [TestData.schedules.IstWorkHours],
destinationCalendar: testDestinationCalendar,
uuid: "uuid-1",
},
{
id: 2,
@@ -47,6 +50,7 @@ const testUsers = [
username: "host-2",
email: "host2@test.com",
schedules: [TestData.schedules.IstWorkHours],
uuid: "uuid-2",
},
{
id: 3,
@@ -55,12 +59,21 @@ const testUsers = [
username: "host-3",
email: "host3@test.com",
schedules: [TestData.schedules.IstWorkHours],
uuid: "uuid-3",
},
];
describe("roundRobinReassignment test", () => {
setupAndTeardown();
beforeEach(() => {
// Set up default mock for BookingEventHandlerService
const mockOnReassignment = vi.fn().mockResolvedValue(undefined);
vi.mocked(getBookingEventHandlerService).mockReturnValue({
onReassignment: mockOnReassignment,
} as unknown as BookingEventHandlerService);
});
test("reassign new round robin organizer", async ({ emails }) => {
const roundRobinReassignment = (await import("./roundRobinReassignment")).default;
const EventManager = (await import("@calcom/features/bookings/lib/EventManager")).default;
@@ -168,6 +181,8 @@ describe("roundRobinReassignment test", () => {
bookingId: 123,
orgId: null,
reassignedById: originalHost.id,
actionSource: "WEBAPP",
reassignedByUuid: originalHost.uuid,
});
expect(eventManagerSpy).toBeCalledTimes(1);
@@ -425,6 +440,8 @@ describe("roundRobinReassignment test", () => {
bookingId: 123,
orgId: null,
reassignedById: originalHost.id,
actionSource: "WEBAPP",
reassignedByUuid: originalHost.uuid,
});
expect(eventManagerSpy).toBeCalledTimes(1);
@@ -484,3 +501,203 @@ describe("roundRobinReassignment test", () => {
}
});
});
describe("roundRobinReassignment - Audit Data Verification", () => {
setupAndTeardown();
test("should call BookingEventHandlerService.onReassignment with correct audit data when organizer changes", async () => {
const roundRobinReassignment = (await import("./roundRobinReassignment")).default;
const EventManager = (await import("@calcom/features/bookings/lib/EventManager")).default;
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
eventManagerSpy.mockClear();
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
const mockOnReassignment = vi.fn().mockResolvedValue(undefined);
vi.mocked(getBookingEventHandlerService).mockReturnValue({
onReassignment: mockOnReassignment,
} as any);
const users = testUsers;
const originalHost = users[0];
const newHost = users[1];
const reassigningUser = users[0]; // originalHost is doing the reassignment
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
const { dateString: dateStringMinusOne } = getDate({ dateIncrement: -1 });
const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 });
const bookingToReassignUid = "booking-audit-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: users.map((user) => ({ id: user.id })),
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
},
],
bookings: [
{
id: 123,
eventTypeId: 1,
userId: originalHost.id,
uid: bookingToReassignUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
attendees: [
getMockBookingAttendee({
id: 2,
name: "attendee",
email: "attendee@test.com",
locale: "en",
timeZone: "Asia/Kolkata",
}),
],
},
{
id: 456,
eventTypeId: 1,
userId: users[2].id,
uid: "other-booking",
status: BookingStatus.ACCEPTED,
startTime: `${dateStringMinusOne}T05:00:00.000Z`,
endTime: `${dateStringMinusOne}T05:15:00.000Z`,
attendees: [
getMockBookingAttendee({
id: 2,
name: "attendee",
email: "attendee@test.com",
locale: "en",
timeZone: "Asia/Kolkata",
}),
],
},
],
organizer: originalHost,
usersApartFromOrganizer: users.slice(1),
})
);
await roundRobinReassignment({
bookingId: 123,
orgId: null,
reassignedById: reassigningUser.id,
actionSource: "WEBAPP",
reassignedByUuid: reassigningUser.uuid,
});
expect(mockOnReassignment).toHaveBeenCalledTimes(1);
expect(mockOnReassignment).toHaveBeenCalledWith({
bookingUid: bookingToReassignUid,
actor: { identifiedBy: "user", userUuid: reassigningUser.uuid },
organizationId: null,
source: "WEBAPP",
auditData: {
organizerUuid: { old: originalHost.uuid, new: newHost.uuid },
reassignmentReason: null,
reassignmentType: "roundRobin",
},
});
});
test("should call BookingEventHandlerService.onReassignment with correct audit data when only attendee changes (fixed host scenario)", async () => {
const roundRobinReassignment = (await import("./roundRobinReassignment")).default;
const EventManager = (await import("@calcom/features/bookings/lib/EventManager")).default;
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
eventManagerSpy.mockClear();
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
const mockOnReassignment = vi.fn().mockResolvedValue(undefined);
vi.mocked(getBookingEventHandlerService).mockReturnValue({
onReassignment: mockOnReassignment,
} as any);
const users = testUsers;
const fixedHost = users[0];
const currentRRHost = users[1];
const newHost = users[2];
const reassigningUser = fixedHost;
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
const bookingToReassignUid = "booking-audit-fixed-host-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: users.map((user) => ({ id: user.id })),
hosts: users.map((user) => ({
userId: user.id,
isFixed: user.id === fixedHost.id,
})),
},
],
bookings: [
{
id: 123,
eventTypeId: 1,
userId: fixedHost.id,
uid: bookingToReassignUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
attendees: [
getMockBookingAttendee({
id: 1,
name: "attendee",
email: "attendee@test.com",
locale: "en",
timeZone: "Asia/Kolkata",
}),
getMockBookingAttendee({
id: currentRRHost.id,
name: currentRRHost.name,
email: currentRRHost.email,
locale: "en",
timeZone: currentRRHost.timeZone,
}),
],
},
],
organizer: fixedHost,
usersApartFromOrganizer: users.slice(1),
})
);
await roundRobinReassignment({
bookingId: 123,
orgId: null,
reassignedById: reassigningUser.id,
actionSource: "WEBAPP",
reassignedByUuid: reassigningUser.uuid,
});
expect(mockOnReassignment).toHaveBeenCalledTimes(1);
const callArgs = mockOnReassignment.mock.calls[0][0];
expect(callArgs.bookingUid).toBe(bookingToReassignUid);
expect(callArgs.actor).toEqual({ identifiedBy: "user", userUuid: reassigningUser.uuid });
expect(callArgs.organizationId).toBe(null);
expect(callArgs.source).toBe("WEBAPP");
// organizerUuid should NOT be included when organizer hasn't changed (fixed host scenario)
expect(callArgs.auditData.organizerUuid).toBeUndefined();
expect(callArgs.auditData.reassignmentType).toBe("roundRobin");
expect(callArgs.auditData.reassignmentReason).toBe(null);
expect(callArgs.auditData.hostAttendeeUpdated).toBeDefined();
expect(callArgs.auditData.hostAttendeeUpdated?.withUserUuid).toEqual({
old: currentRRHost.uuid,
new: newHost.uuid,
});
});
});
@@ -1,10 +1,8 @@
import { cloneDeep } from "lodash";
import {
enrichHostsWithDelegationCredentials,
enrichUserWithDelegationCredentialsIncludeServiceAccountKey,
} from "@calcom/app-store/delegationCredential";
import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations";
import { getLocationValueForDB, OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import {
@@ -12,6 +10,9 @@ import {
sendReassignedScheduledEmailsAndSMS,
sendReassignedUpdatedEmailsAndSMS,
} from "@calcom/emails/email-manager";
import { 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 EventManager from "@calcom/features/bookings/lib/EventManager";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
@@ -31,10 +32,10 @@ import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { prisma } from "@calcom/prisma";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { cloneDeep } from "lodash";
import { handleRescheduleEventManager } from "./handleRescheduleEventManager";
import { handleWorkflowsUpdate } from "./roundRobinManualReassignment";
import { bookingSelect } from "./utils/bookingSelect";
@@ -47,12 +48,16 @@ export const roundRobinReassignment = async ({
emailsEnabled = true,
platformClientParams,
reassignedById,
actionSource,
reassignedByUuid,
}: {
bookingId: number;
orgId: number | null;
emailsEnabled?: boolean;
platformClientParams?: PlatformClientParams;
reassignedById: number;
actionSource: ValidActionSource;
reassignedByUuid: string;
}) => {
const roundRobinReassignLogger = logger.getSubLogger({
prefix: ["roundRobinReassign", `${bookingId}`],
@@ -203,6 +208,8 @@ export const roundRobinReassignment = async ({
const attendeeList = await Promise.all(attendeePromises);
let bookingLocation = booking.location;
let attendeeUpdatedId = null;
let previousRRHostUserUuid = null;
if (hasOrganizerChanged) {
const bookingResponses = booking.responses;
@@ -268,19 +275,49 @@ export const roundRobinReassignment = async ({
const previousRRHostAttendee = booking.attendees.find(
(attendee) => attendee.email === previousRRHost.email
);
await prisma.attendee.update({
where: {
id: previousRRHostAttendee!.id,
},
data: {
name: reassignedRRHost.name || "",
email: reassignedRRHost.email,
timeZone: reassignedRRHost.timeZone,
locale: reassignedRRHost.locale,
},
});
if (previousRRHostAttendee) {
previousRRHostUserUuid = previousRRHost?.uuid ?? null;
attendeeUpdatedId = previousRRHostAttendee.id;
await prisma.attendee.update({
where: {
id: previousRRHostAttendee.id,
},
data: {
name: reassignedRRHost.name || "",
email: reassignedRRHost.email,
timeZone: reassignedRRHost.timeZone,
locale: reassignedRRHost.locale,
},
});
}
}
const bookingEventHandlerService = getBookingEventHandlerService();
await bookingEventHandlerService.onReassignment({
bookingUid: booking.uid,
actor: makeUserActor(reassignedByUuid),
organizationId: orgId,
source: actionSource,
auditData: {
...(hasOrganizerChanged
? { organizerUuid: { old: originalOrganizer.uuid, new: reassignedRRHost.uuid } }
: null),
...(attendeeUpdatedId
? {
hostAttendeeUpdated: {
id: attendeeUpdatedId,
withUserUuid: {
old: previousRRHostUserUuid,
new: reassignedRRHost.uuid ?? null,
},
},
}
: null),
reassignmentReason: null,
reassignmentType: "roundRobin",
},
});
roundRobinReassignLogger.info(`Successfully reassigned to user ${reassignedRRHost.id}`);
await AssignmentReasonRecorder.roundRobinReassignment({
@@ -26,6 +26,7 @@ export const bookingSelect = {
credentials: {
select: credentialForCalendarServiceSelect,
},
uuid: true,
},
},
attendees: true,
@@ -33,6 +33,8 @@ export const roundRobinManualReassignHandler = async ({ ctx, input }: RoundRobin
orgId: ctx.user.organizationId,
reassignReason,
reassignedById: ctx.user.id,
actionSource: "WEBAPP",
reassignedByUuid: ctx.user.uuid,
});
return { success: true };
@@ -1,5 +1,5 @@
import { roundRobinReassignment } from "@calcom/features/ee/round-robin/roundRobinReassignment";
import { getBookingAccessService } from "@calcom/features/di/containers/BookingAccessService";
import { roundRobinReassignment } from "@calcom/features/ee/round-robin/roundRobinReassignment";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -31,6 +31,8 @@ export const roundRobinReassignHandler = async ({ ctx, input }: RoundRobinReassi
bookingId,
orgId: ctx.user.organizationId,
reassignedById: ctx.user.id,
actionSource: "WEBAPP",
reassignedByUuid: ctx.user.uuid,
});
};