Files
calendar/apps/web/app/api/routing-forms/queued-response/route.ts
T
Joe Au-YeungGitHubunknown <>joe@cal.com <j.auyeung419@gmail.com>joe@cal.com <j.auyeung419@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Pedro Castro
620445d85f feat: Use RoutingTraceService to write assignment reasons (#27225)
* 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>
2026-01-28 12:45:15 -05:00

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);