* create seed for org upgrade * migrate about/new pages * pages refactor + use persistant zustand store * invited members view + confirm button * add check to org slug * check reserved subdomain * remove quotes from subdomain env var * intro to creation + billing * create price * fix types * createWithPaymentIntent + permission check on teams * open stripe link in popup * intro to tests * organization price and seat override tests * move permissions to a new permission service * update env and permission check * intro to paid invoice trigger * dont use subId * wip * Get e2e working with migration of teams and members * fix ts errors * Get flow working again * Fix various issues and refactor * Some more fixes * Fix wrong page route link * Platform onboarding fix and moving members of team to org rix * Platform onboarding fix and moving members of team to org rix * Fix tests, found a bug * Get custom price flow working * Get admin impersonation flow working for a non-existent user * Fix unit test * Admin onboarding handover * fix admin onboarding tests * fix ts error * Get updateQuantity working * More fixes * fix test * fix schema name * Add tests * Add missing file * More tests * Add more tests * fix mt-2 moving down input into overflow * fix handover layout removing HOC * Update PR_TODO.md * fix ts error due to merge conflict --------- Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com> Co-authored-by: Hariom <hariombalhara@gmail.com> Co-authored-by: Omar López <zomars@me.com>
453 lines
12 KiB
TypeScript
453 lines
12 KiB
TypeScript
import type { z } from "zod";
|
|
|
|
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import { prisma } from "@calcom/prisma";
|
|
import { MembershipRole } from "@calcom/prisma/enums";
|
|
import type { CreationSource } from "@calcom/prisma/enums";
|
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
|
|
|
import { createAProfileForAnExistingUser } from "../../createAProfileForAnExistingUser";
|
|
import { getParsedTeam } from "./teamUtils";
|
|
import { UserRepository } from "./user";
|
|
|
|
const orgSelect = {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
logoUrl: true,
|
|
};
|
|
|
|
export class OrganizationRepository {
|
|
static async createWithExistingUserAsOwner({
|
|
orgData,
|
|
owner,
|
|
}: {
|
|
orgData: {
|
|
name: string;
|
|
slug: string | null;
|
|
isOrganizationConfigured: boolean;
|
|
isOrganizationAdminReviewed: boolean;
|
|
autoAcceptEmail: string;
|
|
seats: number | null;
|
|
pricePerSeat: number | null;
|
|
isPlatform: boolean;
|
|
billingPeriod?: "MONTHLY" | "ANNUALLY";
|
|
logoUrl: string | null;
|
|
bio: string | null;
|
|
requestedSlug?: string | null;
|
|
};
|
|
owner: {
|
|
id: number;
|
|
email: string;
|
|
nonOrgUsername: string;
|
|
};
|
|
}) {
|
|
logger.debug("createWithExistingUserAsOwner", safeStringify({ orgData, owner }));
|
|
const organization = await this.create(orgData);
|
|
const ownerProfile = await createAProfileForAnExistingUser({
|
|
user: {
|
|
id: owner.id,
|
|
email: owner.email,
|
|
currentUsername: owner.nonOrgUsername,
|
|
},
|
|
organizationId: organization.id,
|
|
});
|
|
|
|
await prisma.membership.create({
|
|
data: {
|
|
userId: owner.id,
|
|
role: MembershipRole.OWNER,
|
|
accepted: true,
|
|
teamId: organization.id,
|
|
},
|
|
});
|
|
return { organization, ownerProfile };
|
|
}
|
|
|
|
static async createWithNonExistentOwner({
|
|
orgData,
|
|
owner,
|
|
creationSource,
|
|
}: {
|
|
orgData: {
|
|
name: string;
|
|
slug: string;
|
|
isOrganizationConfigured: boolean;
|
|
isOrganizationAdminReviewed: boolean;
|
|
autoAcceptEmail: string;
|
|
seats: number | null;
|
|
billingPeriod?: "MONTHLY" | "ANNUALLY";
|
|
pricePerSeat: number | null;
|
|
isPlatform: boolean;
|
|
logoUrl: string | null;
|
|
bio: string | null;
|
|
};
|
|
owner: {
|
|
email: string;
|
|
};
|
|
creationSource: CreationSource;
|
|
}) {
|
|
logger.debug("createWithNonExistentOwner", safeStringify({ orgData, owner }));
|
|
const organization = await this.create(orgData);
|
|
const ownerUsernameInOrg = getOrgUsernameFromEmail(owner.email, orgData.autoAcceptEmail);
|
|
const ownerInDb = await UserRepository.create({
|
|
email: owner.email,
|
|
username: ownerUsernameInOrg,
|
|
organizationId: organization.id,
|
|
locked: false,
|
|
creationSource,
|
|
});
|
|
|
|
await prisma.membership.create({
|
|
data: {
|
|
userId: ownerInDb.id,
|
|
role: MembershipRole.OWNER,
|
|
accepted: true,
|
|
teamId: organization.id,
|
|
},
|
|
});
|
|
|
|
return {
|
|
orgOwner: ownerInDb,
|
|
organization,
|
|
ownerProfile: {
|
|
username: ownerUsernameInOrg,
|
|
},
|
|
};
|
|
}
|
|
|
|
static async create(orgData: {
|
|
name: string;
|
|
slug: string | null;
|
|
isOrganizationConfigured: boolean;
|
|
isOrganizationAdminReviewed: boolean;
|
|
autoAcceptEmail: string;
|
|
seats: number | null;
|
|
billingPeriod?: "MONTHLY" | "ANNUALLY";
|
|
pricePerSeat: number | null;
|
|
isPlatform: boolean;
|
|
logoUrl: string | null;
|
|
bio: string | null;
|
|
requestedSlug?: string | null;
|
|
}) {
|
|
return await prisma.team.create({
|
|
data: {
|
|
name: orgData.name,
|
|
isOrganization: true,
|
|
slug: orgData.slug,
|
|
// This is huge and causes issues, we need to have the logic to convert logo to logoUrl and then use that url ehre.
|
|
// logoUrl: orgData.logoUrl,
|
|
bio: orgData.bio,
|
|
organizationSettings: {
|
|
create: {
|
|
isAdminReviewed: orgData.isOrganizationAdminReviewed,
|
|
isOrganizationVerified: true,
|
|
isOrganizationConfigured: orgData.isOrganizationConfigured,
|
|
orgAutoAcceptEmail: orgData.autoAcceptEmail,
|
|
},
|
|
},
|
|
metadata: {
|
|
isPlatform: orgData.isPlatform,
|
|
|
|
// We still have a case where we don't have slug set directly on organization. The reason is because we want to create an org first(with same slug as another regular team) and then move the team to org.
|
|
// Because in such cases there is a fallback existing at multiple places to use requestedSlug, we set it here still :(
|
|
requestedSlug: orgData.requestedSlug,
|
|
|
|
// We set these here still because some parts of code read it from metadata. This data exists in OrganizationOnboarding as well and should be used from there ideally
|
|
// We also need to think about existing Organizations that might not have OrganizationOnboarding, before taking any decision
|
|
orgSeats: orgData.seats,
|
|
orgPricePerSeat: orgData.pricePerSeat,
|
|
billingPeriod: orgData.billingPeriod,
|
|
},
|
|
isPlatform: orgData.isPlatform,
|
|
},
|
|
});
|
|
}
|
|
|
|
static async findById({ id }: { id: number }) {
|
|
return prisma.team.findUnique({
|
|
where: {
|
|
id,
|
|
isOrganization: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
static async findBySlug({ slug }: { slug: string }) {
|
|
// Slug is unique but could be null as well, so we can't use findUnique
|
|
return prisma.team.findFirst({
|
|
where: {
|
|
slug,
|
|
isOrganization: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
static async findBySlugIncludeOnboarding({ slug }: { slug: string }) {
|
|
return prisma.team.findFirst({
|
|
where: { slug, isOrganization: true },
|
|
include: {
|
|
organizationOnboarding: {
|
|
select: {
|
|
id: true,
|
|
isDomainConfigured: true,
|
|
isPlatform: true,
|
|
slug: true,
|
|
teams: true,
|
|
invitedMembers: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
static async findByIdIncludeOrganizationSettings({ id }: { id: number }) {
|
|
return prisma.team.findUnique({
|
|
where: {
|
|
id,
|
|
isOrganization: true,
|
|
},
|
|
select: {
|
|
...orgSelect,
|
|
organizationSettings: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
static async findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email }: { email: string }) {
|
|
const emailDomain = email.split("@").at(-1);
|
|
const orgs = await prisma.team.findMany({
|
|
where: {
|
|
isOrganization: true,
|
|
isPlatform: false,
|
|
organizationSettings: {
|
|
orgAutoAcceptEmail: emailDomain,
|
|
isOrganizationVerified: true,
|
|
isAdminReviewed: true,
|
|
},
|
|
},
|
|
});
|
|
if (orgs.length > 1) {
|
|
logger.error(
|
|
"Multiple organizations found with the same auto accept email domain",
|
|
safeStringify({ orgs, emailDomain })
|
|
);
|
|
// Detect and fail just in case this situation arises. We should really identify the problem in this case and fix the data.
|
|
throw new Error("Multiple organizations found with the same auto accept email domain");
|
|
}
|
|
const org = orgs[0];
|
|
if (!org) {
|
|
return null;
|
|
}
|
|
return getParsedTeam(org);
|
|
}
|
|
|
|
static async findCurrentOrg({ userId, orgId }: { userId: number; orgId: number }) {
|
|
const membership = await prisma.membership.findFirst({
|
|
where: {
|
|
userId,
|
|
team: {
|
|
id: orgId,
|
|
},
|
|
},
|
|
include: {
|
|
team: true,
|
|
},
|
|
});
|
|
|
|
const organizationSettings = await prisma.organizationSettings.findUnique({
|
|
where: {
|
|
organizationId: orgId,
|
|
},
|
|
select: {
|
|
lockEventTypeCreationForUsers: true,
|
|
adminGetsNoSlotsNotification: true,
|
|
isAdminReviewed: true,
|
|
allowSEOIndexing: true,
|
|
orgProfileRedirectsToVerifiedDomain: true,
|
|
orgAutoAcceptEmail: true,
|
|
},
|
|
});
|
|
|
|
if (!membership) {
|
|
throw new Error("You do not have a membership to your organization");
|
|
}
|
|
|
|
const metadata = teamMetadataSchema.parse(membership?.team.metadata);
|
|
|
|
return {
|
|
canAdminImpersonate: !!organizationSettings?.isAdminReviewed,
|
|
organizationSettings: {
|
|
lockEventTypeCreationForUsers: organizationSettings?.lockEventTypeCreationForUsers,
|
|
adminGetsNoSlotsNotification: organizationSettings?.adminGetsNoSlotsNotification,
|
|
allowSEOIndexing: organizationSettings?.allowSEOIndexing,
|
|
orgProfileRedirectsToVerifiedDomain: organizationSettings?.orgProfileRedirectsToVerifiedDomain,
|
|
orgAutoAcceptEmail: organizationSettings?.orgAutoAcceptEmail,
|
|
},
|
|
user: {
|
|
role: membership?.role,
|
|
accepted: membership?.accepted,
|
|
},
|
|
...membership?.team,
|
|
metadata,
|
|
};
|
|
}
|
|
|
|
static async findTeamsInOrgIamNotPartOf({ userId, parentId }: { userId: number; parentId: number | null }) {
|
|
const teamsInOrgIamNotPartOf = await prisma.team.findMany({
|
|
where: {
|
|
parentId,
|
|
members: {
|
|
none: {
|
|
userId,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return teamsInOrgIamNotPartOf;
|
|
}
|
|
|
|
static async adminFindById({ id }: { id: number }) {
|
|
const org = await prisma.team.findUnique({
|
|
where: {
|
|
id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
metadata: true,
|
|
isOrganization: true,
|
|
members: {
|
|
where: {
|
|
role: "OWNER",
|
|
},
|
|
select: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
organizationSettings: {
|
|
select: {
|
|
isOrganizationConfigured: true,
|
|
isOrganizationVerified: true,
|
|
orgAutoAcceptEmail: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!org) {
|
|
throw new Error("Organization not found");
|
|
}
|
|
|
|
const parsedMetadata = teamMetadataSchema.parse(org.metadata);
|
|
if (!org?.isOrganization) {
|
|
throw new Error("Organization not found");
|
|
}
|
|
return { ...org, metadata: parsedMetadata };
|
|
}
|
|
|
|
static async findByMemberEmail({ email }: { email: string }) {
|
|
const organization = await prisma.team.findFirst({
|
|
where: {
|
|
isOrganization: true,
|
|
members: {
|
|
some: {
|
|
user: { email },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return organization ?? null;
|
|
}
|
|
|
|
static async findByMemberEmailId({ email }: { email: string }) {
|
|
const log = logger.getSubLogger({ prefix: ["findByMemberEmailId"] });
|
|
log.debug("called with", { email });
|
|
const organization = await prisma.team.findFirst({
|
|
where: {
|
|
isOrganization: true,
|
|
members: {
|
|
some: {
|
|
user: {
|
|
email,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return organization;
|
|
}
|
|
|
|
static async findCalVideoLogoByOrgId({ id }: { id: number }) {
|
|
const org = await prisma.team.findUnique({
|
|
where: {
|
|
id,
|
|
},
|
|
select: {
|
|
calVideoLogo: true,
|
|
},
|
|
});
|
|
|
|
return org?.calVideoLogo;
|
|
}
|
|
|
|
static async getVerifiedOrganizationByAutoAcceptEmailDomain(domain: string) {
|
|
return await prisma.team.findFirst({
|
|
where: {
|
|
organizationSettings: {
|
|
isOrganizationVerified: true,
|
|
orgAutoAcceptEmail: domain,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
organizationSettings: {
|
|
select: {
|
|
orgAutoAcceptEmail: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
static async setSlug({ id, slug }: { id: number; slug: string }) {
|
|
return await prisma.team.update({
|
|
where: { id, isOrganization: true },
|
|
data: { slug },
|
|
});
|
|
}
|
|
|
|
static async updateStripeSubscriptionDetails({
|
|
id,
|
|
stripeSubscriptionId,
|
|
stripeSubscriptionItemId,
|
|
existingMetadata,
|
|
}: {
|
|
id: number;
|
|
stripeSubscriptionId: string;
|
|
stripeSubscriptionItemId: string;
|
|
existingMetadata: z.infer<typeof teamMetadataSchema>;
|
|
}) {
|
|
return await prisma.team.update({
|
|
where: { id, isOrganization: true },
|
|
data: {
|
|
metadata: {
|
|
...existingMetadata,
|
|
subscriptionId: stripeSubscriptionId,
|
|
subscriptionItemId: stripeSubscriptionItemId,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|