193 lines
5.3 KiB
TypeScript
193 lines
5.3 KiB
TypeScript
import type {
|
|
Organization,
|
|
OrgMember,
|
|
Prisma,
|
|
Project,
|
|
RuntimeEnvironment,
|
|
User,
|
|
} from "@trigger.dev/database";
|
|
import { customAlphabet } from "nanoid";
|
|
import { generate } from "random-words";
|
|
import slug from "slug";
|
|
import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
|
|
import { env } from "~/env.server";
|
|
import { featuresForUrl } from "~/features.server";
|
|
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
|
|
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
|
|
import { enqueueAttioWorkspaceSync } from "~/services/attio.server";
|
|
import {
|
|
applyBillingLimitPauseAfterEnvCreate,
|
|
getInitialEnvPauseStateForBillingLimit,
|
|
} from "~/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server";
|
|
export type { Organization };
|
|
|
|
const nanoid = customAlphabet("1234567890abcdef", 4);
|
|
|
|
/**
|
|
* Resolve an organization id from its slug for use as an RBAC auth scope.
|
|
* Reads the replica first (the common case) and falls back to the primary on a
|
|
* miss, so replica lag never leaves a real org unresolved, which the dashboard
|
|
* route builder treats as an unauthorized request.
|
|
*/
|
|
export async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
|
|
const fromReplica = await $replica.organization.findFirst({
|
|
where: { slug },
|
|
select: { id: true },
|
|
});
|
|
if (fromReplica) {
|
|
return fromReplica.id;
|
|
}
|
|
|
|
const fromPrimary = await prisma.organization.findFirst({
|
|
where: { slug },
|
|
select: { id: true },
|
|
});
|
|
return fromPrimary?.id ?? null;
|
|
}
|
|
|
|
export async function createOrganization(
|
|
{
|
|
title,
|
|
userId,
|
|
companySize,
|
|
onboardingData,
|
|
avatar,
|
|
}: Pick<Organization, "title" | "companySize"> & {
|
|
userId: User["id"];
|
|
onboardingData?: Prisma.InputJsonValue;
|
|
avatar?: Prisma.InputJsonValue;
|
|
},
|
|
attemptCount = 0
|
|
): Promise<Organization> {
|
|
if (typeof process.env.BLOCKED_USERS === "string" && process.env.BLOCKED_USERS.includes(userId)) {
|
|
throw new Error("Organization could not be created.");
|
|
}
|
|
|
|
const uniqueOrgSlug = `${slug(title)}-${nanoid(4)}`;
|
|
|
|
const orgWithSameSlug = await prisma.organization.findFirst({
|
|
where: { slug: uniqueOrgSlug },
|
|
});
|
|
|
|
if (attemptCount > 100) {
|
|
throw new Error(`Unable to create organization with slug ${uniqueOrgSlug} after 100 attempts`);
|
|
}
|
|
|
|
if (orgWithSameSlug) {
|
|
return createOrganization(
|
|
{
|
|
title,
|
|
userId,
|
|
companySize,
|
|
onboardingData,
|
|
avatar,
|
|
},
|
|
attemptCount + 1
|
|
);
|
|
}
|
|
|
|
const features = featuresForUrl(new URL(env.APP_ORIGIN));
|
|
|
|
const organization = await prisma.organization.create({
|
|
data: {
|
|
title,
|
|
slug: uniqueOrgSlug,
|
|
companySize,
|
|
onboardingData: onboardingData ?? undefined,
|
|
avatar: avatar ?? undefined,
|
|
maximumConcurrencyLimit: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
|
|
members: {
|
|
create: {
|
|
userId: userId,
|
|
role: "ADMIN",
|
|
},
|
|
},
|
|
// Managed-cloud orgs start deactivated so they're routed through
|
|
// select-plan, which provisions their billing entitlement and activates
|
|
// them. Self-hosters have no billing gate, so they're active immediately.
|
|
isActivated: !features.isManagedCloud,
|
|
},
|
|
include: {
|
|
members: true,
|
|
},
|
|
});
|
|
|
|
// Fire-and-forget; never blocks org creation.
|
|
void enqueueAttioWorkspaceSync({
|
|
orgId: organization.id,
|
|
title: organization.title,
|
|
slug: organization.slug,
|
|
companySize: organization.companySize,
|
|
createdAt: organization.createdAt,
|
|
adminUserId: userId,
|
|
});
|
|
|
|
return { ...organization };
|
|
}
|
|
|
|
export async function createEnvironment({
|
|
organization,
|
|
project,
|
|
type,
|
|
isBranchableEnvironment = false,
|
|
member,
|
|
prismaClient = prisma,
|
|
/** When set, skips billing lookup — caller must supply the limit for this org + type. */
|
|
maximumConcurrencyLimit,
|
|
}: {
|
|
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
|
|
project: Pick<Project, "id">;
|
|
type: RuntimeEnvironment["type"];
|
|
isBranchableEnvironment?: boolean;
|
|
member?: OrgMember;
|
|
prismaClient?: PrismaClientOrTransaction;
|
|
maximumConcurrencyLimit?: number;
|
|
}) {
|
|
const slug = envSlug(type);
|
|
const apiKey = createApiKeyForEnv(type);
|
|
const pkApiKey = createPkApiKeyForEnv(type);
|
|
const shortcode = createShortcode().join("-");
|
|
|
|
const limit =
|
|
maximumConcurrencyLimit ?? (await getDefaultEnvironmentConcurrencyLimit(organization.id, type));
|
|
const billingPause = await getInitialEnvPauseStateForBillingLimit(organization.id, type);
|
|
|
|
const environment = await prismaClient.runtimeEnvironment.create({
|
|
data: {
|
|
slug,
|
|
apiKey,
|
|
pkApiKey,
|
|
shortcode,
|
|
autoEnableInternalSources: type !== "DEVELOPMENT",
|
|
maximumConcurrencyLimit: limit,
|
|
paused: billingPause.paused,
|
|
pauseSource: billingPause.pauseSource,
|
|
organization: {
|
|
connect: {
|
|
id: organization.id,
|
|
},
|
|
},
|
|
project: {
|
|
connect: {
|
|
id: project.id,
|
|
},
|
|
},
|
|
orgMember: member ? { connect: { id: member.id } } : undefined,
|
|
type,
|
|
isBranchableEnvironment,
|
|
},
|
|
include: {
|
|
organization: true,
|
|
project: true,
|
|
},
|
|
});
|
|
|
|
await applyBillingLimitPauseAfterEnvCreate(environment);
|
|
|
|
return environment;
|
|
}
|
|
|
|
function createShortcode() {
|
|
return generate({ exactly: 2 });
|
|
}
|