Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
@@ -0,0 +1,54 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Postman A1 Elkotex fixtures — must not appear on Platform Demo / other tenants.
var postmanA1FixtureEANs = []string{"5905575903198", "6970995789942"}
// Integration: Postman Elkotex fixture EANs must not appear outside A1.
func TestA1FixtureEANsIsolatedFromDemo(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
defer pg.Close()
var a1ID uuid.UUID
err = pg.QueryRow(ctx, `
SELECT id FROM companies
WHERE legacy_company_id = $1 OR name = $2
ORDER BY (legacy_company_id = $1) DESC
LIMIT 1`, billing.A1LegacyCompanyID, a1CompanyName).Scan(&a1ID)
if err != nil {
t.Fatalf("resolve A1: %v (run migrate + seed-a1 first)", err)
}
var otherRaw, otherPP int
err = pg.QueryRow(ctx, `
SELECT
(SELECT COUNT(*) FROM raw_products WHERE company_id <> $1 AND gtin = ANY($2::text[])),
(SELECT COUNT(*) FROM processed_products WHERE company_id <> $1 AND product_id = ANY($2::text[]))`,
a1ID, postmanA1FixtureEANs).Scan(&otherRaw, &otherPP)
if err != nil {
t.Fatalf("count other-tenant fixtures: %v", err)
}
if otherRaw > 0 || otherPP > 0 {
t.Fatalf("A1 fixture EANs on non-A1 tenants: raw=%d processed=%d (run seed-a1 -mode backfill-categories or npm run seed:a1)", otherRaw, otherPP)
}
}
+759
View File
@@ -0,0 +1,759 @@
// 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
}
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
const a1CompanyName = "A1 Slovenija"
// Integration smoke: A1 Slovenija must own processed products after migrate + seed-demo.
// Skips when DATABASE_URL is unset (CI without Postgres).
func TestLocalDemoCoHasProducts(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
defer pg.Close()
var (
id uuid.UUID
name string
products int64
)
err = pg.QueryRow(ctx, `
SELECT c.id, c.name,
(SELECT COUNT(*) FROM processed_products p WHERE p.company_id = c.id)
FROM companies c
WHERE c.name = $1
OR c.legacy_company_id = $2
ORDER BY (SELECT COUNT(*) FROM processed_products p WHERE p.company_id = c.id) DESC
LIMIT 1`, a1CompanyName, billing.A1LegacyCompanyID).Scan(&id, &name, &products)
if err != nil {
t.Fatalf("query A1 Slovenija: %v (run migrate + seed-demo first)", err)
}
if products <= 0 {
t.Fatalf("%s %s has %d products; want > 0", name, id, products)
}
t.Logf("%s id=%s products=%d", name, id, products)
}
// Integration smoke: A1 cohort keeps dump-faithful wallet (not fake demo 1M / Legacy rename).
func TestLocalDemoCoLegacyCredits(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
defer pg.Close()
var (
companyName string
planName string
monthly int
maxProd *int
isCustom bool
isLegacy bool
total int
used int
)
err = pg.QueryRow(ctx, `
SELECT c.name, p.name, p.monthly_credits, p.max_products, p.is_custom,
COALESCE(p.is_legacy, false),
cb.total_credits, cb.used_credits
FROM companies c
JOIN company_plans cp ON cp.company_id = c.id AND cp.is_active = true
JOIN plans p ON p.id = cp.plan_id
JOIN credit_balances cb ON cb.company_id = c.id
WHERE c.name = $1 OR c.legacy_company_id = $2
ORDER BY (SELECT COUNT(*) FROM processed_products pp WHERE pp.company_id = c.id) DESC
LIMIT 1`, a1CompanyName, billing.A1LegacyCompanyID).
Scan(&companyName, &planName, &monthly, &maxProd, &isCustom, &isLegacy, &total, &used)
if err != nil {
t.Fatalf("query A1 plan/wallet: %v (run migrate + seed-demo first)", err)
}
if !strings.EqualFold(companyName, a1CompanyName) {
t.Fatalf("company=%s want %s", companyName, a1CompanyName)
}
if !billing.IsLegacyPlan(planName, isLegacy) {
t.Fatalf("plan=%s is_legacy=%v want A1/Legacy cohort", planName, isLegacy)
}
if total == 1_000_000 && used == 0 {
t.Fatalf("wallet looks like fake demo pack total=%d used=%d; want dump credit_balances", total, used)
}
// Dump A1 was ~2500/216; allow local drift but reject empty or fake demo packs.
if total < 1000 {
t.Fatalf("total_credits=%d want >= 1000 (dump-shaped A1 wallet)", total)
}
if used < 0 {
t.Fatalf("used_credits=%d want >= 0", used)
}
if monthly == 1_000_000 {
t.Fatalf("plan monthly_credits inflated to demo 1M; dump A1 monthly was 0")
}
t.Logf("company=%s plan=%s monthly=%d total=%d used=%d remaining=%d is_legacy=%v is_custom=%v",
companyName, planName, monthly, total, used, total-used, isLegacy, isCustom)
}
// Integration smoke: demo users belong only to Platform Demo (not A1).
func TestDemoUsersIsolatedFromA1(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
defer pg.Close()
rows, err := pg.Query(ctx, `
SELECT u.email, c.name, COALESCE(c.legacy_company_id, '')
FROM users u
JOIN memberships m ON m.user_id = u.id AND m.status = 'active'
JOIN companies c ON c.id = m.company_id
WHERE lower(u.email) IN ('demo@descrybe.local', 'demo@descrybe.test')
ORDER BY u.email, c.name`)
if err != nil {
t.Fatalf("query demo memberships: %v (run seed-demo first)", err)
}
defer rows.Close()
type mem struct {
email, company, legacy string
}
var found []mem
for rows.Next() {
var m mem
if err := rows.Scan(&m.email, &m.company, &m.legacy); err != nil {
t.Fatalf("scan: %v", err)
}
found = append(found, m)
if strings.EqualFold(m.legacy, billing.A1LegacyCompanyID) ||
strings.EqualFold(m.company, a1CompanyName) ||
strings.EqualFold(m.company, "Local Demo Co") {
t.Fatalf("demo %s still member of A1 tenant %q (legacy=%s)", m.email, m.company, m.legacy)
}
if !strings.EqualFold(m.company, "Platform Demo") && !strings.EqualFold(m.company, "Demo") {
t.Fatalf("demo %s company=%q want Platform Demo", m.email, m.company)
}
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
if len(found) == 0 {
t.Fatal("no demo memberships found — run go run ./cmd/seed-demo")
}
t.Logf("demo memberships ok: %+v", found)
}
// Integration smoke: Free plan definition stays at 0 AI credits after A1 seed.
func TestFreePlanUnaffectedByEnterpriseSeed(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
defer pg.Close()
var (
name string
monthly int
maxProd *int
custom bool
)
err = pg.QueryRow(ctx, `
SELECT name, monthly_credits, max_products, is_custom
FROM plans WHERE lower(name) = 'free' ORDER BY id LIMIT 1`).
Scan(&name, &monthly, &maxProd, &custom)
if err != nil {
t.Fatalf("query Free plan: %v", err)
}
if monthly != 0 {
t.Fatalf("Free monthly_credits=%d want 0", monthly)
}
wantMax := billing.PlanMaxProducts("Free")
if wantMax == nil || maxProd == nil || *maxProd != *wantMax {
t.Fatalf("Free max_products=%v want %v", maxProd, wantMax)
}
t.Logf("Free plan ok name=%s monthly=%d max_products=%d", name, monthly, *maxProd)
}
@@ -0,0 +1,27 @@
package main
import (
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
)
func TestRefuseA1SmokePurge(t *testing.T) {
t.Parallel()
if err := refuseA1SmokePurge(billing.A1LegacyCompanyID, "Platform Demo"); err == nil {
t.Fatal("want refuse when legacy_company_id is A1")
}
for _, name := range []string{"A1 Slovenija", "A1", "Local Demo Co", " a1 slovenija "} {
if err := refuseA1SmokePurge("", name); err == nil {
t.Fatalf("want refuse for A1 alias %q", name)
}
}
if err := refuseA1SmokePurge("", "Platform Demo"); err != nil {
t.Fatalf("Platform Demo must be allowed: %v", err)
}
if !strings.HasPrefix(processSmokeEANPrefix, "8700999") {
t.Fatalf("processSmokeEANPrefix=%q want 8700999…", processSmokeEANPrefix)
}
}