Skip to content

Commit 1aae57d

Browse files
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>
1 parent 12e95a1 commit 1aae57d

9 files changed

Lines changed: 414 additions & 115 deletions

File tree

apps/web/modules/booking-audit/components/BookingHistory.tsx

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ type TranslationWithParams = {
2929
components?: TranslationComponent[];
3030
};
3131

32+
type DisplayFieldValue =
33+
| { type: "translationKey"; valueKey: string }
34+
| { type: "rawValue"; value: string }
35+
| { type: "rawValues"; values: string[] }
36+
| { type: "translationsWithParams"; valuesWithParams: TranslationWithParams[] };
37+
38+
type DisplayField = {
39+
labelKey: string;
40+
fieldValue: DisplayFieldValue;
41+
};
42+
3243
type AuditLog = {
3344
id: string;
3445
action: string;
@@ -37,7 +48,7 @@ type AuditLog = {
3748
source: string;
3849
displayJson?: Record<string, unknown> | null;
3950
actionDisplayTitle: TranslationWithParams;
40-
displayFields?: Array<{ labelKey: string; valueKey?: string; value?: string; values?: string[] }> | null;
51+
displayFields?: DisplayField[] | null;
4152
actor: {
4253
type: AuditActorType;
4354
displayName: string | null;
@@ -188,30 +199,39 @@ function JsonViewer({ data }: JsonViewerProps) {
188199
);
189200
}
190201

191-
interface DisplayFieldValueProps {
192-
field: {
193-
valueKey?: string;
194-
value?: string;
195-
values?: string[];
196-
};
202+
interface DisplayFieldValueComponentProps {
203+
fieldValue: DisplayFieldValue;
197204
}
198205

199-
function DisplayFieldValue({ field }: DisplayFieldValueProps) {
206+
function DisplayFieldValueComponent({ fieldValue }: DisplayFieldValueComponentProps) {
200207
const { t } = useLocale();
201208

202-
if (field.values) {
203-
return (
204-
<span className="flex flex-col">
205-
{field.values.map((v, i) => (
206-
<span className="p-0.5" key={i}>
207-
{v}
208-
</span>
209-
))}
210-
</span>
211-
);
209+
switch (fieldValue.type) {
210+
case "translationsWithParams":
211+
return (
212+
<span className="flex flex-col">
213+
{fieldValue.valuesWithParams.map((v, i) => (
214+
<span className="p-0.5" key={i}>
215+
{t(v.key, v.params)}
216+
</span>
217+
))}
218+
</span>
219+
);
220+
case "rawValues":
221+
return (
222+
<span className="flex flex-col">
223+
{fieldValue.values.map((v, i) => (
224+
<span className="p-0.5" key={i}>
225+
{v}
226+
</span>
227+
))}
228+
</span>
229+
);
230+
case "rawValue":
231+
return <>{fieldValue.value}</>;
232+
case "translationKey":
233+
return <>{t(fieldValue.valueKey)}</>;
212234
}
213-
214-
return <>{field.value ?? (field.valueKey ? t(field.valueKey) : "")}</>;
215235
}
216236

217237
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
@@ -318,7 +338,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
318338
className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
319339
<span className="font-medium text-emphasis w-[140px]">{t(field.labelKey)}</span>
320340
<span className="font-medium">
321-
<DisplayFieldValue field={field} />
341+
<DisplayFieldValueComponent fieldValue={field.fieldValue} />
322342
</span>
323343
</div>
324344
))
@@ -384,18 +404,27 @@ function useBookingLogsFilters(auditLogs: AuditLog[], searchTerm: string, actorF
384404
return (
385405
log.displayFields?.some((field) => {
386406
const searchLower = searchTerm.toLowerCase();
387-
388407
const translatedLabel = field.labelKey ? t(field.labelKey) : "";
389-
const translatedValue = field.valueKey ? t(field.valueKey) : "";
390-
const displayValue = field.value ?? "";
391-
const displayValues = field.values ?? [];
392-
393-
return (
394-
translatedLabel.toLowerCase().includes(searchLower) ||
395-
translatedValue.toLowerCase().includes(searchLower) ||
396-
displayValue.toLowerCase().includes(searchLower) ||
397-
displayValues.some((v) => v.toLowerCase().includes(searchLower))
398-
);
408+
if (translatedLabel.toLowerCase().includes(searchLower)) {
409+
return true;
410+
}
411+
const { fieldValue } = field;
412+
switch (fieldValue.type) {
413+
case "translationKey":
414+
return t(fieldValue.valueKey).toLowerCase().includes(searchLower);
415+
case "rawValue":
416+
return fieldValue.value.toLowerCase().includes(searchLower);
417+
case "rawValues":
418+
return fieldValue.values.some((v) => v.toLowerCase().includes(searchLower));
419+
case "translationsWithParams":
420+
return fieldValue.valuesWithParams.some(
421+
(v) =>
422+
t(v.key, v.params).toLowerCase().includes(searchLower) ||
423+
Object.values(v.params ?? {}).some((param) =>
424+
param?.toString().toLowerCase().includes(searchLower)
425+
)
426+
);
427+
}
399428
}) ?? false
400429
);
401430
};

apps/web/public/static/locales/en/common.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4563,7 +4563,14 @@
45634563
"assignment_type_round_robin": "Round robin",
45644564
"actor_impersonated_by": "Impersonator",
45654565
"source": "Source",
4566-
"error_processing": "Unable to load details - {{actionType}}"
4566+
"error_processing": "Unable to load details - {{actionType}}",
4567+
"attendees": "Attendees",
4568+
"host": "Host",
4569+
"attendee_no_show_status_yes": "{{email}}: Yes",
4570+
"attendee_no_show_status_no": "{{email}}: No",
4571+
"host_no_show_status_yes": "{{name}}: Yes",
4572+
"host_no_show_status_no": "{{name}}: No",
4573+
"unknown_user": "Unknown"
45674574
},
45684575
"error_loading_booking_logs": "Error loading booking logs",
45694576
"no_audit_logs_found": "No audit logs found",

packages/features/booking-audit/lib/actions/IAuditActionService.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@ export type GetDisplayFieldsParams = {
4444
dbStore: EnrichmentDataStore;
4545
};
4646

47+
/**
48+
* Discriminated union for display field values
49+
* Each variant represents a different way to render the field value
50+
*/
51+
export type DisplayFieldValue =
52+
| { type: "translationKey"; valueKey: string }
53+
| { type: "rawValue"; value: string }
54+
| { type: "rawValues"; values: string[] }
55+
| { type: "translationsWithParams"; valuesWithParams: TranslationWithParams[] };
56+
57+
export type DisplayField = {
58+
labelKey: string;
59+
fieldValue: DisplayFieldValue;
60+
};
61+
4762
/**
4863
* Interface for Audit Action Services
4964
*
@@ -116,16 +131,9 @@ export interface IAuditActionService {
116131
* Optional - implement only if custom display fields are needed
117132
* @param params - Object containing storedData
118133
* @param params.storedData - Parsed stored data { version, fields }
119-
* @returns Promise of array of field objects with label and value (either translation key or raw value)
134+
* @returns Promise of array of DisplayField objects with label and discriminated value type
120135
*/
121-
getDisplayFields?(params: GetDisplayFieldsParams): Promise<
122-
Array<{
123-
labelKey: string; // Translation key for field label
124-
valueKey?: string; // Translation key for field value (will be translated)
125-
value?: string; // Raw value for field value (will NOT be translated)
126-
values?: string[]; // Array of raw values (will NOT be translated, rendered as separate lines)
127-
}>
128-
>;
136+
getDisplayFields?(params: GetDisplayFieldsParams): Promise<DisplayField[]>;
129137

130138
/**
131139
* Migrate old version data to latest version

packages/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService.ts

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { DataRequirements } from "../service/EnrichmentDataStore";
55
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
66
import type {
77
BaseStoredAuditData,
8+
DisplayField,
89
GetDisplayFieldsParams,
910
GetDisplayJsonParams,
1011
GetDisplayTitleParams,
@@ -111,30 +112,47 @@ export class NoShowUpdatedAuditActionService implements IAuditActionService {
111112
throw new Error("Audit action data is invalid");
112113
}
113114

114-
async getDisplayFields({ storedData, dbStore }: GetDisplayFieldsParams): Promise<
115-
Array<{
116-
labelKey: string;
117-
valueKey?: string;
118-
value?: string;
119-
values?: string[];
120-
}>
121-
> {
115+
async getDisplayFields({ storedData, dbStore }: GetDisplayFieldsParams): Promise<DisplayField[]> {
122116
const { fields: parsedFields } = this.parseStored(storedData);
123-
const displayFields: { labelKey: string; valueKey?: string; value?: string; values?: string[] }[] = [];
117+
const displayFields: DisplayField[] = [];
124118

125119
if (this.isAttendeesNoShowSet(parsedFields)) {
126-
const attendeesFieldValues = parsedFields.attendeesNoShow.map((attendee) => {
127-
const noShowStatus = attendee.noShow.new ? "Yes" : "No";
128-
return `${attendee.attendeeEmail}: ${noShowStatus}`;
120+
const attendeesValuesWithParams: TranslationWithParams[] = parsedFields.attendeesNoShow.map((attendee) => ({
121+
key: attendee.noShow.new
122+
? "booking_audit_action.attendee_no_show_status_yes"
123+
: "booking_audit_action.attendee_no_show_status_no",
124+
params: {
125+
email: attendee.attendeeEmail,
126+
},
127+
}));
128+
displayFields.push({
129+
labelKey: "booking_audit_action.attendees",
130+
fieldValue: {
131+
type: "translationsWithParams",
132+
valuesWithParams: attendeesValuesWithParams,
133+
},
129134
});
130-
displayFields.push({ labelKey: "Attendees", values: attendeesFieldValues });
131135
}
132136

133137
if (this.isHostSet(parsedFields)) {
134138
const user = dbStore.getUserByUuid(parsedFields.host.userUuid);
135139
const hostName = user?.name || "Unknown";
136-
const hostFieldValue = `${hostName}:${parsedFields.host.noShow.new ? "Yes" : "No"}`;
137-
displayFields.push({ labelKey: "Host", value: hostFieldValue });
140+
displayFields.push({
141+
labelKey: "booking_audit_action.host",
142+
fieldValue: {
143+
type: "translationsWithParams",
144+
valuesWithParams: [
145+
{
146+
key: parsedFields.host.noShow.new
147+
? "booking_audit_action.host_no_show_status_yes"
148+
: "booking_audit_action.host_no_show_status_no",
149+
params: {
150+
name: hostName,
151+
},
152+
},
153+
],
154+
},
155+
});
138156
}
139157

140158
return displayFields;

packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { z } from "zod";
22
import { StringChangeSchema } from "../common/changeSchemas";
3-
import type { DataRequirements, EnrichmentDataStore } from "../service/EnrichmentDataStore";
3+
import type { DataRequirements } from "../service/EnrichmentDataStore";
44
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
55
import type {
66
BaseStoredAuditData,
7+
DisplayField,
8+
DisplayFieldValue,
79
GetDisplayFieldsParams,
810
GetDisplayJsonParams,
911
GetDisplayTitleParams,
@@ -125,12 +127,7 @@ export class ReassignmentAuditActionService implements IAuditActionService {
125127
};
126128
}
127129

128-
async getDisplayFields({ storedData, dbStore }: GetDisplayFieldsParams): Promise<
129-
Array<{
130-
labelKey: string;
131-
valueKey: string;
132-
}>
133-
> {
130+
async getDisplayFields({ storedData, dbStore }: GetDisplayFieldsParams): Promise<DisplayField[]> {
134131
const { fields } = this.parseStored(storedData);
135132
const map = {
136133
manual: "manual",
@@ -139,14 +136,17 @@ export class ReassignmentAuditActionService implements IAuditActionService {
139136
const typeTranslationKey = `booking_audit_action.assignment_type_${map[fields.reassignmentType]}`;
140137
const { previousHostUuid } = this.getHostUuids(fields);
141138
const previousUser = previousHostUuid ? dbStore.getUserByUuid(previousHostUuid) : null;
139+
const previousAssigneeFieldValue: DisplayFieldValue = previousUser?.name
140+
? { type: "rawValue", value: previousUser.name }
141+
: { type: "translationKey", valueKey: "booking_audit_action.unknown_user" };
142142
return [
143143
{
144144
labelKey: "booking_audit_action.assignment_type",
145-
valueKey: typeTranslationKey,
145+
fieldValue: { type: "translationKey", valueKey: typeTranslationKey },
146146
},
147147
{
148148
labelKey: "booking_audit_action.previous_assignee",
149-
valueKey: previousUser?.name ?? "Unknown",
149+
fieldValue: previousAssigneeFieldValue,
150150
},
151151
];
152152
}

0 commit comments

Comments
 (0)