feat: routing forms for api v2 (#18885)

* init router endpoint for v2

* fixup fixup

* resolve merge conflicts

* update libraries

* update router controller

* make router endpoint public

* chore: refactor router endpoint and add team/org id

* cleanup

* chore: Only import Sentry when needed (#18882)

* perf: prerender routes in main navigation (#18883)

* chore: migrate /apps/categories pages (#18871)

* migrate apps/categories pages

* remove pages dir

* chore: app router - /payment pages (#18152)

* remove Head from payment page comp

* remove route from pages router

* move out of future folder

* add to config.matcher

* remove unneeded code

* update middleware

* fix

* fix

* perf: replace `getServerSessionForAppDir` usages with our original `getServerSession` function (#18897)

* chore: migrate /apps/installation pages (#18870)

* migrate apps/installation pages

* fix test

* chore: migrate `/auth/logout` (#18901)

* migrate /auth/verify-email page

* update middleware

* migrate /auth/logout page

* expose router endpoint

* small refactor pageprops not necessary

* sentry is already tracking performance

* reuse logic

* fixup! reuse logic

* fixup! fixup! reuse logic

* fixup! fixup! fixup! reuse logic

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2025-01-27 19:02:11 +00:00
1 parent ae51c8b599
commit e0527587c1
9 files changed
+271 -197

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.91",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.92",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -4,6 +4,7 @@ import { BillingModule } from "@/modules/billing/billing.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
@@ -24,6 +25,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
AtomsModule,
StripeModule,
ConferencingModule,
RouterModule,
],
})
export class EndpointsModule implements NestModule {
@@ -0,0 +1,39 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Controller, Get, Req, NotFoundException, Res, Query, Param } from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { Request, Response } from "express";
import { getRoutedUrl } from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/v2/router",
version: API_VERSIONS_VALUES,
})
@DocsTags("Router controller")
@DocsExcludeController(true)
export class RouterController {
@Get("/forms/:formId")
async getRoutingFormResponse(
@Req() request: Request,
@Res() res: Response,
@Param("formId") formId: string,
@Query() query: Record<string, string>
): Promise<void | ApiResponse<unknown>> {
const routedUrlData = await getRoutedUrl({ req: request, query: { ...query, form: formId } });
if (routedUrlData?.notFound) {
throw new NotFoundException("Route not found. Please check the provided form parameter.");
}
if (routedUrlData?.redirect?.destination) {
return res.redirect(307, routedUrlData.redirect.destination);
}
if (routedUrlData?.props) {
return { status: "success", data: routedUrlData.props };
}
return { status: "success", data: "route nor custom message found" };
}
}
@@ -0,0 +1,11 @@
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RouterController } from "@/modules/router/controllers/router.controller";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [],
exports: [],
controllers: [RouterController],
})
export class RouterModule {}
@@ -1,200 +1,7 @@
import { stringify } from "querystring";
import z from "zod";
import { GetServerSidePropsContext } from "next";
import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData";
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier";
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 { getFieldResponseForJsonLogic } from "@calcom/app-store/routing-forms/lib/transformResponse";
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 { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
import { TRPCError } from "@calcom/trpc/server";
import type { GetServerSidePropsContext } from "next";
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/");
}
import { getRoutedUrl } from "@calcom/lib/server";
export const getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
const queryParsed = querySchema.safeParse(context.query);
const isEmbed = hasEmbedPath(context.req.url || "");
const pageProps = {
isEmbed,
};
if (!queryParsed.success) {
log.warn("Error parsing query", queryParsed.error);
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, ...fieldsResponses } = queryParsed.data;
const isBookingDryRun = isBookingDryRunParam === "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 { UserRepository } = await import("@calcom/lib/server/repository/user");
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;
const response: FormResponse = {};
if (!serializableForm.fields) {
throw new Error("Form has no fields");
}
serializableForm.fields.forEach((field) => {
const fieldResponse = fieldsResponses[getFieldIdentifier(field)] || "";
response[field.id] = {
label: field.label,
value: getFieldResponseForJsonLogic({ field, value: fieldResponse }),
};
});
const matchingRoute = findMatchingRoute({ form: serializableForm, response });
if (!matchingRoute) {
throw new Error("No matching route could be found");
}
const decidedAction = matchingRoute.action;
const { v4: uuidv4 } = await import("uuid");
let teamMembersMatchingAttributeLogic = null;
let formResponseId = null;
let attributeRoutingConfig = null;
try {
const result = await handleResponse({
form: serializableForm,
formFillerId: uuidv4(),
response: response,
chosenRouteId: matchingRoute.id,
isPreview: isBookingDryRun,
});
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
formResponseId = result.formResponse.id;
attributeRoutingConfig = result.attributeRoutingConfig;
timeTaken = {
...timeTaken,
...result.timeTaken,
};
} catch (e) {
if (e instanceof TRPCError) {
return {
props: {
...pageProps,
form: serializableForm,
message: 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,
},
};
} 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)),
teamMembersMatchingAttributeLogic,
// formResponseId is guaranteed to be set because in catch block of trpc request we return from the function and otherwise it would have been set
formResponseId: formResponseId!,
attributeRoutingConfig: attributeRoutingConfig ?? null,
}),
isEmbed: pageProps.isEmbed,
}),
permanent: false,
},
};
} else if (decidedAction.type === "externalRedirectUrl") {
return {
redirect: {
destination: `${decidedAction.value}?${stringify(context.query)}`,
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: "Unhandled type of action",
},
};
return getRoutedUrl(context);
};
@@ -24,6 +24,8 @@ type GetUrlSearchParamsToForwardOptions = {
teamMembersMatchingAttributeLogic: number[] | null;
attributeRoutingConfig: AttributeRoutingConfig | null;
reroutingFormResponses?: FormResponseValueOnly;
teamId?: number | null;
orgId?: number | null;
};
export function getUrlSearchParamsToForward({
@@ -34,6 +36,8 @@ export function getUrlSearchParamsToForward({
formResponseId,
attributeRoutingConfig,
reroutingFormResponses,
teamId,
orgId,
}: GetUrlSearchParamsToForwardOptions) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
@@ -101,6 +105,8 @@ export function getUrlSearchParamsToForward({
}
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,
+204
View File
@@ -0,0 +1,204 @@
// !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 z from "zod";
import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData";
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier";
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 { getFieldResponseForJsonLogic } from "@calcom/app-store/routing-forms/lib/transformResponse";
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 { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
import { TRPCError } from "@calcom/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/");
}
export 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", queryParsed.error);
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, ...fieldsResponses } = queryParsed.data;
const isBookingDryRun = isBookingDryRunParam === "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 { UserRepository } = await import("@calcom/lib/server/repository/user");
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;
const response: FormResponse = {};
if (!serializableForm.fields) {
throw new Error("Form has no fields");
}
serializableForm.fields.forEach((field) => {
const fieldResponse = fieldsResponses[getFieldIdentifier(field)] || "";
response[field.id] = {
label: field.label,
value: getFieldResponseForJsonLogic({ field, value: fieldResponse }),
};
});
const matchingRoute = findMatchingRoute({ form: serializableForm, response });
if (!matchingRoute) {
throw new Error("No matching route could be found");
}
const decidedAction = matchingRoute.action;
const { v4: uuidv4 } = await import("uuid");
let teamMembersMatchingAttributeLogic = null;
let formResponseId = null;
let attributeRoutingConfig = null;
try {
const result = await handleResponse({
form: serializableForm,
formFillerId: uuidv4(),
response: response,
chosenRouteId: matchingRoute.id,
isPreview: isBookingDryRun,
});
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
formResponseId = result.formResponse.id;
attributeRoutingConfig = result.attributeRoutingConfig;
timeTaken = {
...timeTaken,
...result.timeTaken,
};
} catch (e) {
if (e instanceof TRPCError) {
return {
props: {
...pageProps,
form: serializableForm,
message: 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,
},
};
} 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)),
teamMembersMatchingAttributeLogic,
// formResponseId is guaranteed to be set because in catch block of trpc request we return from the function and otherwise it would have been set
formResponseId: formResponseId!,
attributeRoutingConfig: attributeRoutingConfig ?? null,
teamId: form?.teamId,
orgId: form.team?.parentId,
}),
isEmbed: pageProps.isEmbed,
}),
permanent: false,
},
};
} else if (decidedAction.type === "externalRedirectUrl") {
return {
redirect: {
destination: `${decidedAction.value}?${stringify(context.query)}`,
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: "Unhandled type of action",
},
};
};
+1
View File
@@ -8,3 +8,4 @@ export { getServerErrorFromUnknown } from "./getServerErrorFromUnknown";
export { getTranslation } from "./i18n";
export { getDefaultLocations } from "./getDefaultLocations";
export { default as perfObserver } from "./perfObserver";
export { getRoutedUrl } from "./getRoutedUrl";
+4
View File
@@ -31,6 +31,7 @@ import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCr
import getAllUserBookings from "@calcom/lib/bookings/getAllUserBookings";
import { symmetricEncrypt, symmetricDecrypt } from "@calcom/lib/crypto";
import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes";
import { getRoutedUrl } from "@calcom/lib/server/getRoutedUrl";
import { getTranslation } from "@calcom/lib/server/i18n";
import { MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -53,6 +54,7 @@ export type UpdateScheduleOutputType = Awaited<
typeof import("@calcom/trpc/server/routers/viewer/availability/schedule/update.handler").updateHandler
>
>;
export { getEventTypeById } from "@calcom/lib/event-types/getEventTypeById";
export { getEventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
export { getEventTypesPublic } from "@calcom/lib/event-types/getEventTypesPublic";
@@ -232,3 +234,5 @@ export { handleDeleteCredential };
export { getBulkEventTypes };
export { getBookingFieldsWithSystemFields };
export { getRoutedUrl };