import logger from "@calcom/lib/logger"; import type { dynamicFieldValueOperands, dynamicFieldValueOperandsResponse } from "@calcom/lib/raqb/types"; import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; import type { Attribute } from "@calcom/lib/service/attribute/server/getAttributes"; import { caseInsensitive } from "./utils"; // Type for JSON values that can be in the query type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; type MinimalField = { type: string; options?: { id: string | null; label: string; }[]; }; type FieldResponseValue = dynamicFieldValueOperandsResponse[keyof dynamicFieldValueOperandsResponse]["value"]; const moduleLogger = logger.getSubLogger({ prefix: ["raqb/resolveQueryValue"] }); /** * Replace "Attribute Options' Ids" with "Attribute Options' Labels". It makes the query value compatible to be matched with 'Value of Field' operand */ const replaceAttributeOptionIdsWithOptionLabel = ({ queryValue, attributes, }: { queryValue: AttributesQueryValue; attributes: Attribute[]; }) => { const queryValueString = JSON.stringify(queryValue); let queryValueWithLabels = queryValueString; const allAttributesOptions = attributes.map((attribute) => attribute.options).flat(); // Because all attribute option Ids are unique, we can reliably identify them along any number of attribute options of different attributes allAttributesOptions.forEach((attributeOption) => { const attributeOptionId = attributeOption.id; queryValueWithLabels = queryValueWithLabels.replace( new RegExp(`${attributeOptionId}`, "g"), caseInsensitive(attributeOption.value) ); }); return JSON.parse(queryValueWithLabels) as AttributesQueryValue; }; function getFieldResponseValueAsLabel({ field, fieldResponseValue, }: { fieldResponseValue: FieldResponseValue; field: MinimalField; }) { const nonNumberFieldResponseValue = typeof fieldResponseValue === "number" ? fieldResponseValue.toString() : fieldResponseValue; if (!field.options) { return nonNumberFieldResponseValue; } if (nonNumberFieldResponseValue instanceof Array) { return nonNumberFieldResponseValue.map(transformValue); } return transformValue(nonNumberFieldResponseValue); function transformValue(idOrLabel: string) { const foundOptionById = field.options?.find((option) => { return option.id === idOrLabel; }); if (foundOptionById) { return foundOptionById.label; } else { return idOrLabel; } } } /** * Resolves query values by: * 1. Converting attribute option IDs to their lowercase labels * 2. Replacing field template placeholders (e.g., "{field:location}") with actual field values * * Important: When a field template resolves to an array value, the array items are flattened * into the parent array. For example: * - Input: [["{{field:location}}", "Delhi"]] where location = ["New York", "Amsterdam"] * - Output: [["new york", "amsterdam", "Delhi"]] (flattened into single array) * @param queryValue - JSON string containing RAQB query with field template placeholders like {field:fieldId} * @param dynamicFieldValueOperands - Optional object containing fields metadata and user's form field responses * @param dynamicFieldValueOperands.fields - Array of field definitions with id, type, label, etc. * @param dynamicFieldValueOperands.response - Object mapping field IDs to their response values * * @example * ```ts * resolveQueryValue({ * queryValue: { * "id": "9b8ba888-0123-4456-b89a-b1984020d699", * "type": "group", * "children1": { * "a8a89bba-89ab-4cde-b012-31984020ebaa": { * "type": "rule", * "id": "a8a89bba-89ab-4cde-b012-31984020ebaa", * "properties": { * "field": "88be6104-d350-411d-aa0f-092b2deda257", * "operator": "multiselect_some_in", * "value": [ * [ * "{field:0bf77a89-2bd0-4df7-9648-758014ba3189}", // 'Value of field' is stored like this and we call it a 'field template' * "899c846d-7c02-43dc-9057-c3f8c118d41f" // A regular attribute option id * ] * ], * "valueSrc": [ * "value" * ], * "valueError": [ * null * ], * "valueType": [ * "multiselect" * ] * } * } * } * }, * dynamicFieldValueOperands: { * fields: [ * { id: "location", type: "MULTI_SELECT", label: "Location" } * ], * response: { * location: { value: ["Delhi", "Mumbai"] } * } * } * }) * // Returns: '{"value": [["delhi", "mumbai"]], "operator": "multiselect_some_in"}' * ``` */ export const resolveQueryValue = ({ queryValue, dynamicFieldValueOperands, attributes, }: { queryValue: AttributesQueryValue; dynamicFieldValueOperands?: dynamicFieldValueOperands; attributes: Attribute[]; }) => { const queryValueWithLabels = replaceAttributeOptionIdsWithOptionLabel({ queryValue, attributes, }); if (!dynamicFieldValueOperands) { return queryValueWithLabels; } const { fields, response } = dynamicFieldValueOperands; const isFieldTemplate = (str: string): boolean => { const trimmedStr = str.trim(); return trimmedStr.startsWith("{field:") && trimmedStr.endsWith("}"); }; // Extract field ID from template string const extractFieldId = (template: string): string | null => { const trimmedTemplate = template.trim(); if (!isFieldTemplate(trimmedTemplate)) return null; // Remove {field: and } to get the ID return trimmedTemplate.slice(7, -1); }; // Resolve a field ID to its actual value const resolveFieldId = (fieldId: string): string | string[] | null => { const field = fields.find((f) => f.id === fieldId); if (!field || !response[fieldId]?.value) { // Maybe invalid fieldId return null; } const fieldResponseValue = response[fieldId].value; return getFieldResponseValueAsLabel({ field, fieldResponseValue }); }; // Process a string value that might be a field template and thus could be an array const processStringValue = (value: string): string | string[] => { const fieldId = extractFieldId(value); if (!fieldId) { // Regular non-field template string return value; } const resolvedValue = resolveFieldId(fieldId); if (!resolvedValue) { // We keep the field template look-alike as it is return value; } return caseInsensitive(resolvedValue); }; // Process an array that might contain field templates. We keep it generic instead of typing it as QueryValue to handle any property where the queryValue can have a field template // Note: When a field template resolves to an array, its items are flattened into the parent array const processArray = (arr: JsonValue[]): JsonValue[] => { const hasFieldTemplate = arr.some((item) => typeof item === "string" && isFieldTemplate(item)); // Short circuit if there are no field templates if (!hasFieldTemplate) { return arr.map((item) => processAnyValue(item)); } const result: JsonValue[] = []; for (const item of arr) { if (typeof item === "string" && isFieldTemplate(item)) { const processed = processStringValue(item); if (Array.isArray(processed)) { // Flattening behavior: Array values from field templates are spread into parent array // Example: ["{field:location}", "Delhi"] where location = ["New York", "Amsterdam"] // Result: ["New York", "Amsterdam", "Delhi"] (items flattened) // This is intentional to merge field values at the same array level result.push(...processed); } else { // [{field:location}, Delhi] -> becomes [New York, Delhi]. New York was the response for the field `location` result.push(processed); } } else { result.push(processAnyValue(item)); } } return result; }; // Process any value recursively const processAnyValue = (value: JsonValue): JsonValue => { if (value instanceof Array) { return processArray(value); } if (typeof value === "object" && value !== null) { const result: { [key: string]: JsonValue } = {}; for (const key in value) { result[key] = processAnyValue(value[key]); } return result; } if (typeof value === "string") { const processedValue = processStringValue(value); return processedValue; } return value; }; try { // Even though it recursively processes the queryValue, the object(see example in `resolveQueryValue` fn signature) isn't that complex and thus the performance is not an issue const processed = processAnyValue(queryValueWithLabels) as AttributesQueryValue; return processed; } catch (e) { moduleLogger.error("Failed to resolve field templates", { error: e, queryValue: JSON.stringify(queryValueWithLabels), fieldsCount: fields?.length || 0, responseKeys: Object.keys(response || {}), dynamicFieldValueOperandsProvided: !!dynamicFieldValueOperands, }); throw new Error( `Failed to resolve field templates in query value: ${e instanceof Error ? e.message : String(e)}` ); } };