fix: team booking page having same slug as the org (#12213)

This commit is contained in:
Hariom Balhara 2023-11-14 18:25:46 +05:30 committed by GitHub
parent d3ab11e38e
commit 8c2ce972cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 562 additions and 104 deletions

View File

@ -1,13 +1,13 @@
import { Prisma } from "@prisma/client";
import { getAppFromSlug } from "@calcom/app-store/utils";
import { getOrgFullOrigin, getSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { WEBAPP_URL } from "../../../constants";
import logger from "../../../logger";
import { getTeam, getOrg } from "../../repository/team";
export type TeamWithMembers = Awaited<ReturnType<typeof getTeamWithMembers>>;
@ -24,6 +24,9 @@ export async function getTeamWithMembers(args: {
isOrgView?: boolean;
}) {
const { id, slug, userId, orgSlug, isTeamView, isOrgView, includeTeamLogo } = args;
// This should improve performance saving already app data found.
const appDataMap = new Map();
const userSelect = Prisma.validator<Prisma.UserSelect>()({
username: true,
email: true,
@ -61,115 +64,100 @@ export async function getTeamWithMembers(args: {
},
},
});
const teamSelect = Prisma.validator<Prisma.TeamSelect>()({
id: true,
name: true,
slug: true,
...(!!includeTeamLogo ? { logo: true } : {}),
bio: true,
hideBranding: true,
hideBookATeamMember: true,
isPrivate: true,
metadata: true,
parent: {
select: {
id: true,
slug: true,
name: true,
let lookupBy;
if (id) {
lookupBy = { id, havingMemberWithId: userId };
} else if (slug) {
lookupBy = { slug, havingMemberWithId: userId };
} else {
throw new Error("Must provide either id or slug");
}
const arg = {
lookupBy,
forOrgWithSlug: orgSlug ?? null,
isOrg: !!isOrgView,
teamSelect: {
id: true,
name: true,
slug: true,
...(!!includeTeamLogo ? { logo: true } : {}),
bio: true,
hideBranding: true,
hideBookATeamMember: true,
isPrivate: true,
metadata: true,
parent: {
select: {
id: true,
slug: true,
name: true,
},
},
},
children: {
select: {
name: true,
slug: true,
children: {
select: {
name: true,
slug: true,
},
},
},
members: {
select: {
accepted: true,
role: true,
disableImpersonation: true,
user: {
select: userSelect,
members: {
select: {
accepted: true,
role: true,
disableImpersonation: true,
user: {
select: userSelect,
},
},
},
theme: true,
brandColor: true,
darkBrandColor: true,
eventTypes: {
where: {
hidden: false,
schedulingType: {
not: SchedulingType.MANAGED,
},
},
select: {
users: {
select: userSelect,
},
metadata: true,
...baseEventTypeSelect,
},
},
inviteTokens: {
select: {
token: true,
expires: true,
expiresInDays: true,
identifier: true,
},
},
},
theme: true,
brandColor: true,
darkBrandColor: true,
eventTypes: {
where: {
hidden: false,
schedulingType: {
not: SchedulingType.MANAGED,
},
},
select: {
users: {
select: userSelect,
},
metadata: true,
...baseEventTypeSelect,
},
},
inviteTokens: {
select: {
token: true,
expires: true,
expiresInDays: true,
identifier: true,
},
},
});
} as const;
const where: Prisma.TeamFindFirstArgs["where"] = {};
const teamOrOrg = isOrgView ? await getOrg(arg) : await getTeam(arg);
if (userId) where.members = { some: { userId } };
if (orgSlug && orgSlug !== slug) {
where.parent = getSlugOrRequestedSlug(orgSlug);
}
if (id) where.id = id;
if (slug) where.slug = slug;
if (isOrgView) {
// We must fetch only the organization here.
// Note that an organization and a team that doesn't belong to an organization, both have parentId null
// If the organization has null slug(but requestedSlug is 'test') and the team also has slug 'test', we can't distinguish them without explicitly checking the metadata.isOrganization
// Note that, this isn't possible now to have same requestedSlug as the slug of a team not part of an organization. This is legacy teams handling mostly. But it is still safer to be sure that you are fetching an Organization only in case of isOrgView
where.metadata = {
path: ["isOrganization"],
equals: true,
};
}
if (!teamOrOrg) return null;
const teams = await prisma.team.findMany({
where,
select: teamSelect,
});
if (teams.length > 1) {
logger.error("Found more than one team/Org. We should be doing something wrong.", {
where,
teams: teams.map((team) => ({ id: team.id, slug: team.slug })),
});
}
const team = teams[0];
if (!team) return null;
// This should improve performance saving already app data found.
const appDataMap = new Map();
const members = team.members.map((obj) => {
const { credentials, ...restUser } = obj.user;
const members = teamOrOrg.members.map((m) => {
const { credentials, ...restUser } = m.user;
return {
...restUser,
role: obj.role,
accepted: obj.accepted,
disableImpersonation: obj.disableImpersonation,
role: m.role,
accepted: m.accepted,
disableImpersonation: m.disableImpersonation,
subteams: orgSlug
? obj.user.teams.filter((obj) => obj.team.slug !== orgSlug).map((obj) => obj.team.slug)
? m.user.teams
.filter((membership) => membership.team.slug !== orgSlug)
.map((membership) => membership.team.slug)
: null,
avatar: `${WEBAPP_URL}/${obj.user.username}/avatar.png`,
orgOrigin: getOrgFullOrigin(obj.user.organization?.slug || ""),
avatar: `${WEBAPP_URL}/${m.user.username}/avatar.png`,
orgOrigin: getOrgFullOrigin(m.user.organization?.slug || ""),
connectedApps: !isTeamView
? credentials?.map((cred) => {
const appSlug = cred.app?.slug;
@ -193,15 +181,15 @@ export async function getTeamWithMembers(args: {
};
});
const eventTypes = team.eventTypes.map((eventType) => ({
const eventTypes = teamOrOrg.eventTypes.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
}));
// Don't leak invite tokens to the frontend
const { inviteTokens, ...teamWithoutInviteTokens } = team;
const { inviteTokens, ...teamWithoutInviteTokens } = teamOrOrg;
// Don't leak stripe payment ids
const teamMetadata = teamMetadataSchema.parse(team.metadata);
const teamMetadata = teamOrOrg.metadata;
const {
paymentId: _,
subscriptionId: __,
@ -214,7 +202,7 @@ export async function getTeamWithMembers(args: {
/** To prevent breaking we only return non-email attached token here, if we have one */
inviteToken: inviteTokens.find(
(token) =>
token.identifier === `invite-link-for-teamId-${team.id}` &&
token.identifier === `invite-link-for-teamId-${teamOrOrg.id}` &&
token.expires > new Date(new Date().setHours(24))
),
metadata: restTeamMetadata,

View File

@ -0,0 +1,322 @@
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { it, describe, expect } from "vitest";
import { getTeam, getOrg } from "./team";
const sampleTeamProps = {
logo: null,
appLogo: null,
bio: null,
description: null,
hideBranding: false,
isPrivate: false,
appIconLogo: null,
hideBookATeamMember: false,
createdAt: new Date(),
theme: null,
brandColor: "",
darkBrandColor: "",
timeFormat: null,
timeZone: "",
weekStart: "",
parentId: null,
};
describe("getOrg", () => {
it("should return an Organization correctly by slug even if there is a team with the same slug", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
id: 101,
name: "Test Team",
slug: "test-slug",
metadata: {
isOrganization: true,
},
},
]);
const org = await getOrg({
lookupBy: {
slug: "test-slug",
},
forOrgWithSlug: null,
teamSelect: {
id: true,
slug: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "test-slug",
metadata: {
path: ["isOrganization"],
equals: true,
},
},
select: {
id: true,
slug: true,
metadata: true,
},
});
expect(org?.metadata?.isOrganization).toBe(true);
});
it("should not return an org result if metadata.isOrganization isn't true", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
...sampleTeamProps,
id: 101,
name: "Test Team",
slug: "test-slug",
metadata: {},
},
]);
const org = await getOrg({
lookupBy: {
slug: "test-slug",
},
forOrgWithSlug: null,
teamSelect: {
id: true,
slug: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "test-slug",
metadata: {
path: ["isOrganization"],
equals: true,
},
},
select: {
id: true,
slug: true,
metadata: true,
},
});
expect(org).toBe(null);
});
it("should error if metadata isn't valid", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
...sampleTeamProps,
id: 101,
name: "Test Team",
slug: "test-slug",
metadata: [],
},
]);
await expect(() =>
getOrg({
lookupBy: {
slug: "test-slug",
},
forOrgWithSlug: null,
teamSelect: {
id: true,
slug: true,
},
})
).rejects.toThrow("invalid_type");
});
});
describe("getTeam", () => {
it("should query a team correctly", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
...sampleTeamProps,
id: 101,
name: "Test Team",
slug: "test-slug",
metadata: {
anything: "here",
paymentId: "1",
},
},
]);
const team = await getTeam({
lookupBy: {
slug: "test-slug",
},
forOrgWithSlug: null,
teamSelect: {
id: true,
slug: true,
name: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "test-slug",
},
select: {
id: true,
slug: true,
name: true,
metadata: true,
},
});
expect(team).not.toBeNull();
// 'anything' is not in the teamMetadata schema, so it should be stripped out
expect(team?.metadata).toEqual({ paymentId: "1" });
});
it("should not return a team result if the queried result isn't a team", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
...sampleTeamProps,
id: 101,
name: "Test Team",
slug: "test-slug",
metadata: {
isOrganization: true,
},
},
]);
const team = await getTeam({
lookupBy: {
slug: "test-slug",
},
forOrgWithSlug: null,
teamSelect: {
id: true,
slug: true,
name: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "test-slug",
},
select: {
id: true,
slug: true,
name: true,
metadata: true,
},
});
expect(team).toBe(null);
});
it("should return a team by slug within an org", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
...sampleTeamProps,
id: 101,
name: "Test Team",
slug: "test-slug",
parentId: 100,
metadata: null,
},
]);
await getTeam({
lookupBy: {
slug: "team-in-test-org",
},
forOrgWithSlug: "test-org",
teamSelect: {
id: true,
slug: true,
name: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "team-in-test-org",
parent: {
OR: [
{
slug: "test-org",
},
{
metadata: {
path: ["requestedSlug"],
equals: "test-org",
},
},
],
metadata: {
path: ["isOrganization"],
equals: true,
},
},
},
select: {
id: true,
name: true,
slug: true,
metadata: true,
},
});
});
it("should return a team by requestedSlug within an org", async () => {
prismaMock.team.findMany.mockResolvedValue([]);
await getTeam({
lookupBy: {
slug: "test-team",
},
forOrgWithSlug: "test-org",
teamSelect: {
id: true,
slug: true,
name: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "test-team",
parent: {
metadata: {
path: ["isOrganization"],
equals: true,
},
OR: [
{
slug: "test-org",
},
{
metadata: {
path: ["requestedSlug"],
equals: "test-org",
},
},
],
},
},
select: {
id: true,
slug: true,
name: true,
metadata: true,
},
});
});
});

