Skip to content

Commit 9d39590

Browse files
feat: create BookingHistoryViewerService to combine audit logs with routing form submissions (calcom#26045)
* feat: create BookingHistoryViewerService to combine audit logs with routing form submissions Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor(booking): rename audit log query and enhance type safety - Updated the booking logs view to use the new getBookingHistory query instead of getAuditLogs. - Introduced DisplayBookingAuditLog type for improved clarity in BookingAuditViewerService. - Refactored BookingHistoryViewerService to utilize the new DisplayBookingAuditLog type and added sorting functionality for history logs. - Adjusted related types and methods to ensure consistency across services. * refactor(routing-forms): streamline imports and enhance type definitions - Consolidated type exports and imports from the features library for better organization. - Removed redundant type definitions and functions in zod.ts, findFieldValueByIdentifier.ts, getFieldIdentifier.ts, and parseRoutingFormResponse.ts. - Introduced new utility functions for handling field responses and parsing routing form responses. - Improved type safety and clarity across routing form response handling. * fix: remove double prefix from uniqueId in form submission entry Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * 1c97f9c (calcom#26453) --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent a7fd789 commit 9d39590

24 files changed

Lines changed: 341 additions & 111 deletions
Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1 @@
1-
import getFieldIdentifier from "./getFieldIdentifier";
2-
import type { RoutingFormResponseData } from "./responseData/types";
3-
4-
type FindFieldValueByIdentifierResult =
5-
| { success: true; data: string | string[] | number | null }
6-
| { success: false; error: string };
7-
8-
export function findFieldValueByIdentifier(
9-
data: RoutingFormResponseData,
10-
identifier: string
11-
): FindFieldValueByIdentifierResult {
12-
const field = data.fields.find((field) => getFieldIdentifier(field) === identifier);
13-
if (!field) {
14-
return { success: false, error: `Field with identifier ${identifier} not found` };
15-
}
16-
17-
const fieldValue = data.response[field.id]?.value;
18-
19-
return { success: true, data: fieldValue ?? null };
20-
}
1+
export { findFieldValueByIdentifier } from "@calcom/features/routing-forms/lib/findFieldValueByIdentifier";
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1 @@
1-
import type { Field } from "../types/types";
2-
3-
const getFieldIdentifier = (field: Field) => field.identifier || field.label;
4-
5-
export default getFieldIdentifier;
1+
export { default } from "@calcom/features/routing-forms/lib/getFieldIdentifier";
Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1 @@
1-
import { zodNonRouterField } from "@calcom/app-store/routing-forms/zod";
2-
import { routingFormResponseInDbSchema } from "@calcom/app-store/routing-forms/zod";
3-
4-
import type { RoutingFormResponseData } from "./types";
5-
6-
export function parseRoutingFormResponse(rawResponse: unknown, formFields: unknown): RoutingFormResponseData {
7-
const response = routingFormResponseInDbSchema.parse(rawResponse);
8-
const fields = zodNonRouterField.array().parse(formFields);
9-
return { response, fields };
10-
}
1+
export { parseRoutingFormResponse } from "@calcom/features/routing-forms/lib/parseRoutingFormResponse";

packages/app-store/routing-forms/zod.ts

Lines changed: 8 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,50 +4,15 @@ import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod";
44

55
import { routingFormAppDataSchemas } from "./appDataSchemas";
66

7-
export type FieldOption = {
8-
label: string;
9-
id: string | null;
10-
};
7+
export {
8+
zodNonRouterField,
9+
routingFormResponseInDbSchema,
10+
type FieldOption,
11+
type TNonRouterField,
12+
} from "@calcom/features/routing-forms/lib/zod";
1113

12-
export type TNonRouterField = {
13-
id: string;
14-
label: string;
15-
identifier?: string;
16-
placeholder?: string;
17-
type: string;
18-
/** @deprecated in favour of `options` */
19-
selectText?: string;
20-
required?: boolean;
21-
deleted?: boolean;
22-
options?: FieldOption[];
23-
};
24-
25-
// Note: zodNonRouterField is NOT annotated with z.ZodType because it uses .extend() below
26-
// which requires the full ZodObject type to be preserved
27-
export const zodNonRouterField = z.object({
28-
id: z.string(),
29-
label: z.string(),
30-
identifier: z.string().optional(),
31-
placeholder: z.string().optional(),
32-
type: z.string(),
33-
/**
34-
* @deprecated in favour of `options`
35-
*/
36-
selectText: z.string().optional(),
37-
required: z.boolean().optional(),
38-
deleted: z.boolean().optional(),
39-
options: z
40-
.array(
41-
z.object({
42-
label: z.string(),
43-
// To keep backwards compatibility with the options generated from legacy selectText, we allow saving null as id
44-
// It helps in differentiating whether the routing logic should consider the option.label as value or option.id as value.
45-
// This is important for legacy routes which has option.label saved in conditions and it must keep matching with the value of the option
46-
id: z.string().or(z.null()),
47-
})
48-
)
49-
.optional(),
50-
});
14+
import type { TNonRouterField } from "@calcom/features/routing-forms/lib/zod";
15+
import { zodNonRouterField } from "@calcom/features/routing-forms/lib/zod";
5116

5217
export type TRouterField = TNonRouterField & {
5318
routerId: string;
@@ -158,12 +123,3 @@ export const zodRoutesView = z.union([z.array(zodRouteView), z.null()]).optional
158123
export const appDataSchema = z.any();
159124

160125
export const appKeysSchema = z.object({});
161-
162-
// This is different from FormResponse in types.d.ts in that it has label optional. We don't seem to be using label at this point, so we might want to use this only while saving the response when Routing Form is submitted
163-
// Record key is formFieldId
164-
export const routingFormResponseInDbSchema = z.record(
165-
z.object({
166-
label: z.string().optional(),
167-
value: z.union([z.string(), z.number(), z.array(z.string())]),
168-
})
169-
);

packages/features/booking-audit/client/components/BookingHistory.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ export function BookingHistory({ bookingUid }: BookingHistoryProps) {
360360
const [searchTerm, setSearchTerm] = useState("");
361361
const [actorFilter, setActorFilter] = useState<string | null>(null);
362362
const { t } = useLocale();
363-
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
363+
const { data, isLoading, error } = trpc.viewer.bookings.getBookingHistory.useQuery({
364364
bookingUid,
365365
});
366366

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createContainer } from "@calcom/features/di/di";
2+
3+
import {
4+
type BookingHistoryViewerService,
5+
moduleLoader as bookingHistoryViewerServiceModule,
6+
} from "./BookingHistoryViewerService.module";
7+
8+
const container = createContainer();
9+
10+
export function getBookingHistoryViewerService() {
11+
bookingHistoryViewerServiceModule.loadModule(container);
12+
13+
return container.get<BookingHistoryViewerService>(bookingHistoryViewerServiceModule.token);
14+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { BookingHistoryViewerService } from "@calcom/features/booking-audit/lib/service/BookingHistoryViewerService";
2+
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
3+
import { moduleLoader as bookingAuditViewerServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditViewerService.module";
4+
import { moduleLoader as routingFormResponseRepositoryModuleLoader } from "@calcom/features/routing-forms/di/RoutingFormResponseRepository.module";
5+
6+
import { createModule, bindModuleToClassOnToken } from "../../di/di";
7+
8+
export const bookingHistoryViewerServiceModule = createModule();
9+
const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_HISTORY_VIEWER_SERVICE;
10+
const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_HISTORY_VIEWER_SERVICE_MODULE;
11+
12+
export { BookingHistoryViewerService };
13+
14+
const loadModule = bindModuleToClassOnToken({
15+
module: bookingHistoryViewerServiceModule,
16+
moduleToken,
17+
token,
18+
classs: BookingHistoryViewerService,
19+
depsMap: {
20+
bookingAuditViewerService: bookingAuditViewerServiceModuleLoader,
21+
routingFormResponseRepository: routingFormResponseRepositoryModuleLoader,
22+
},
23+
});
24+
25+
export const moduleLoader = {
26+
token,
27+
loadModule,
28+
};

packages/features/booking-audit/di/tokens.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ export const BOOKING_AUDIT_DI_TOKENS = {
99
BOOKING_AUDIT_REPOSITORY_MODULE: Symbol("BookingAuditRepositoryModule"),
1010
AUDIT_ACTOR_REPOSITORY: Symbol("AuditActorRepository"),
1111
AUDIT_ACTOR_REPOSITORY_MODULE: Symbol("AuditActorRepositoryModule"),
12+
BOOKING_HISTORY_VIEWER_SERVICE: Symbol("BookingHistoryViewerService"),
13+
BOOKING_HISTORY_VIEWER_SERVICE_MODULE: Symbol("BookingHistoryViewerServiceModule"),
1214
};

packages/features/booking-audit/lib/service/BookingAuditViewerService.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ type EnrichedAuditLog = {
4848
};
4949
};
5050

51+
export type DisplayBookingAuditLog = EnrichedAuditLog;
52+
5153
/**
5254
* BookingAuditViewerService - Service for viewing and formatting booking audit logs
5355
*/
@@ -93,7 +95,7 @@ export class BookingAuditViewerService {
9395
userEmail: string;
9496
userTimeZone: string;
9597
organizationId: number | null;
96-
}): Promise<{ bookingUid: string; auditLogs: EnrichedAuditLog[] }> {
98+
}): Promise<{ bookingUid: string; auditLogs: DisplayBookingAuditLog[] }> {
9799
const { bookingUid, userId, userTimeZone, organizationId } = params;
98100
await this.accessService.assertPermissions({
99101
bookingUid,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { RoutingFormResponseRepositoryInterface } from "@calcom/lib/server/repository/RoutingFormResponseRepository.interface";
2+
3+
import type { BookingAuditViewerService, DisplayBookingAuditLog } from "./BookingAuditViewerService";
4+
import { getFieldResponseByIdentifier } from "@calcom/features/routing-forms/lib/getFieldResponseByIdentifier";
5+
6+
type GetHistoryForBookingParams = {
7+
bookingUid: string;
8+
userId: number;
9+
userEmail: string;
10+
userTimeZone: string;
11+
organizationId: number | null;
12+
};
13+
14+
type BookingHistoryLog = DisplayBookingAuditLog;
15+
16+
interface BookingHistoryViewerServiceDeps {
17+
bookingAuditViewerService: BookingAuditViewerService;
18+
routingFormResponseRepository: RoutingFormResponseRepositoryInterface;
19+
}
20+
21+
export class BookingHistoryViewerService {
22+
private readonly bookingAuditViewerService: BookingAuditViewerService;
23+
private readonly routingFormResponseRepository: RoutingFormResponseRepositoryInterface;
24+
25+
constructor(private readonly deps: BookingHistoryViewerServiceDeps) {
26+
this.bookingAuditViewerService = deps.bookingAuditViewerService;
27+
this.routingFormResponseRepository = deps.routingFormResponseRepository;
28+
}
29+
30+
private sortLogsReverseChronologically(historyLogs: BookingHistoryLog[]): BookingHistoryLog[] {
31+
return historyLogs.sort((a, b) => {
32+
const timestampA = new Date(a.timestamp).getTime();
33+
const timestampB = new Date(b.timestamp).getTime();
34+
return timestampB - timestampA;
35+
});
36+
}
37+
38+
private async getFormAuditLogsForBooking(bookingUid: string): Promise<BookingHistoryLog[]> {
39+
// TODO: Form doesn't have its Audit Logs yet, so we replicate them using the Form Response directly for now.
40+
const formResponse = await this.routingFormResponseRepository.findByBookingUidIncludeForm(bookingUid);
41+
if (!formResponse) {
42+
return [];
43+
}
44+
return [this.createFormSubmissionEntry({ formResponse, bookingUid })];
45+
}
46+
47+
async getHistoryForBooking(
48+
params: GetHistoryForBookingParams
49+
): Promise<{ bookingUid: string; auditLogs: BookingHistoryLog[] }> {
50+
const { bookingUid } = params;
51+
52+
const { auditLogs: bookingAuditLogs } = await this.bookingAuditViewerService.getAuditLogsForBooking(params);
53+
54+
const historyEntries: BookingHistoryLog[] = [...bookingAuditLogs, ...await this.getFormAuditLogsForBooking(bookingUid)];
55+
56+
const sortedLogs = this.sortLogsReverseChronologically(historyEntries);
57+
58+
return {
59+
bookingUid,
60+
auditLogs: sortedLogs,
61+
};
62+
}
63+
64+
private createFormSubmissionEntry({
65+
formResponse,
66+
bookingUid,
67+
}: {
68+
formResponse: NonNullable<
69+
Awaited<ReturnType<RoutingFormResponseRepositoryInterface["findByBookingUidIncludeForm"]>>
70+
>;
71+
bookingUid: string;
72+
}): BookingHistoryLog {
73+
const timestamp = formResponse.createdAt.toISOString();
74+
75+
const emailFieldResult = getFieldResponseByIdentifier({ responsePayload: formResponse.response, formFields: formResponse.form.fields, identifier: "email" });
76+
const emailFieldValueFromResponse = emailFieldResult.success ? emailFieldResult.data : null;
77+
// A valid string can be the email otherwise we assume it is not an email
78+
const submitterEmail = typeof emailFieldValueFromResponse === "string" ? emailFieldValueFromResponse : null;
79+
const uniqueId = `form-submission-${formResponse.id}`;
80+
return {
81+
id: uniqueId,
82+
bookingUid,
83+
type: "RECORD_CREATED",
84+
action: "CREATED",
85+
timestamp,
86+
createdAt: timestamp,
87+
source: "WEBAPP",
88+
operationId: uniqueId,
89+
displayJson: null,
90+
actionDisplayTitle: { key: "form_submitted" },
91+
displayFields: null,
92+
actor: {
93+
id: `form-submission-actor-${formResponse.id}`,
94+
type: "GUEST",
95+
userUuid: null,
96+
attendeeId: null,
97+
name: null,
98+
createdAt: formResponse.createdAt,
99+
displayName: submitterEmail ? `${submitterEmail}` : "Guest",
100+
displayEmail: submitterEmail || null,
101+
displayAvatar: null,
102+
},
103+
};
104+
}
105+
}

0 commit comments

Comments
 (0)