feat(db): add script to create an org with its admin and plan

Adds `db:create-org` which provisions an organization, adds the given
email as a verified admin, and attaches an active plan (BASIC by
default). Creates the user + profile first when the email is unknown,
derives the site name from the org name unless overridden, and supports
a --dry-run preview mode.
This commit is contained in:
Rotimi Best
2026-08-14 01:06:06 +01:00
parent 56c944a74e
commit a93acd0f6d
2 changed files with 160 additions and 0 deletions
+1
View File
@@ -67,6 +67,7 @@
"db:assets-backfill": "tsx src/scripts/backfill-lesson-assets.ts",
"db:rewrite-media-host": "tsx src/scripts/rewrite-media-host.ts",
"db:login-link": "tsx src/scripts/generate-login-link.ts",
"db:create-org": "tsx src/scripts/create-org-with-admin.ts",
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
"db:backfill-member-email-notifications": "tsx src/scripts/backfill-member-email-notifications-from-profile.ts",
@@ -0,0 +1,159 @@
/**
* Creates an organization and makes the given email its admin.
*
* If no user exists for the email yet, a user + profile are created first so
* the membership has a profile to point at. The admin is added as ROLE.ADMIN
* with verified = true and an active plan is attached to the new org.
*
* Usage:
* pnpm --filter @cio/db db:create-org "<Org Name>" <email> [PLAN] [site-name] [--dry-run]
*
* PLAN is one of BASIC (default), EARLY_ADOPTER, ENTERPRISE.
* site-name defaults to a slug of the org name.
* --dry-run prints what would be created without writing anything.
*/
import 'dotenv/config';
import { db, eq, organization, organizationmember, organizationPlan, profile, user } from '@db/drizzle';
import { ROLE } from '@cio/utils/constants';
import { PLAN } from '@cio/utils/plans';
type PlanName = keyof typeof PLAN;
const VALID_PLANS = new Set<string>([PLAN.BASIC, PLAN.EARLY_ADOPTER, PLAN.ENTERPRISE]);
function parseArgs() {
const args = process.argv.slice(2);
const isDryRun = args.includes('--dry-run');
const [orgName, email, planArg, siteNameArg] = args.filter((arg) => !arg.startsWith('--'));
if (!orgName || !email) {
throw new Error('Usage: pnpm --filter @cio/db db:create-org "<Org Name>" <email> [PLAN] [site-name] [--dry-run]');
}
const plan = (planArg ?? PLAN.BASIC).toUpperCase() as PlanName;
if (!VALID_PLANS.has(plan)) {
throw new Error(`Invalid plan '${planArg}'. Must be one of: ${[...VALID_PLANS].join(', ')}`);
}
return {
orgName: orgName.trim(),
email: email.trim().toLowerCase(),
plan,
siteName: siteNameArg?.trim(),
isDryRun
};
}
function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
async function createUser(email: string): Promise<string> {
const name = email.split('@')[0] || 'user';
const [created] = await db.insert(user).values({ name, email, emailVerified: true }).returning({ id: user.id });
return created.id;
}
async function ensureProfile(userId: string, email: string): Promise<void> {
const emailPrefix = email.split('@')[0] || 'user';
const username = `${emailPrefix}${Date.now()}`;
await db.insert(profile).values({
id: userId,
username,
fullname: emailPrefix,
email,
canAddCourse: true,
isEmailVerified: true
});
}
async function hasProfile(userId: string): Promise<boolean> {
const rows = await db.select({ id: profile.id }).from(profile).where(eq(profile.id, userId)).limit(1);
return rows.length > 0;
}
async function main() {
const { orgName, email, plan, siteName: providedSiteName, isDryRun } = parseArgs();
const siteName = providedSiteName || slugify(orgName);
if (!siteName) {
throw new Error('Could not derive a site name from the organization name. Pass one explicitly.');
}
const existingOrg = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.siteName, siteName))
.limit(1);
if (existingOrg.length > 0) {
throw new Error(`Organization with site name '${siteName}' already exists (${existingOrg[0].id})`);
}
const existingUser = await db.select({ id: user.id }).from(user).where(eq(user.email, email)).limit(1);
const userId = existingUser[0]?.id;
const userExists = Boolean(userId);
const profileExists = userId ? await hasProfile(userId) : false;
console.log('');
console.log(isDryRun ? '[DRY RUN] No changes made.' : 'Creating organization:');
console.log(` Name: ${orgName}`);
console.log(` Site name: ${siteName}`);
console.log(` Admin: ${email}`);
console.log(` Admin user: ${userExists ? `exists (${userId})` : 'will be created'}`);
console.log(` Profile: ${profileExists ? 'exists' : 'will be created'}`);
console.log(` Plan: ${plan}`);
if (isDryRun) {
console.log('');
console.log('Re-run without --dry-run to apply.');
return;
}
const resolvedUserId = userExists ? (userId as string) : await createUser(email);
if (!profileExists) {
await ensureProfile(resolvedUserId, email);
}
const result = await db.transaction(async (tx) => {
const [org] = await tx.insert(organization).values({ name: orgName, siteName }).returning();
const [member] = await tx
.insert(organizationmember)
.values({ organizationId: org.id, profileId: resolvedUserId, roleId: ROLE.ADMIN, verified: true })
.returning();
await tx.insert(organizationPlan).values({
orgId: org.id,
planName: plan,
subscriptionId: `script-${org.id}`,
triggeredBy: member.id,
provider: 'script',
payload: {},
isActive: true
});
return { org, member };
});
console.log('');
console.log('Created:');
console.log(` Org ID: ${result.org.id}`);
console.log(` Member ID: ${result.member.id}`);
console.log('');
console.log('The admin has no password yet; use "forgot password" on login to set one.');
}
main().catch((error) => {
console.error('create-org-with-admin error:', error instanceof Error ? error.message : error);
process.exit(1);
});