Skip to content

Commit f85f226

Browse files
joeauyeungdevin-ai-integration[bot]emrysal
authored
fix: Create RoutingFormResponseService to get field value from identifier (calcom#22396)
* Make identifier required * Fallback to null if identifier isn't present * Type fix * Type fixes * Type fix * Create `RoutingFormResponseRepository` * Create `RoutingFormResponseService` * Use repsotiories to find form value * Delete console.logs * Undo change in `ZResponseInputSchema` schema * Type fix * Undo changes * fix: correct import path in RoutingFormResponseService to resolve runtime errors - Change relative import path to use @calcom alias - Prevents import resolution failures that cause app startup issues Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Undo changes * Update type * Update typing * Address feedback * Address feedback * chore: Provide a suggestion for pr 22396, new structure (calcom#22491) * chore: Provide a suggestion for pr 22396, new structure * Refactor to create two create methods with bookingUid and id * Use `createWithBookingUid` * Extract routing form response parser to seperate util * Added more and improved test cases * Fix --------- Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com> * Factory included wrong calls * Fix test for findFieldValueByIdentifier * Add tests * Fix test * Fix test --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
1 parent f4b48e8 commit f85f226

10 files changed

Lines changed: 379 additions & 27 deletions

packages/app-store/salesforce/lib/CrmService.ts

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import jsforce from "@jsforce/jsforce-node";
33
import { RRule } from "rrule";
44
import { z } from "zod";
55

6-
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
76
import { getLocation } from "@calcom/lib/CalEventParser";
87
import { WEBAPP_URL } from "@calcom/lib/constants";
98
import { RetryableError } from "@calcom/lib/crmManager/errors";
109
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
1110
import logger from "@calcom/lib/logger";
1211
import { safeStringify } from "@calcom/lib/safeStringify";
12+
import { PrismaRoutingFormResponseRepository as RoutingFormResponseRepository } from "@calcom/lib/server/repository/PrismaRoutingFormResponseRepository";
1313
import { AssignmentReasonRepository } from "@calcom/lib/server/repository/assignmentReason";
14+
import { RoutingFormResponseDataFactory } from "@calcom/lib/server/service/routingForm/RoutingFormResponseDataFactory";
15+
import { findFieldValueByIdentifier } from "@calcom/lib/server/service/routingForm/responseData/findFieldValueByIdentifier";
1416
import { prisma } from "@calcom/prisma";
1517
import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
1618
import type { CredentialPayload } from "@calcom/types/Credential";
@@ -433,7 +435,6 @@ export default class SalesforceCRMService implements CRM {
433435
accessToken: this.accessToken,
434436
instanceUrl: this.instanceUrl,
435437
});
436-
437438
return await client.GetAccountRecordsForRRSkip(emailArray[0]);
438439
} catch (error) {
439440
log.error("Error getting account records for round robin skip", safeStringify({ error }));
@@ -1238,7 +1239,8 @@ export default class SalesforceCRMService implements CRM {
12381239
log.error(`BookingUid not passed. Cannot get form responses without it`);
12391240
return;
12401241
}
1241-
valueToWrite = await this.getTextValueFromRoutingFormResponse(fieldValue, bookingUid, recordId);
1242+
const formValue = await this.getTextValueFromRoutingFormResponse(fieldValue, bookingUid, recordId);
1243+
valueToWrite = formValue || "";
12421244
} else if (fieldValue.startsWith("{utm:")) {
12431245
if (!bookingUid) {
12441246
log.error(`BookingUid not passed. Cannot get tracking values without it`);
@@ -1283,20 +1285,8 @@ export default class SalesforceCRMService implements CRM {
12831285
prefix: [`[getTextValueFromRoutingFormResponse]: ${recordId} - bookingUid: ${bookingUid}`],
12841286
});
12851287

1286-
// Get the form response
1287-
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findFirst({
1288-
where: {
1289-
routedToBookingUid: bookingUid,
1290-
},
1291-
select: {
1292-
response: true,
1293-
},
1294-
});
1295-
if (!routingFormResponse) {
1296-
log.error("Routing form response not found");
1297-
return fieldValue;
1298-
}
1299-
const response = routingFormResponse.response as FormResponse;
1288+
let value;
1289+
13001290
const regex = /\{form:(.*?)\}/;
13011291
const regexMatch = fieldValue.match(regex);
13021292
if (!regexMatch) {
@@ -1310,19 +1300,23 @@ export default class SalesforceCRMService implements CRM {
13101300
return fieldValue;
13111301
}
13121302

1313-
// Search for fieldValue, only handle raw text return for now
1314-
for (const fieldId of Object.keys(response)) {
1315-
const field = response[fieldId];
1316-
if (field?.identifier === identifierField) {
1317-
return field.value.toString();
1318-
}
1303+
const routingFormResponseDataFactory = new RoutingFormResponseDataFactory({
1304+
logger: log,
1305+
routingFormResponseRepo: new RoutingFormResponseRepository(),
1306+
});
1307+
const findFieldResult = findFieldValueByIdentifier(
1308+
await routingFormResponseDataFactory.createWithBookingUid(bookingUid),
1309+
identifierField
1310+
);
1311+
if (findFieldResult.success) {
1312+
value = findFieldResult.data;
1313+
return String(value);
13191314
}
13201315
log.error(
1321-
`Could not find form response value for identifierField ${identifierField} in response keys ${Object.keys(
1322-
response
1323-
)}`
1316+
`Could not find field value for identifier ${identifierField} in bookingUid ${bookingUid}`,
1317+
`failed with error: ${findFieldResult.error}`
13241318
);
1325-
1319+
// If the field is not found, return the original field value
13261320
return fieldValue;
13271321
}
13281322

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { PrismaClient } from "@calcom/prisma";
2+
import prisma from "@calcom/prisma";
3+
4+
import type { RoutingFormResponseRepositoryInterface } from "./RoutingFormResponseRepository.interface";
5+
6+
export class PrismaRoutingFormResponseRepository implements RoutingFormResponseRepositoryInterface {
7+
constructor(private readonly prismaClient: PrismaClient = prisma) {}
8+
9+
findByIdIncludeForm(id: number) {
10+
return this.prismaClient.app_RoutingForms_FormResponse.findUnique({
11+
where: {
12+
id,
13+
},
14+
include: {
15+
form: {
16+
select: {
17+
fields: true,
18+
},
19+
},
20+
},
21+
});
22+
}
23+
24+
findByBookingUidIncludeForm(bookingUid: string) {
25+
return this.prismaClient.app_RoutingForms_FormResponse.findUnique({
26+
where: {
27+
routedToBookingUid: bookingUid,
28+
},
29+
include: {
30+
form: {
31+
select: {
32+
fields: true,
33+
},
34+
},
35+
},
36+
});
37+
}
38+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { App_RoutingForms_Form, App_RoutingForms_FormResponse } from "@prisma/client";
2+
3+
export interface RoutingFormResponseRepositoryInterface {
4+
findByIdIncludeForm(
5+
id: number
6+
): Promise<(App_RoutingForms_FormResponse & { form: { fields: App_RoutingForms_Form["fields"] } }) | null>;
7+
8+
findByBookingUidIncludeForm(
9+
bookingUid: string
10+
): Promise<(App_RoutingForms_FormResponse & { form: { fields: App_RoutingForms_Form["fields"] } }) | null>;
11+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
3+
import type { RoutingFormResponseRepositoryInterface } from "../../repository/RoutingFormResponseRepository.interface";
4+
import { RoutingFormResponseDataFactory } from "./RoutingFormResponseDataFactory";
5+
import { parseRoutingFormResponse } from "./responseData/parseRoutingFormResponse";
6+
7+
vi.mock("./responseData/parseRoutingFormResponse", () => ({
8+
parseRoutingFormResponse: vi.fn(),
9+
}));
10+
11+
const mockLogger = {
12+
getSubLogger: () => ({
13+
error: vi.fn(),
14+
}),
15+
};
16+
17+
const mockRoutingFormResponseRepo: RoutingFormResponseRepositoryInterface = {
18+
findByBookingUidIncludeForm: vi.fn(),
19+
findByIdIncludeForm: vi.fn(),
20+
};
21+
22+
describe("RoutingFormResponseDataFactory", () => {
23+
let factory: RoutingFormResponseDataFactory;
24+
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
factory = new RoutingFormResponseDataFactory({
28+
logger: mockLogger as any,
29+
routingFormResponseRepo: mockRoutingFormResponseRepo,
30+
});
31+
});
32+
33+
describe("createWithBookingUid", () => {
34+
it("should call parseRoutingFormResponse with correct data when form response is found", async () => {
35+
const mockFormResponse = {
36+
id: 1,
37+
response: { name: "test" },
38+
form: { fields: [{ label: "name", type: "text" }] },
39+
};
40+
const bookingUid = "test-uid";
41+
vi.mocked(mockRoutingFormResponseRepo.findByBookingUidIncludeForm).mockResolvedValue(
42+
mockFormResponse as any
43+
);
44+
45+
const result = await factory.createWithBookingUid(bookingUid);
46+
47+
expect(mockRoutingFormResponseRepo.findByBookingUidIncludeForm).toHaveBeenCalledWith(bookingUid);
48+
expect(parseRoutingFormResponse).toHaveBeenCalledWith(
49+
mockFormResponse.response,
50+
mockFormResponse.form.fields
51+
);
52+
});
53+
54+
it("should throw an error if form response is not found", async () => {
55+
const bookingUid = "test-uid";
56+
vi.mocked(mockRoutingFormResponseRepo.findByBookingUidIncludeForm).mockResolvedValue(null);
57+
58+
await expect(factory.createWithBookingUid(bookingUid)).rejects.toThrow("Form response not found");
59+
60+
expect(mockRoutingFormResponseRepo.findByBookingUidIncludeForm).toHaveBeenCalledWith(bookingUid);
61+
expect(parseRoutingFormResponse).not.toHaveBeenCalled();
62+
});
63+
});
64+
65+
describe("createWithResponseId", () => {
66+
it("should call parseRoutingFormResponse with correct data when form response is found", async () => {
67+
const mockFormResponse = {
68+
id: 1,
69+
response: { email: "test@example.com" },
70+
form: { fields: [{ label: "email", type: "email" }] },
71+
};
72+
const responseId = 1;
73+
vi.mocked(mockRoutingFormResponseRepo.findByIdIncludeForm).mockResolvedValue(mockFormResponse as any);
74+
75+
const result = await factory.createWithResponseId(responseId);
76+
77+
expect(mockRoutingFormResponseRepo.findByIdIncludeForm).toHaveBeenCalledWith(responseId);
78+
expect(parseRoutingFormResponse).toHaveBeenCalledWith(
79+
mockFormResponse.response,
80+
mockFormResponse.form.fields
81+
);
82+
});
83+
84+
it("should throw an error if form response is not found", async () => {
85+
const responseId = 1;
86+
vi.mocked(mockRoutingFormResponseRepo.findByIdIncludeForm).mockResolvedValue(null);
87+
88+
await expect(factory.createWithResponseId(responseId)).rejects.toThrow("Form response not found");
89+
90+
expect(mockRoutingFormResponseRepo.findByIdIncludeForm).toHaveBeenCalledWith(responseId);
91+
expect(parseRoutingFormResponse).not.toHaveBeenCalled();
92+
});
93+
});
94+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type logger from "@calcom/lib/logger";
2+
3+
import type { RoutingFormResponseRepositoryInterface } from "../../repository/RoutingFormResponseRepository.interface";
4+
import { parseRoutingFormResponse } from "./responseData/parseRoutingFormResponse";
5+
6+
interface Dependencies {
7+
logger: typeof logger;
8+
routingFormResponseRepo: RoutingFormResponseRepositoryInterface;
9+
}
10+
11+
export class RoutingFormResponseDataFactory {
12+
constructor(private readonly deps: Dependencies) {}
13+
14+
async createWithBookingUid(bookingUid: string) {
15+
const log = this.deps.logger.getSubLogger({
16+
prefix: ["[routingFormFieldService]", { bookingUid }],
17+
});
18+
19+
const formResponse = await this.deps.routingFormResponseRepo.findByBookingUidIncludeForm(bookingUid);
20+
21+
if (!formResponse) {
22+
log.error("Form response not found");
23+
throw new Error("Form response not found");
24+
}
25+
26+
return parseRoutingFormResponse(formResponse.response, formResponse.form.fields);
27+
}
28+
29+
async createWithResponseId(responseId: number) {
30+
const log = this.deps.logger.getSubLogger({
31+
prefix: ["[routingFormFieldService]", { responseId }],
32+
});
33+
34+
const formResponse = await this.deps.routingFormResponseRepo.findByIdIncludeForm(responseId);
35+
36+
if (!formResponse) {
37+
log.error("Form response not found");
38+
throw new Error("Form response not found");
39+
}
40+
41+
return parseRoutingFormResponse(formResponse.response, formResponse.form.fields);
42+
}
43+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import { findFieldValueByIdentifier } from "./findFieldValueByIdentifier";
4+
import type { RoutingFormResponseData } from "./types";
5+
6+
describe("findFieldValueByIdentifier", () => {
7+
const responseData: RoutingFormResponseData = {
8+
response: {
9+
"field-123": { value: "test@example.com" },
10+
"field-456": { value: "John Doe" },
11+
},
12+
fields: [
13+
{ id: "field-123", label: "E-mail", identifier: "email", type: "text" },
14+
{ id: "field-456", label: "Name", identifier: "name", type: "text" },
15+
],
16+
};
17+
18+
it("returns the correct value for an existing field identifier", async () => {
19+
const result = findFieldValueByIdentifier(responseData, "email");
20+
expect(result.success).toBe(true);
21+
// @ts-expect-error we know data is defined here
22+
expect(result.data).toBe("test@example.com");
23+
});
24+
25+
it("throws an error and logs when identifier is not found", () => {
26+
const invalidIdentifier = "unknown";
27+
28+
const result = findFieldValueByIdentifier(responseData, invalidIdentifier);
29+
expect(result.success).toBe(false);
30+
// @ts-expect-error we know error is defined here
31+
expect(result.error).toBe(`Field with identifier ${invalidIdentifier} not found`);
32+
});
33+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier";
2+
3+
import type { RoutingFormResponseData } from "./types";
4+
5+
type FindFieldValueByIdentifierResult =
6+
| { success: true; data: string | string[] | number | null }
7+
| { success: false; error: string };
8+
9+
export function findFieldValueByIdentifier(
10+
data: RoutingFormResponseData,
11+
identifier: string
12+
): FindFieldValueByIdentifierResult {
13+
const field = data.fields.find((field) => getFieldIdentifier(field) === identifier);
14+
if (!field) {
15+
return { success: false, error: `Field with identifier ${identifier} not found` };
16+
}
17+
18+
const fieldValue = data.response[field.id]?.value;
19+
20+
return { success: true, data: fieldValue ?? null };
21+
}

0 commit comments

Comments
 (0)