diff --git a/apps/api/cmd/seed-platform-admin/main.go b/apps/api/cmd/seed-platform-admin/main.go index 0445761..4db11dd 100644 --- a/apps/api/cmd/seed-platform-admin/main.go +++ b/apps/api/cmd/seed-platform-admin/main.go @@ -1,22 +1,16 @@ -// Command seed-platform-admin upserts the first (or additional) platform admin -// for greenfield / production deploys where no legacy admin_users cutover ran -// and seed-demo must not be used. +// Command seed-platform-admin upserts a platform admin with a tenant company +// (membership admin + Free plan), matching self-serve register. Without a +// company, login leaves session company empty and every /api/* tenant call +// returns 400 "company not selected". // // Usage: // -// cd apps/api -// go run ./cmd/seed-platform-admin \ -// -postgres "$env:DATABASE_URL" \ -// -email you@example.com \ -// -password 'choose-a-strong-password' \ -// -confirm +// npm run seed:platform-admin -- --email you@example.com --password '…' +// npm run seed:platform-admin -- --email you@example.com --promote-only +// npm run seed:platform-admin -- --email you@example.com --password '…' --company 'Acme Ops' // -// Env aliases: none for credentials — pass -email / -password (or use -// `npm run seed:platform-admin -- --email … --password …`). DATABASE_URL may -// come from the shell or monorepo-root .env via config.LoadDotEnv. -// -// -promote-only grants admin on an existing user without changing the password -// (-password is ignored). Always requires -confirm. +// Credentials are CLI-only (never from .env). DATABASE_URL may come from the +// shell or monorepo-root .env via config.LoadDotEnv. Always requires -confirm. package main import ( @@ -30,6 +24,7 @@ import ( "time" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" + "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/config" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -43,11 +38,13 @@ func main() { email := flag.String("email", "", "Platform admin email (required; do not put in .env)") password := flag.String("password", "", "Password (min 8; ignored with -promote-only; do not put in .env)") name := flag.String("name", "", "Display name (defaults to email local-part)") + company := flag.String("company", "", "Company/workspace name (default: \" workspace\")") + noCompany := flag.Bool("no-company", false, "Skip company creation (staff-only; dashboard tenant APIs will 400)") promoteOnly := flag.Bool("promote-only", false, "Grant platform admin on an existing user; do not set password") - confirm := flag.Bool("confirm", false, "Required: acknowledge this writes is_platform_admin + staff_role=admin") + confirm := flag.Bool("confirm", false, "Required: acknowledge privilege + optional company writes") flag.Parse() - opts, err := parseOptions(*postgresURL, *email, *password, *name, *promoteOnly, *confirm, os.Getenv("APP_ENV")) + opts, err := parseOptions(*postgresURL, *email, *password, *name, *company, *promoteOnly, *noCompany, *confirm, os.Getenv("APP_ENV")) if err != nil { log.Fatal(err) } @@ -80,8 +77,30 @@ func main() { } else { fmt.Println(" password: set (argon2id)") } + + if opts.NoCompany { + fmt.Println(" company: skipped (-no-company)") + fmt.Println() + fmt.Println("WARNING: without a company, dashboard /api/* calls return 400 company not selected.") + return + } + + companyID, companyName, companyCreated, err := ensureAdminCompany(ctx, pg, userID, opts.CompanyName) + if err != nil { + log.Fatal(err) + } + billingSvc := &billing.Service{Pool: pg} + if err := billingSvc.ProvisionFreePlan(ctx, companyID); err != nil { + log.Fatalf("provision Free plan: %v", err) + } + companyAction := "existing" + if companyCreated { + companyAction = "created" + } + fmt.Printf(" company: %s (%s) [%s]\n", companyName, companyID, companyAction) + fmt.Println(" plan: Free (provisioned)") fmt.Println() - fmt.Println("Sign in on the web app, then open /admin.") + fmt.Println("Sign in on the web app (company is selected on login). Platform panel: /admin.") } type options struct { @@ -89,10 +108,12 @@ type options struct { Email string Password string Name string + CompanyName string PromoteOnly bool + NoCompany bool } -func parseOptions(postgresURL, email, password, name string, promoteOnly, confirm bool, appEnv string) (options, error) { +func parseOptions(postgresURL, email, password, name, company string, promoteOnly, noCompany, confirm bool, appEnv string) (options, error) { if !confirm { return options{}, fmt.Errorf("-confirm is required (refuses silent privilege grants)") } @@ -116,11 +137,18 @@ func parseOptions(postgresURL, email, password, name string, promoteOnly, confir } } + companyName := strings.TrimSpace(company) + if !noCompany && companyName == "" { + companyName = display + " workspace" + } + opts := options{ PostgresURL: pg, Email: emailNorm, Name: display, + CompanyName: companyName, PromoteOnly: promoteOnly, + NoCompany: noCompany, } if promoteOnly { return opts, nil @@ -225,3 +253,61 @@ func upsertPlatformAdmin(ctx context.Context, pg *pgxpool.Pool, opts options) (u } return userID, !existed, nil } + +// ensureAdminCompany attaches the user to a company as membership admin. +// If they already have any active membership, reuses the first company (idempotent repair). +func ensureAdminCompany(ctx context.Context, pg *pgxpool.Pool, userID uuid.UUID, companyName string) (uuid.UUID, string, bool, error) { + var existingID uuid.UUID + var existingName string + err := pg.QueryRow(ctx, ` + SELECT c.id, c.name + FROM memberships m + JOIN companies c ON c.id = m.company_id + WHERE m.user_id = $1 AND m.status = 'active' + ORDER BY m.created_at NULLS LAST, c.name + LIMIT 1`, userID).Scan(&existingID, &existingName) + if err == nil { + _, _ = pg.Exec(ctx, ` + UPDATE memberships + SET role = 'admin', status = 'active', updated_at = now() + WHERE user_id = $1 AND company_id = $2`, userID, existingID) + return existingID, existingName, false, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, "", false, fmt.Errorf("lookup memberships: %w", err) + } + + tx, err := pg.Begin(ctx) + if err != nil { + return uuid.Nil, "", false, fmt.Errorf("begin company: %w", err) + } + defer tx.Rollback(ctx) + + var companyID uuid.UUID + err = tx.QueryRow(ctx, `INSERT INTO companies (name) VALUES ($1) RETURNING id`, companyName).Scan(&companyID) + if err != nil { + return uuid.Nil, "", false, fmt.Errorf("insert company: %w", err) + } + _, err = tx.Exec(ctx, ` + INSERT INTO memberships (company_id, user_id, role, status) + VALUES ($1, $2, 'admin', 'active')`, companyID, userID) + if err != nil { + return uuid.Nil, "", false, fmt.Errorf("insert membership: %w", err) + } + _, err = tx.Exec(ctx, ` + INSERT INTO company_settings (company_id) VALUES ($1) + ON CONFLICT DO NOTHING`, companyID) + if err != nil { + return uuid.Nil, "", false, fmt.Errorf("company_settings: %w", err) + } + _, err = tx.Exec(ctx, ` + INSERT INTO credit_balances (company_id) VALUES ($1) + ON CONFLICT DO NOTHING`, companyID) + if err != nil { + return uuid.Nil, "", false, fmt.Errorf("credit_balances: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return uuid.Nil, "", false, fmt.Errorf("commit company: %w", err) + } + return companyID, companyName, true, nil +} diff --git a/apps/api/cmd/seed-platform-admin/main_test.go b/apps/api/cmd/seed-platform-admin/main_test.go index e3a5b08..b627844 100644 --- a/apps/api/cmd/seed-platform-admin/main_test.go +++ b/apps/api/cmd/seed-platform-admin/main_test.go @@ -7,7 +7,7 @@ import ( func TestParseOptionsRequiresPostgres(t *testing.T) { t.Parallel() - _, err := parseOptions("", "a@b.com", "password1", "", false, true, "development") + _, err := parseOptions("", "a@b.com", "password1", "", "", false, false, true, "development") if err == nil || !strings.Contains(err.Error(), "DATABASE_URL") { t.Fatalf("err = %v, want DATABASE_URL required", err) } @@ -15,7 +15,7 @@ func TestParseOptionsRequiresPostgres(t *testing.T) { func TestParseOptionsRequiresConfirm(t *testing.T) { t.Parallel() - _, err := parseOptions("postgres://x", "a@b.com", "password1", "", false, false, "development") + _, err := parseOptions("postgres://x", "a@b.com", "password1", "", "", false, false, false, "development") if err == nil || !strings.Contains(err.Error(), "-confirm") { t.Fatalf("err = %v, want -confirm required", err) } @@ -23,19 +23,37 @@ func TestParseOptionsRequiresConfirm(t *testing.T) { func TestParseOptionsRequiresEmailAndPassword(t *testing.T) { t.Parallel() - _, err := parseOptions("postgres://x", "", "password1", "", false, true, "development") + _, err := parseOptions("postgres://x", "", "password1", "", "", false, false, true, "development") if err == nil { t.Fatal("expected email error") } - _, err = parseOptions("postgres://x", "a@b.com", "short", "", false, true, "development") + _, err = parseOptions("postgres://x", "a@b.com", "short", "", "", false, false, true, "development") if err == nil || !strings.Contains(err.Error(), "8") { t.Fatalf("err = %v, want min length", err) } } +func TestParseOptionsDefaultCompanyName(t *testing.T) { + t.Parallel() + opts, err := parseOptions("postgres://x", "ops@example.com", "password1", "Ops", "", false, false, true, "development") + if err != nil { + t.Fatal(err) + } + if opts.CompanyName != "Ops workspace" { + t.Fatalf("company = %q", opts.CompanyName) + } + opts, err = parseOptions("postgres://x", "ops@example.com", "password1", "Ops", "Acme", false, false, true, "development") + if err != nil { + t.Fatal(err) + } + if opts.CompanyName != "Acme" { + t.Fatalf("company = %q", opts.CompanyName) + } +} + func TestParseOptionsPromoteOnlySkipsPassword(t *testing.T) { t.Parallel() - opts, err := parseOptions("postgres://x", "Ops@Example.COM", "", "Ops", true, true, "production") + opts, err := parseOptions("postgres://x", "Ops@Example.COM", "", "Ops", "Acme", true, false, true, "production") if err != nil { t.Fatal(err) } @@ -45,18 +63,18 @@ func TestParseOptionsPromoteOnlySkipsPassword(t *testing.T) { if opts.Password != "" || !opts.PromoteOnly { t.Fatalf("promote-only opts = %+v", opts) } - if opts.Name != "Ops" { - t.Fatalf("name = %q", opts.Name) + if opts.CompanyName != "Acme" { + t.Fatalf("company = %q", opts.CompanyName) } } func TestParseOptionsRejectsDemoInProduction(t *testing.T) { t.Parallel() - _, err := parseOptions("postgres://x", "demo@descrybe.local", "securepass", "", false, true, "production") + _, err := parseOptions("postgres://x", "demo@descrybe.local", "securepass", "", "", false, false, true, "production") if err == nil || !strings.Contains(err.Error(), "demo/local") { t.Fatalf("err = %v, want demo/local refuse", err) } - _, err = parseOptions("postgres://x", "you@example.com", "DemoPass123!", "", false, true, "prod") + _, err = parseOptions("postgres://x", "you@example.com", "DemoPass123!", "", "", false, false, true, "prod") if err == nil || !strings.Contains(err.Error(), "seed-demo password") { t.Fatalf("err = %v, want demo password refuse", err) } @@ -64,7 +82,7 @@ func TestParseOptionsRejectsDemoInProduction(t *testing.T) { func TestParseOptionsAllowsDemoLocally(t *testing.T) { t.Parallel() - opts, err := parseOptions("postgres://x", "demo@descrybe.local", "DemoPass123!", "", false, true, "development") + opts, err := parseOptions("postgres://x", "demo@descrybe.local", "DemoPass123!", "", "", false, false, true, "development") if err != nil { t.Fatal(err) } diff --git a/scripts/seed-local.mjs b/scripts/seed-local.mjs index 6480ef7..4246315 100644 --- a/scripts/seed-local.mjs +++ b/scripts/seed-local.mjs @@ -46,6 +46,8 @@ run([ adminPass, "-name", "Local Admin", + "-company", + "Local Admin Co", "-confirm", ]); diff --git a/scripts/seed-platform-admin.mjs b/scripts/seed-platform-admin.mjs index c0db8e9..27eabd6 100644 --- a/scripts/seed-platform-admin.mjs +++ b/scripts/seed-platform-admin.mjs @@ -1,14 +1,13 @@ #!/usr/bin/env node /** - * Upsert a platform admin (is_platform_admin + staff_role=admin). - * Loads monorepo-root `.env` only for DATABASE_URL (same as the API). + * Upsert a platform admin with a tenant company (fixes empty-company 400s). + * Loads monorepo-root `.env` only for DATABASE_URL. * Email/password are CLI flags only — never read from .env. * * Usage (from repo root): * npm run seed:platform-admin -- --email you@example.com --password '…' * npm run seed:platform-admin -- --email you@example.com --promote-only - * - * Optional: --name "Display Name" + * npm run seed:platform-admin -- --email you@example.com --password '…' --company 'Acme Ops' */ import { spawnSync } from "node:child_process"; import path from "node:path"; @@ -22,22 +21,34 @@ function usage(msg) { console.error(`Usage: npm run seed:platform-admin -- --email you@example.com --password 'strong-password' npm run seed:platform-admin -- --email you@example.com --promote-only - npm run seed:platform-admin -- --email you@example.com --password '…' --name 'Ops' + npm run seed:platform-admin -- --email you@example.com --password '…' --company 'Acme Ops' Email/password must be passed on the CLI (not stored in .env). -DATABASE_URL comes from the shell or monorepo-root .env.`); +DATABASE_URL comes from the shell or monorepo-root .env. +Creates a company + admin membership + Free plan unless --no-company.`); process.exit(1); } /** Parse `--key value` / `--flag` from argv after npm's `--`. */ function parseArgs(argv) { - const out = { email: "", password: "", name: "", promoteOnly: false }; + const out = { + email: "", + password: "", + name: "", + company: "", + promoteOnly: false, + noCompany: false, + }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--promote-only") { out.promoteOnly = true; continue; } + if (a === "--no-company") { + out.noCompany = true; + continue; + } if (a === "--email" || a === "-email") { out.email = String(argv[++i] || "").trim(); continue; @@ -50,6 +61,10 @@ function parseArgs(argv) { out.name = String(argv[++i] || "").trim(); continue; } + if (a === "--company" || a === "-company") { + out.company = String(argv[++i] || "").trim(); + continue; + } if (a === "--help" || a === "-h") usage(); usage(`Unknown argument: ${a}`); } @@ -78,9 +93,9 @@ const args = [ opts.email, "-confirm", ]; -if (opts.name) { - args.push("-name", opts.name); -} +if (opts.name) args.push("-name", opts.name); +if (opts.company) args.push("-company", opts.company); +if (opts.noCompany) args.push("-no-company"); if (opts.promoteOnly) { args.push("-promote-only"); } else {