268 lines
9.2 KiB
TypeScript
268 lines
9.2 KiB
TypeScript
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)}`
|
|
);
|
|
}
|
|
};
|