760 lines
29 KiB
Go
760 lines
29 KiB
Go
// 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 <key>")
|
||
|
|
fmt.Println(" or X-API-Key: <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
|
||
|
|
}
|