// Command seed-a1-teammate upserts a second A1 company member (not the owner) // with must_set_password=true and prints a one-time accept-invite URL. // // Usage: // // go run ./cmd/seed-a1-teammate -postgres "$DATABASE_URL" // go run ./cmd/seed-a1-teammate -email a1-other@descrybe.local -role member package main import ( "context" "flag" "fmt" "log" "os" "strings" "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/mail" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func main() { postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL") email := flag.String("email", "a1-other@descrybe.local", "Teammate email to upsert") name := flag.String("name", "A1 teammate", "Display name") role := flag.String("role", "member", "Membership role: member|admin") webOrigin := flag.String("web-origin", firstNonEmpty(os.Getenv("WEB_ORIGIN"), "http://localhost:28472"), "Web origin for accept-invite URL") flag.Parse() if strings.TrimSpace(*postgresURL) == "" { log.Fatal("-postgres / DATABASE_URL is required") } emailNorm := strings.ToLower(strings.TrimSpace(*email)) if emailNorm == "" { log.Fatal("-email is required") } if emailNorm == "a1-primary@descrybe.local" { log.Fatal("-email must not be a1-primary@descrybe.local (that user remains the company owner)") } memRole := auth.NormalizeMembershipRole(*role) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() pg, err := pgxpool.New(ctx, *postgresURL) if err != nil { log.Fatalf("postgres: %v", err) } defer pg.Close() var companyID uuid.UUID var companyName string err = pg.QueryRow(ctx, ` SELECT id, name FROM companies WHERE legacy_company_id = $1 ORDER BY created_at ASC LIMIT 1`, billing.A1LegacyCompanyID).Scan(&companyID, &companyName) if err != nil { log.Fatalf("find A1 company (legacy_company_id=%s): %v", billing.A1LegacyCompanyID, err) } tx, err := pg.Begin(ctx) if err != nil { log.Fatalf("begin: %v", err) } defer tx.Rollback(ctx) var userID uuid.UUID err = tx.QueryRow(ctx, ` INSERT INTO users (email, name, must_set_password, is_platform_admin, is_active, updated_at) VALUES ($1, $2, true, false, true, now()) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, must_set_password = true, password_hash = NULL, is_active = true, updated_at = now() RETURNING id`, emailNorm, strings.TrimSpace(*name)).Scan(&userID) if err != nil { log.Fatalf("upsert user: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO memberships (company_id, user_id, role, status) VALUES ($1, $2, $3, 'active') ON CONFLICT (company_id, user_id) DO UPDATE SET role = EXCLUDED.role, status = 'active', updated_at = now()`, companyID, userID, memRole) if err != nil { log.Fatalf("upsert membership: %v", err) } // Ensure a1-primary remains owner when present. _, _ = tx.Exec(ctx, ` UPDATE companies c SET owner_user_id = u.id, updated_at = now() FROM users u INNER JOIN memberships m ON m.user_id = u.id AND m.status = 'active' WHERE c.id = m.company_id AND c.id = $1 AND lower(u.email) = 'a1-primary@descrybe.local'`, companyID) if err := tx.Commit(ctx); err != nil { log.Fatalf("commit: %v", err) } svc := &auth.Service{Pool: pg} inv, err := svc.ReissueSetPasswordInvite(ctx, userID, 0) if err != nil { log.Fatalf("issue set-password invite: %v", err) } acceptURL := mail.AcceptInviteURL(strings.TrimSpace(*webOrigin), inv.Token) fmt.Printf("A1 teammate ready\n") fmt.Printf(" company: %s (%s)\n", companyName, companyID) fmt.Printf(" email: %s\n", emailNorm) fmt.Printf(" user_id: %s\n", userID) fmt.Printf(" role: %s\n", memRole) fmt.Printf(" expires_at: %s\n", inv.ExpiresAt.UTC().Format(time.RFC3339)) fmt.Printf(" accept_url: %s\n", acceptURL) fmt.Printf("\nShare accept_url with the teammate (or Admin → Users → Send set-password for this user).\n") fmt.Printf("While impersonating a1-primary, Settings → Team → Invite also returns a copyable link when SMTP is off.\n") } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" }