// Command seed-demo upserts a local demo user with argon2id password, // platform-admin flag, and admin membership on a standalone Platform Demo // company only (never A1 / migrated customer tenants). // Assigns a custom full-feature "Platform Demo" plan for staff QA. // // Usage: // // go run ./cmd/seed-demo -postgres "$DATABASE_URL" // go run ./cmd/seed-demo -email demo@descrybe.local -password 'DemoPass123!' // go run ./cmd/seed-demo -local-demo-name "Platform Demo" 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/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // defaultDemoAPIKey is local/staging only. Documented in docs/demo-user.md. // Never reuse in production. const defaultDemoAPIKey = "dk_demo_local_descrybe_test_key_v1" const defaultLocalDemoName = "Platform Demo" const platformDemoPlanName = "Platform Demo" func main() { postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL") email := flag.String("email", "demo@descrybe.local", "Demo user email (canonical)") alsoEmail := flag.String("also-email", "demo@descrybe.test", "Optional second demo email to upsert with the same password (empty to skip)") password := flag.String("password", "DemoPass123!", "Demo user password") name := flag.String("name", "Demo User", "Display name") apiKeyFlag := flag.String("api-key", defaultDemoAPIKey, "Demo API key plaintext (hashed before store)") localDemoName := flag.String("local-demo-name", defaultLocalDemoName, "Standalone demo sandbox company name (not A1)") claimRichest := flag.Bool("claim-richest", false, "DANGEROUS: move richest non-A1 catalog onto the demo company (off by default)") purgeSmokeEANs := flag.Bool("purge-smoke-eans", false, "Hard-delete process-smoke EANs (8700999...) from Platform Demo only; never A1 (no product soft-delete API)") flag.Parse() if strings.TrimSpace(*postgresURL) == "" { log.Fatal("-postgres / DATABASE_URL is required") } emailNorm := strings.ToLower(strings.TrimSpace(*email)) if emailNorm == "" || *password == "" { log.Fatal("-email and -password are required") } alsoEmailNorm := strings.ToLower(strings.TrimSpace(*alsoEmail)) if alsoEmailNorm == emailNorm { alsoEmailNorm = "" } demoCompanyName := strings.TrimSpace(*localDemoName) if demoCompanyName == "" { log.Fatal("-local-demo-name is required") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() pg, err := pgxpool.New(ctx, *postgresURL) if err != nil { log.Fatalf("postgres: %v", err) } defer pg.Close() hash, err := auth.HashPassword(*password) if err != nil { log.Fatalf("hash password: %v", 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, password_hash, must_set_password, is_platform_admin, is_active, updated_at ) VALUES ($1, $2, $3, false, true, true, now()) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, password_hash = EXCLUDED.password_hash, must_set_password = false, is_platform_admin = true, is_active = true, updated_at = now() RETURNING id`, emailNorm, strings.TrimSpace(*name), hash).Scan(&userID) if err != nil { log.Fatalf("upsert user: %v", err) } var alsoUserID uuid.UUID if alsoEmailNorm != "" { err = tx.QueryRow(ctx, ` INSERT INTO users ( email, name, password_hash, must_set_password, is_platform_admin, is_active, updated_at ) VALUES ($1, $2, $3, false, true, true, now()) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, password_hash = EXCLUDED.password_hash, must_set_password = false, is_platform_admin = true, is_active = true, updated_at = now() RETURNING id`, alsoEmailNorm, strings.TrimSpace(*name), hash).Scan(&alsoUserID) if err != nil { log.Fatalf("upsert also-email user: %v", err) } } localDemoID, renameNote, err := ensureStandaloneDemoCompany(ctx, tx, demoCompanyName) if err != nil { log.Fatalf("ensure Platform Demo company: %v", err) } var claimNote string if *claimRichest { claimNote, err = claimRichestCatalog(ctx, tx, localDemoID, demoCompanyName) if err != nil { log.Fatalf("claim richest catalog: %v", err) } } else { claimNote = "skipped (-claim-richest=false; demo sandbox stays isolated from A1)" } var smokePurgeNote string if *purgeSmokeEANs { rawN, ppN, purgeErr := purgeProcessSmokeEANsFromDemo(ctx, tx, localDemoID, demoCompanyName) if purgeErr != nil { log.Fatalf("purge process-smoke EANs: %v", purgeErr) } smokePurgeNote = fmt.Sprintf("purged process-smoke EANs (8700999...) from %s: raw=%d processed=%d", demoCompanyName, rawN, ppN) } else { smokePurgeNote = "skipped (-purge-smoke-eans=false; optional Demo cleanup - see scripts/cleanup-process-smoke-eans.sql)" } memberships, err := bindDemoUserToCompanyOnly(ctx, tx, userID, localDemoID) if err != nil { log.Fatalf("upsert demo memberships: %v", err) } var alsoMemberships int64 if alsoUserID != uuid.Nil { alsoMemberships, err = bindDemoUserToCompanyOnly(ctx, tx, alsoUserID, localDemoID) if err != nil { log.Fatalf("upsert also-email memberships: %v", err) } } // Demo API key + session land on the isolated Platform Demo tenant — not A1. primaryCompanyID := localDemoID const demoAPIKeyName = "Demo local API key" demoAPIKey := strings.TrimSpace(*apiKeyFlag) if demoAPIKey == "" { demoAPIKey = defaultDemoAPIKey } if !strings.HasPrefix(demoAPIKey, "dk_") { log.Fatal("demo API key must start with dk_") } keyHash := auth.HashAPIKey(demoAPIKey) keyPrefix := demoAPIKey if len(keyPrefix) > 10 { keyPrefix = keyPrefix[:10] } _, err = tx.Exec(ctx, ` UPDATE api_keys SET revoked_at = now(), updated_at = now() WHERE company_id = $1 AND name = $2 AND key_hash <> $3 AND revoked_at IS NULL`, primaryCompanyID, demoAPIKeyName, keyHash) if err != nil { log.Fatalf("revoke old demo keys: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO api_keys (company_id, user_id, name, key_hash, key_prefix) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key_hash) DO UPDATE SET company_id = EXCLUDED.company_id, user_id = EXCLUDED.user_id, name = EXCLUDED.name, key_prefix = EXCLUDED.key_prefix, revoked_at = NULL, updated_at = now()`, primaryCompanyID, userID, demoAPIKeyName, keyHash, keyPrefix) if err != nil { log.Fatalf("upsert demo api key: %v", err) } if err := tx.Commit(ctx); err != nil { log.Fatalf("commit: %v", err) } billingSvc := &billing.Service{Pool: pg} if err := billingSvc.EnsureDefaultPlans(ctx); err != nil { log.Fatalf("EnsureDefaultPlans: %v", err) } // Guard: Enterprise seed must leave Free packaging intact for new signups. var freeCredits int var freeMax *int err = pg.QueryRow(ctx, ` SELECT monthly_credits, max_products FROM plans WHERE lower(name) = 'free' ORDER BY id LIMIT 1`). Scan(&freeCredits, &freeMax) if err != nil { log.Fatalf("Free plan missing after EnsureDefaultPlans: %v", err) } if freeCredits != 0 { log.Fatalf("Free plan monthly_credits=%d want 0 (Enterprise seed regression)", freeCredits) } wantFreeMax := billing.PlanMaxProducts("Free") if wantFreeMax == nil || freeMax == nil || *freeMax != *wantFreeMax { got := "nil" if freeMax != nil { got = fmt.Sprintf("%d", *freeMax) } want := "nil" if wantFreeMax != nil { want = fmt.Sprintf("%d", *wantFreeMax) } log.Fatalf("Free plan max_products=%s want %s", got, want) } var planID int64 planAssignName := platformDemoPlanName err = pg.QueryRow(ctx, `SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, platformDemoPlanName).Scan(&planID) if err != nil { if err != pgx.ErrNoRows { log.Fatalf("lookup %s plan: %v", platformDemoPlanName, err) } desc := "Local staff sandbox — full product features, high credits, unlimited SKUs (not a Stripe product)" err = pg.QueryRow(ctx, ` INSERT INTO plans ( name, description, monthly_credits, yearly_credits, max_products, is_custom, is_legacy, term, features ) VALUES ( $1, $2, $3, NULL, NULL, true, false, 'monthly', '{}'::jsonb ) RETURNING id`, platformDemoPlanName, desc, billing.EnterpriseUnlimitedCredits, ).Scan(&planID) if err != nil { log.Fatalf("create %s plan: %v", platformDemoPlanName, err) } } else { _, err = pg.Exec(ctx, ` UPDATE plans SET monthly_credits = $2, yearly_credits = NULL, max_products = NULL, is_custom = true, is_legacy = false, term = 'monthly', features = COALESCE(features, '{}'::jsonb), updated_at = now() WHERE id = $1`, planID, billing.EnterpriseUnlimitedCredits) if err != nil { log.Fatalf("refresh %s plan packaging: %v", platformDemoPlanName, err) } } if err := billingSvc.AssignPlan(ctx, primaryCompanyID, planID, false, 0); err != nil { log.Fatalf("AssignPlan %s %s: %v", planAssignName, primaryCompanyID, err) } // Keep a generous wallet for QA (AssignPlan already sets total from monthly_credits). _, err = pg.Exec(ctx, ` UPDATE credit_balances SET total_credits = GREATEST(total_credits, $2), updated_at = now() WHERE company_id = $1`, primaryCompanyID, billing.EnterpriseUnlimitedCredits) if err != nil { log.Fatalf("ensure demo wallet: %v", err) } // Platform Demo QA expects marketing (and related) master switches ON. Global // platform_feature_gates can leave sections/features OFF from prior admin toggles; // re-seed must restore the full-feature sandbox docs promise. marketingGateFeatures := map[string]bool{ "marketing.brand_ai_apply": true, "marketing.brand_kit": true, "marketing.campaigns": true, "marketing.campaigns.create": true, "marketing.campaigns.generate_ai": true, "marketing.campaigns.send": true, "marketing.content_calendar": true, "marketing.reviews": true, "marketing.seo": true, "marketing.seo.ai_rewrite": true, "marketing.seo.template_fill": true, "capability.brand_ai_apply": true, "capability.campaign_ai": true, "capability.seo_ai_rewrite": true, "integrations.email": true, "integrations.email.test": true, } if _, err := billingSvc.SetFeatureGates(ctx, map[string]bool{ "marketing": true, "integrations": true, }, marketingGateFeatures, &userID); err != nil { log.Fatalf("enable Platform Demo marketing feature gates: %v", err) } planName, monthly, maxProducts, isCustom, total, used, rem, err := loadCompanyPlanCredits(ctx, pg, primaryCompanyID) if err != nil { log.Fatalf("verify %s credits: %v", planAssignName, err) } if !strings.EqualFold(planName, platformDemoPlanName) { log.Fatalf("plan verify failed: plan=%s want %s", planName, platformDemoPlanName) } if !isCustom { log.Fatalf("plan verify failed: %s must be is_custom=true for full feature matrix", planName) } maxNote := "null (unlimited SKUs)" if maxProducts != nil { maxNote = fmt.Sprintf("%d", *maxProducts) } log.Printf("demo primary company %s → %s (monthly=%d max_products=%s is_custom=%v total=%d used=%d remaining=%d)", primaryCompanyID, planName, monthly, maxNote, isCustom, total, used, rem) type companyStats struct { ID uuid.UUID Name string InputFeeds int64 Products int64 RawProducts int64 ExportFeeds int64 Categories int64 Mappings int64 } rows, err := pg.Query(ctx, ` SELECT c.id, c.name, (SELECT COUNT(*) FROM input_feeds f WHERE f.company_id = c.id), (SELECT COUNT(*) FROM processed_products p WHERE p.company_id = c.id), (SELECT COUNT(*) FROM raw_products rp WHERE rp.company_id = c.id), (SELECT COUNT(*) FROM export_feeds ef WHERE ef.company_id = c.id), (SELECT COUNT(*) FROM categories cat WHERE cat.company_id = c.id), (SELECT COUNT(*) FROM feed_mappings fm JOIN input_feeds f ON f.id = fm.feed_id WHERE f.company_id = c.id) FROM companies c JOIN memberships m ON m.company_id = c.id AND m.user_id = $1 AND m.status = 'active' ORDER BY CASE WHEN c.id = $2 THEN 0 ELSE 1 END, (SELECT COUNT(*) FROM processed_products p WHERE p.company_id = c.id) DESC, (SELECT COUNT(*) FROM input_feeds f WHERE f.company_id = c.id) DESC, c.name`, userID, localDemoID) if err != nil { log.Fatalf("stats: %v", err) } defer rows.Close() var companies []companyStats for rows.Next() { var c companyStats if err := rows.Scan(&c.ID, &c.Name, &c.InputFeeds, &c.Products, &c.RawProducts, &c.ExportFeeds, &c.Categories, &c.Mappings); err != nil { log.Fatalf("scan stats: %v", err) } companies = append(companies, c) } if err := rows.Err(); err != nil { log.Fatalf("stats rows: %v", err) } fmt.Println("=== Descrybe v2 demo user ===") fmt.Printf("email: %s\n", emailNorm) if alsoEmailNorm != "" { fmt.Printf("also email: %s (same password; memberships=%d)\n", alsoEmailNorm, alsoMemberships) } fmt.Printf("password: %s\n", *password) fmt.Printf("user_id: %s\n", userID) fmt.Printf("must_set_password: false\n") fmt.Printf("is_platform_admin: true\n") fmt.Printf("is_active: true\n") fmt.Printf("admin memberships: %d\n", memberships) fmt.Printf("demo company: %s\n", renameNote) fmt.Printf("claim richest: %s\n", claimNote) fmt.Printf("smoke EANs: %s\n", smokePurgeNote) fmt.Printf("plan: %s (monthly_credits=%d max_products=%s is_custom=%v)\n", planName, monthly, maxNote, isCustom) fmt.Printf("credits: total=%d used=%d remaining=%d\n", total, used, rem) if len(companies) == 0 { fmt.Println("WARNING: no companies found — migrate data first") return } var localStats *companyStats for i := range companies { if companies[i].ID == localDemoID { localStats = &companies[i] break } } if localStats == nil { fmt.Printf("WARNING: %s missing from membership stats\n", demoCompanyName) } else { fmt.Println() fmt.Printf("Primary company (%s):\n", demoCompanyName) fmt.Printf(" name: %s\n", localStats.Name) fmt.Printf(" id: %s\n", localStats.ID) fmt.Printf(" input_feeds: %d\n", localStats.InputFeeds) fmt.Printf(" products: %d\n", localStats.Products) fmt.Printf(" raw_products: %d\n", localStats.RawProducts) fmt.Printf(" export_feeds: %d\n", localStats.ExportFeeds) fmt.Printf(" categories: %d\n", localStats.Categories) fmt.Printf(" mappings: %d\n", localStats.Mappings) } fmt.Println() fmt.Println("Feeds + mapping counts for Platform Demo:") feedRows, err := pg.Query(ctx, ` SELECT f.name, (SELECT COUNT(*) FROM feed_mappings fm WHERE fm.feed_id = f.id) AS mapping_count, (SELECT COUNT(*) FROM feed_mappings fm WHERE fm.feed_id = f.id AND fm.company_id <> f.company_id) AS orphan_company_mismatch FROM input_feeds f WHERE f.company_id = $1 ORDER BY f.name`, localDemoID) if err != nil { log.Fatalf("feed list: %v", err) } defer feedRows.Close() var totalMappings int64 var feedCount int for feedRows.Next() { var feedName string var mappingCount, orphanMismatch int64 if err := feedRows.Scan(&feedName, &mappingCount, &orphanMismatch); err != nil { log.Fatalf("scan feed: %v", err) } feedCount++ totalMappings += mappingCount orphanNote := "" if orphanMismatch > 0 { orphanNote = fmt.Sprintf(" ⚠ %d mappings with company_id mismatch", orphanMismatch) } fmt.Printf(" %-20s mappings=%d%s\n", feedName, mappingCount, orphanNote) } if err := feedRows.Err(); err != nil { log.Fatalf("feed rows: %v", err) } fmt.Printf(" (%d feeds, %d total mapping rows)\n", feedCount, totalMappings) fmt.Println() fmt.Println("Demo API key (local only — stored as SHA-256 hash):") fmt.Printf(" key: %s\n", demoAPIKey) fmt.Printf(" prefix: %s\n", keyPrefix) fmt.Printf(" company_id: %s\n", primaryCompanyID) fmt.Println(" header: Authorization: Bearer ") fmt.Println(" or X-API-Key: ") fmt.Println() fmt.Printf("%s on plan %s (wallet total=%d used=%d remaining=%d; full-feature sandbox).\n", demoCompanyName, planName, total, used, rem) fmt.Println("Demo users are members of Platform Demo only — not A1. Act for customers via Admin → Users → Switch to user.") fmt.Println("Use POST /api/auth/select-company {\"company_id\":\"...\"} only for companies you belong to.") fmt.Printf("Suggested primary for testing: %s (%s)\n", demoCompanyName, localDemoID) fmt.Println() fmt.Println("Re-run (idempotent):") fmt.Println(" cd apps/api") fmt.Println(" go run ./cmd/seed-demo -postgres $env:DATABASE_URL") } // bindDemoUserToCompanyOnly grants admin on the sandbox company and removes // memberships on every other company (including A1). func bindDemoUserToCompanyOnly(ctx context.Context, tx pgx.Tx, userID, companyID uuid.UUID) (int64, error) { ct, err := tx.Exec(ctx, ` INSERT INTO memberships (company_id, user_id, role, status) VALUES ($1, $2, 'admin', 'active') ON CONFLICT (company_id, user_id) DO UPDATE SET role = 'admin', status = 'active', updated_at = now()`, companyID, userID) if err != nil { return 0, err } _, err = tx.Exec(ctx, ` DELETE FROM memberships WHERE user_id = $1 AND company_id <> $2`, userID, companyID) if err != nil { return 0, err } return ct.RowsAffected(), nil } // ensureStandaloneDemoCompany finds or creates a sandbox company that is never // the A1 migrated tenant (by legacy_company_id or dump name). func ensureStandaloneDemoCompany(ctx context.Context, tx pgx.Tx, name string) (uuid.UUID, string, error) { name = strings.TrimSpace(name) if name == "" { name = defaultLocalDemoName } if strings.EqualFold(name, "A1 Slovenija") || strings.EqualFold(name, "A1") || strings.EqualFold(name, "Local Demo Co") { return uuid.Nil, "", fmt.Errorf("demo company name %q collides with A1 tenant — use %q", name, defaultLocalDemoName) } var id uuid.UUID err := tx.QueryRow(ctx, ` SELECT c.id FROM companies c WHERE c.name = $1 AND COALESCE(c.legacy_company_id, '') <> $2 ORDER BY c.created_at ASC LIMIT 1`, name, billing.A1LegacyCompanyID).Scan(&id) if err == nil { if err := ensureCompanySideTables(ctx, tx, id); err != nil { return uuid.Nil, "", err } return id, fmt.Sprintf("kept existing %s (%s)", name, id), nil } if err != pgx.ErrNoRows { return uuid.Nil, "", err } err = tx.QueryRow(ctx, `INSERT INTO companies (name) VALUES ($1) RETURNING id`, name).Scan(&id) if err != nil { return uuid.Nil, "", err } if err := ensureCompanySideTables(ctx, tx, id); err != nil { return uuid.Nil, "", err } return id, fmt.Sprintf("created empty sandbox %s (%s)", name, id), nil } func ensureCompanySideTables(ctx context.Context, tx pgx.Tx, id uuid.UUID) error { if _, err := tx.Exec(ctx, `INSERT INTO company_settings (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, id); err != nil { return err } if _, err := tx.Exec(ctx, `INSERT INTO credit_balances (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, id); err != nil { return err } return nil } func loadCompanyPlanCredits(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID) ( planName string, monthly int, maxProducts *int, isCustom bool, total, used, remaining int, err error, ) { err = pg.QueryRow(ctx, ` SELECT p.name, p.monthly_credits, p.max_products, p.is_custom, cb.total_credits, cb.used_credits FROM company_plans cp JOIN plans p ON p.id = cp.plan_id JOIN credit_balances cb ON cb.company_id = cp.company_id WHERE cp.company_id = $1 AND cp.is_active = true ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&planName, &monthly, &maxProducts, &isCustom, &total, &used) if err != nil { return "", 0, nil, false, 0, 0, 0, err } return planName, monthly, maxProducts, isCustom, total, used, total - used, nil } func richestCompany(ctx context.Context, tx pgx.Tx, exclude uuid.UUID) (uuid.UUID, string, error) { var id uuid.UUID var name string err := tx.QueryRow(ctx, ` SELECT c.id, c.name FROM companies c WHERE ($1::uuid IS NULL OR c.id <> $1) AND COALESCE(c.legacy_company_id, '') <> $2 AND lower(c.name) NOT IN ('a1 slovenija', 'a1', 'local demo co') ORDER BY (SELECT COUNT(*) FROM processed_products p WHERE p.company_id = c.id) DESC, (SELECT COUNT(*) FROM input_feeds f WHERE f.company_id = c.id) DESC, (SELECT COUNT(*) FROM raw_products rp WHERE rp.company_id = c.id) DESC, c.name LIMIT 1`, exclude, billing.A1LegacyCompanyID).Scan(&id, &name) return id, name, err } func claimRichestCatalog(ctx context.Context, tx pgx.Tx, destID uuid.UUID, destName string) (string, error) { srcID, srcName, err := richestCompany(ctx, tx, uuid.Nil) if err != nil { return "", fmt.Errorf("find richest: %w", err) } if srcID == destID { return fmt.Sprintf("noop — %s already richest", destName), nil } // Clear dest catalog first (avoids company_id+gtin and attribute_key collisions // when Local Demo Co already has a partial Janus feed). if err := clearCompanyCatalog(ctx, tx, destID); err != nil { return "", fmt.Errorf("clear %s catalog: %w", destName, err) } moved, err := moveCompanyCatalog(ctx, tx, srcID, destID) if err != nil { return "", fmt.Errorf("move %s → %s: %w", srcName, destName, err) } // Keep feed_mappings.company_id aligned with the feed (no orphans). ct, err := tx.Exec(ctx, ` UPDATE feed_mappings fm SET company_id = f.company_id FROM input_feeds f WHERE fm.feed_id = f.id AND fm.company_id <> f.company_id`) if err != nil { return "", fmt.Errorf("repair mapping company_id: %w", err) } repaired := ct.RowsAffected() return fmt.Sprintf("moved from %s (%s): %s; repaired_mapping_company_id=%d", srcName, srcID, moved, repaired), nil } func clearCompanyCatalog(ctx context.Context, tx pgx.Tx, companyID uuid.UUID) error { // Order respects FKs. feed_tag_mappings / processing_job_products cascade or are job-scoped. stmts := []string{ `DELETE FROM processed_products WHERE company_id = $1`, `DELETE FROM raw_products WHERE company_id = $1`, `DELETE FROM export_feeds WHERE company_id = $1`, `DELETE FROM feed_mappings WHERE company_id = $1`, `DELETE FROM feed_sync_jobs WHERE company_id = $1`, `DELETE FROM input_feeds WHERE company_id = $1`, `DELETE FROM category_attributes WHERE company_id = $1`, `DELETE FROM categories WHERE company_id = $1`, `DELETE FROM attributes WHERE company_id = $1`, `DELETE FROM custom_variables WHERE company_id = $1`, `DELETE FROM standard_fields WHERE company_id = $1`, `DELETE FROM field_groups WHERE company_id = $1`, `DELETE FROM structured_description_fields WHERE company_id = $1`, `DELETE FROM feed_tags WHERE company_id = $1`, `DELETE FROM files WHERE company_id = $1`, `DELETE FROM processing_jobs WHERE company_id = $1`, `DELETE FROM schema_extraction_tasks WHERE company_id = $1`, `DELETE FROM tasks WHERE company_id = $1`, `DELETE FROM product_reviews WHERE company_id = $1`, `DELETE FROM woo_order_items WHERE company_id = $1`, `DELETE FROM woo_orders WHERE company_id = $1`, } for _, q := range stmts { if _, err := tx.Exec(ctx, q, companyID); err != nil { return fmt.Errorf("%s: %w", q, err) } } return nil } func moveCompanyCatalog(ctx context.Context, tx pgx.Tx, src, dest uuid.UUID) (string, error) { type step struct { label string sql string } // Move catalog + feed graph. Skip billing/memberships/api_keys/email marketing. steps := []step{ {"input_feeds", `UPDATE input_feeds SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"feed_mappings", `UPDATE feed_mappings SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"feed_sync_jobs", `UPDATE feed_sync_jobs SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"raw_products", `UPDATE raw_products SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"processed_products", `UPDATE processed_products SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"categories", `UPDATE categories SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"attributes", `UPDATE attributes SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"category_attributes", `UPDATE category_attributes SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"export_feeds", `UPDATE export_feeds SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"custom_variables", `UPDATE custom_variables SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"files", `UPDATE files SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"feed_tags", `UPDATE feed_tags SET company_id = $2 WHERE company_id = $1`}, {"field_groups", `UPDATE field_groups SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"standard_fields", `UPDATE standard_fields SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"structured_description_fields", `UPDATE structured_description_fields SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"processing_jobs", `UPDATE processing_jobs SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"schema_extraction_tasks", `UPDATE schema_extraction_tasks SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"tasks", `UPDATE tasks SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"product_reviews", `UPDATE product_reviews SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"woo_orders", `UPDATE woo_orders SET company_id = $2, updated_at = now() WHERE company_id = $1`}, {"woo_order_items", `UPDATE woo_order_items SET company_id = $2, updated_at = now() WHERE company_id = $1`}, } parts := make([]string, 0, len(steps)+2) for _, s := range steps { ct, err := tx.Exec(ctx, s.sql, src, dest) if err != nil { return "", fmt.Errorf("%s: %w", s.label, err) } parts = append(parts, fmt.Sprintf("%s=%d", s.label, ct.RowsAffected())) } // PK-per-company: move only when dest has no row. ct, err := tx.Exec(ctx, ` UPDATE woocommerce_configs SET company_id = $2, updated_at = now() WHERE company_id = $1 AND NOT EXISTS (SELECT 1 FROM woocommerce_configs WHERE company_id = $2)`, src, dest) if err != nil { return "", fmt.Errorf("woocommerce_configs: %w", err) } parts = append(parts, fmt.Sprintf("woocommerce_configs=%d", ct.RowsAffected())) ct, err = tx.Exec(ctx, ` UPDATE company_brand SET company_id = $2, updated_at = now() WHERE company_id = $1 AND NOT EXISTS (SELECT 1 FROM company_brand WHERE company_id = $2)`, src, dest) if err != nil { return "", fmt.Errorf("company_brand: %w", err) } parts = append(parts, fmt.Sprintf("company_brand=%d", ct.RowsAffected())) return strings.Join(parts, ", "), nil } // processSmokeEANPrefix matches scripts/v1-process-smoke defaultSmokeEAN (8700999000001). const processSmokeEANPrefix = "8700999" // refuseA1SmokePurge blocks process-smoke cleanup when the target is (or looks like) A1. func refuseA1SmokePurge(legacyCompanyID, companyName string) error { if billing.IsA1CohortCompany(legacyCompanyID, companyName) { return fmt.Errorf("refusing process-smoke EAN purge on A1 cohort (legacy_company_id=%q)", legacyCompanyID) } switch strings.ToLower(strings.TrimSpace(companyName)) { case "a1 slovenija", "a1", "local demo co": return fmt.Errorf("refusing process-smoke EAN purge on A1 cohort alias %q", companyName) } return nil } // purgeProcessSmokeEANsFromDemo hard-deletes synthetic process-smoke EANs from the // Platform Demo company only. Products have no soft-delete API - this is the Demo path. func purgeProcessSmokeEANsFromDemo(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, companyName string) (rawN, ppN int64, err error) { var legacyCID *string err = tx.QueryRow(ctx, `SELECT legacy_company_id FROM companies WHERE id = $1`, companyID).Scan(&legacyCID) if err != nil { return 0, 0, fmt.Errorf("load company for smoke purge: %w", err) } leg := "" if legacyCID != nil { leg = *legacyCID } if err := refuseA1SmokePurge(leg, companyName); err != nil { return 0, 0, err } like := processSmokeEANPrefix + "%" _, err = tx.Exec(ctx, ` DELETE FROM processing_job_products pjp WHERE pjp.raw_product_id IN ( SELECT id FROM raw_products WHERE company_id = $1 AND gtin LIKE $2 ) OR pjp.processed_product_id IN ( SELECT id FROM processed_products WHERE company_id = $1 AND product_id LIKE $2 )`, companyID, like) if err != nil { return 0, 0, fmt.Errorf("purge smoke job products: %w", err) } ct, err := tx.Exec(ctx, ` DELETE FROM processed_products WHERE company_id = $1 AND product_id LIKE $2`, companyID, like) if err != nil { return 0, 0, fmt.Errorf("purge smoke processed: %w", err) } ppN = ct.RowsAffected() ct, err = tx.Exec(ctx, ` DELETE FROM raw_products WHERE company_id = $1 AND gtin LIKE $2`, companyID, like) if err != nil { return 0, 0, fmt.Errorf("purge smoke raw: %w", err) } rawN = ct.RowsAffected() return rawN, ppN, nil }