diff --git a/.env.example b/.env.example index d6cf1908bc..78de86dae1 100644 --- a/.env.example +++ b/.env.example @@ -135,6 +135,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE= NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0 NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE= STRIPE_TEAM_MONTHLY_PRICE_ID= +STRIPE_ORG_MONTHLY_PRICE_ID= STRIPE_WEBHOOK_SECRET= STRIPE_PRIVATE_KEY= STRIPE_CLIENT_ID= diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index b0c5c13c77..0ccaef0223 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -1,18 +1,21 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; +import dayjs from "@calcom/dayjs"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; import slugify from "@calcom/lib/slugify"; import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; import prisma from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; const signupSchema = z.object({ username: z.string(), email: z.string().email(), password: z.string().min(7), language: z.string().optional(), + token: z.string().optional(), }); export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -26,7 +29,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const data = req.body; - const { email, password, language } = signupSchema.parse(data); + const { email, password, language, token } = signupSchema.parse(data); const username = slugify(data.username); const userEmail = email.toLowerCase(); @@ -85,25 +88,100 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - // If user has been invitedTo a team, we accept the membership - if (user.invitedTo) { - const team = await prisma.team.findFirst({ - where: { id: user.invitedTo }, + if (token) { + const foundToken = await prisma.verificationToken.findFirst({ + where: { + token, + }, }); - if (team) { - const membership = await prisma.membership.update({ + if (!foundToken) { + return res.status(401).json({ message: "Invalid Token" }); + } + + if (dayjs(foundToken?.expires).isBefore(dayjs())) { + return res.status(401).json({ message: "Token expired" }); + } + + if (foundToken.teamId) { + const team = await prisma.team.findUnique({ where: { - userId_teamId: { userId: user.id, teamId: user.invitedTo }, - }, - data: { - accepted: true, + id: foundToken.teamId, }, }); - // Sync Services: Close.com - closeComUpsertTeamUser(team, user, membership.role); + if (team) { + const teamMetadata = teamMetadataSchema.parse(team?.metadata); + if (teamMetadata?.isOrganization) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + organizationId: team.id, + }, + }); + } + + const membership = await prisma.membership.update({ + where: { + userId_teamId: { userId: user.id, teamId: team.id }, + }, + data: { + accepted: true, + }, + }); + closeComUpsertTeamUser(team, user, membership.role); + + // Accept any child team invites for orgs. + if (team.parentId) { + // Join ORG + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + organizationId: team.parentId, + }, + }); + + /** We do a membership update twice so we can join the ORG invite if the user is invited to a team witin a ORG. */ + await prisma.membership.updateMany({ + where: { + userId: user.id, + team: { + id: team.parentId, + }, + accepted: false, + }, + data: { + accepted: true, + }, + }); + + // Join any other invites + await prisma.membership.updateMany({ + where: { + userId: user.id, + team: { + parentId: team.parentId, + }, + accepted: false, + }, + data: { + accepted: true, + }, + }); + } + } } + + // Cleanup token after use + await prisma.verificationToken.delete({ + where: { + id: foundToken.id, + }, + }); } await sendEmailVerification({ diff --git a/apps/web/pages/settings/admin/organizations/index.tsx b/apps/web/pages/settings/admin/organizations/index.tsx new file mode 100644 index 0000000000..83fa10880e --- /dev/null +++ b/apps/web/pages/settings/admin/organizations/index.tsx @@ -0,0 +1,9 @@ +import UnverifiedOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/unverifiedOrgPage"; + +import type { CalPageWrapper } from "@components/PageWrapper"; +import PageWrapper from "@components/PageWrapper"; + +const Page = UnverifiedOrgsPage as CalPageWrapper; +Page.PageWrapper = PageWrapper; + +export default Page; diff --git a/apps/web/pages/settings/organizations/billing.tsx b/apps/web/pages/settings/organizations/billing.tsx new file mode 100644 index 0000000000..abf744c320 --- /dev/null +++ b/apps/web/pages/settings/organizations/billing.tsx @@ -0,0 +1,9 @@ +import TeamBillingView from "@calcom/features/ee/teams/pages/team-billing-view"; + +import type { CalPageWrapper } from "@components/PageWrapper"; +import PageWrapper from "@components/PageWrapper"; + +const Page = TeamBillingView as CalPageWrapper; +Page.PageWrapper = PageWrapper; + +export default Page; diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 88d540ae48..84f7f69475 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -27,6 +27,7 @@ type FormValues = { email: string; password: string; apiError: string; + token?: string; }; export default function Signup({ prepopulateFormValues, token }: inferSSRProps) { @@ -54,6 +55,7 @@ export default function Signup({ prepopulateFormValues, token }: inferSSRProps { await sendEmail(() => new TeamInviteEmail(teamInviteEvent)); }; +export const sendOrganizationAutoJoinEmail = async (orgInviteEvent: OrgAutoInvite) => { + await sendEmail(() => new OrgAutoJoinEmail(orgInviteEvent)); +}; + export const sendEmailVerificationLink = async (verificationInput: EmailVerifyLink) => { await sendEmail(() => new AccountVerifyEmail(verificationInput)); }; diff --git a/packages/emails/src/templates/OrgAutoInviteEmail.tsx b/packages/emails/src/templates/OrgAutoInviteEmail.tsx new file mode 100644 index 0000000000..2133ec932e --- /dev/null +++ b/packages/emails/src/templates/OrgAutoInviteEmail.tsx @@ -0,0 +1,103 @@ +import type { TFunction } from "next-i18next"; + +import { APP_NAME, WEBAPP_URL, IS_PRODUCTION } from "@calcom/lib/constants"; + +import { V2BaseEmailHtml, CallToAction } from "../components"; + +type TeamInvite = { + language: TFunction; + from: string; + to: string; + orgName: string; + joinLink: string; +}; + +export const OrgAutoInviteEmail = ( + props: TeamInvite & Partial> +) => { + return ( + +

