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:
@@ -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;
|
||||
};
|
||||
|
||||
+328
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user