Files
calendar/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts
T
Hariom BalharaGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1aae57daae refactor(booking-audit): discriminated union for displayFields and i18n param support (#27373)
* feat: Add infrastructure for no-show audit integration

- Add Prisma migrations for SYSTEM source and NO_SHOW_UPDATED audit action
- Add NoShowUpdatedAuditActionService with array-based attendeesNoShow schema
- Update BookingAuditActionServiceRegistry to include NO_SHOW_UPDATED
- Update BookingAuditTaskConsumer and BookingAuditViewerService
- Add AttendeeRepository methods for no-show queries
- Update IAuditActionService interface with values array support
- Update locales with no-show audit translation keys

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Remove HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED from BookingAuditAction type to match Prisma schema

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update BookingAuditActionSchema to use NO_SHOW_UPDATED instead of HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Remove deprecated no-show audit services and unify to NoShowUpdatedAuditActionService

- Delete HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService
- Update BookingAuditProducerService.interface.ts to use queueNoShowUpdatedAudit
- Update BookingAuditTaskerProducerService.ts to use queueNoShowUpdatedAudit
- Update BookingEventHandlerService.ts to use onNoShowUpdated
- Add integration tests for NoShowUpdatedAuditActionService

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Add data migration step for deprecated no-show enum values

Addresses Cubic AI review feedback (confidence 9/10): The migration now
includes an UPDATE statement to convert existing records using the
deprecated 'host_no_show_updated' or 'attendee_no_show_updated' enum
values to the new unified 'no_show_updated' value before the type cast.
This prevents migration failures if any existing data uses the old values.

Co-Authored-By: unknown <>

* fix: Use CASE expression in USING clause for enum migration

Fixes PostgreSQL error 'unsafe use of new value of enum type' by avoiding
the ADD VALUE statement and instead using a CASE expression in the ALTER
TABLE USING clause to convert deprecated enum values (host_no_show_updated,
attendee_no_show_updated) to the new unified value (no_show_updated) during
the type conversion.

Co-Authored-By: unknown <>

* fix: Replace hardcoded color with semantic text-success class

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Remove color class completely from display fields

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: Add valuesWithParams support for translatable complex field values

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor(booking-audit): use discriminated union for displayFields and update consumers

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: add explicit return type to getBookingHistoryHandler to bust stale tRPC build cache

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: replace $t() nested interpolation with separate translation keys and add translationsWithParams tests

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-20 14:34:12 +05:30

165 lines
5.7 KiB
TypeScript

import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import type { DataRequirements } from "../service/EnrichmentDataStore";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type {
BaseStoredAuditData,
DisplayField,
DisplayFieldValue,
GetDisplayFieldsParams,
GetDisplayJsonParams,
GetDisplayTitleParams,
IAuditActionService,
TranslationWithParams,
} from "./IAuditActionService";
/**
* Reassignment Audit Action Service
* Handles REASSIGNMENT action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
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,
});
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() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: ReassignmentAuditActionService.latestFieldsSchema,
storedDataSchema: ReassignmentAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(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 };
}
getDataRequirements(storedData: BaseStoredAuditData): DataRequirements {
const { fields } = this.parseStored(storedData);
const userUuids: string[] = [];
const { newHostUuid, previousHostUuid } = this.getHostUuids(fields);
if (newHostUuid) userUuids.push(newHostUuid);
if (previousHostUuid) userUuids.push(previousHostUuid);
return { userUuids };
}
private getHostUuids(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 { newHostUuid, previousHostUuid };
}
async getDisplayTitle({ storedData, dbStore }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const { newHostUuid } = this.getHostUuids(fields);
const newUser = newHostUuid ? dbStore.getUserByUuid(newHostUuid) : null;
const reassignedToName = newUser?.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,
}
: {}),
...(fields.hostAttendeeUpdated
? {
hostAttendeeIdUpdated: fields.hostAttendeeUpdated.id,
hostAttendeeUserUuidNew: fields.hostAttendeeUpdated.withUserUuid?.new,
hostAttendeeUserUuidOld: fields.hostAttendeeUpdated.withUserUuid?.old,
}
: {}),
reassignmentReason: fields.reassignmentReason ?? null,
};
}
async getDisplayFields({ storedData, dbStore }: GetDisplayFieldsParams): Promise<DisplayField[]> {
const { fields } = this.parseStored(storedData);
const map = {
manual: "manual",
roundRobin: "round_robin",
};
const typeTranslationKey = `booking_audit_action.assignment_type_${map[fields.reassignmentType]}`;
const { previousHostUuid } = this.getHostUuids(fields);
const previousUser = previousHostUuid ? dbStore.getUserByUuid(previousHostUuid) : null;
const previousAssigneeFieldValue: DisplayFieldValue = previousUser?.name
? { type: "rawValue", value: previousUser.name }
: { type: "translationKey", valueKey: "booking_audit_action.unknown_user" };
return [
{
labelKey: "booking_audit_action.assignment_type",
fieldValue: { type: "translationKey", valueKey: typeTranslationKey },
},
{
labelKey: "booking_audit_action.previous_assignee",
fieldValue: previousAssigneeFieldValue,
},
];
}
}
export type ReassignmentAuditData = z.infer<typeof fieldsSchemaV1>;
export type ReassignmentAuditDisplayData = {
previousOrganizerUuid?: string | null;
newOrganizerUuid?: string | null;
hostAttendeeIdUpdated?: number | null;
hostAttendeeUserUuidNew?: string | null;
hostAttendeeUserUuidOld?: string | null;
reassignmentReason: string | null;
};