+ <> + {props.language("organization_admin_invited_heading", { + orgName: props.orgName, + })} + +

+ +

+ <> + {props.language("organization_admin_invited_body", { + orgName: props.orgName, + })} + +

+
+ +
+ +
+

+ <> + {props.language("email_no_user_signoff", { + appName: APP_NAME, + entity: props.language("organization").toLowerCase(), + })} + +

+
+ +
+

+ <> + {props.language("have_any_questions")}{" "} + + <>{props.language("contact")} + {" "} + {props.language("our_support_team")} + +

+
+
+ ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index 91f64b4ce9..880a2d2a98 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -27,3 +27,4 @@ export { VerifyAccountEmail } from "./VerifyAccountEmail"; export * from "@calcom/app-store/routing-forms/emails/components"; export { AttendeeDailyVideoDownloadRecordingEmail } from "./AttendeeDailyVideoDownloadRecordingEmail"; export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail"; +export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail"; diff --git a/packages/emails/templates/org-auto-join-invite.ts b/packages/emails/templates/org-auto-join-invite.ts new file mode 100644 index 0000000000..002ebf7482 --- /dev/null +++ b/packages/emails/templates/org-auto-join-invite.ts @@ -0,0 +1,39 @@ +import type { TFunction } from "next-i18next"; + +import { APP_NAME } from "@calcom/lib/constants"; + +import { renderEmail } from ".."; +import BaseEmail from "./_base-email"; + +export type OrgAutoInvite = { + language: TFunction; + from: string; + to: string; + orgName: string; + joinLink: string; +}; + +export default class OrgAutoJoinEmail extends BaseEmail { + orgAutoInviteEvent: OrgAutoInvite; + + constructor(orgAutoInviteEvent: OrgAutoInvite) { + super(); + this.name = "SEND_TEAM_INVITE_EMAIL"; + this.orgAutoInviteEvent = orgAutoInviteEvent; + } + + protected getNodeMailerPayload(): Record { + return { + to: this.orgAutoInviteEvent.to, + from: `${APP_NAME} <${this.getMailerOptions().from}>`, + subject: this.orgAutoInviteEvent.language("user_invited_you", { + user: this.orgAutoInviteEvent.from, + team: this.orgAutoInviteEvent.orgName, + appName: APP_NAME, + entity: this.orgAutoInviteEvent.language("organization").toLowerCase(), + }), + html: renderEmail("OrgAutoInviteEmail", this.orgAutoInviteEvent), + text: "", + }; + } +} diff --git a/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx b/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx index 207837c929..0ccf320747 100644 --- a/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx +++ b/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx @@ -129,7 +129,7 @@ export default function ApiKeyDialogForm({ } }} className="space-y-4"> -
+

{defaultValues ? t("edit_api_key") : t("create_api_key")}

