* Add DB schema * Init zod schema * Init RoutingTrace and PendingRoutingTrace repository interfaces * Create PrismaPendingRoutingTraceRepository * Init RoutingTraceService * Create RoutingTraceService container * User routing trace service in routing * Create RoutingTraceRepository and PrismaRoutingTraceRepoistory * Add findByFormResponseId and findByQueuedFormResponseId to PendingRoutingTraceRepository * Update DI containers * RoutingTraceService create process booking method * Use pending routing trace rather than URL params * Fix schema * Fix writing assignment reason for routed booking * Remove from service * Refactor RoutingTraceService to not rely on async local storage * Pass RoutingTraceService through routing call * Add attribute-logic-evaluated to routing trace step * Add routing trace to trpc endpoint * Add CRM routing trace step * Fix extracting routing trace to assignment reason * Add back CRM params to prevent refetching * test: Add unit tests for RoutingTraceService and repositories Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * test: Add missing mock for RoutingTraceService in getRoutedUrl tests Also fix pre-existing lint issues in the test file: - Add explicit types to mockForm and mockSerializableForm variables - Add explicit type to url parameter in mockContext - Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>' Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Link pending form submission to routing trace * Clean up * Add lookup field assignment reasons * Rename to PendingRoutingTrace * Add migration file * fix: Update RoutingTraceService tests to use assignmentReasonRepository mock - Add getStepsCount() method back to RoutingTraceService - Add queuedFormResponseId support to processForBooking method - Update tests to use mockAssignmentReasonRepository instead of prisma mock - Remove test for missing routingTraceRepository (all deps now required) Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: Remove PII (organizer email) from log payload in RoutingTraceService Addresses Cubic AI review feedback with confidence 9/10. Logging PII violates sensitive information logging rules. Co-Authored-By: unknown <> * Write field values at the time of routing * Write attributes used to route * fix: Add CHECK constraint to ensure at least one response ID is set in routing trace tables Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: Address PR review comments for routing trace feature - Add checkedFallback to routing trace step data (Comment 11) - Extract hardcoded domain/step strings to constants (Comment 13) - Use @default(now()) for createdAt in PendingRoutingTrace and RoutingTrace (Comment 15) - Add DEFAULT CURRENT_TIMESTAMP to migration for createdAt fields Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: Use UTC-safe timezone expression for createdAt defaults in routing trace tables Addresses Cubic AI review feedback (confidence 9/10) to prevent timezone issues by using dbgenerated("timezone('UTC', now())") instead of bare now() for the createdAt fields in PendingRoutingTrace and RoutingTrace models. Co-Authored-By: unknown <> * fix: Align migration SQL with Prisma's expected timezone syntax Use 'UTC'::text cast in timezone() function to match Prisma's generated SQL. Co-Authored-By: unknown <> * fix: Revert migration SQL to match Prisma schema timezone syntax Remove ::text cast from timezone() function to match what Prisma generates from the schema definition. Co-Authored-By: unknown <> * fix: Use explicit ::text cast in timezone() for PostgreSQL compatibility PostgreSQL normalizes timezone('UTC', now()) to timezone('UTC'::text, now()) internally. Update both schema and migration to use the explicit cast to ensure they match and pass the migration check. Co-Authored-By: unknown <> * fix: Use CURRENT_TIMESTAMP for createdAt defaults (standard Cal.com pattern) Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: Use @default(now()) for createdAt in routing trace tables to match migration Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Pedro Castro <pedro@cal.com>
205 lines
7.0 KiB
TypeScript
205 lines
7.0 KiB
TypeScript
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
|
|
import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier";
|
|
import type { FormResponse, LocalRoute } from "@calcom/app-store/routing-forms/types/types";
|
|
|
|
type FormResponseValueOnly = { [key: string]: { value: FormResponse[keyof FormResponse]["value"] } };
|
|
|
|
type AttributeRoutingConfig = NonNullable<LocalRoute["attributeRoutingConfig"]>;
|
|
type GetUrlSearchParamsToForwardOptions = {
|
|
formResponse: Record<
|
|
string,
|
|
{
|
|
value: number | string | string[];
|
|
}
|
|
>;
|
|
fields: {
|
|
id: string;
|
|
type: string;
|
|
label: string;
|
|
options?: {
|
|
id: string | null;
|
|
label: string;
|
|
}[];
|
|
identifier?: string;
|
|
}[];
|
|
searchParams: URLSearchParams;
|
|
formResponseId: number | null;
|
|
queuedFormResponseId: string | null;
|
|
teamMembersMatchingAttributeLogic: number[] | null;
|
|
attributeRoutingConfig: AttributeRoutingConfig | null;
|
|
crmContactOwnerEmail?: string | null;
|
|
crmContactOwnerRecordType?: string | null;
|
|
crmAppSlug?: string | null;
|
|
reroutingFormResponses?: FormResponseValueOnly;
|
|
teamId?: number | null;
|
|
orgId?: number | null;
|
|
};
|
|
|
|
export function getUrlSearchParamsToForward({
|
|
formResponse,
|
|
fields,
|
|
searchParams,
|
|
teamMembersMatchingAttributeLogic,
|
|
formResponseId,
|
|
queuedFormResponseId,
|
|
attributeRoutingConfig,
|
|
crmContactOwnerEmail,
|
|
crmContactOwnerRecordType,
|
|
crmAppSlug,
|
|
reroutingFormResponses,
|
|
teamId,
|
|
orgId,
|
|
}: GetUrlSearchParamsToForwardOptions) {
|
|
type Params = Record<string, string | string[]>;
|
|
const paramsFromResponse: Params = {};
|
|
const paramsFromCurrentUrl: Params = {};
|
|
|
|
// Build query params from response
|
|
Object.entries(formResponse).forEach(([key, fieldResponse]) => {
|
|
const foundField = fields.find((f) => f.id === key);
|
|
if (!foundField) {
|
|
// If for some reason, the field isn't there, let's just
|
|
return;
|
|
}
|
|
let valueAsStringOrStringArray =
|
|
typeof fieldResponse.value === "number" ? String(fieldResponse.value) : fieldResponse.value;
|
|
if (foundField.type === "select" || foundField.type === "multiselect") {
|
|
const options = foundField.options || [];
|
|
let arr =
|
|
valueAsStringOrStringArray instanceof Array
|
|
? valueAsStringOrStringArray
|
|
: [valueAsStringOrStringArray];
|
|
arr = arr.map((idOrLabel) => {
|
|
const foundOptionById = options.find((option) => {
|
|
return option.id === idOrLabel;
|
|
});
|
|
if (foundOptionById) {
|
|
return foundOptionById.label;
|
|
}
|
|
return idOrLabel;
|
|
});
|
|
valueAsStringOrStringArray = foundField.type === "select" ? arr[0] : arr;
|
|
}
|
|
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
|
|
valueAsStringOrStringArray;
|
|
});
|
|
|
|
// Build query params from current URL. It excludes route params
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
for (const [name, value] of searchParams.entries()) {
|
|
const target = paramsFromCurrentUrl[name];
|
|
if (target instanceof Array) {
|
|
target.push(value);
|
|
} else {
|
|
paramsFromCurrentUrl[name] = [value];
|
|
}
|
|
}
|
|
|
|
const attributeRoutingConfigParams: Record<string, any> = {};
|
|
|
|
if (attributeRoutingConfig) {
|
|
for (const key of Object.keys(attributeRoutingConfig)) {
|
|
if (key === "skipContactOwner" && attributeRoutingConfig[key]) {
|
|
attributeRoutingConfigParams["cal.skipContactOwner"] = "true";
|
|
}
|
|
|
|
// TODO: How do we move this logic to their respective app packages
|
|
if (key === "salesforce") {
|
|
const salesforceData = attributeRoutingConfig[key];
|
|
|
|
if (salesforceData?.rrSkipToAccountLookupField && salesforceData.rrSKipToAccountLookupFieldName) {
|
|
attributeRoutingConfigParams["cal.salesforce.rrSkipToAccountLookupField"] = "true";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const allQueryParams: Params = {
|
|
...(teamId && { ["cal.teamId"]: `${teamId}` }),
|
|
...(orgId && { ["cal.orgId"]: `${orgId}` }),
|
|
...paramsFromCurrentUrl,
|
|
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
|
|
...paramsFromResponse,
|
|
...(teamMembersMatchingAttributeLogic
|
|
? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") }
|
|
: null),
|
|
...(typeof formResponseId === "number"
|
|
? { [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId) }
|
|
: null),
|
|
...(queuedFormResponseId ? { ["cal.queuedFormResponseId"]: queuedFormResponseId } : null),
|
|
...attributeRoutingConfigParams,
|
|
...(crmContactOwnerEmail ? { ["cal.crmContactOwnerEmail"]: crmContactOwnerEmail } : null),
|
|
...(crmContactOwnerRecordType ? { ["cal.crmContactOwnerRecordType"]: crmContactOwnerRecordType } : null),
|
|
...(crmAppSlug ? { ["cal.crmAppSlug"]: crmAppSlug } : null),
|
|
...(reroutingFormResponses
|
|
? { ["cal.reroutingFormResponses"]: JSON.stringify(reroutingFormResponses) }
|
|
: null),
|
|
};
|
|
|
|
const allQueryURLSearchParams = new URLSearchParams();
|
|
|
|
// Make serializable URLSearchParams instance
|
|
Object.entries(allQueryParams).forEach(([param, value]) => {
|
|
const valueArray = value instanceof Array ? value : [value];
|
|
valueArray.forEach((v) => {
|
|
allQueryURLSearchParams.append(param, v);
|
|
});
|
|
});
|
|
|
|
return allQueryURLSearchParams;
|
|
}
|
|
|
|
export function getUrlSearchParamsToForwardForReroute({
|
|
formResponse,
|
|
formResponseId,
|
|
fields,
|
|
searchParams,
|
|
teamMembersMatchingAttributeLogic,
|
|
attributeRoutingConfig,
|
|
rescheduleUid,
|
|
reroutingFormResponses,
|
|
}: Omit<GetUrlSearchParamsToForwardOptions, "queuedFormResponseId"> & {
|
|
rescheduleUid: string;
|
|
reroutingFormResponses: FormResponseValueOnly;
|
|
}) {
|
|
searchParams.set("rescheduleUid", rescheduleUid);
|
|
searchParams.set("cal.rerouting", "true");
|
|
return getUrlSearchParamsToForward({
|
|
formResponse,
|
|
formResponseId,
|
|
// Queued form response id is not available in rerouting
|
|
queuedFormResponseId: null,
|
|
fields,
|
|
searchParams,
|
|
teamMembersMatchingAttributeLogic,
|
|
attributeRoutingConfig,
|
|
reroutingFormResponses,
|
|
});
|
|
}
|
|
|
|
export function getUrlSearchParamsToForwardForTestPreview({
|
|
formResponse,
|
|
fields,
|
|
attributeRoutingConfig,
|
|
teamMembersMatchingAttributeLogic,
|
|
}: Pick<
|
|
GetUrlSearchParamsToForwardOptions,
|
|
"formResponse" | "fields" | "attributeRoutingConfig" | "teamMembersMatchingAttributeLogic"
|
|
>) {
|
|
// There are no existing query params to forward in test preview. These are available only when doing the actual form submission
|
|
const searchParams = new URLSearchParams();
|
|
searchParams.set("cal.isTestPreviewLink", "true");
|
|
return getUrlSearchParamsToForward({
|
|
formResponse,
|
|
fields,
|
|
attributeRoutingConfig,
|
|
teamMembersMatchingAttributeLogic,
|
|
// There is no form response being stored in test preview
|
|
formResponseId: null,
|
|
// Queued form response id is not available in test preview
|
|
queuedFormResponseId: null,
|
|
searchParams,
|
|
});
|
|
}
|