Files
descrybe/apps/api/cmd/seed-a1-teammate/main.go
T
2026-08-17 00:39:25 +02:00

137 lines
4.3 KiB
Go

// 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
JOIN memberships m ON m.user_id = u.id AND m.company_id = c.id AND m.status = 'active'
WHERE 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 ""
}