Files
calendar/packages/lib/server/getRoutedUrl.ts
T
22f136d19b 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>
2025-06-17 12:11:49 -03:00

224 lines
8.1 KiB
TypeScript

// !IMPORTANT! changes to this file requires publishing new version of platform libraries in order for the changes to be applied to APIV2
import type { GetServerSidePropsContext } from "next";
import { stringify } from "querystring";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData";
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
import { getServerTimingHeader } from "@calcom/app-store/routing-forms/lib/getServerTimingHeader";
import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse";
import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute";
import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables";
import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
import logger from "@calcom/lib/logger";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { TRPCError } from "@trpc/server";
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
const querySchema = z
.object({
form: z.string(),
})
.catchall(z.string().or(z.array(z.string())));
function hasEmbedPath(pathWithQuery: string) {
const onlyPath = pathWithQuery.split("?")[0];
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
}
const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" | "req">) => {
const queryParsed = querySchema.safeParse(context.query);
const isEmbed = hasEmbedPath(context.req.url || "");
const pageProps = {
isEmbed,
};
if (!queryParsed.success) {
log.warn("Error parsing query", { issues: queryParsed.error.issues });
return {
notFound: true,
};
}
// TODO: Known params reserved by Cal.com are form, embed, layout and other cal. prefixed params. We should exclude all of them from fieldsResponses.
// But they must be present in `paramsToBeForwardedAsIs` as they could be needed by Booking Page as well.
const {
form: formId,
"cal.isBookingDryRun": isBookingDryRunParam,
"cal.queueFormResponse": queueFormResponseParam,
...fieldsResponses
} = queryParsed.data;
const isBookingDryRun = isBookingDryRunParam === "true";
const shouldQueueFormResponse = queueFormResponseParam === "true";
const paramsToBeForwardedAsIs = {
...fieldsResponses,
// Must be forwarded if present to Booking Page. Setting it explicitly here as it is critical to be present in the URL.
...(isBookingDryRunParam ? { "cal.isBookingDryRun": isBookingDryRunParam } : null),
};
const { currentOrgDomain } = orgDomainConfig(context.req);
let timeTaken: Record<string, number | null> = {};
const formQueryStart = performance.now();
const form = await RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
timeTaken.formQuery = performance.now() - formQueryStart;
if (!form) {
return {
notFound: true,
};
}
const profileEnrichmentStart = performance.now();
const formWithUserProfile = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
};
timeTaken.profileEnrichment = performance.now() - profileEnrichmentStart;
if (
!isAuthorizedToViewFormOnOrgDomain({ user: formWithUserProfile.user, currentOrgDomain, team: form.team })
) {
return {
notFound: true,
};
}
const getSerializableFormStart = performance.now();
const serializableForm = await getSerializableForm({
form: enrichFormWithMigrationData(formWithUserProfile),
});
timeTaken.getSerializableForm = performance.now() - getSerializableFormStart;
if (!serializableForm.fields) {
throw new Error("Form has no fields");
}
const response: FormResponse = getResponseToStore({
formFields: serializableForm.fields,
fieldsResponses,
});
const matchingRoute = findMatchingRoute({ form: serializableForm, response });
if (!matchingRoute) {
throw new Error("No matching route could be found");
}
const decidedAction = matchingRoute.action;
let teamMembersMatchingAttributeLogic = null;
let formResponseId = null;
let attributeRoutingConfig = null;
let crmContactOwnerEmail: string | null = null;
let crmContactOwnerRecordType: string | null = null;
let crmAppSlug: string | null = null;
let queuedFormResponseId;
try {
const result = await handleResponse({
form: serializableForm,
formFillerId: uuidv4(),
response: response,
chosenRouteId: matchingRoute.id,
isPreview: isBookingDryRun,
queueFormResponse: shouldQueueFormResponse,
});
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
formResponseId = result.formResponse?.id;
queuedFormResponseId = result.queuedFormResponse?.id;
attributeRoutingConfig = result.attributeRoutingConfig;
timeTaken = {
...timeTaken,
...result.timeTaken,
};
crmContactOwnerEmail = result.crmContactOwnerEmail;
crmContactOwnerRecordType = result.crmContactOwnerRecordType;
crmAppSlug = result.crmAppSlug;
} catch (e) {
if (e instanceof TRPCError) {
return {
props: {
...pageProps,
form: serializableForm,
message: null,
errorMessage: e.message,
},
};
}
}
// TODO: To be done using sentry tracing
console.log("Server-Timing", getServerTimingHeader(timeTaken));
//TODO: Maybe take action after successful mutation
if (decidedAction.type === "customPageMessage") {
return {
props: {
...pageProps,
form: serializableForm,
message: decidedAction.value,
errorMessage: null,
},
};
} else if (decidedAction.type === "eventTypeRedirectUrl") {
const eventTypeUrlWithResolvedVariables = substituteVariables(
decidedAction.value,
response,
serializableForm.fields
);
return {
redirect: {
destination: getAbsoluteEventTypeRedirectUrlWithEmbedSupport({
eventTypeRedirectUrl: eventTypeUrlWithResolvedVariables,
form: serializableForm,
allURLSearchParams: getUrlSearchParamsToForward({
formResponse: response,
fields: serializableForm.fields,
searchParams: new URLSearchParams(
stringify({ ...paramsToBeForwardedAsIs, "cal.action": "eventTypeRedirectUrl" })
),
teamMembersMatchingAttributeLogic,
formResponseId: formResponseId ?? null,
queuedFormResponseId: queuedFormResponseId ?? null,
attributeRoutingConfig: attributeRoutingConfig ?? null,
teamId: form?.teamId,
orgId: form.team?.parentId,
crmContactOwnerEmail,
crmContactOwnerRecordType,
crmAppSlug,
}),
isEmbed: pageProps.isEmbed,
}),
permanent: false,
},
};
} else if (decidedAction.type === "externalRedirectUrl") {
return {
redirect: {
destination: `${decidedAction.value}?${stringify(context.query)}&cal.action=externalRedirectUrl`,
permanent: false,
},
};
}
// TODO: Consider throwing error here as there is no value of decidedAction.type that would cause the flow to be here
return {
props: {
...pageProps,
form: serializableForm,
message: null,
errorMessage: "Unhandled type of action",
},
};
};
export const getRoutedUrl = withReporting(_getRoutedUrl, "getRoutedUrl");