Files
calendar/packages/features/ee/organizations/lib/OrganizationPermissionService.ts
sean-brydonGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Hariom Balhara
fa35cc5210 chore: organization onboarding refactor (#24381)
* feat: redirect to new onboarding flow

* Getting started

* Brand details

* Preview organization brands

* Orgs team pages

* Invite team steps

* Move to global zustand store

* Few darkmdoe fixes

* Wip onboarding + stripe flow

* Default plan state

Server Action for gettting slug satus of org

* Remove onboardingId

* Confirmation prompt

* Update old onboarding flow handlers to handle new fields

* update onboarding hook

* Filter out organization section for none -company emails

* Match placeholders to users domain

* Drop migration

* Wip new onboarding intent

* WIP flow for self-hosted. Same service call just split logic

* WIP

* Add TODO

* Use onboarding user type instead of trpc session

* WIP

* WIP

* pass role and team name from onboarding to save in schema

* Add test to ensure role + name + team are persisted into onboarding table

* migrate roles to enum values

* Update ENUM

* Fix type error

* Redirect if flag is disabled

* Remove web

* WIP

* WIP

* Fix migration

* Fix calls

* User onboarding User types instead of trpc session

* Fix factory tests

* Fix flow for self hoste

* Type error

* More type fixes

* Fix handler tests

* Fix enum return type being different

* Use consistant types across the oganization stuff

* Fix

* Use TEAM_BILLING for e2e test

* Refactor is not company email and add tests

* Fix

* Fix

* Refactor flow to submit after form complete

* Fix flow with billing disabled

* Fix tests

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Rename and move test files

* WIP

* Fix types

* Update repo paths + tests

* Move to service folder

* Fix tests

* Fix types

* Remove old test files

* Restore lock

* Fix path

* Fix tests with new paths and factory logic

* Fix updaetdAt

* WIP onboardingID isolation

* Fix e2e test

* verify test

* Code rabbit

* Rename SelfHostedOnboardongService -> SelfHostedOrganizationOnboardingService

* Fix stores

* Fix type error

* Fix types

* remove tsignore

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* NITS

* Add the logic to auto complete admin org when billing enabled

* Fix store being weird

* We need to return the parsed value

* fixes

* sync from db always

* Add onboardingSgtore tests

* fix test

* remove step and status

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <[email protected]>
2025-10-18 14:13:30 +00:00

142 lines
4.7 KiB
TypeScript

import { UserPermissionRole } from "@calcom/kysely/types";
import { ORGANIZATION_SELF_SERVE_MIN_SEATS, ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { OnboardingUser } from "./service/onboarding/types";
const log = logger.getSubLogger({ prefix: ["ee", "organizations", "OrganizationPermissionService"] });
type SeatsPrice = {
seats?: number | null;
pricePerSeat?: number | null;
};
export interface validatePermissionsIOrganizationPermissionService {
hasPermissionToCreateForEmail(targetEmail: string): Promise<boolean>;
hasPendingOrganizations(email: string, slug?: string): Promise<boolean>;
hasPermissionToModifyDefaultPayment(): boolean;
hasPermissionToMigrateTeams(teamIds: number[]): Promise<boolean>;
hasModifiedDefaultPayment(
input: {
billingPeriod?: string;
} & SeatsPrice
): boolean;
}
export class OrganizationPermissionService {
constructor(private readonly user: OnboardingUser) {}
async hasPermissionToCreateForEmail(targetEmail: string): Promise<boolean> {
return this.user.email === targetEmail || this.user.role === "ADMIN";
}
/**
* If an onboarding is complete then it also means that org is created already.
*/
async hasConflictingOrganization({ slug }: { slug: string }): Promise<boolean> {
return !!(await OrganizationRepository.findBySlug({ slug }));
}
async hasCompletedOnboarding(email: string): Promise<boolean> {
const orgOnboarding = await prisma.organizationOnboarding.findUnique({
where: {
orgOwnerEmail: email,
},
});
return !!(orgOnboarding && orgOnboarding.isComplete);
}
hasPermissionToModifyDefaultPayment(): boolean {
return this.user.role === UserPermissionRole.ADMIN;
}
hasModifiedDefaultPayment(data: SeatsPrice & { billingPeriod?: string }): boolean {
const isBillingPeriodModified =
data.billingPeriod !== undefined && data.billingPeriod !== null && data.billingPeriod !== "MONTHLY";
const isSeatsModified =
data.seats !== undefined && data.seats !== null && data.seats !== ORGANIZATION_SELF_SERVE_MIN_SEATS;
const isPricePerSeatModified =
data.pricePerSeat !== undefined &&
data.pricePerSeat !== null &&
data.pricePerSeat !== ORGANIZATION_SELF_SERVE_PRICE;
log.debug(
"hasModifiedDefaultPayment",
safeStringify({ isBillingPeriodModified, isSeatsModified, isPricePerSeatModified })
);
return isBillingPeriodModified || isSeatsModified || isPricePerSeatModified;
}
async hasPermissionToMigrateTeams(teamIds: number[]): Promise<boolean> {
if (teamIds.length === 0) return true;
const teamMemberships = await prisma.membership.findMany({
where: {
userId: this.user.id,
teamId: {
in: teamIds,
},
role: {
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
},
},
});
return teamMemberships.length === teamIds.length;
}
/**
* Doesn't validate custom price permission as that is validated before onboarding is created and this method also runs when checkout is to be started
*/
async validatePermissions(
input: {
orgOwnerEmail: string;
teams?: { id: number; isBeingMigrated: boolean }[];
billingPeriod?: string;
slug: string;
} & SeatsPrice
): Promise<boolean> {
if (!(await this.hasPermissionToCreateForEmail(input.orgOwnerEmail))) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "you_do_not_have_permission_to_create_an_organization_for_this_email",
});
}
if (await this.hasConflictingOrganization({ slug: input.slug })) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "organization_already_exists_with_this_slug",
});
}
if (await this.hasCompletedOnboarding(input.orgOwnerEmail)) {
// TODO: Consider redirecting to success page
throw new TRPCError({
code: "BAD_REQUEST",
message: "organization_onboarding_already_completed",
});
}
const teamsToMigrate = input.teams
?.filter((team) => team.id > 0 && team.isBeingMigrated)
.map((team) => team.id);
if (teamsToMigrate && !(await this.hasPermissionToMigrateTeams(teamsToMigrate))) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "you_do_not_have_permission_to_migrate_one_or_more_of_the_teams",
});
}
return true;
}
}