diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 3b62af5049..bbea556700 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -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": "*", diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts index 33e6cf6728..168f80fba9 100644 --- a/apps/api/v2/src/modules/endpoints.module.ts +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -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 { diff --git a/apps/api/v2/src/modules/router/controllers/router.controller.ts b/apps/api/v2/src/modules/router/controllers/router.controller.ts new file mode 100644 index 0000000000..78c2c728c7 --- /dev/null +++ b/apps/api/v2/src/modules/router/controllers/router.controller.ts @@ -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 + ): Promise> { + 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" }; + } +} diff --git a/apps/api/v2/src/modules/router/router.module.ts b/apps/api/v2/src/modules/router/router.module.ts new file mode 100644 index 0000000000..f7725675f4 --- /dev/null +++ b/apps/api/v2/src/modules/router/router.module.ts @@ -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 {} diff --git a/apps/web/server/lib/router/getServerSideProps.ts b/apps/web/server/lib/router/getServerSideProps.ts index 8653967c02..1ab8891489 100644 --- a/apps/web/server/lib/router/getServerSideProps.ts +++ b/apps/web/server/lib/router/getServerSideProps.ts @@ -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 = {}; - - 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); }; diff --git a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts index 35b008f1b0..0a8eb8332f 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts @@ -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; 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, diff --git a/packages/lib/server/getRoutedUrl.ts b/packages/lib/server/getRoutedUrl.ts new file mode 100644 index 0000000000..6b1794d10c --- /dev/null +++ b/packages/lib/server/getRoutedUrl.ts @@ -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) => { + 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 = {}; + + 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", + }, + }; +}; diff --git a/packages/lib/server/index.ts b/packages/lib/server/index.ts index 8dacc9c665..64711454f1 100644 --- a/packages/lib/server/index.ts +++ b/packages/lib/server/index.ts @@ -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"; diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 0843639ce4..75a441e447 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -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 };