Files
Matt Aitken fd4f02b2f8 fix(webapp): onboard new cloud orgs via plan selection; allow Free plan without GitHub verification (#4109)
## What & why

Two related fixes to how new cloud organizations get onboarded onto the
Free plan.

### 1. Route new cloud orgs through plan selection

New cloud organizations were created already activated, so they skipped
the plan-selection step and went straight to creating projects — which
meant their plan and usage limits were never set up. They're now created
deactivated and routed through plan selection, which activates them once
a plan is chosen. Self-hosted installs have no plan-selection step, so
they're activated immediately on creation and are unaffected.

The `Organization.v3Enabled` field is renamed to `isActivated` to better
describe what it now gates. It's mapped to the existing `v3Enabled`
column, so there's no data migration — only a schema/code rename.

### 2. Allow selecting the Free plan without GitHub verification

Choosing the Free plan no longer requires connecting and verifying a
GitHub account. The plan is applied immediately when selected. This
removes:

- the "Connect to GitHub" dialog and the GitHub-verified badge from the
plan picker
- the account-rejected state
- the now-unreachable GitHub-connect return routes

## Notes

- These changes pair with the corresponding change in the billing
service that applies the Free plan directly; they should be released
together.

## Testing

Verified locally end to end: a new cloud org is routed to plan
selection, the Free plan applies in one click with no GitHub step, the
org is activated, its usage allowance is provisioned, and it lands on
the new-project page.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-02 16:34:41 +02:00

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 });
}