From a93acd0f6d7082beec848616b99d839b8e3ffa72 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Fri, 14 Aug 2026 01:04:48 +0100 Subject: [PATCH] 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. --- packages/db/package.json | 1 + .../db/src/scripts/create-org-with-admin.ts | 159 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 packages/db/src/scripts/create-org-with-admin.ts diff --git a/packages/db/package.json b/packages/db/package.json index bce6c0e3..4eace404 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -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", diff --git a/packages/db/src/scripts/create-org-with-admin.ts b/packages/db/src/scripts/create-org-with-admin.ts new file mode 100644 index 00000000..643615a8 --- /dev/null +++ b/packages/db/src/scripts/create-org-with-admin.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 "" [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([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 "" [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 { + 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 { + 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 { + 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); +});