Files
calendar/apps/web/lib/handleOrgRedirect.ts
T
6c6cf0433a fix: handle TempOrgRedirect when NEXT_PUBLIC_SINGLE_ORG_SLUG forces org context (#23009)
* fix: rename getTemporaryOrgRedirect to handleOrgRedirect and prevent duplicate orgRedirection query parameter

* refactor: improve handleOrgRedirect test robustness with scenario-based mocking

- Replace brittle mockResolvedValue with mockImplementation that derives behavior from input
- Create createRedirectScenario function that sets up mocks based on actual query parameters
- Add expectRedirectUsesData helper to verify redirects use the correct data
- Add comprehensive mock verification tests to ensure different inputs return different outputs
- Ensure tests verify the actual usage of Prisma results, not just that Prisma was called

This prevents false positives where tests pass even if the code doesn't use the database results correctly.

* Fix bug with Private links redirecting an exposing users booking page URL

* Narrow the type to what is being used

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-12 08:47:26 -03:00

204 lines
6.4 KiB
TypeScript

import type { ParsedUrlQuery } from "querystring";
import { stringify } from "querystring";
import { SINGLE_ORG_SLUG } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { RedirectType } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["lib", "handleOrgRedirect"] });
type NextJsRedirect = {
redirect: {
permanent: false;
/**
* It could be a full URL or a relative path
*/
destination: string;
};
};
function getSearchString(relativeOrAbsoluteUrl: string) {
const url = new URL(relativeOrAbsoluteUrl, "http://localhost");
return url.search;
}
const getTemporaryOrgRedirect = async ({
slugs,
redirectType,
eventTypeSlug,
currentQuery,
useRelativePath = false,
}: {
slugs: string[];
redirectType: RedirectType;
eventTypeSlug: string | null;
currentQuery: ParsedUrlQuery;
useRelativePath?: boolean;
}): Promise<NextJsRedirect | null> => {
const prisma = (await import("@calcom/prisma")).default;
log.debug(
`Looking for redirect for`,
safeStringify({
slugs,
redirectType,
eventTypeSlug,
})
);
const redirects = await prisma.tempOrgRedirect.findMany({
where: {
type: redirectType,
from: {
in: slugs,
},
fromOrgId: 0,
},
});
if (!redirects.length) {
return null;
}
// Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group
const newOrigin = new URL(redirects[0].toUrl).origin;
// Filter out any existing orgRedirection parameter to avoid duplicates
// querystring.stringify transforms undefined values to empty strings, so we need to filter those out as well initially
const filteredQuery = Object.entries(currentQuery).reduce((acc, [key, value]) => {
if (key !== "orgRedirection" && value !== undefined) {
acc[key] = value;
}
return acc;
}, {} as ParsedUrlQuery);
const currentQueryString = stringify(filteredQuery);
const query = currentQueryString ? `?${currentQueryString}&orgRedirection=true` : "?orgRedirection=true";
// Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things
const newSlugs = slugs.map((slug) => {
const redirect = redirects.find((redirect) => redirect.from === slug);
if (!redirect) {
return slug;
}
const newSlug = new URL(redirect.toUrl).pathname.slice(1);
return newSlug;
});
const newSlug = newSlugs.join("+");
const newPath = newSlug ? `/${newSlug}` : "";
// When in single org mode and not on actual org subdomain, use relative path to stay on same domain
const newDestination = useRelativePath
? `${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`
: `${newOrigin}${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`;
log.debug(`Suggesting redirect from ${slugs} to ${newDestination}`);
return {
redirect: {
permanent: false,
destination: newDestination,
},
} as const;
};
interface HandleOrgRedirectParams {
slugs: string[];
redirectType: RedirectType;
eventTypeSlug: string | null;
context: {
query: ParsedUrlQuery;
};
currentOrgDomain: string | null;
}
/**
* Handles organization redirects for both regular org context and SINGLE_ORG_SLUG mode
* The redirect is required for all existing user links and team links to keep working when a user/team is moved to an organization
* Example:
* - User "john87" is added to organization "acme" and his username in the organization is "john". So, cal.com/john87 is redirected to cal.com/john
* - Team "acme-sales" is added to organization "acme" and its slug in the organization is "sales". So, cal.com/acme-sales is redirected to cal.com/sales
*
* Returns a redirect object if a redirect is needed, null otherwise
*/
export async function handleOrgRedirect({
slugs,
redirectType,
eventTypeSlug,
context,
currentOrgDomain,
}: HandleOrgRedirectParams) {
const isOrgContext = !!currentOrgDomain;
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
// If we're not in an org context, the request could clearly be eligible for a redirect
if (!isOrgContext) {
const nextJsRedirect = await getTemporaryOrgRedirect({
slugs,
redirectType,
eventTypeSlug,
currentQuery: context.query,
});
if (nextJsRedirect) {
return nextJsRedirect;
}
}
const isSingleOrgMode = SINGLE_ORG_SLUG && isOrgContext;
// When SINGLE_ORG_SLUG is set(which is possible in Self-Hosted instances), isOrgContext could be true even when the current domain is not an org subdomain
// Example: my-instance.com could technically mean acme.my-instance.com where acme is the org slug
// In such a case, the existing links which were on my-instance.com e.g. my-instance.com/john87 should be redirected to my-instance.com/john where john is the new username in Organization
// This is why we need to follow redirect even when in Org Context
if (!isSingleOrgMode) {
return null;
}
// If already redirected from a non-org link, we shouldn't redirect again. Protects against infinite redirects
if (isARedirectFromNonOrgLink) {
return null;
}
// We're in single org mode but not on an actual org subdomain
// Check if there's a redirect for this username
// Use relative path since we want to stay on the same domain (my-instance.com)
const nextJsRedirect = await getTemporaryOrgRedirect({
slugs,
redirectType,
eventTypeSlug,
currentQuery: context.query,
useRelativePath: true,
});
return nextJsRedirect;
}
export async function getRedirectWithOriginAndSearchString({
slugs,
redirectType,
context,
currentOrgDomain,
}: Omit<HandleOrgRedirectParams, "eventTypeSlug">) {
const nextJsRedirect = await handleOrgRedirect({
slugs,
redirectType,
eventTypeSlug: null,
context,
currentOrgDomain,
});
if (!nextJsRedirect) {
return null;
}
const newDestination = nextJsRedirect.redirect.destination;
// If there is an origin use it, otherwise mark no new Origin and it remains the same domain
const newOrigin =
newDestination.startsWith("http://") || newDestination.startsWith("https://")
? new URL(newDestination).origin
: null;
return {
origin: newOrigin,
searchString: getSearchString(newDestination),
};
}