* 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>
125 lines
4.1 KiB
TypeScript
125 lines
4.1 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
import { z, ZodError } from "zod";
|
|
|
|
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
|
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
|
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
|
import { PrismaPendingRoutingTraceRepository } from "@calcom/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
|
import prisma from "@calcom/prisma";
|
|
|
|
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
|
|
|
const queuedResponseSchema = z.object({
|
|
queuedFormResponseId: z.string(),
|
|
params: z.record(z.string(), z.string().or(z.array(z.string()))),
|
|
});
|
|
|
|
export const queuedResponseHandler = async ({
|
|
queuedFormResponseId,
|
|
params,
|
|
}: {
|
|
queuedFormResponseId: string;
|
|
params: Record<string, string | string[]>;
|
|
}) => {
|
|
const formResponseRepo = new RoutingFormResponseRepository(prisma);
|
|
|
|
// Get the queued response
|
|
const queuedFormResponse = await formResponseRepo.getQueuedFormResponseFromId(queuedFormResponseId);
|
|
|
|
if (!queuedFormResponse) {
|
|
return {
|
|
formResponseId: null,
|
|
message: "Already processed",
|
|
};
|
|
}
|
|
|
|
const serializableForm = await getSerializableForm({
|
|
form: queuedFormResponse.form,
|
|
});
|
|
|
|
if (!serializableForm.fields) {
|
|
throw new Error("Form has no fields");
|
|
}
|
|
|
|
const response = getResponseToStore({
|
|
formFields: serializableForm.fields,
|
|
fieldsResponses: params,
|
|
});
|
|
|
|
const formResponse = await formResponseRepo.recordFormResponse({
|
|
formId: queuedFormResponse.formId,
|
|
queuedFormResponseId: queuedFormResponse.id,
|
|
// We record new response here as that might be different from the queued response depending on if the user changed something in b/w before clicking CTA and that something wasn't prerendered
|
|
response,
|
|
// We use the queuedFormResponse's chosenRouteId because that is what decided routed team members
|
|
chosenRouteId: queuedFormResponse.chosenRouteId,
|
|
});
|
|
|
|
// Link the pending routing trace to the new formResponseId so it can be found when booking is created
|
|
try {
|
|
const pendingTraceRepo = new PrismaPendingRoutingTraceRepository(prisma);
|
|
await pendingTraceRepo.linkToFormResponse({
|
|
queuedFormResponseId: queuedFormResponse.id,
|
|
formResponseId: formResponse.id,
|
|
});
|
|
} catch (error) {
|
|
// Log but don't fail - trace linking is not critical
|
|
logger.warn("Failed to link pending routing trace to form response", safeStringify(error));
|
|
}
|
|
|
|
const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId);
|
|
await onSubmissionOfFormResponse({
|
|
form: {
|
|
...queuedFormResponse.form,
|
|
...serializableForm,
|
|
},
|
|
formResponseInDb: formResponse,
|
|
chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null,
|
|
});
|
|
|
|
return {
|
|
formResponseId: formResponse.id,
|
|
message: "Processed",
|
|
};
|
|
};
|
|
|
|
export const handler = async (req: NextRequest) => {
|
|
try {
|
|
const body = await req.json();
|
|
const { params, queuedFormResponseId } = queuedResponseSchema.parse(body);
|
|
const result = await queuedResponseHandler({
|
|
queuedFormResponseId,
|
|
params,
|
|
});
|
|
|
|
return NextResponse.json({ status: "success", data: result });
|
|
} catch (error) {
|
|
if (error instanceof ZodError) {
|
|
logger.error("Invalid input", safeStringify(error));
|
|
return NextResponse.json(
|
|
{
|
|
status: "error",
|
|
message: "Invalid input",
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
logger.error("Error in queuedResponseHandler", safeStringify(error));
|
|
|
|
return NextResponse.json(
|
|
{
|
|
status: "error",
|
|
message: "Internal server error",
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
};
|
|
|
|
export const POST = defaultResponderForAppDir(handler);
|