* refactor: convert UserRepository to use dependency injection pattern - Convert all static methods to public instance methods - Add constructor that takes PrismaClient parameter - Update all usage sites to use new instantiation pattern: new UserRepository(prisma).method() - Follow same pattern as PrismaOOORepository for consistency - Maintain all existing method logic and signatures unchanged - Update 125+ files across the codebase to adapt to new pattern Co-Authored-By: morgan@cal.com <morgan@cal.com> * optimize: reuse UserRepository instances within same function scope - Create single UserRepository instance per function scope - Reuse instance for multiple method calls within same function - Reduces object instantiation overhead and improves performance - Apply optimization pattern consistently across codebase Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: repository * fixup! fix: repository * fixup! fixup! fix: repository * fixup! fixup! fixup! fix: repository * fix: update test mocking strategies for UserRepository dependency injection - Convert static method mocks to instance method mocks in userCreationService.test.ts - Update vi.spyOn calls to work with constructor injection pattern in getAllCredentials.test.ts - Fix UserRepository mocking in getRoutedUrl.test.ts to use constructor injection - Ensure consistent mocking approach across all test files - Fix 'UserRepository is not a constructor' errors in tests Co-Authored-By: morgan@cal.com <morgan@cal.com> * feat: optimize UserRepository instance reuse and add SessionUser type - Reuse UserRepository instance in OrganizationRepository.createWithNonExistentOwner - Add comprehensive SessionUser type definition for type safety - Improve type constraints in enrichUserWithTheProfile and enrichUserWithItsProfile - Ensure proper return types with profile information Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: make UserRepository mocking strategy more robust for CI environments - Add defensive checks for vi.mocked() to handle CI environment differences - Ensure mockImplementation is available before calling it - Maintain consistent mocking pattern across all test files - Fix 'Cannot read properties of undefined' error in CI Co-Authored-By: morgan@cal.com <morgan@cal.com> * fixup! fix: make UserRepository mocking strategy more robust for CI environments * refactor: convert direct UserRepository instantiations to two-step pattern - Change await new UserRepository(prisma).method(...) to const userRepo = new UserRepository(prisma); await userRepo.method(...) - Optimize instance reuse within same function scopes - Apply pattern consistently across all modified files in PR - Fix type errors in organization.ts and sessionMiddleware.ts Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor: complete two-step UserRepository pattern for remaining files - Apply two-step instantiation pattern to all remaining modified files in PR - Ensure consistent UserRepository usage across entire codebase - Maintain instance reuse optimization within function scopes Co-Authored-By: morgan@cal.com <morgan@cal.com> * chore: bump platform libs --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: morgan@cal.com <morgan@cal.com> Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
250 lines
9.1 KiB
TypeScript
250 lines
9.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 { createHash } from "crypto";
|
|
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 { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
|
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 prisma from "@calcom/prisma";
|
|
|
|
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())));
|
|
|
|
const getDeterministicHashForResponse = (fieldsResponses: Record<string, unknown>) => {
|
|
const sortedFields = Object.keys(fieldsResponses)
|
|
.sort()
|
|
.reduce((obj: Record<string, unknown>, key) => {
|
|
obj[key] = fieldsResponses[key];
|
|
return obj;
|
|
}, {});
|
|
const paramsString = JSON.stringify(sortedFields);
|
|
const hash = createHash("sha256").update(paramsString).digest("hex");
|
|
return hash;
|
|
};
|
|
|
|
export function hasEmbedPath(pathWithQuery: string) {
|
|
const onlyPath = pathWithQuery.split("?")[0];
|
|
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
|
|
}
|
|
|
|
// We have fetchCrm as configurable temporarily to allow us to test the CRM logic in the APIV2. Soon after we would hardcode it to true
|
|
const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" | "req">, fetchCrm = false) => {
|
|
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 responseHash = getDeterministicHashForResponse(fieldsResponses);
|
|
|
|
await checkRateLimitAndThrowError({
|
|
identifier: `form:${formId}:hash:${responseHash}`,
|
|
});
|
|
|
|
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 userRepo = new UserRepository(prisma);
|
|
const formWithUserProfile = {
|
|
...form,
|
|
user: await userRepo.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,
|
|
identifierKeyedResponse: fieldsResponses,
|
|
chosenRouteId: matchingRoute.id,
|
|
isPreview: isBookingDryRun,
|
|
queueFormResponse: shouldQueueFormResponse,
|
|
fetchCrm,
|
|
});
|
|
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");
|