diff --git a/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx b/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx index e37c417452..9b9a18e5ad 100644 --- a/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx +++ b/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx @@ -8,7 +8,7 @@ export function OrgUpgradeBanner() { const { t } = useLocale(); const router = useRouter(); const { data } = trpc.viewer.organizations.checkIfOrgNeedsUpgrade.useQuery(); - const publishTeamMutation = trpc.viewer.organizations.publish.useMutation({ + const publishOrgMutation = trpc.viewer.organizations.publish.useMutation({ onSuccess(data) { router.push(data.url); }, @@ -29,7 +29,7 @@ export function OrgUpgradeBanner() { diff --git a/packages/features/ee/organizations/pages/settings/admin/unverifiedOrgPage.tsx b/packages/features/ee/organizations/pages/settings/admin/unverifiedOrgPage.tsx new file mode 100644 index 0000000000..53c8c151ca --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/admin/unverifiedOrgPage.tsx @@ -0,0 +1,102 @@ +import NoSSR from "@calcom/core/components/NoSSR"; +import LicenseRequired from "@calcom/ee/common/components/LicenseRequired"; +import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils"; +import { trpc } from "@calcom/trpc/react"; +import { Meta } from "@calcom/ui"; +import { DropdownActions, showToast, Table } from "@calcom/ui"; +import { Check, X } from "@calcom/ui/components/icon"; + +import { getLayout } from "../../../../../settings/layouts/SettingsLayout"; + +const { Body, Cell, ColumnTitle, Header, Row } = Table; + +function UnverifiedOrgTable() { + const utils = trpc.useContext(); + const [data] = trpc.viewer.organizations.adminGetUnverified.useSuspenseQuery(); + const mutation = trpc.viewer.organizations.adminVerify.useMutation({ + onSuccess: async () => { + showToast("Org has been processed", "success"); + await utils.viewer.organizations.adminGetUnverified.invalidate(); + }, + onError: (err) => { + console.error(err.message); + showToast("There has been an error processing this org.", "error"); + }, + }); + + return ( +
+ +
+ Organization + Owner + + Edit + +
+ + + {data.map((org) => ( + + +
+ {org.name} +
+ + {org.slug}.{extractDomainFromWebsiteUrl} + +
+
+ + {org.members[0].user.email} + + + +
+ { + mutation.mutate({ + orgId: org.id, + status: "ACCEPT", + }); + }, + icon: Check, + }, + { + id: "reject", + label: "Reject", + icon: X, + }, + ]} + /> +
+
+
+ ))} + +
+
+ ); +} + +const UnverifiedOrgList = () => { + return ( + + + + + + + ); +}; + +UnverifiedOrgList.getLayout = getLayout; + +export default UnverifiedOrgList; diff --git a/packages/features/ee/sso/components/OIDCConnection.tsx b/packages/features/ee/sso/components/OIDCConnection.tsx index 4346b7c770..551b4eb864 100644 --- a/packages/features/ee/sso/components/OIDCConnection.tsx +++ b/packages/features/ee/sso/components/OIDCConnection.tsx @@ -91,7 +91,7 @@ const CreateConnectionDialog = ({

{t("sso_oidc_configuration_title")}

-

{t("sso_oidc_configuration_description")}

+

{t("sso_oidc_configuration_description")}

void; + orgMembers?: RouterOutputs["viewer"]["organizations"]["getMembers"]; onSubmit: (values: NewMemberForm, resetFields: () => void) => void; onSettingsOpen?: () => void; teamId: number; @@ -86,6 +88,25 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) ]; }, [t]); + const toggleGroupOptions = useMemo(() => { + const array = [ + { + value: "INDIVIDUAL", + label: t("invite_team_individual_segment"), + iconLeft: , + }, + { value: "BULK", label: t("invite_team_bulk_segment"), iconLeft: }, + ]; + if (props.orgMembers) { + array.unshift({ + value: "ORGANIZATION", + label: t("organization"), + iconLeft: , + }); + } + return array; + }, [t, props.orgMembers]); + const newMemberFormMethods = useForm(); const validateUniqueInvite = (value: string) => { @@ -149,23 +170,12 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) isFullWidth={true} onValueChange={(val) => setModalInputMode(val as ModalMode)} defaultValue="INDIVIDUAL" - options={[ - { - value: "INDIVIDUAL", - label: {t("invite_team_individual_segment")}, - iconLeft: , - }, - { - value: "BULK", - label: {t("invite_team_bulk_segment")}, - iconLeft: , - }, - ]} + options={toggleGroupOptions} />
props.onSubmit(values, resetFields)}> -
+
{/* Indivdual Invite */} {modalImportMode === "INDIVIDUAL" && ( -
-
- -
- -
- - -
+
+
+ + + diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts index d7197eab87..048964b9ff 100644 --- a/packages/features/ee/teams/lib/payments.ts +++ b/packages/features/ee/teams/lib/payments.ts @@ -28,10 +28,17 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => { return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` }; }; -export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; userId: number }) => { - const { teamId, seats, userId } = input; +export const purchaseTeamSubscription = async (input: { + teamId: number; + seats: number; + userId: number; + isOrg?: boolean; +}) => { + const { teamId, seats, userId, isOrg } = input; const { url } = await checkIfTeamPaymentRequired({ teamId }); if (url) return { url }; + // For orgs, enforce minimum of 30 seats + const quantity = isOrg ? (seats < 30 ? 30 : seats) : seats; const customer = await getStripeCustomerIdFromUserId(userId); const session = await stripe.checkout.sessions.create({ customer, @@ -42,8 +49,8 @@ export const purchaseTeamSubscription = async (input: { teamId: number; seats: n line_items: [ { /** We only need to set the base price and we can upsell it directly on Stripe's checkout */ - price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID, - quantity: seats, + price: isOrg ? process.env.STRIPE_ORG_MONTHLY_PRICE_ID : process.env.STRIPE_TEAM_MONTHLY_PRICE_ID, + quantity: quantity, }, ], customer_update: { @@ -95,8 +102,22 @@ export const updateQuantitySubscriptionFromStripe = async (teamId: number) => { if (!url) return; const team = await getTeamWithPaymentMetadata(teamId); const { subscriptionId, subscriptionItemId } = team.metadata; + const membershipCount = team.members.length; + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const subscriptionQuantity = subscription.items.data.find( + (sub) => sub.id === subscriptionItemId + )?.quantity; + if (!subscriptionQuantity) throw new Error("Subscription not found"); + + if (membershipCount < subscriptionQuantity) { + console.info(`Team ${teamId} has less members than seats, skipping updating subscription.`); + return; + } + + const newQuantity = membershipCount - subscriptionQuantity; + await stripe.subscriptions.update(subscriptionId, { - items: [{ quantity: team.members.length, id: subscriptionItemId }], + items: [{ quantity: membershipCount + newQuantity, id: subscriptionItemId }], }); console.info( `Updated subscription ${subscriptionId} for team ${teamId} to ${team.members.length} seats.` diff --git a/packages/features/ee/workflows/components/EmptyScreen.tsx b/packages/features/ee/workflows/components/EmptyScreen.tsx index 5881d654ae..d8a62e0b92 100644 --- a/packages/features/ee/workflows/components/EmptyScreen.tsx +++ b/packages/features/ee/workflows/components/EmptyScreen.tsx @@ -74,7 +74,7 @@ export default function EmptyScreen(props: { isFilteredView: boolean }) {

{t("workflows")}

-

+

{t("no_workflows_description")}

diff --git a/packages/features/eventtypes/components/CheckedTeamSelect.tsx b/packages/features/eventtypes/components/CheckedTeamSelect.tsx index 808a0d858e..6cdc1ff1d4 100644 --- a/packages/features/eventtypes/components/CheckedTeamSelect.tsx +++ b/packages/features/eventtypes/components/CheckedTeamSelect.tsx @@ -46,7 +46,7 @@ export const CheckedTeamSelect = ({ key={option.value} className={`flex px-3 py-2 ${index === value.length - 1 ? "" : "border-subtle border-b"}`}> -

{option.label}

+

{option.label}

props.onChange(value.filter((item) => item.value !== option.value))} className="my-auto ml-auto h-4 w-4" diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 8800439bda..58a405d879 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -119,6 +119,7 @@ const tabs: VerticalTabItemProps[] = [ { name: "impersonation", href: "/settings/admin/impersonation" }, { name: "apps", href: "/settings/admin/apps/calendar" }, { name: "users", href: "/settings/admin/users" }, + { name: "organizations", href: "/settings/admin/organizations" }, ], }, ]; @@ -193,6 +194,7 @@ const SettingsSidebarContainer = ({ useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>(); const { data: teams } = trpc.viewer.teams.list.useQuery(); + const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(); useEffect(() => { if (teams) { @@ -385,14 +387,15 @@ const SettingsSidebarContainer = ({ ); })} - + {(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && ( + + )}
); diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 5ed5543da1..9baf5f2c05 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -313,6 +313,8 @@ export const teamMetadataSchema = z subscriptionId: z.string().nullable(), subscriptionItemId: z.string().nullable(), isOrganization: z.boolean().nullable(), + isOrganizationVerified: z.boolean().nullable(), + orgAutoAcceptEmail: z.string().nullable(), }) .partial() .nullable(); diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 92169ca69e..c5f9afa6c1 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -1,7 +1,9 @@ -import authedProcedure from "../../../procedures/authedProcedure"; +import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; +import { ZAdminVerifyInput } from "./adminVerify.schema"; import { ZCreateInputSchema } from "./create.schema"; import { ZCreateTeamsSchema } from "./createTeams.schema"; +import { ZGetMembersInput } from "./getMembers.schema"; import { ZSetPasswordSchema } from "./setPassword.schema"; import { ZUpdateInputSchema } from "./update.schema"; import { ZVerifyCodeInputSchema } from "./verifyCode.schema"; @@ -15,6 +17,9 @@ type OrganizationsRouterHandlerCache = { verifyCode?: typeof import("./verifyCode.handler").verifyCodeHandler; createTeams?: typeof import("./createTeams.handler").createTeamsHandler; setPassword?: typeof import("./setPassword.handler").setPasswordHandler; + adminGetUnverified?: typeof import("./adminGetUnverified.handler").adminGetUnverifiedHandler; + adminVerify?: typeof import("./adminVerify.handler").adminVerifyHandler; + getMembers?: typeof import("./getMembers.handler").getMembersHandler; listMembers?: typeof import("./listMembers.handler").listMembersHandler; getBrand?: typeof import("./getBrand.handler").getBrandHandler; }; @@ -141,6 +146,37 @@ export const viewerOrganizationsRouter = router({ input, }); }), + adminGetUnverified: authedAdminProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.adminGetUnverified) { + UNSTABLE_HANDLER_CACHE.adminGetUnverified = await import("./adminGetUnverified.handler").then( + (mod) => mod.adminGetUnverifiedHandler + ); + } + if (!UNSTABLE_HANDLER_CACHE.adminGetUnverified) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.adminGetUnverified({ + ctx, + }); + }), + adminVerify: authedAdminProcedure.input(ZAdminVerifyInput).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.adminVerify) { + UNSTABLE_HANDLER_CACHE.adminVerify = await import("./adminVerify.handler").then( + (mod) => mod.adminVerifyHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.adminVerify) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.adminVerify({ + ctx, + input, + }); + }), listMembers: authedProcedure.query(async ({ ctx }) => { if (!UNSTABLE_HANDLER_CACHE.listMembers) { UNSTABLE_HANDLER_CACHE.listMembers = await import("./listMembers.handler").then( @@ -171,4 +207,21 @@ export const viewerOrganizationsRouter = router({ ctx, }); }), + getMembers: authedProcedure.input(ZGetMembersInput).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getMembers) { + UNSTABLE_HANDLER_CACHE.getMembers = await import("./getMembers.handler").then( + (mod) => mod.getMembersHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getMembers) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getMembers({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/organizations/adminGetUnverified.handler.ts b/packages/trpc/server/routers/viewer/organizations/adminGetUnverified.handler.ts new file mode 100644 index 0000000000..999bee0fa7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/adminGetUnverified.handler.ts @@ -0,0 +1,51 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type AdminGetUnverifiedOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptions) => { + const unVerifiedTeams = await prisma.team.findMany({ + where: { + AND: [ + { + metadata: { + path: ["isOrganization"], + equals: true, + }, + }, + { + metadata: { + path: ["isOrganizationVerified"], + equals: false, + }, + }, + ], + }, + select: { + id: true, + name: true, + slug: true, + members: { + where: { + role: "OWNER", + }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }, + }, + }); + + return unVerifiedTeams; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts b/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts new file mode 100644 index 0000000000..c3f0359b7e --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts @@ -0,0 +1,61 @@ +import { prisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TAdminVerifyInput } from "./adminVerify.schema"; + +type AdminVerifyOptions = { + ctx: { + user: NonNullable; + }; + input: TAdminVerifyInput; +}; + +export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => { + const foundOrg = await prisma.team.findFirst({ + where: { + id: input.orgId, + metadata: { + path: ["isOrganization"], + equals: true, + }, + }, + include: { + members: { + where: { + role: "OWNER", + }, + include: { + user: true, + }, + }, + }, + }); + + if (!foundOrg) + throw new TRPCError({ + code: "FORBIDDEN", + message: "This team isnt a org or doesnt exist", + }); + + const acceptedEmailDomain = foundOrg.members[0].user.email.split("@")[1]; + + const metaDataParsed = teamMetadataSchema.parse(foundOrg.metadata); + + await prisma.team.update({ + where: { + id: input.orgId, + }, + data: { + metadata: { + ...metaDataParsed, + isOrganizationVerified: true, + orgAutoAcceptEmail: acceptedEmailDomain, + }, + }, + }); + + return { ok: true, message: "Verified Organization" }; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/adminVerify.schema.ts b/packages/trpc/server/routers/viewer/organizations/adminVerify.schema.ts new file mode 100644 index 0000000000..a4d27ce75d --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/adminVerify.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +const statusSchema = z.enum(["ACCEPT", "DENY"] as const); + +export const ZAdminVerifyInput = z.object({ + orgId: z.number(), + status: statusSchema, +}); + +export type TAdminVerifyInput = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index e24e3dc9de..e63de91bb4 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -75,6 +75,8 @@ export const createHandler = async ({ input }: CreateOptions) => { .digest("hex"); const hashedPassword = await hashPassword(password); + const emailDomain = adminEmail.split("@")[1]; + if (check === false) { const createOwnerOrg = await prisma.user.create({ data: { @@ -89,6 +91,8 @@ export const createHandler = async ({ input }: CreateOptions) => { metadata: { ...(IS_TEAM_BILLING_ENABLED && { requestedSlug: slug }), isOrganization: true, + isOrganizationVerified: false, + orgAutoAcceptEmail: emailDomain, }, }, }, diff --git a/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts new file mode 100644 index 0000000000..877c30e381 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts @@ -0,0 +1,42 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetMembersInputSchema } from "./getMembers.schema"; + +type CreateOptions = { + ctx: { + user: NonNullable; + }; + input: TGetMembersInputSchema; +}; + +export const getMembersHandler = async ({ input, ctx }: CreateOptions) => { + const { teamIdToExclude } = input; + + if (!ctx.user.organizationId) return null; + + const users = await prisma.membership.findMany({ + where: { + user: { + organizationId: ctx.user.organizationId, + }, + ...(teamIdToExclude && { + teamId: { + not: teamIdToExclude, + }, + }), + }, + include: { + user: { + select: { + id: true, + username: true, + email: true, + completedOnboarding: true, + }, + }, + }, + }); + + return users; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/getMembers.schema.ts b/packages/trpc/server/routers/viewer/organizations/getMembers.schema.ts new file mode 100644 index 0000000000..61afd0aeb2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/getMembers.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetMembersInput = z.object({ + teamIdToExclude: z.number().optional(), +}); + +export type TGetMembersInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts index 6847dade49..e156d801ab 100644 --- a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts @@ -35,12 +35,13 @@ export const publishHandler = async ({ ctx }: PublishOptions) => { const metadata = teamMetadataSchema.safeParse(prevTeam.metadata); if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" }); - // Since this is an ORG we neeed to make sure ORG members are scyned with the team. Every time a user is added to the TEAM, we need to add them to the ORG + // Since this is an ORG we need to make sure ORG members are scyned with the team. Every time a user is added to the TEAM, we need to add them to the ORG if (IS_TEAM_BILLING_ENABLED) { const checkoutSession = await purchaseTeamSubscription({ teamId: prevTeam.id, seats: prevTeam.members.length, userId: ctx.user.id, + isOrg: true, }); if (!checkoutSession.url) throw new TRPCError({ diff --git a/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts b/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts index e6925200f3..7587867e34 100644 --- a/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts @@ -37,6 +37,7 @@ export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => { teams = teams.filter((m) => { const metadata = teamMetadataSchema.safeParse(m.team.metadata); if (metadata.success && metadata.data?.subscriptionId) return false; + if (metadata.success && metadata.data?.isOrganization) return false; // We also dont return ORGs as it will be handled in OrgUpgradeBanner if (m.team.children.length > 0) return false; // We also dont return ORGs as it will be handled in OrgUpgradeBanner return true; }); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts index 029c6390f4..098d242906 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts @@ -1,13 +1,17 @@ import { Prisma } from "@prisma/client"; import { randomBytes } from "crypto"; +import type { TFunction } from "next-i18next"; -import { sendTeamInviteEmail } from "@calcom/emails"; +import { sendOrganizationAutoJoinEmail, sendTeamInviteEmail } from "@calcom/emails"; import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; -import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; +import { isTeamAdmin } from "@calcom/lib/server/queries"; +import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import { prisma } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; +import type { Team, User } from "@calcom/prisma/client"; +import type { MembershipRole } from "@calcom/prisma/enums"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; @@ -22,106 +26,359 @@ type InviteMemberOptions = { input: TInviteMemberInputSchema; }; -export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => { - if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); - if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) - throw new TRPCError({ code: "UNAUTHORIZED" }); - const translation = await getTranslation(input.language ?? "en", "common"); +type TeamWithParent = Team & { + parent: Team | null; +}; +async function checkPermissions({ + userId, + teamId, + isOrg, +}: { + userId: number; + teamId: number; + isOrg?: boolean; +}) { + // Checks if the team they are inviteing to IS the org. Not a child team + if (isOrg) { + if (!(await isOrganisationAdmin(userId, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + } else { + // TODO: do some logic here to check if the user is inviting a NEW user to a team that ISNT in the same org + if (!(await isTeamAdmin(userId, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + } +} + +async function getTeamOrThrow(teamId: number, isOrg?: boolean) { const team = await prisma.team.findFirst({ where: { - id: input.teamId, + id: teamId, + }, + include: { + parent: true, }, }); if (!team) - throw new TRPCError({ code: "NOT_FOUND", message: `${input.isOrg ? "Organization" : "Team"} not found` }); + throw new TRPCError({ code: "NOT_FOUND", message: `${isOrg ? "Organization" : "Team"} not found` }); - const emailsToInvite = Array.isArray(input.usernameOrEmail) - ? input.usernameOrEmail - : [input.usernameOrEmail]; + return team; +} - emailsToInvite.forEach(async (usernameOrEmail) => { - const invitee = await prisma.user.findFirst({ - where: { - OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }], +async function getEmailsToInvite(usernameOrEmail: string | string[]) { + const emailsToInvite = Array.isArray(usernameOrEmail) ? usernameOrEmail : [usernameOrEmail]; + + if (emailsToInvite.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You must provide at least one email address to invite.", + }); + } + + return emailsToInvite; +} + +async function getUserToInviteOrThrowIfExists({ + usernameOrEmail, + orgId, + isOrg, +}: { + usernameOrEmail: string; + orgId: number; + isOrg?: boolean; +}) { + // Check if user exists in ORG or exists all together + const invitee = await prisma.user.findFirst({ + where: { + OR: [{ username: usernameOrEmail, organizationId: orgId }, { email: usernameOrEmail }], + }, + }); + + // We throw on error cause we can't have two users in the same org with the same username + if (isOrg && invitee) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Email ${usernameOrEmail} already exists, you can't invite existing users.`, + }); + } + + return invitee; +} + +function checkInputEmailIsValid(email: string) { + if (!isEmail(email)) + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invite failed because ${email} is not a valid email address`, + }); +} + +async function createNewUserConnectToOrgIfExists({ + usernameOrEmail, + input, + parentId, + connectionInfo, +}: { + usernameOrEmail: string; + input: InviteMemberOptions["input"]; + parentId?: number | null; + connectionInfo: ReturnType; +}) { + const { orgId, autoAccept } = connectionInfo; + + const createdUser = await prisma.user.create({ + data: { + email: usernameOrEmail, + invitedTo: input.teamId, + organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org + teams: { + create: { + teamId: input.teamId, + role: input.role as MembershipRole, + accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted + }, + }, + }, + }); + + // We also need to create the membership in the parent org if it exists + if (parentId) { + await prisma.membership.create({ + data: { + teamId: parentId, + userId: createdUser.id, + role: input.role as MembershipRole, + accepted: autoAccept, + }, + }); + } +} + +async function createProvisionalMembership({ + input, + invitee, + parentId, +}: { + input: InviteMemberOptions["input"]; + invitee: User; + parentId?: number; +}) { + try { + await prisma.membership.create({ + data: { + teamId: input.teamId, + userId: invitee.id, + role: input.role as MembershipRole, + }, + }); + // Create the membership in the parent also if it exists + if (parentId) { + await prisma.membership.create({ + data: { + teamId: parentId, + userId: invitee.id, + role: input.role as MembershipRole, + }, + }); + } + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + // Don't throw an error if the user is already a member of the team when inviting multiple users + if (!Array.isArray(input.usernameOrEmail) && e.code === "P2002") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This user is a member of this team / has a pending invitation.", + }); + } else { + console.log(`User ${invitee.id} is already a member of this team.`); + } + } else throw e; + } +} + +async function sendVerificationEmail({ + usernameOrEmail, + team, + translation, + ctx, + input, + connectionInfo, +}: { + usernameOrEmail: string; + team: Awaited>; + translation: TFunction; + ctx: { user: NonNullable }; + input: { + teamId: number; + role: "ADMIN" | "MEMBER" | "OWNER"; + usernameOrEmail: string | string[]; + language: string; + sendEmailInvitation: boolean; + isOrg: boolean; + }; + connectionInfo: ReturnType; +}) { + const token: string = randomBytes(32).toString("hex"); + + if (!connectionInfo.autoAccept) { + await prisma.verificationToken.create({ + data: { + identifier: usernameOrEmail, + token, + expires: new Date(new Date().setHours(168)), // +1 week + team: { + connect: { + id: connectionInfo.orgId || input.teamId, + }, + }, + }, + }); + await sendTeamInviteEmail({ + language: translation, + from: ctx.user.name || `${team.name}'s admin`, + to: usernameOrEmail, + teamName: team?.parent?.name || team.name, + joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, + isCalcomMember: false, + isOrg: input.isOrg, + }); + } else { + // we have already joined the team in createNewUserConnectToOrgIfExists so we dont need to connect via token + await prisma.verificationToken.create({ + data: { + identifier: usernameOrEmail, + token, + expires: new Date(new Date().setHours(168)), // +1 week }, }); - if (input.isOrg && invitee) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `Email ${usernameOrEmail} already exists, you can't invite existing users.`, - }); + await sendOrganizationAutoJoinEmail({ + language: translation, + from: ctx.user.name || `${team.name}'s admin`, + to: usernameOrEmail, + orgName: team?.parent?.name || team.name, + joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, + }); + } +} + +function checkIfUserIsInDifOrg( + invitee: User, + team: Team & { + parent: Team | null; + } +) { + if (invitee.organizationId !== team.parentId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `User ${invitee.username} is already a member of another organization.`, + }); + } +} + +function getIsOrgVerified( + isOrg: boolean, + team: Team & { + parent: Team | null; + } +) { + const teamMetadata = teamMetadataSchema.parse(team.metadata); + const orgMetadataSafeParse = teamMetadataSchema.safeParse(team.parent?.metadata); + const orgMetadataIfExists = orgMetadataSafeParse.success ? orgMetadataSafeParse.data : null; + + if (isOrg && teamMetadata?.orgAutoAcceptEmail) { + return { + isInOrgScope: true, + orgVerified: teamMetadata.isOrganizationVerified, + autoAcceptEmailDomain: teamMetadata.orgAutoAcceptEmail, + }; + } else if (orgMetadataIfExists?.orgAutoAcceptEmail) { + return { + isInOrgScope: true, + orgVerified: orgMetadataIfExists.isOrganizationVerified, + autoAcceptEmailDomain: orgMetadataIfExists.orgAutoAcceptEmail, + }; + } + + return { + isInOrgScope: false, + } as { isInOrgScope: false; orgVerified: never; autoAcceptEmailDomain: never }; +} + +function getOrgConnectionInfo({ + orgAutoAcceptDomain, + orgVerified, + isOrg, + usersEmail, + team, +}: { + orgAutoAcceptDomain?: string | null; + orgVerified?: boolean | null; + usersEmail: string; + team: TeamWithParent; + isOrg: boolean; +}) { + let orgId: number | undefined = undefined; + let autoAccept = false; + + if (team.parentId || isOrg) { + orgId = team.parentId || team.id; + if (usersEmail.split("@")[1] == orgAutoAcceptDomain) { + autoAccept = orgVerified ?? true; + } else { + // No longer throw error - not needed we just dont auto accept them + orgId = undefined; + autoAccept = false; } + } + + return { orgId, autoAccept }; +} + +export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => { + const team = await getTeamOrThrow(input.teamId, input.isOrg); + const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(input.isOrg, team); + + await checkPermissions({ userId: ctx.user.id, teamId: input.teamId, isOrg: input.isOrg }); + + const translation = await getTranslation(input.language ?? "en", "common"); + + const emailsToInvite = await getEmailsToInvite(input.usernameOrEmail); + + for (const usernameOrEmail of emailsToInvite) { + const connectionInfo = getOrgConnectionInfo({ + orgVerified, + orgAutoAcceptDomain: autoAcceptEmailDomain, + usersEmail: usernameOrEmail, + team, + isOrg: input.isOrg, + }); + const invitee = await getUserToInviteOrThrowIfExists({ + usernameOrEmail, + orgId: input.teamId, + isOrg: input.isOrg, + }); if (!invitee) { - // liberal email match - - if (!isEmail(usernameOrEmail)) - throw new TRPCError({ - code: "NOT_FOUND", - message: `Invite failed because there is no corresponding user for ${usernameOrEmail}`, - }); + checkInputEmailIsValid(usernameOrEmail); // valid email given, create User and add to team - await prisma.user.create({ - data: { - email: usernameOrEmail, - invitedTo: input.teamId, - ...(input.isOrg && { organizationId: input.teamId }), - teams: { - create: { - teamId: input.teamId, - role: input.role as MembershipRole, - }, - }, - }, + await createNewUserConnectToOrgIfExists({ + usernameOrEmail, + input, + connectionInfo, + parentId: team.parentId, }); - const token: string = randomBytes(32).toString("hex"); - - await prisma.verificationToken.create({ - data: { - identifier: usernameOrEmail, - token, - expires: new Date(new Date().setHours(168)), // +1 week - }, - }); - if (team?.name) { - await sendTeamInviteEmail({ - language: translation, - from: ctx.user.name || `${team.name}'s admin`, - to: usernameOrEmail, - teamName: team.name, - joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, // we know that the user has not completed onboarding yet, so we can redirect them to the onboarding flow - isCalcomMember: false, - isOrg: input.isOrg, - }); - } + await sendVerificationEmail({ usernameOrEmail, team, translation, ctx, input, connectionInfo }); } else { + checkIfUserIsInDifOrg(invitee, team); + // create provisional membership - try { - await prisma.membership.create({ - data: { - teamId: input.teamId, - userId: invitee.id, - role: input.role as MembershipRole, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - // Don't throw an error if the user is already a member of the team when inviting multiple users - if (!Array.isArray(input.usernameOrEmail) && e.code === "P2002") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "This user is a member of this team / has a pending invitation.", - }); - } else { - console.log(`User ${invitee.id} is already a member of this team.`); - } - } else throw e; - } + await createProvisionalMembership({ + input, + invitee, + ...(team.parentId ? { parentId: team.parentId } : {}), + }); let sendTo = usernameOrEmail; if (!isEmail(usernameOrEmail)) { @@ -161,7 +418,14 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = }); } } - }); - if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); + } + + if (IS_TEAM_BILLING_ENABLED) { + if (team.parentId) { + await updateQuantitySubscriptionFromStripe(team.parentId); + } else { + await updateQuantitySubscriptionFromStripe(input.teamId); + } + } return input; }; diff --git a/packages/trpc/server/routers/viewer/teams/publish.handler.ts b/packages/trpc/server/routers/viewer/teams/publish.handler.ts index d492d4caff..5bb5dbb195 100644 --- a/packages/trpc/server/routers/viewer/teams/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/publish.handler.ts @@ -1,6 +1,12 @@ +import type { Prisma } from "@prisma/client"; + import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; -import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { + purchaseTeamSubscription, + updateQuantitySubscriptionFromStripe, +} from "@calcom/features/ee/teams/lib/payments"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager"; import { prisma } from "@calcom/prisma"; @@ -8,16 +14,103 @@ import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; +import type { TrpcSessionUser } from "../../../trpc"; import type { TPublishInputSchema } from "./publish.schema"; type PublishOptions = { ctx: { - user: NonNullable<{ id: number }>; + user: NonNullable; }; input: TPublishInputSchema; }; +const parseMetadataOrThrow = (metadata: Prisma.JsonValue) => { + const parsedMetadata = teamMetadataSchema.safeParse(metadata); + + if (!parsedMetadata.success || !parsedMetadata.data) + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" }); + return parsedMetadata.data; +}; + +const generateCheckoutSession = async ({ + teamId, + seats, + userId, +}: { + teamId: number; + seats: number; + userId: number; +}) => { + if (!IS_TEAM_BILLING_ENABLED) return; + + const checkoutSession = await purchaseTeamSubscription({ + teamId, + seats, + userId, + }); + if (!checkoutSession.url) + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed retrieving a checkout session URL.", + }); + return { url: checkoutSession.url, message: "Payment required to publish team" }; +}; + +const publishOrganizationTeamHandler = async ({ ctx, input }: PublishOptions) => { + if (!ctx.user.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" }); + + if (!isOrganisationAdmin(ctx.user.id, ctx.user?.organizationId)) + throw new TRPCError({ code: "UNAUTHORIZED" }); + + const createdTeam = await prisma.team.findFirst({ + where: { id: input.teamId, parentId: ctx.user.organizationId }, + include: { + parent: { + include: { + members: true, + }, + }, + }, + }); + + if (!createdTeam || !createdTeam.parentId) + throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); + + const metadata = parseMetadataOrThrow(createdTeam.metadata); + + // We update the quantity of the parent ID (organization) subscription + if (IS_TEAM_BILLING_ENABLED) { + await updateQuantitySubscriptionFromStripe(createdTeam.parentId); + } + + if (!metadata?.requestedSlug) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" }); + } + const { requestedSlug, ...newMetadata } = metadata; + let updatedTeam: Awaited>; + + try { + updatedTeam = await prisma.team.update({ + where: { id: createdTeam.id }, + data: { + slug: requestedSlug, + metadata: { ...newMetadata }, + }, + }); + } catch (error) { + const { message } = getRequestedSlugError(error, requestedSlug); + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); + } + + return { + url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile`, + message: "Team published successfully", + }; +}; + export const publishHandler = async ({ ctx, input }: PublishOptions) => { + if (ctx.user.organizationId) return publishOrganizationTeamHandler({ ctx, input }); + if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); const { teamId: id } = input; @@ -25,30 +118,22 @@ export const publishHandler = async ({ ctx, input }: PublishOptions) => { if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); - const metadata = teamMetadataSchema.safeParse(prevTeam.metadata); - - if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" }); + const metadata = parseMetadataOrThrow(prevTeam.metadata); // if payment needed, respond with checkout url - if (IS_TEAM_BILLING_ENABLED) { - const checkoutSession = await purchaseTeamSubscription({ - teamId: prevTeam.id, - seats: prevTeam.members.length, - userId: ctx.user.id, - }); - if (!checkoutSession.url) - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed retrieving a checkout session URL.", - }); - return { url: checkoutSession.url, message: "Payment required to publish team" }; - } + const checkoutSession = await generateCheckoutSession({ + teamId: prevTeam.id, + seats: prevTeam.members.length, + userId: ctx.user.id, + }); - if (!metadata.data?.requestedSlug) { + if (checkoutSession) return checkoutSession; + + if (!metadata?.requestedSlug) { throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" }); } - const { requestedSlug, ...newMetadata } = metadata.data; + const { requestedSlug, ...newMetadata } = metadata; let updatedTeam: Awaited>; try { diff --git a/turbo.json b/turbo.json index 6303706e3a..0d0f14d528 100644 --- a/turbo.json +++ b/turbo.json @@ -33,7 +33,8 @@ "NEXT_PUBLIC_WEBAPP_URL", "NEXT_PUBLIC_WEBSITE_URL", "STRIPE_PREMIUM_PLAN_PRODUCT_ID", - "STRIPE_TEAM_MONTHLY_PRICE_ID" + "STRIPE_TEAM_MONTHLY_PRICE_ID", + "STRIPE_ORG_MONTHLY_PRICE_ID" ] }, "@calcom/web#dx": {