feat: Headless router - queue recording booking response (#21805)

* Add queued booking response table

* Create `RoutingFormResponseRepository`

* Pass `queueFormResponse` param

* Queue up form response if param is passed

* Forward queued form response parma to booker

* Pass `queuedFormResponse` from booker to `handleNewBooking`

* Write queued routing form response

* Type fixes

* Clean up

* Allow dry run to work which wont have any QueuedFormResponse or FormResponse

* Support passing the time when the modal was actually shown to the user and consider that time as the time of form submission

* fix ts error

* Queue -> Response through separate endpoint that would be triggered by
embed

* Make queueResponseId a non-guessable uid

* Change queueFormResponse query param

* fix ts error

* Support useQueuedResponse to record new response data

* revert handleNewBooking

* Remove dead code formResponse

* Refactor use repository

* Unify migration files

* refactor: moved api endpoint to app dir

Signed-off-by: Omar López <zomars@me.com>

* Update formResponse.ts

* Refactor use-queued-response for test

* Add tests

* Fix ts error and unit test. recordFormResponse cant return nullish response

* fix schema

* feat: Support full reuse of preloaded iframe (#21803)

* feat: support updating cal video settings in API v2 (#21784)

* feat: support updating cal video settings in API v2

* chore: update descriptio

* feat: support create event type

* test: add test for updating event type

* test: add test for create event type

* chore: undo openapi

* chore: bump libraries

* Revert "chore: bump libraries"

This reverts commit bdf36d09b021fc531497a7b7ea66ab9c52b7d136.

* chore: bump libraries

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>

* fix tests and ts

* Fix tests

* wip-useQueuedResponseEndpoint

* Add one more test

* Change queueFormResponse query param

* wip

* Support useQueuedResponse to record new response data

* Use the update useQueuedResponse endpoint

* self-review addressed

* Use queuedResponse if available in slots/utils

* Add documentation

* Remove use-queued-response from critical-path

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>

* Update schema.prisma

* refactor: renamed to avoid react hooks confusion

Signed-off-by: Omar López <zomars@me.com>

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Joe Au-Yeung
2025-06-17 12:11:49 -03:00
committed by GitHub
co-authored by Lauris Skraucis supalarry Udit Takkar Hariom Balhara Omar López Peer Richelsen
parent 9a40e54159
commit 22f136d19b
28 changed files with 1408 additions and 424 deletions
@@ -0,0 +1,152 @@
import { describe, it, expect, vi } from "vitest";
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 { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
import { queuedResponseHandler } from "../route";
vi.mock("@calcom/lib/server/repository/formResponse");
vi.mock("@calcom/app-store/routing-forms/lib/getSerializableForm");
vi.mock("@calcom/app-store/routing-forms/lib/getResponseToStore");
vi.mock("@calcom/app-store/routing-forms/lib/formSubmissionUtils");
const mockQueuedFormResponse = {
id: "1",
formId: "mock-form-id",
form: {
id: "mock-form-id",
name: "Test Form",
description: "Test Form Description",
createdAt: new Date(),
updatedAt: new Date(),
fields: [],
routes: [],
userId: 1,
user: {
id: 1,
email: "test@example.com",
},
team: null,
teamId: null,
position: 1,
updatedById: null,
settings: {},
disabled: false,
},
chosenRouteId: "mock-chosen-route-id",
createdAt: new Date(),
updatedAt: new Date(),
response: {},
actualResponseId: 1,
};
describe("queuedResponseHandler", () => {
it("should process a queued form response", async () => {
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(
mockQueuedFormResponse
);
vi.mocked(getSerializableForm).mockResolvedValue({
id: "mock-form-id",
name: "Test Form",
description: "Test Form Description",
fields: [],
routes: [],
userId: 1,
teamId: null,
position: 1,
updatedById: null,
disabled: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
settings: {},
} as unknown as Awaited<ReturnType<typeof getSerializableForm>>);
vi.mocked(getResponseToStore).mockResolvedValue({
id: "mock-form-id",
name: "Test Form",
description: "Test Form Description",
fields: [],
routes: [],
user: {
id: 1,
email: "test@example.com",
},
team: null,
} as unknown as Awaited<ReturnType<typeof getResponseToStore>>);
vi.mocked(onSubmissionOfFormResponse).mockResolvedValue({
id: "mock-form-id",
name: "Test Form",
description: "Test Form Description",
fields: [],
routes: [],
user: {
id: 1,
email: "test@example.com",
},
team: null,
} as unknown as Awaited<ReturnType<typeof onSubmissionOfFormResponse>>);
vi.mocked(RoutingFormResponseRepository.recordFormResponse).mockResolvedValue({
id: "mock-form-id",
name: "Test Form",
description: "Test Form Description",
fields: [],
routes: [],
user: {
id: 1,
email: "test@example.com",
},
team: null,
chosenRouteId: "mock-chosen-route-id",
} as unknown as Awaited<ReturnType<typeof RoutingFormResponseRepository.recordFormResponse>>);
const response = await queuedResponseHandler({
queuedFormResponseId: "1",
params: {},
});
expect(response).toEqual({
formResponseId: "mock-form-id",
message: "Processed",
});
});
it("if no queued form response is found, should return early", async () => {
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(null);
const response = await queuedResponseHandler({
queuedFormResponseId: "1",
params: {},
});
expect(response).toEqual({
formResponseId: null,
message: "Already processed",
});
});
it("should throw if form has no fields", async () => {
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(
mockQueuedFormResponse
);
vi.mocked(getSerializableForm).mockResolvedValue({
id: "mock-form-id",
name: "Test Form",
description: "Test Form Description",
fields: undefined,
routes: [],
user: {
id: 1,
email: "test@example.com",
},
team: null,
} as unknown as Awaited<ReturnType<typeof getSerializableForm>>);
await expect(
queuedResponseHandler({
queuedFormResponseId: "1",
params: {},
})
).rejects.toThrow("Form has no fields");
});
});
@@ -0,0 +1,110 @@
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 logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
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[]>;
}) => {
// Get the queued response
const queuedFormResponse = await RoutingFormResponseRepository.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 RoutingFormResponseRepository.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,
});
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);