diff --git a/packages/app-store/salesforce/lib/CrmService.ts b/packages/app-store/salesforce/lib/CrmService.ts index 9f9db76f22..6e789474b5 100644 --- a/packages/app-store/salesforce/lib/CrmService.ts +++ b/packages/app-store/salesforce/lib/CrmService.ts @@ -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; } diff --git a/packages/lib/server/repository/PrismaRoutingFormResponseRepository.ts b/packages/lib/server/repository/PrismaRoutingFormResponseRepository.ts new file mode 100644 index 0000000000..4f6bb455e1 --- /dev/null +++ b/packages/lib/server/repository/PrismaRoutingFormResponseRepository.ts @@ -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, + }, + }, + }, + }); + } +} diff --git a/packages/lib/server/repository/RoutingFormResponseRepository.interface.ts b/packages/lib/server/repository/RoutingFormResponseRepository.interface.ts new file mode 100644 index 0000000000..588566a2e1 --- /dev/null +++ b/packages/lib/server/repository/RoutingFormResponseRepository.interface.ts @@ -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>; +} diff --git a/packages/lib/server/service/routingForm/RoutingFormResponseDataFactory.test.ts b/packages/lib/server/service/routingForm/RoutingFormResponseDataFactory.test.ts new file mode 100644 index 0000000000..179d31a59c --- /dev/null +++ b/packages/lib/server/service/routingForm/RoutingFormResponseDataFactory.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/lib/server/service/routingForm/RoutingFormResponseDataFactory.ts b/packages/lib/server/service/routingForm/RoutingFormResponseDataFactory.ts new file mode 100644 index 0000000000..fa0dee97e3 --- /dev/null +++ b/packages/lib/server/service/routingForm/RoutingFormResponseDataFactory.ts @@ -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); + } +} diff --git a/packages/lib/server/service/routingForm/responseData/findFieldValueByIdentifier.test.ts b/packages/lib/server/service/routingForm/responseData/findFieldValueByIdentifier.test.ts new file mode 100644 index 0000000000..8494d01222 --- /dev/null +++ b/packages/lib/server/service/routingForm/responseData/findFieldValueByIdentifier.test.ts @@ -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`); + }); +}); diff --git a/packages/lib/server/service/routingForm/responseData/findFieldValueByIdentifier.ts b/packages/lib/server/service/routingForm/responseData/findFieldValueByIdentifier.ts new file mode 100644 index 0000000000..2ed36065fc --- /dev/null +++ b/packages/lib/server/service/routingForm/responseData/findFieldValueByIdentifier.ts @@ -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 }; +} diff --git a/packages/lib/server/service/routingForm/responseData/parseRoutingFormResponse.test.ts b/packages/lib/server/service/routingForm/responseData/parseRoutingFormResponse.test.ts new file mode 100644 index 0000000000..3da3dca758 --- /dev/null +++ b/packages/lib/server/service/routingForm/responseData/parseRoutingFormResponse.test.ts @@ -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(); + }); +}); diff --git a/packages/lib/server/service/routingForm/responseData/parseRoutingFormResponse.ts b/packages/lib/server/service/routingForm/responseData/parseRoutingFormResponse.ts new file mode 100644 index 0000000000..438ffdb9d4 --- /dev/null +++ b/packages/lib/server/service/routingForm/responseData/parseRoutingFormResponse.ts @@ -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 }; +} diff --git a/packages/lib/server/service/routingForm/responseData/types.ts b/packages/lib/server/service/routingForm/responseData/types.ts new file mode 100644 index 0000000000..0641f3ed3b --- /dev/null +++ b/packages/lib/server/service/routingForm/responseData/types.ts @@ -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[]; + response: z.infer; +};