View File

@ -0,0 +1,148 @@
import type { Prisma } from "@prisma/client";
import type { z } from "zod";
import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
type TeamGetPayloadWithParsedMetadata<TeamSelect extends Prisma.TeamSelect> =
| (Omit<Prisma.TeamGetPayload<{ select: TeamSelect }>, "metadata"> & {
metadata: z.infer<typeof teamMetadataSchema>;
})
| null;
type GetTeamOrOrgArg<TeamSelect extends Prisma.TeamSelect> = {
lookupBy: (
| {
id: number;
}
| {
slug: string;
}
) & {
havingMemberWithId?: number;
};
/**
* If we are fetching a team, this is the slug of the organization that the team belongs to.
*/
forOrgWithSlug: string | null;
/**
* If true, means that we need to fetch an organization with the given slug. Otherwise, we need to fetch a team with the given slug.
*/
isOrg: boolean;
teamSelect: TeamSelect;
};
const log = logger.getSubLogger({ prefix: ["repository", "team"] });
/**
* Get's the team or organization with the given slug or id reliably along with parsed metadata.
*/
async function getTeamOrOrg<TeamSelect extends Prisma.TeamSelect>({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
isOrg,
teamSelect,
}: GetTeamOrOrgArg<TeamSelect>): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
const where: Prisma.TeamFindFirstArgs["where"] = {};
teamSelect = {
...teamSelect,
metadata: true,
};
if (lookupBy.havingMemberWithId) where.members = { some: { userId: lookupBy.havingMemberWithId } };
if ("id" in lookupBy) {
where.id = lookupBy.id;
} else {
where.slug = lookupBy.slug;
}
if (isOrg) {
// We must fetch only the organization here.
// Note that an organization and a team that doesn't belong to an organization, both have parentId null
// If the organization has null slug(but requestedSlug is 'test') and the team also has slug 'test', we can't distinguish them without explicitly checking the metadata.isOrganization
// Note that, this isn't possible now to have same requestedSlug as the slug of a team not part of an organization. This is legacy teams handling mostly. But it is still safer to be sure that you are fetching an Organization only in case of isOrgView
where.metadata = {
path: ["isOrganization"],
equals: true,
};
// We must fetch only the team here.
} else {
if (forOrgWithSlug) {
where.parent = whereClauseForOrgWithSlugOrRequestedSlug(forOrgWithSlug);
}
}
log.debug({
orgSlug: forOrgWithSlug,
teamLookupBy: lookupBy,
isOrgView: isOrg,
where,
});
// teamSelect extends Prisma.TeamSelect but still teams doesn't contain a valid team as per TypeScript and thus it doesn't consider it having team.metadata, team.id and other fields
// This is the reason below code is using a lot of assertions.
const teams = await prisma.team.findMany({
where,
select: teamSelect,
});
const teamsWithParsedMetadata = teams
.map((team) => ({
...team,
// Using Type assertion here because we know that the metadata is present and Prisma and TypeScript aren't playing well together
metadata: teamMetadataSchema.parse((team as { metadata: z.infer<typeof teamMetadataSchema> }).metadata),
}))
// In cases where there are many teams with the same slug, we need to find out the one and only one that matches our criteria
.filter((team) => {
// We need an org if isOrgView otherwise we need a team
return isOrg ? team.metadata?.isOrganization : !team.metadata?.isOrganization;
});
if (teamsWithParsedMetadata.length > 1) {
log.error("Found more than one team/Org. We should be doing something wrong.", {
isOrgView: isOrg,
where,
teams: teamsWithParsedMetadata.map((team) => {
const t = team as unknown as { id: number; slug: string };
return {
id: t.id,
slug: t.slug,
};
}),
});
}
const team = teamsWithParsedMetadata[0];
if (!team) return null;
// HACK: I am not sure how to make Prisma in peace with TypeScript with this repository pattern
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return team as any;
}
export async function getTeam<TeamSelect extends Prisma.TeamSelect>({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
teamSelect,
}: Omit<GetTeamOrOrgArg<TeamSelect>, "isOrg">): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
return getTeamOrOrg({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
isOrg: false,
teamSelect,
});
}
export async function getOrg<TeamSelect extends Prisma.TeamSelect>({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
teamSelect,
}: Omit<GetTeamOrOrgArg<TeamSelect>, "isOrg">): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
return getTeamOrOrg({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
isOrg: true,
teamSelect,
});
}