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:
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
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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>;
|
||||
};
|
||||
Reference in New Issue
Block a user