// Command seed-woo-demo inserts sample WooCommerce orders, line items, reviews, // and a draft campaign so UI + audience targeting work without a live store. // // When WOO_STORE_URL + WOO_CONSUMER_KEY + WOO_CONSUMER_SECRET are set (or -live), // also upserts woocommerce_configs and optionally tests the REST connection. // // Usage: // // go run ./cmd/seed-woo-demo -postgres "$DATABASE_URL" // go run ./cmd/seed-woo-demo -company "A1 Slovenija" // go run ./cmd/seed-woo-demo -live # require WOO_* and test connection package main import ( "context" "encoding/json" "errors" "flag" "fmt" "log" "net/http" "os" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) const ( demoCategoryName = "Demo Electronics" demoCategoryUID = "demo-electronics" demoSKUPrefix = "DEMO-WOO-" ) type demoProduct struct { SKU string Name string Category string Price string WCID int64 } type demoCustomer struct { Email string Name string } func main() { postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL") companyName := flag.String("company", "A1 Slovenija", "Target company name") live := flag.Bool("live", false, "Require WOO_* env and test REST connection after seeding") flag.Parse() if strings.TrimSpace(*postgresURL) == "" { log.Fatal("-postgres / DATABASE_URL is required") } companyNameNorm := strings.TrimSpace(*companyName) if companyNameNorm == "" { log.Fatal("-company is required") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() pg, err := pgxpool.New(ctx, *postgresURL) if err != nil { log.Fatalf("postgres: %v", err) } defer pg.Close() companyID, err := resolveCompany(ctx, pg, companyNameNorm) if err != nil { log.Fatalf("company: %v", err) } tx, err := pg.Begin(ctx) if err != nil { log.Fatalf("begin: %v", err) } defer tx.Rollback(ctx) categoryID, err := ensureDemoCategory(ctx, tx, companyID) if err != nil { log.Fatalf("category: %v", err) } products := []demoProduct{ {SKU: demoSKUPrefix + "TV-50", Name: "Demo 4K TV 50\"", Category: demoCategoryUID, Price: "499.00", WCID: 90001}, {SKU: demoSKUPrefix + "SOUND", Name: "Demo Soundbar", Category: demoCategoryUID, Price: "149.00", WCID: 90002}, {SKU: demoSKUPrefix + "HEAD", Name: "Demo Wireless Headphones", Category: demoCategoryUID, Price: "89.00", WCID: 90003}, } if err := seedDemoProducts(ctx, tx, companyID, products); err != nil { log.Fatalf("products: %v", err) } customers := []demoCustomer{ {Email: "anna.buyer@example.com", Name: "Anna Buyer"}, {Email: "ben.buyer@example.com", Name: "Ben Buyer"}, {Email: "cara.buyer@example.com", Name: "Cara Buyer"}, {Email: "dan.other@example.com", Name: "Dan Other"}, } orderCount, itemCount, err := seedDemoOrders(ctx, tx, companyID, products, customers) if err != nil { log.Fatalf("orders: %v", err) } reviewCount, err := seedDemoReviews(ctx, tx, companyID, products, customers) if err != nil { log.Fatalf("reviews: %v", err) } encKey := woocommerce.DeriveKey( os.Getenv("CREDENTIALS_ENCRYPTION_KEY"), os.Getenv("TOKEN_SIGNING_SECRET")+"|"+*postgresURL, ) storeURL, key, secret, fromEnv := wooCredsFromEnv() if err := upsertWooConfig(ctx, tx, companyID, encKey, storeURL, key, secret, fromEnv); err != nil { log.Fatalf("woocommerce_configs: %v", err) } campaignID, err := seedDemoCampaign(ctx, tx, companyID, categoryID) if err != nil { log.Fatalf("campaign: %v", err) } if err := tx.Commit(ctx); err != nil { log.Fatalf("commit: %v", err) } woo := &woocommerce.Service{Pool: pg} audience, err := woo.AudienceBoughtCategories(ctx, companyID, demoCategoryName, "", 100) if err != nil { log.Fatalf("audience check: %v", err) } fmt.Println("=== Descrybe v2 WooCommerce demo seed ===") fmt.Printf("company: %s (%s)\n", companyNameNorm, companyID) fmt.Printf("category: %s (%s)\n", demoCategoryName, categoryID) fmt.Printf("demo products: %d (SKU prefix %s)\n", len(products), demoSKUPrefix) fmt.Printf("orders upserted: %d\n", orderCount) fmt.Printf("order items: %d\n", itemCount) fmt.Printf("reviews upserted: %d\n", reviewCount) fmt.Printf("audience (%s): %d customers\n", demoCategoryName, audience.Total) for _, c := range audience.Customers { fmt.Printf(" - %s <%s>\n", c.Name, c.Email) } fmt.Printf("draft campaign: %s\n", campaignID) fmt.Printf("config store_url: %s\n", storeURL) if fromEnv { fmt.Println("credentials: from WOO_* env (encrypted at rest)") } else { fmt.Println("credentials: demo placeholders (no live REST)") } fmt.Println() fmt.Println("Next:") fmt.Println(" 1. Open /woocommerce — Orders & Reviews tabs should list seeded rows") fmt.Println(" 2. POST /api/woocommerce/audience {\"bought_category\":\"Demo Electronics\"}") fmt.Println(" 3. Open /campaigns — draft \"Woo demo — purchased electronics\" uses purchased audience") fmt.Println(" 4. Live store: set WOO_STORE_URL / WOO_CONSUMER_KEY / WOO_CONSUMER_SECRET then re-run with -live") if *live || (fromEnv && flag.Lookup("live").Value.String() == "true") { // handled below when -live } if *live { if !fromEnv { log.Fatal("-live requires WOO_STORE_URL, WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET") } client := woocommerce.NewClient(storeURL, key, secret, &http.Client{Timeout: 20 * time.Second}) if err := client.TestConnection(ctx); err != nil { log.Fatalf("live Woo test failed: %v", err) } fmt.Println("live Woo test: OK") } else if fromEnv { client := woocommerce.NewClient(storeURL, key, secret, &http.Client{Timeout: 20 * time.Second}) if err := client.TestConnection(ctx); err != nil { fmt.Printf("live Woo test: skipped/failed (%v) — demo DB seed still applied\n", err) } else { fmt.Println("live Woo test: OK (WOO_* present)") } } } func resolveCompany(ctx context.Context, pg *pgxpool.Pool, name string) (uuid.UUID, error) { var id uuid.UUID err := pg.QueryRow(ctx, ` SELECT id FROM companies WHERE name = $1 ORDER BY updated_at DESC LIMIT 1`, name).Scan(&id) if err != nil { return uuid.Nil, fmt.Errorf("%q: %w", name, err) } return id, nil } func ensureDemoCategory(ctx context.Context, tx pgx.Tx, companyID uuid.UUID) (uuid.UUID, error) { var id uuid.UUID err := tx.QueryRow(ctx, ` INSERT INTO categories (company_id, name, unique_id, path, level, position, is_active, updated_at) VALUES ($1, $2, $3, $2, 0, 0, true, now()) ON CONFLICT (company_id, unique_id) DO UPDATE SET name = EXCLUDED.name, is_active = true, updated_at = now() RETURNING id`, companyID, demoCategoryName, demoCategoryUID).Scan(&id) return id, err } func seedDemoProducts(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, products []demoProduct) error { for _, p := range products { mapped, _ := json.Marshal(map[string]any{ "sku": p.SKU, "price": p.Price, "regular_price": p.Price, "images": []string{}, "source": "seed-woo-demo", }) attrs, _ := json.Marshal(map[string]any{"Brand": "Descrybe Demo"}) _, err := tx.Exec(ctx, ` INSERT INTO processed_products ( company_id, product_id, name, category, description, processed_name, processed_description, attributes, processed_attributes, status, updated_at ) VALUES ( $1, $2, $3, $4, $5, $3, $5, $6::jsonb, $6::jsonb, 'completed', now() ) ON CONFLICT DO NOTHING`, companyID, p.SKU, p.Name, p.Category, "Seeded demo product for WooCommerce integration testing.", attrs) if err != nil { // processed_products may lack a unique on product_id; fall back to upsert-by-lookup. var existing uuid.UUID qerr := tx.QueryRow(ctx, ` SELECT id FROM processed_products WHERE company_id = $1 AND product_id = $2 LIMIT 1`, companyID, p.SKU).Scan(&existing) if qerr == nil { _, err = tx.Exec(ctx, ` UPDATE processed_products SET name = $3, category = $4, description = $5, processed_name = $3, processed_description = $5, attributes = $6::jsonb, processed_attributes = $6::jsonb, status = 'completed', updated_at = now() WHERE id = $2 AND company_id = $1`, companyID, existing, p.Name, p.Category, "Seeded demo product for WooCommerce integration testing.", attrs) if err != nil { return err } } else if qerr == pgx.ErrNoRows { _, err = tx.Exec(ctx, ` INSERT INTO processed_products ( company_id, product_id, name, category, description, processed_name, processed_description, attributes, processed_attributes, status, updated_at ) VALUES ( $1, $2, $3, $4, $5, $3, $5, $6::jsonb, $6::jsonb, 'completed', now() )`, companyID, p.SKU, p.Name, p.Category, "Seeded demo product for WooCommerce integration testing.", attrs) if err != nil { return err } } else { return qerr } } _ = mapped } return nil } func seedDemoOrders(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, products []demoProduct, customers []demoCustomer) (int, int, error) { type line struct { product demoProduct qty int cats []string } type orderSpec struct { externalID int64 status string customer demoCustomer total string lines []line daysAgo int } specs := []orderSpec{ { externalID: 88001, status: "completed", customer: customers[0], total: "648.00", daysAgo: 12, lines: []line{ {product: products[0], qty: 1, cats: []string{demoCategoryName}}, {product: products[1], qty: 1, cats: []string{demoCategoryName}}, }, }, { externalID: 88002, status: "processing", customer: customers[1], total: "89.00", daysAgo: 5, lines: []line{{product: products[2], qty: 1, cats: []string{demoCategoryName}}}, }, { externalID: 88003, status: "completed", customer: customers[2], total: "499.00", daysAgo: 3, lines: []line{{product: products[0], qty: 1, cats: []string{demoCategoryName}}}, }, { externalID: 88004, status: "completed", customer: customers[3], total: "29.00", daysAgo: 8, lines: []line{{ product: demoProduct{SKU: "OTHER-SKU-1", Name: "Demo Cable Pack", Price: "29.00", WCID: 90100}, qty: 1, cats: []string{"Accessories"}, }}, }, } orders := 0 items := 0 for _, spec := range specs { payload, _ := json.Marshal(map[string]any{ "id": spec.externalID, "status": spec.status, "currency": "EUR", "total": spec.total, "billing": map[string]any{"email": spec.customer.Email, "first_name": strings.Split(spec.customer.Name, " ")[0]}, "line_items": len(spec.lines), "seed": "seed-woo-demo", }) orderedAt := time.Now().UTC().Add(-time.Duration(spec.daysAgo) * 24 * time.Hour) var orderID uuid.UUID err := tx.QueryRow(ctx, ` INSERT INTO woo_orders ( company_id, external_id, status, currency, total, customer_email, customer_name, ordered_at, payload, synced_at, updated_at ) VALUES ( $1, $2, $3, 'EUR', $4::numeric, $5, $6, $7, $8::jsonb, now(), now() ) ON CONFLICT (company_id, external_id) DO UPDATE SET status = EXCLUDED.status, total = EXCLUDED.total, customer_email = EXCLUDED.customer_email, customer_name = EXCLUDED.customer_name, ordered_at = EXCLUDED.ordered_at, payload = EXCLUDED.payload, synced_at = now(), updated_at = now() RETURNING id`, companyID, spec.externalID, spec.status, spec.total, strings.ToLower(spec.customer.Email), spec.customer.Name, orderedAt, payload, ).Scan(&orderID) if err != nil { return orders, items, err } orders++ if _, err := tx.Exec(ctx, `DELETE FROM woo_order_items WHERE company_id = $1 AND order_id = $2`, companyID, orderID); err != nil { return orders, items, err } for i, ln := range spec.lines { catsRaw, _ := json.Marshal(ln.cats) itemPayload, _ := json.Marshal(map[string]any{ "id": int64(spec.externalID*10 + int64(i+1)), "product_id": ln.product.WCID, "sku": ln.product.SKU, "name": ln.product.Name, "quantity": ln.qty, "total": ln.product.Price, "categories": ln.cats, }) _, err := tx.Exec(ctx, ` INSERT INTO woo_order_items ( company_id, order_id, external_id, product_id, sku, name, quantity, total, categories, payload, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8::numeric, $9::jsonb, $10::jsonb, now() )`, companyID, orderID, spec.externalID*10+int64(i+1), ln.product.WCID, ln.product.SKU, ln.product.Name, ln.qty, ln.product.Price, catsRaw, itemPayload, ) if err != nil { return orders, items, err } items++ } } return orders, items, nil } func seedDemoReviews(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, products []demoProduct, customers []demoCustomer) (int, error) { type rev struct { externalID int64 product demoProduct customer demoCustomer rating int status string body string daysAgo int } specs := []rev{ {88011, products[0], customers[0], 5, "approved", "Picture quality is excellent for the price.", 10}, {88012, products[2], customers[1], 4, "approved", "Comfortable and clear sound.", 4}, {88013, products[1], customers[2], 3, "hold", "Good bass, wish the remote was better.", 2}, } n := 0 for _, r := range specs { payload, _ := json.Marshal(map[string]any{ "id": r.externalID, "product_id": r.product.WCID, "rating": r.rating, "seed": "seed-woo-demo", }) reviewedAt := time.Now().UTC().Add(-time.Duration(r.daysAgo) * 24 * time.Hour) _, err := tx.Exec(ctx, ` INSERT INTO product_reviews ( company_id, external_id, product_id, product_name, status, reviewer, reviewer_email, rating, review, reviewed_at, payload, synced_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, now(), now() ) ON CONFLICT (company_id, external_id) DO UPDATE SET product_id = EXCLUDED.product_id, product_name = EXCLUDED.product_name, status = EXCLUDED.status, reviewer = EXCLUDED.reviewer, reviewer_email = EXCLUDED.reviewer_email, rating = EXCLUDED.rating, review = EXCLUDED.review, reviewed_at = EXCLUDED.reviewed_at, payload = EXCLUDED.payload, synced_at = now(), updated_at = now()`, companyID, r.externalID, r.product.WCID, r.product.Name, r.status, r.customer.Name, strings.ToLower(r.customer.Email), r.rating, r.body, reviewedAt, payload, ) if err != nil { return n, err } n++ } return n, nil } func wooCredsFromEnv() (storeURL, key, secret string, ok bool) { storeURL = strings.TrimSpace(os.Getenv("WOO_STORE_URL")) if storeURL == "" { storeURL = strings.TrimSpace(os.Getenv("WOOCOMMERCE_STORE_URL")) } key = strings.TrimSpace(os.Getenv("WOO_CONSUMER_KEY")) if key == "" { key = strings.TrimSpace(os.Getenv("WOOCOMMERCE_CONSUMER_KEY")) } secret = strings.TrimSpace(os.Getenv("WOO_CONSUMER_SECRET")) if secret == "" { secret = strings.TrimSpace(os.Getenv("WOOCOMMERCE_CONSUMER_SECRET")) } if storeURL != "" && key != "" && secret != "" { return storeURL, key, secret, true } return "https://demo.woocommerce.local", "ck_demo_placeholder", "cs_demo_placeholder", false } func upsertWooConfig(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, encKey []byte, storeURL, key, secret string, live bool) error { normalized, err := woocommerce.NormalizeStoreURL(storeURL) if err != nil { if errors.Is(err, woocommerce.ErrBlockedStoreURL) || live { return err } // Offline demo placeholder only (never a private/metadata IP). normalized = "https://demo.woocommerce.local" } keyEnc, err := woocommerce.EncryptSecret(encKey, key) if err != nil { return err } secretEnc, err := woocommerce.EncryptSecret(encKey, secret) if err != nil { return err } now := time.Now().UTC() opt := woocommerce.SyncOptions{ MatchStrategy: "sku", LastSyncStatus: "success", LastOrdersSyncStatus: "success", LastReviewsSyncStatus: "success", LastOrdersSyncedAt: &now, LastReviewsSyncedAt: &now, ProductIDs: map[string]int{}, CategoryMappings: map[string]woocommerce.CategoryMap{}, AttributeMappings: map[string]woocommerce.AttributeMap{}, } raw, err := json.Marshal(opt) if err != nil { return err } enabled := live testStatus := "demo" if live { testStatus = "ok" } _, err = tx.Exec(ctx, ` INSERT INTO woocommerce_configs ( company_id, store_url, consumer_key, consumer_secret, is_enabled, sync_options, last_synced_at, last_test_at, last_test_status, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6::jsonb, now(), now(), $7, now() ) ON CONFLICT (company_id) DO UPDATE SET store_url = EXCLUDED.store_url, consumer_key = EXCLUDED.consumer_key, consumer_secret = EXCLUDED.consumer_secret, is_enabled = EXCLUDED.is_enabled, sync_options = EXCLUDED.sync_options, last_synced_at = EXCLUDED.last_synced_at, last_test_at = EXCLUDED.last_test_at, last_test_status = EXCLUDED.last_test_status, updated_at = now()`, companyID, normalized, keyEnc, secretEnc, enabled, raw, testStatus) return err } func seedDemoCampaign(ctx context.Context, tx pgx.Tx, companyID, categoryID uuid.UUID) (uuid.UUID, error) { af, _ := json.Marshal(map[string]any{ "type": "purchased", "category_ids": []string{categoryID.String()}, "bought_category": demoCategoryName, "bought_categories": []string{demoCategoryName}, }) name := "Woo demo — purchased electronics" var id uuid.UUID err := tx.QueryRow(ctx, ` SELECT id FROM email_campaigns WHERE company_id = $1 AND name = $2 ORDER BY created_at DESC LIMIT 1`, companyID, name).Scan(&id) if err == nil { _, err = tx.Exec(ctx, ` UPDATE email_campaigns SET category_ids = ARRAY[$2]::uuid[], audience_filter = $3::jsonb, status = 'draft', updated_at = now() WHERE id = $4 AND company_id = $1`, companyID, categoryID, af, id) return id, err } if err != pgx.ErrNoRows { return uuid.Nil, err } err = tx.QueryRow(ctx, ` INSERT INTO email_campaigns ( company_id, name, template_key, status, category_ids, product_ids, prompt, use_default_prompt, audience_filter, updated_at ) VALUES ( $1, $2, 'black_friday', 'draft', ARRAY[$3]::uuid[], ARRAY[]::uuid[], 'Highlight Demo Electronics for past buyers.', true, $4::jsonb, now() ) RETURNING id`, companyID, name, categoryID, af).Scan(&id) return id, err }