fix: Create RoutingFormResponseService to get field value from identifier (#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 (#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>
This commit is contained in:
Joe Au-Yeung
2025-07-17 22:40:18 +01:00
committed by GitHub
co-authored by joe@cal.com <j.auyeung419@gmail.com> Joe Au-Yeung Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Alex van Andel
parent f4b48e863b
commit f85f226982
10 changed files with 379 additions and 27 deletions
+21 -27
View File
@@ -3,14 +3,16 @@ import jsforce from "@jsforce/jsforce-node";
import { RRule } from "rrule";
import { z } from "zod";
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
import { getLocation } from "@calcom/lib/CalEventParser";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { RetryableError } from "@calcom/lib/crmManager/errors";
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { PrismaRoutingFormResponseRepository as RoutingFormResponseRepository } from "@calcom/lib/server/repository/PrismaRoutingFormResponseRepository";
import { AssignmentReasonRepository } from "@calcom/lib/server/repository/assignmentReason";
import { RoutingFormResponseDataFactory } from "@calcom/lib/server/service/routingForm/RoutingFormResponseDataFactory";
import { findFieldValueByIdentifier } from "@calcom/lib/server/service/routingForm/responseData/findFieldValueByIdentifier";
import { prisma } from "@calcom/prisma";
import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
@@ -433,7 +435,6 @@ export default class SalesforceCRMService implements CRM {
accessToken: this.accessToken,
instanceUrl: this.instanceUrl,
});
return await client.GetAccountRecordsForRRSkip(emailArray[0]);
} catch (error) {
log.error("Error getting account records for round robin skip", safeStringify({ error }));
@@ -1238,7 +1239,8 @@ export default class SalesforceCRMService implements CRM {
log.error(`BookingUid not passed. Cannot get form responses without it`);
return;
}
valueToWrite = await this.getTextValueFromRoutingFormResponse(fieldValue, bookingUid, recordId);
const formValue = await this.getTextValueFromRoutingFormResponse(fieldValue, bookingUid, recordId);
valueToWrite = formValue || "";
} else if (fieldValue.startsWith("{utm:")) {
if (!bookingUid) {
log.error(`BookingUid not passed. Cannot get tracking values without it`);
@@ -1283,20 +1285,8 @@ export default class SalesforceCRMService implements CRM {
prefix: [`[getTextValueFromRoutingFormResponse]: ${recordId} - bookingUid: ${bookingUid}`],
});
// Get the form response
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findFirst({
where: {
routedToBookingUid: bookingUid,
},
select: {
response: true,
},
});
if (!routingFormResponse) {
log.error("Routing form response not found");
return fieldValue;
}
const response = routingFormResponse.response as FormResponse;
let value;
const regex = /\{form:(.*?)\}/;
const regexMatch = fieldValue.match(regex);
if (!regexMatch) {
@@ -1310,19 +1300,23 @@ export default class SalesforceCRMService implements CRM {
return fieldValue;
}
// Search for fieldValue, only handle raw text return for now
for (const fieldId of Object.keys(response)) {
const field = response[fieldId];
if (field?.identifier === identifierField) {
return field.value.toString();
}
const routingFormResponseDataFactory = new RoutingFormResponseDataFactory({
logger: log,
routingFormResponseRepo: new RoutingFormResponseRepository(),
});
const findFieldResult = findFieldValueByIdentifier(
await routingFormResponseDataFactory.createWithBookingUid(bookingUid),
identifierField
);
if (findFieldResult.success) {
value = findFieldResult.data;
return String(value);
}
log.error(
`Could not find form response value for identifierField ${identifierField} in response keys ${Object.keys(
response
)}`
`Could not find field value for identifier ${identifierField} in bookingUid ${bookingUid}`,
`failed with error: ${findFieldResult.error}`
);
// If the field is not found, return the original field value
return fieldValue;
}
@@ -0,0 +1,38 @@
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import type { RoutingFormResponseRepositoryInterface } from "./RoutingFormResponseRepository.interface";
export class PrismaRoutingFormResponseRepository implements RoutingFormResponseRepositoryInterface {
constructor(private readonly prismaClient: PrismaClient = prisma) {}
findByIdIncludeForm(id: number) {
return this.prismaClient.app_RoutingForms_FormResponse.findUnique({
where: {
id,
},
include: {
form: {
select: {
fields: true,
},
},
},
});
}
findByBookingUidIncludeForm(bookingUid: string) {
return this.prismaClient.app_RoutingForms_FormResponse.findUnique({
where: {
routedToBookingUid: bookingUid,
},
include: {
form: {
select: {
fields: true,
},
},
},
});
}
}
@@ -0,0 +1,11 @@
import type { App_RoutingForms_Form, App_RoutingForms_FormResponse } from "@prisma/client";
export interface RoutingFormResponseRepositoryInterface {
findByIdIncludeForm(
id: number
): Promise<(App_RoutingForms_FormResponse & { form: { fields: App_RoutingForms_Form["fields"] } }) | null>;
findByBookingUidIncludeForm(
bookingUid: string
): Promise<(App_RoutingForms_FormResponse & { form: { fields: App_RoutingForms_Form["fields"] } }) | null>;
}
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { RoutingFormResponseRepositoryInterface } from "../../repository/RoutingFormResponseRepository.interface";
import { RoutingFormResponseDataFactory } from "./RoutingFormResponseDataFactory";
import { parseRoutingFormResponse } from "./responseData/parseRoutingFormResponse";
vi.mock("./responseData/parseRoutingFormResponse", () => ({
parseRoutingFormResponse: vi.fn(),
}));
const mockLogger = {
getSubLogger: () => ({
error: vi.fn(),
}),
};
const mockRoutingFormResponseRepo: RoutingFormResponseRepositoryInterface = {
findByBookingUidIncludeForm: vi.fn(),
findByIdIncludeForm: vi.fn(),
};
describe("RoutingFormResponseDataFactory", () => {
let factory: RoutingFormResponseDataFactory;
beforeEach(() => {
vi.clearAllMocks();
factory = new RoutingFormResponseDataFactory({
logger: mockLogger as any,
routingFormResponseRepo: mockRoutingFormResponseRepo,
});
});
describe("createWithBookingUid", () => {
it("should call parseRoutingFormResponse with correct data when form response is found", async () => {
const mockFormResponse = {
id: 1,
response: { name: "test" },
form: { fields: [{ label: "name", type: "text" }] },
};
const bookingUid = "test-uid";
vi.mocked(mockRoutingFormResponseRepo.findByBookingUidIncludeForm).mockResolvedValue(
mockFormResponse as any
);
const result = await factory.createWithBookingUid(bookingUid);
expect(mockRoutingFormResponseRepo.findByBookingUidIncludeForm).toHaveBeenCalledWith(bookingUid);
expect(parseRoutingFormResponse).toHaveBeenCalledWith(
mockFormResponse.response,
mockFormResponse.form.fields
);
});
it("should throw an error if form response is not found", async () => {
const bookingUid = "test-uid";
vi.mocked(mockRoutingFormResponseRepo.findByBookingUidIncludeForm).mockResolvedValue(null);
await expect(factory.createWithBookingUid(bookingUid)).rejects.toThrow("Form response not found");
expect(mockRoutingFormResponseRepo.findByBookingUidIncludeForm).toHaveBeenCalledWith(bookingUid);
expect(parseRoutingFormResponse).not.toHaveBeenCalled();
});
});
describe("createWithResponseId", () => {
it("should call parseRoutingFormResponse with correct data when form response is found", async () => {
const mockFormResponse = {
id: 1,
response: { email: "test@example.com" },
form: { fields: [{ label: "email", type: "email" }] },
};
const responseId = 1;
vi.mocked(mockRoutingFormResponseRepo.findByIdIncludeForm).mockResolvedValue(mockFormResponse as any);
const result = await factory.createWithResponseId(responseId);
expect(mockRoutingFormResponseRepo.findByIdIncludeForm).toHaveBeenCalledWith(responseId);
expect(parseRoutingFormResponse).toHaveBeenCalledWith(
mockFormResponse.response,
mockFormResponse.form.fields
);
});
it("should throw an error if form response is not found", async () => {
const responseId = 1;
vi.mocked(mockRoutingFormResponseRepo.findByIdIncludeForm).mockResolvedValue(null);
await expect(factory.createWithResponseId(responseId)).rejects.toThrow("Form response not found");
expect(mockRoutingFormResponseRepo.findByIdIncludeForm).toHaveBeenCalledWith(responseId);
expect(parseRoutingFormResponse).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,43 @@
import type logger from "@calcom/lib/logger";
import type { RoutingFormResponseRepositoryInterface } from "../../repository/RoutingFormResponseRepository.interface";
import { parseRoutingFormResponse } from "./responseData/parseRoutingFormResponse";
interface Dependencies {
logger: typeof logger;
routingFormResponseRepo: RoutingFormResponseRepositoryInterface;
}
export class RoutingFormResponseDataFactory {
constructor(private readonly deps: Dependencies) {}
async createWithBookingUid(bookingUid: string) {
const log = this.deps.logger.getSubLogger({
prefix: ["[routingFormFieldService]", { bookingUid }],
});
const formResponse = await this.deps.routingFormResponseRepo.findByBookingUidIncludeForm(bookingUid);
if (!formResponse) {
log.error("Form response not found");
throw new Error("Form response not found");
}
return parseRoutingFormResponse(formResponse.response, formResponse.form.fields);
}
async createWithResponseId(responseId: number) {
const log = this.deps.logger.getSubLogger({
prefix: ["[routingFormFieldService]", { responseId }],
});
const formResponse = await this.deps.routingFormResponseRepo.findByIdIncludeForm(responseId);
if (!formResponse) {
log.error("Form response not found");
throw new Error("Form response not found");
}
return parseRoutingFormResponse(formResponse.response, formResponse.form.fields);
}
}
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { findFieldValueByIdentifier } from "./findFieldValueByIdentifier";
import type { RoutingFormResponseData } from "./types";
describe("findFieldValueByIdentifier", () => {
const responseData: RoutingFormResponseData = {
response: {
"field-123": { value: "test@example.com" },
"field-456": { value: "John Doe" },
},
fields: [
{ id: "field-123", label: "E-mail", identifier: "email", type: "text" },
{ id: "field-456", label: "Name", identifier: "name", type: "text" },
],
};
it("returns the correct value for an existing field identifier", async () => {
const result = findFieldValueByIdentifier(responseData, "email");
expect(result.success).toBe(true);
// @ts-expect-error we know data is defined here
expect(result.data).toBe("test@example.com");
});
it("throws an error and logs when identifier is not found", () => {
const invalidIdentifier = "unknown";
const result = findFieldValueByIdentifier(responseData, invalidIdentifier);
expect(result.success).toBe(false);
// @ts-expect-error we know error is defined here
expect(result.error).toBe(`Field with identifier ${invalidIdentifier} not found`);
});
});
@@ -0,0 +1,21 @@
import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier";
import type { RoutingFormResponseData } from "./types";
type FindFieldValueByIdentifierResult =
| { success: true; data: string | string[] | number | null }
| { success: false; error: string };
export function findFieldValueByIdentifier(
data: RoutingFormResponseData,
identifier: string
): FindFieldValueByIdentifierResult {
const field = data.fields.find((field) => getFieldIdentifier(field) === identifier);
if (!field) {
return { success: false, error: `Field with identifier ${identifier} not found` };
}
const fieldValue = data.response[field.id]?.value;
return { success: true, data: fieldValue ?? null };
}
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { parseRoutingFormResponse } from "./parseRoutingFormResponse";
describe("parseRoutingFormResponse", () => {
const validFields = [
{ id: "field-123", label: "E-mail", identifier: "email", type: "text" },
{ id: "field-456", label: "Name", identifier: "name", type: "text" },
];
const validResponse = {
"field-123": { value: "test@example.com" },
"field-456": { value: "John Doe" },
};
it("parses valid form response and fields", () => {
const parsed = parseRoutingFormResponse(validResponse, validFields);
expect(parsed.response["field-123"].value).toBe("test@example.com");
expect(parsed.fields.length).toBe(2);
});
it("handles response with optional label", () => {
const responseWithLabels = {
"field-123": { value: "test@example.com", label: "E-mail" },
"field-456": { value: "John Doe", label: "Name" },
};
const parsed = parseRoutingFormResponse(responseWithLabels, validFields);
expect(parsed.response["field-123"].label).toBe("E-mail");
expect(parsed.response["field-456"].value).toBe("John Doe");
});
it("handles array value in response", () => {
const multiSelectResponse = {
"field-789": { value: ["opt1", "opt2"] },
};
const multiSelectFields = [
{
id: "field-789",
label: "Options",
identifier: "options",
type: "select",
options: [
{ label: "Option 1", id: "opt1" },
{ label: "Option 2", id: "opt2" },
],
},
];
const parsed = parseRoutingFormResponse(multiSelectResponse, multiSelectFields);
expect(parsed.response["field-789"].value).toEqual(["opt1", "opt2"]);
});
it("throws if response has unexpected field value type", () => {
const badResponse = {
"field-123": { value: { nested: true } }, // invalid type
};
expect(() => parseRoutingFormResponse(badResponse, validFields)).toThrow();
});
it("throws if a field is missing required keys like 'id'", () => {
const badField = [
{
label: "E-mail",
type: "text",
},
];
expect(() => parseRoutingFormResponse(validResponse, badField)).toThrow();
});
it("allows optional fields like 'selectText' and 'deleted'", () => {
const optionalField = [
{
id: "field-999",
label: "Optional",
identifier: "opt",
type: "text",
selectText: "Pick one",
deleted: true,
},
];
const optionalResponse = {
"field-999": { value: "Some value" },
};
const parsed = parseRoutingFormResponse(optionalResponse, optionalField);
expect(parsed.fields[0].selectText).toBe("Pick one");
expect(parsed.fields[0].deleted).toBe(true);
});
it("throws if raw inputs are not objects", () => {
expect(() => parseRoutingFormResponse("not-an-object" as any, validFields)).toThrow();
expect(() => parseRoutingFormResponse(validResponse, "not-an-array" as any)).toThrow();
});
});
@@ -0,0 +1,10 @@
import { zodNonRouterField } from "@calcom/app-store/routing-forms/zod";
import { routingFormResponseInDbSchema } from "@calcom/app-store/routing-forms/zod";
import type { RoutingFormResponseData } from "./types";
export function parseRoutingFormResponse(rawResponse: unknown, formFields: unknown): RoutingFormResponseData {
const response = routingFormResponseInDbSchema.parse(rawResponse);
const fields = zodNonRouterField.array().parse(formFields);
return { response, fields };
}
@@ -0,0 +1,9 @@
import type z from "zod";
import type { zodNonRouterField } from "@calcom/app-store/routing-forms/zod";
import type { routingFormResponseInDbSchema } from "@calcom/app-store/routing-forms/zod";
export type RoutingFormResponseData = {
fields: z.infer<typeof zodNonRouterField>[];
response: z.infer<typeof routingFormResponseInDbSchema>;
};