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
+262
View File
@@ -0,0 +1,262 @@
package main
import (
"bufio"
"context"
"fmt"
"io"
"log"
"os"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Classic A1 Postman / Elkotex fixture EANs — must never live on Platform Demo.
var a1FixtureEANs = []string{
"5905575903198",
"6970995789942",
}
type categoryBackfillResult struct {
ProcessedUpdated int64
ProcessedInserted int64
MappedUpdated int64
DumpPairs int
PurgedOtherRaw int64
PurgedOtherPP int64
ProcessedWithCat int
ProcessedWithoutCat int
MappedWithCat int
MappedWithoutCat int
}
// backfillMappedCategoriesFromProcessed copies processed_products.category into
// raw_products.mapped_data.category for A1 only. Legacy dumps store category on
// processed rows (unique_id codes); feed mappings never mapped a category field,
// so re-processing without this backfill yields Uncategorized / grey C coverage.
func backfillMappedCategoriesFromProcessed(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID) (int64, error) {
ct, err := pg.Exec(ctx, `
UPDATE raw_products r
SET mapped_data = jsonb_set(
COALESCE(r.mapped_data, '{}'::jsonb),
'{category}',
to_jsonb(p.category),
true
),
updated_at = now()
FROM processed_products p
WHERE p.raw_product_id = r.id
AND p.company_id = $1
AND r.company_id = $1
AND COALESCE(NULLIF(trim(p.category), ''), '') <> ''
AND lower(trim(p.category)) <> 'none'
AND COALESCE(NULLIF(trim(r.mapped_data->>'category'), ''), '') = ''`, companyID)
if err != nil {
return 0, fmt.Errorf("backfill mapped category: %w", err)
}
return ct.RowsAffected(), nil
}
// backfillCategoriesFromMySQLDump streams dump processed_products for the A1
// legacy company and writes product_id (GTIN) → category onto A1 Postgres
// mapped_data.category (and updates any existing processed_products.category).
// It does not insert processed rows — A1 demo seed stays at processed=0.
func backfillCategoriesFromMySQLDump(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, dumpPath string) (categoryBackfillResult, error) {
var out categoryBackfillResult
legacyCompany := billing.A1LegacyCompanyID
var legacy string
_ = pg.QueryRow(ctx, `SELECT COALESCE(legacy_company_id::text, '') FROM companies WHERE id = $1`, companyID).Scan(&legacy)
if legacy != "" {
legacyCompany = legacy
}
f, err := os.Open(dumpPath)
if err != nil {
return out, fmt.Errorf("open mysql dump: %w", err)
}
defer f.Close()
byGTIN, err := scanA1ProcessedCategories(f, legacyCompany)
if err != nil {
return out, err
}
out.DumpPairs = len(byGTIN)
if len(byGTIN) == 0 {
return out, fmt.Errorf("no A1 processed_products categories for legacy %s in dump", legacyCompany)
}
log.Printf("dump: %d A1 gtin→category pairs", len(byGTIN))
gtins := make([]string, 0, len(byGTIN))
cats := make([]string, 0, len(byGTIN))
for g, c := range byGTIN {
gtins = append(gtins, g)
cats = append(cats, c)
}
ct, err := pg.Exec(ctx, `
UPDATE processed_products p
SET category = v.category,
updated_at = now()
FROM unnest($2::text[], $3::text[]) AS v(gtin, category)
WHERE p.company_id = $1
AND p.product_id = v.gtin
AND COALESCE(NULLIF(trim(v.category), ''), '') <> ''
AND (
COALESCE(NULLIF(trim(p.category), ''), '') = ''
OR lower(trim(p.category)) = 'none'
OR p.category IS DISTINCT FROM v.category
)`, companyID, gtins, cats)
if err != nil {
return out, fmt.Errorf("update processed category from dump: %w", err)
}
out.ProcessedUpdated = ct.RowsAffected()
// A1 demo seed keeps processed=0. Do not INSERT processed rows from the dump —
// only refresh mapped_data.category (and any existing processed rows if present).
ct, err = pg.Exec(ctx, `
UPDATE raw_products r
SET mapped_data = jsonb_set(
COALESCE(r.mapped_data, '{}'::jsonb),
'{category}',
to_jsonb(v.category),
true
),
updated_at = now()
FROM unnest($2::text[], $3::text[]) AS v(gtin, category)
WHERE r.company_id = $1
AND r.gtin = v.gtin
AND COALESCE(NULLIF(trim(v.category), ''), '') <> ''
AND (
COALESCE(NULLIF(trim(r.mapped_data->>'category'), ''), '') = ''
OR r.mapped_data->>'category' IS DISTINCT FROM v.category
)`, companyID, gtins, cats)
if err != nil {
return out, fmt.Errorf("update mapped category from dump: %w", err)
}
out.MappedUpdated = ct.RowsAffected()
n, err := backfillMappedCategoriesFromProcessed(ctx, pg, companyID)
if err != nil {
return out, err
}
out.MappedUpdated += n
if err := fillCategoryCoverage(ctx, pg, companyID, &out); err != nil {
return out, err
}
return out, nil
}
func fillCategoryCoverage(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, out *categoryBackfillResult) error {
err := pg.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (
WHERE COALESCE(NULLIF(trim(category), ''), '') <> ''
AND lower(trim(category)) <> 'none'
),
COUNT(*) FILTER (
WHERE COALESCE(NULLIF(trim(category), ''), '') = ''
OR lower(trim(category)) = 'none'
)
FROM processed_products
WHERE company_id = $1`, companyID).Scan(&out.ProcessedWithCat, &out.ProcessedWithoutCat)
if err != nil {
return fmt.Errorf("count processed categories: %w", err)
}
err = pg.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (
WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') <> ''
),
COUNT(*) FILTER (
WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') = ''
)
FROM raw_products
WHERE company_id = $1`, companyID).Scan(&out.MappedWithCat, &out.MappedWithoutCat)
if err != nil {
return fmt.Errorf("count mapped categories: %w", err)
}
return nil
}
// purgeA1FixtureEANsFromOtherTenants deletes the Postman Elkotex fixture EANs
// from every company except A1 (Platform Demo must not mirror A1 fixtures).
func purgeA1FixtureEANsFromOtherTenants(ctx context.Context, pg *pgxpool.Pool, a1CompanyID uuid.UUID) (rawN, ppN int64, err error) {
ct, err := pg.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 = ANY($2::text[])
)
OR pjp.processed_product_id IN (
SELECT id FROM processed_products
WHERE company_id <> $1 AND product_id = ANY($2::text[])
)`, a1CompanyID, a1FixtureEANs)
if err != nil {
return 0, 0, fmt.Errorf("purge fixture job products: %w", err)
}
_ = ct
ct, err = pg.Exec(ctx, `
DELETE FROM processed_products
WHERE company_id <> $1 AND product_id = ANY($2::text[])`, a1CompanyID, a1FixtureEANs)
if err != nil {
return 0, 0, fmt.Errorf("purge fixture processed: %w", err)
}
ppN = ct.RowsAffected()
ct, err = pg.Exec(ctx, `
DELETE FROM raw_products
WHERE company_id <> $1 AND gtin = ANY($2::text[])`, a1CompanyID, a1FixtureEANs)
if err != nil {
return 0, 0, fmt.Errorf("purge fixture raw: %w", err)
}
rawN = ct.RowsAffected()
return rawN, ppN, nil
}
func scanA1ProcessedCategories(r io.Reader, legacyCompany string) (map[string]string, error) {
br := bufio.NewReaderSize(r, 1<<20)
inTable := false
out := make(map[string]string, 4096)
for {
line, err := br.ReadString('\n')
if len(line) > 0 {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "INSERT INTO `processed_products`") ||
strings.HasPrefix(trimmed, "INSERT INTO processed_products") {
inTable = true
} else if inTable && strings.HasPrefix(trimmed, "CREATE TABLE") {
break
} else if inTable && strings.HasPrefix(trimmed, "INSERT INTO `") &&
!strings.Contains(trimmed, "processed_products") {
break
} else if inTable && looksLikeTupleLine(line) && strings.Contains(line, legacyCompany) {
fields := parseMySQLTupleFieldsN(line, 6)
if len(fields) >= 5 {
gtin := strings.TrimSpace(fields[2])
cat := strings.TrimSpace(fields[4])
if gtin != "" && cat != "" && !strings.EqualFold(cat, "NULL") {
out[gtin] = cat
}
}
}
}
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
}
return out, nil
}
func logCategoryBackfillResult(res categoryBackfillResult, source string) {
log.Printf("category backfill (%s): dump_pairs=%d processed_updated=%d processed_inserted=%d mapped_updated=%d purged_other_raw=%d purged_other_pp=%d",
source, res.DumpPairs, res.ProcessedUpdated, res.ProcessedInserted, res.MappedUpdated, res.PurgedOtherRaw, res.PurgedOtherPP)
log.Printf("A1 coverage: processed with_cat=%d without_cat=%d | raw mapped with_cat=%d without_cat=%d",
res.ProcessedWithCat, res.ProcessedWithoutCat, res.MappedWithCat, res.MappedWithoutCat)
}
@@ -0,0 +1,50 @@
package main
import (
"strings"
"testing"
)
func TestScanA1ProcessedCategories(t *testing.T) {
const legacy = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
dump := strings.Join([]string{
"INSERT INTO `processed_products` (`id`, `user_id`, `product_id`, `name`, `category`, `description`, `processed_description`, `attributes`, `processed_attributes`, `status`, `gpt_response`, `total_tokens`, `created_at`, `updated_at`, `feed_id`, `company_id`, `raw_product_id`) VALUES",
"(1,\t'user_x',\t'6970995789942',\t'Roborock',\t'46',\tNULL,\tNULL,\tNULL,\tNULL,\t'completed',\tNULL,\t0,\t'2026-01-01 00:00:00',\t'2026-01-01 00:00:00',\t41,\t'" + legacy + "',\t1),",
"(2,\t'user_x',\t'5905575903198',\t'Adler',\t'120',\tNULL,\tNULL,\tNULL,\tNULL,\t'completed',\tNULL,\t0,\t'2026-01-01 00:00:00',\t'2026-01-01 00:00:00',\t41,\t'" + legacy + "',\t2),",
"(3,\t'user_y',\t'111',\t'Other',\t'999',\tNULL,\tNULL,\tNULL,\tNULL,\t'completed',\tNULL,\t0,\t'2026-01-01 00:00:00',\t'2026-01-01 00:00:00',\t1,\t'other-company',\t3);",
"CREATE TABLE `processing_job_products` (",
}, "\n")
got, err := scanA1ProcessedCategories(strings.NewReader(dump), legacy)
if err != nil {
t.Fatalf("scan: %v", err)
}
if got["6970995789942"] != "46" {
t.Fatalf("roborock category=%q want 46", got["6970995789942"])
}
if got["5905575903198"] != "120" {
t.Fatalf("adler category=%q want 120", got["5905575903198"])
}
if _, ok := got["111"]; ok {
t.Fatalf("other-company product leaked into A1 map")
}
if len(got) != 2 {
t.Fatalf("len=%d want 2", len(got))
}
}
func TestA1FixtureEANs(t *testing.T) {
if len(a1FixtureEANs) != 2 {
t.Fatalf("fixture EANs=%d want 2", len(a1FixtureEANs))
}
seen := map[string]bool{}
for _, e := range a1FixtureEANs {
if e == "" || seen[e] {
t.Fatalf("bad fixture EAN %q", e)
}
seen[e] = true
}
if !seen["6970995789942"] || !seen["5905575903198"] {
t.Fatalf("missing Postman fixture EANs: %#v", a1FixtureEANs)
}
}
+237
View File
@@ -0,0 +1,237 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"unicode"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// MaxCategoryPromptRunes bounds stored category.prompt (aligned with campaign prompts).
const MaxCategoryPromptRunes = security.MaxCampaignPromptRunes
// categoryPromptsFile is the committed A1 overlay of legacy Name → Prompt pairs.
type categoryPromptsFile struct {
Version int `json:"version"`
Source string `json:"source"`
Entries []categoryPromptEntry `json:"entries"`
}
type categoryPromptEntry struct {
Name string `json:"name"`
Prompt string `json:"prompt"`
}
var (
// Legacy v1 placeholders → v2 {{variables}} used by aiprompts.Render.
reLegacyDesc = regexp.MustCompile(`(?i)\{\s*""?\s*OPIS\s+IZDELKA\s*""?\s*\}`)
reLegacyName = regexp.MustCompile(`(?i)\{\s*""?\s*STARO\s+IME\s+IZDELKA\s*""?\s*\}`)
)
func defaultCategoryPromptsPath(archivePath string) string {
if strings.TrimSpace(archivePath) != "" {
return filepath.Join(filepath.Dir(archivePath), "a1-category-prompts.json")
}
return filepath.Join("..", "..", "scripts", "seed", "a1-category-prompts.json")
}
func loadCategoryPromptsFile(path string) (categoryPromptsFile, error) {
path = filepath.Clean(strings.TrimSpace(path))
if path == "" || path == "." {
return categoryPromptsFile{}, fmt.Errorf("category prompts path is empty")
}
// Allowlist the committed seed filename (blocks accidental reads of unrelated dumps).
if filepath.Base(path) != "a1-category-prompts.json" {
return categoryPromptsFile{}, fmt.Errorf("refusing unexpected category prompts file name %q", filepath.Base(path))
}
raw, err := os.ReadFile(path)
if err != nil {
return categoryPromptsFile{}, fmt.Errorf("read category prompts: %w", err)
}
if len(raw) > 8<<20 {
return categoryPromptsFile{}, fmt.Errorf("category prompts file too large (%d bytes)", len(raw))
}
var f categoryPromptsFile
if err := json.Unmarshal(raw, &f); err != nil {
return categoryPromptsFile{}, fmt.Errorf("parse category prompts JSON: %w", err)
}
if len(f.Entries) == 0 {
return categoryPromptsFile{}, fmt.Errorf("category prompts file has no entries")
}
if len(f.Entries) > 5000 {
return categoryPromptsFile{}, fmt.Errorf("category prompts file has too many entries (%d)", len(f.Entries))
}
return f, nil
}
// modernizeLegacyPromptPlaceholders rewrites v1 {""OPIS IZDELKA""} tokens to {{description}} / {{name}}.
func modernizeLegacyPromptPlaceholders(prompt string) string {
prompt = reLegacyDesc.ReplaceAllString(prompt, "{{description}}")
prompt = reLegacyName.ReplaceAllString(prompt, "{{name}}")
return prompt
}
func prepareCategoryPrompt(prompt string) string {
prompt = modernizeLegacyPromptPlaceholders(prompt)
return security.SanitizePrompt(prompt, MaxCategoryPromptRunes)
}
// normalizeCategoryName keys categories for matching (case/space/diacritic tolerant).
func normalizeCategoryName(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.ToLower(s)
var b strings.Builder
b.Grow(len(s))
prevSpace := false
for _, r := range s {
r = foldSloveneRune(r)
if unicode.IsSpace(r) {
if prevSpace || b.Len() == 0 {
continue
}
b.WriteByte(' ')
prevSpace = true
continue
}
prevSpace = false
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '/' || r == '&' || r == '+' {
b.WriteRune(r)
continue
}
// Drop punctuation noise from names.
}
return strings.TrimSpace(b.String())
}
func foldSloveneRune(r rune) rune {
switch r {
case 'č', 'ć':
return 'c'
case 'š':
return 's'
case 'ž':
return 'z'
case 'đ':
return 'd'
default:
return r
}
}
type categoryPromptApplyResult struct {
Updated int
Unmatched []string
Skipped int // empty prompt after sanitize
}
func applyCategoryPrompts(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, promptsPath string) (categoryPromptApplyResult, error) {
var out categoryPromptApplyResult
file, err := loadCategoryPromptsFile(promptsPath)
if err != nil {
return out, err
}
byNorm := make(map[string]string, len(file.Entries))
for _, e := range file.Entries {
name := strings.TrimSpace(e.Name)
prompt := prepareCategoryPrompt(e.Prompt)
if name == "" || prompt == "" {
out.Skipped++
continue
}
key := normalizeCategoryName(name)
if key == "" {
out.Skipped++
continue
}
byNorm[key] = prompt
}
if len(byNorm) == 0 {
return out, fmt.Errorf("no usable category prompts in %s", promptsPath)
}
rows, err := pg.Query(ctx, `
SELECT id, name
FROM categories
WHERE company_id = $1`, companyID)
if err != nil {
return out, fmt.Errorf("list categories: %w", err)
}
defer rows.Close()
ids := make([]uuid.UUID, 0, len(byNorm))
prompts := make([]string, 0, len(byNorm))
matchedKeys := make(map[string]struct{}, len(byNorm))
for rows.Next() {
var id uuid.UUID
var name string
if err := rows.Scan(&id, &name); err != nil {
return out, err
}
key := normalizeCategoryName(name)
prompt, ok := byNorm[key]
if !ok {
continue
}
matchedKeys[key] = struct{}{}
ids = append(ids, id)
prompts = append(prompts, prompt)
}
if err := rows.Err(); err != nil {
return out, err
}
for key := range byNorm {
if _, ok := matchedKeys[key]; !ok {
out.Unmatched = append(out.Unmatched, key)
}
}
sort.Strings(out.Unmatched)
if len(ids) == 0 {
return out, fmt.Errorf("no A1 categories matched any of %d seed prompts (check names)", len(byNorm))
}
// Single parameterized batch update — company_id gate prevents cross-tenant writes.
// ASSUMPTION: A1 seed company content language is Slovenian ("sl").
tag, err := pg.Exec(ctx, `
UPDATE categories AS c
SET prompt = jsonb_build_object('sl', v.prompt), updated_at = now()
FROM (
SELECT * FROM unnest($1::uuid[], $2::text[]) AS t(id, prompt)
) AS v
WHERE c.id = v.id AND c.company_id = $3`, ids, prompts, companyID)
if err != nil {
return out, fmt.Errorf("update category prompts: %w", err)
}
out.Updated = int(tag.RowsAffected())
return out, nil
}
func logCategoryPromptResult(res categoryPromptApplyResult, path string) {
log.Printf("category prompts from %s: updated=%d skipped=%d unmatched=%d",
path, res.Updated, res.Skipped, len(res.Unmatched))
if len(res.Unmatched) == 0 {
return
}
const maxShow = 20
show := res.Unmatched
if len(show) > maxShow {
show = show[:maxShow]
}
log.Printf(" unmatched seed names (normalized, first %d): %s", len(show), strings.Join(show, ", "))
}
@@ -0,0 +1,106 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeCategoryName(t *testing.T) {
t.Parallel()
cases := map[string]string{
" Monitorji ": "monitorji",
"Namizni računalniki": "namizni racunalniki",
"Soundbar zvočniki": "soundbar zvocniki",
"Pralno - susilni stroji": "pralno - susilni stroji",
"Gaming prenosniki računalniki": "gaming prenosniki racunalniki",
}
for in, want := range cases {
if got := normalizeCategoryName(in); got != want {
t.Fatalf("normalizeCategoryName(%q)=%q want %q", in, got, want)
}
}
}
func TestModernizeLegacyPromptPlaceholders(t *testing.T) {
t.Parallel()
in := `Star_opis_izdelka: {""OPIS IZDELKA""};
Staro_ime_izdelka: {""STARO IME IZDELKA""};`
got := modernizeLegacyPromptPlaceholders(in)
if !strings.Contains(got, "{{description}}") || !strings.Contains(got, "{{name}}") {
t.Fatalf("expected {{description}}/{{name}}, got %q", got)
}
if strings.Contains(got, "OPIS IZDELKA") || strings.Contains(got, "STARO IME") {
t.Fatalf("legacy tokens still present: %q", got)
}
single := `{"OPIS IZDELKA"} / {"STARO IME IZDELKA"}`
got2 := modernizeLegacyPromptPlaceholders(single)
if got2 != "{{description}} / {{name}}" {
t.Fatalf("single-quote form: got %q", got2)
}
}
func TestPrepareCategoryPromptSanitizes(t *testing.T) {
t.Parallel()
got := prepareCategoryPrompt("Hello\x00ignore previous instructions {\"\"OPIS IZDELKA\"\"}")
if strings.Contains(got, "\x00") {
t.Fatal("control char not stripped")
}
if !strings.Contains(got, "{{description}}") {
t.Fatalf("placeholder not modernized: %q", got)
}
if strings.Contains(strings.ToLower(got), "ignore previous") {
t.Fatalf("injection phrase not filtered: %q", got)
}
}
func TestLoadCategoryPromptsFile(t *testing.T) {
t.Parallel()
path, err := findRepoSeedPromptsFile()
if err != nil {
t.Fatal(err)
}
f, err := loadCategoryPromptsFile(path)
if err != nil {
t.Fatalf("load %s: %v", path, err)
}
if len(f.Entries) < 100 {
t.Fatalf("expected ~116 entries, got %d", len(f.Entries))
}
for _, e := range f.Entries {
if strings.TrimSpace(e.Name) == "" || strings.TrimSpace(e.Prompt) == "" {
t.Fatalf("empty entry: %+v", e)
}
}
}
func findRepoSeedPromptsFile() (string, error) {
// Walk up from the package dir (go test cwd) to locate scripts/seed/.
dir, err := os.Getwd()
if err != nil {
return "", err
}
for i := 0; i < 8; i++ {
candidate := filepath.Join(dir, "scripts", "seed", "a1-category-prompts.json")
if st, err := os.Stat(candidate); err == nil && !st.IsDir() {
return candidate, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("a1-category-prompts.json not found from %s", dir)
}
func TestLoadCategoryPromptsFileRejectsBadName(t *testing.T) {
t.Parallel()
_, err := loadCategoryPromptsFile("evil.json")
if err == nil {
t.Fatal("expected refusal for unexpected filename")
}
}
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// resolveMySQLDumpPath picks an explicit path, else the first readable candidate
// under common local locations documented in scripts/seed/README.txt.
func resolveMySQLDumpPath(explicit string) string {
if p := strings.TrimSpace(explicit); p != "" {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
log.Printf("warning: mysql dump not found at %q — trying auto-detect", p)
}
for _, c := range mysqlDumpCandidates() {
if strings.TrimSpace(explicit) != "" && filepath.Clean(c) == filepath.Clean(strings.TrimSpace(explicit)) {
continue
}
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
func mysqlDumpCandidates() []string {
var out []string
if v := strings.TrimSpace(os.Getenv("SEED_A1_MYSQL_DUMP")); v != "" {
out = append(out, v)
}
home, _ := os.UserHomeDir()
names := []string{
"descrybe_new (1).sql",
"descrybe_new.sql",
"descrybe_new(1).sql",
}
if home != "" {
for _, n := range names {
out = append(out, filepath.Join(home, "Downloads", n))
out = append(out, filepath.Join(home, "downloads", n))
}
}
// Repo-relative guesses (cwd may be apps/api or repo root).
for _, n := range names {
out = append(out,
n,
filepath.Join("..", "..", n),
filepath.Join("scripts", "seed", n),
filepath.Join("..", "..", "scripts", "seed", n),
)
}
return out
}
type mappedCoverage struct {
Total int
WithDesc int
WithCat int
WithAttrs int
Processed int
Jobs int
}
func (c mappedCoverage) pct(n int) float64 {
if c.Total == 0 {
return 0
}
return 100 * float64(n) / float64(c.Total)
}
// feedAttrsSQL matches catalog.processedHasFeedAttributesSQL (mapped_data aliases).
const feedAttrsSQL = `(
CASE jsonb_typeof(mapped_data->'specifications')
WHEN 'string' THEN length(trim(mapped_data->>'specifications')) > 0
WHEN 'object' THEN mapped_data->'specifications' <> '{}'::jsonb
WHEN 'array' THEN jsonb_array_length(mapped_data->'specifications') > 0
ELSE false
END
OR CASE jsonb_typeof(mapped_data->'specs')
WHEN 'string' THEN length(trim(mapped_data->>'specs')) > 0
WHEN 'object' THEN mapped_data->'specs' <> '{}'::jsonb
WHEN 'array' THEN jsonb_array_length(mapped_data->'specs') > 0
ELSE false
END
OR COALESCE(NULLIF(trim(mapped_data->>'eprel_id'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'eprel'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'netwidth'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'net_width'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'netheight'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'net_height'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'netdepth'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'net_depth'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'netmass'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'net_mass'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'warranty'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'productmodel'), ''), '') <> ''
OR COALESCE(NULLIF(trim(mapped_data->>'product_model'), ''), '') <> ''
)`
func measureMappedCoverage(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID) (mappedCoverage, error) {
var c mappedCoverage
err := pg.QueryRow(ctx, fmt.Sprintf(`
SELECT
count(*)::int,
count(*) FILTER (WHERE COALESCE(NULLIF(trim(mapped_data->>'description'), ''), '') <> '')::int,
count(*) FILTER (
WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') <> ''
AND lower(trim(mapped_data->>'category')) <> 'none'
)::int,
count(*) FILTER (WHERE %s)::int
FROM raw_products
WHERE company_id = $1`, feedAttrsSQL), companyID).Scan(
&c.Total, &c.WithDesc, &c.WithCat, &c.WithAttrs,
)
if err != nil {
return c, fmt.Errorf("measure mapped coverage: %w", err)
}
_ = pg.QueryRow(ctx, `SELECT count(*)::int FROM processed_products WHERE company_id = $1`, companyID).Scan(&c.Processed)
_ = pg.QueryRow(ctx, `SELECT count(*)::int FROM processing_jobs WHERE company_id = $1`, companyID).Scan(&c.Jobs)
return c, nil
}
func logMappedCoverage(c mappedCoverage, label string) {
log.Printf("A1 mapped coverage (%s): total=%d desc=%.1f%% (%d) category=%.1f%% (%d) feed_attrs=%.1f%% (%d) processed=%d jobs=%d",
label, c.Total, c.pct(c.WithDesc), c.WithDesc, c.pct(c.WithCat), c.WithCat, c.pct(c.WithAttrs), c.WithAttrs, c.Processed, c.Jobs)
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolveMySQLDumpPathExplicit(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "descrybe_new.sql")
if err := os.WriteFile(p, []byte("-- dump\n"), 0o644); err != nil {
t.Fatal(err)
}
got := resolveMySQLDumpPath(p)
if got != p {
t.Fatalf("got %q want %q", got, p)
}
}
func TestResolveMySQLDumpPathMissingExplicitFallsBack(t *testing.T) {
dir := t.TempDir()
want := filepath.Join(dir, "descrybe_new (1).sql")
if err := os.WriteFile(want, []byte("-- dump\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("SEED_A1_MYSQL_DUMP", want)
got := resolveMySQLDumpPath(filepath.Join(dir, "missing.sql"))
if got != want {
t.Fatalf("got %q want fallback %q", got, want)
}
}
func TestMysqlDumpCandidatesIncludeDownloadsName(t *testing.T) {
found := false
for _, c := range mysqlDumpCandidates() {
if filepath.Base(c) == "descrybe_new (1).sql" {
found = true
break
}
}
if !found {
t.Fatal("expected Downloads/descrybe_new (1).sql among candidates")
}
}
func TestMappedCoveragePct(t *testing.T) {
c := mappedCoverage{Total: 200, WithDesc: 100, WithCat: 50, WithAttrs: 200}
if c.pct(c.WithDesc) != 50 {
t.Fatalf("desc pct=%v want 50", c.pct(c.WithDesc))
}
if c.pct(c.WithCat) != 25 {
t.Fatalf("cat pct=%v want 25", c.pct(c.WithCat))
}
empty := mappedCoverage{}
if empty.pct(1) != 0 {
t.Fatalf("empty total should yield 0")
}
}
func TestScanA1ProcessedCategoriesKeepsDescriptionNullSafe(t *testing.T) {
// Dump original description/attributes are often NULL; category must still map.
const legacy = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
dump := strings.Join([]string{
"INSERT INTO `processed_products` VALUES",
"(1,\t'u',\t'790069217715',\t'Name',\t'103',\tNULL,\t'<p>ai</p>',\tNULL,\tNULL,\t'completed',\tNULL,\t0,\t'2026-01-01 00:00:00',\t'2026-01-01 00:00:00',\t1,\t'" + legacy + "',\t1);",
"CREATE TABLE `x` (",
}, "\n")
got, err := scanA1ProcessedCategories(strings.NewReader(dump), legacy)
if err != nil {
t.Fatal(err)
}
if got["790069217715"] != "103" {
t.Fatalf("category=%q want 103", got["790069217715"])
}
}
+763
View File
@@ -0,0 +1,763 @@
// Command seed-a1 exports or reimports the A1 Slovenija tenant (catalog, feeds,
// mappings, raw products, settings) as a gzipped COPY archive.
//
// Source of truth for "correct mapped fields" is live Postgres (already migrated),
// not the raw MySQL dump. Reimport wipes only the A1 company_id and preserves
// other tenants. Processed products and processing jobs are intentionally NOT
// part of npm run seed:a1 (skipped on import + cleared after). Use
// -mode recover-jobs only when restoring legacy job history from a MySQL dump.
//
// After reimport, legacy per-category GPT prompts are overlaid from
// scripts/seed/a1-category-prompts.json (Name→Prompt export), matched by
// normalized category name, with v1 placeholders rewritten to {{name}} /
// {{description}}. Use -skip-category-prompts to skip, or
// -mode apply-category-prompts to overlay without a full wipe/reimport.
//
// Categories: dump/archive store assignment on processed_products.category
// (category unique_id). Feed mappings do not map category. After reimport,
// seed-a1 copies those codes into raw_products.mapped_data.category so the UI
// coverage chip and later re-processing keep the assignment. Prefer
// -mysql-dump / SEED_A1_MYSQL_DUMP; when unset, seed-a1 auto-detects common
// dump paths (e.g. ~/Downloads/descrybe_new (1).sql). Dump backfill is
// mapped-only — it does not create processed rows. Without a dump,
// backfillMappedCategoriesFromProcessed is a no-op when processed was cleared
// (archive mapped_data.category is still restored from COPY). Original
// description + feed attributes live on raw_products.mapped_data and are
// exported/imported as-is (skip-processed must not strip them). For a
// polluted local DB without a full wipe, use -mode backfill-categories.
// Fixture EANs are purged from non-A1 tenants automatically.
//
// Usage:
//
// go run ./cmd/seed-a1 -mode export -file ../../scripts/seed/a1-demo-data.sql.gz
// go run ./cmd/seed-a1 -mode reimport -file ../../scripts/seed/a1-demo-data.sql.gz
// go run ./cmd/seed-a1 -mode apply-category-prompts -file ../../scripts/seed/a1-demo-data.sql.gz
// go run ./cmd/seed-a1 -mode recover-jobs -mysql-dump path/to/descrybe_new.sql
// go run ./cmd/seed-a1 -mode backfill-categories -mysql-dump path/to/descrybe_new.sql
//
// DATABASE_URL / -postgres required.
// Day-to-day: `npm run seed:a1` (reimport + category prompts + mapped category backfill). Maintainer snapshot: `npm run seed:a1:export`.
// recover-jobs is opt-in only and will reintroduce job history.
package main
import (
"bufio"
"context"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"compress/gzip"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Canonical Postgres id for the migrated A1 Slovenija tenant.
const defaultA1CompanyID = "604f23a8-b66e-4b21-8b45-0d72b68f4790"
const archiveMagic = "# seed-a1 v1"
// tableSpec describes one archive section.
// selectSQL must return columns matching cols (order matters for COPY).
type tableSpec struct {
name string
cols string
selectSQL string // may use $1 = company uuid
scoped bool // true → DELETE WHERE company_id = $1 before load
}
func main() {
postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL (DATABASE_URL)")
mode := flag.String("mode", "reimport", "export | reimport | recover-jobs | apply-category-prompts | backfill-categories")
file := flag.String("file", "", "gzipped seed archive path (required for export/reimport)")
mysqlDump := flag.String("mysql-dump", os.Getenv("SEED_A1_MYSQL_DUMP"), "mysqldump path for recover-jobs / backfill-categories (or SEED_A1_MYSQL_DUMP)")
company := flag.String("company", defaultA1CompanyID, "Postgres companies.id for A1")
categoryPrompts := flag.String("category-prompts", "", "A1 category prompts JSON (default: sibling a1-category-prompts.json next to -file)")
skipCategoryPrompts := flag.Bool("skip-category-prompts", false, "skip overlay of legacy category prompts after reimport")
skipCategoryBackfill := flag.Bool("skip-category-backfill", false, "skip mapped_data.category backfill after reimport")
flag.Parse()
if strings.TrimSpace(*postgresURL) == "" {
log.Fatal("-postgres / DATABASE_URL is required")
}
companyID, err := uuid.Parse(strings.TrimSpace(*company))
if err != nil {
log.Fatalf("-company: %v", err)
}
modeVal := strings.ToLower(strings.TrimSpace(*mode))
if modeVal != "recover-jobs" && modeVal != "apply-category-prompts" && modeVal != "backfill-categories" && strings.TrimSpace(*file) == "" {
log.Fatal("-file is required (e.g. ../../scripts/seed/a1-demo-data.sql.gz)")
}
dumpPath := resolveMySQLDumpPath(*mysqlDump)
if dumpPath != "" && strings.TrimSpace(*mysqlDump) == "" {
log.Printf("auto-detected MySQL dump: %s", dumpPath)
}
if modeVal == "recover-jobs" && dumpPath == "" {
log.Fatal("-mysql-dump or SEED_A1_MYSQL_DUMP is required for recover-jobs (or place descrybe_new.sql in Downloads)")
}
if modeVal == "backfill-categories" && dumpPath == "" {
log.Fatal("-mysql-dump or SEED_A1_MYSQL_DUMP is required for backfill-categories (or place descrybe_new.sql in Downloads)")
}
promptsPath := strings.TrimSpace(*categoryPrompts)
if promptsPath == "" {
promptsPath = defaultCategoryPromptsPath(*file)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
pg, err := pgxpool.New(ctx, *postgresURL)
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pg.Close()
switch modeVal {
case "export":
if err := exportArchive(ctx, pg, companyID, *file); err != nil {
log.Fatalf("export: %v", err)
}
log.Printf("exported A1 company %s → %s", companyID, *file)
case "reimport", "import":
if err := reimportArchive(ctx, pg, companyID, *file); err != nil {
log.Fatalf("reimport: %v", err)
}
log.Printf("reimported A1 company %s from %s", companyID, *file)
if !*skipCategoryPrompts {
res, err := applyCategoryPrompts(ctx, pg, companyID, promptsPath)
if err != nil {
log.Fatalf("category prompts: %v", err)
}
logCategoryPromptResult(res, promptsPath)
}
if !*skipCategoryBackfill {
if dumpPath != "" {
res, err := backfillCategoriesFromMySQLDump(ctx, pg, companyID, dumpPath)
if err != nil {
log.Fatalf("category backfill: %v", err)
}
logCategoryBackfillResult(res, dumpPath)
} else {
n, err := backfillMappedCategoriesFromProcessed(ctx, pg, companyID)
if err != nil {
log.Fatalf("category backfill: %v", err)
}
log.Printf("mapped_data.category backfill from processed: updated=%d (A1 only; 0 if processed was cleared — place dump in Downloads or set SEED_A1_MYSQL_DUMP)", n)
}
}
rawN, ppN, err := purgeA1FixtureEANsFromOtherTenants(ctx, pg, companyID)
if err != nil {
log.Fatalf("purge demo fixtures: %v", err)
}
if rawN > 0 || ppN > 0 {
log.Printf("purged A1 fixture EANs from other tenants: raw=%d processed=%d", rawN, ppN)
}
if cov, err := measureMappedCoverage(ctx, pg, companyID); err != nil {
log.Printf("mapped coverage: %v", err)
} else {
logMappedCoverage(cov, "post-reimport")
}
case "apply-category-prompts":
res, err := applyCategoryPrompts(ctx, pg, companyID, promptsPath)
if err != nil {
log.Fatalf("category prompts: %v", err)
}
logCategoryPromptResult(res, promptsPath)
case "backfill-categories":
res, err := backfillCategoriesFromMySQLDump(ctx, pg, companyID, dumpPath)
if err != nil {
log.Fatalf("backfill-categories: %v", err)
}
rawN, ppN, err := purgeA1FixtureEANsFromOtherTenants(ctx, pg, companyID)
if err != nil {
log.Fatalf("purge demo fixtures: %v", err)
}
res.PurgedOtherRaw, res.PurgedOtherPP = rawN, ppN
logCategoryBackfillResult(res, dumpPath)
case "recover-jobs":
if err := recoverJobsFromMySQLDump(ctx, pg, companyID, dumpPath); err != nil {
log.Fatalf("recover-jobs: %v", err)
}
log.Printf("recovered A1 processing jobs into company %s from %s", companyID, dumpPath)
default:
log.Fatalf("unknown -mode %q (want export|reimport|recover-jobs|apply-category-prompts|backfill-categories)", *mode)
}
}
func tableSpecs(companyID uuid.UUID) []tableSpec {
_ = companyID // reserved; SELECT SQLs bind $1 at export time
return []tableSpec{
{
name: "plans",
cols: "id,name,description,monthly_credits,yearly_credits,max_products,is_custom,term,created_at,updated_at,features,is_legacy",
selectSQL: `SELECT p.id, p.name, p.description, p.monthly_credits, p.yearly_credits, p.max_products, p.is_custom, p.term, p.created_at, p.updated_at, p.features, p.is_legacy FROM plans p WHERE p.id IN (SELECT plan_id FROM company_plans WHERE company_id = $1)`,
scoped: false,
},
{
name: "companies",
cols: "id,name,language,merge_products_by_gtin,legacy_company_id,created_at,updated_at,stripe_customer_id",
selectSQL: `SELECT id, name, language, merge_products_by_gtin, legacy_company_id, created_at, updated_at, stripe_customer_id FROM companies WHERE id = $1`,
scoped: false,
},
{
name: "users",
cols: "id,email,name,password_hash,must_set_password,email_verified_at,is_platform_admin,is_active,legacy_user_id,last_login_at,created_at,updated_at,staff_role",
selectSQL: `SELECT u.id, u.email, u.name, u.password_hash, u.must_set_password, u.email_verified_at, u.is_platform_admin, u.is_active, u.legacy_user_id, u.last_login_at, u.created_at, u.updated_at, u.staff_role FROM users u WHERE u.id IN (SELECT user_id FROM memberships WHERE company_id = $1)`,
scoped: false,
},
{
name: "memberships",
cols: "id,company_id,user_id,role,status,created_at,updated_at",
selectSQL: `SELECT id, company_id, user_id, role, status, created_at, updated_at FROM memberships WHERE company_id = $1`,
scoped: true,
},
{
name: "company_settings",
cols: "company_id,settings,updated_at",
selectSQL: `SELECT company_id, settings, updated_at FROM company_settings WHERE company_id = $1`,
scoped: true,
},
{
name: "company_brand",
cols: "company_id,voice_tone,dos,donts,primary_color,secondary_color,logo_url,preferred_terms,updated_at",
selectSQL: `SELECT company_id, voice_tone, dos, donts, primary_color, secondary_color, logo_url, preferred_terms, updated_at FROM company_brand WHERE company_id = $1`,
scoped: true,
},
{
name: "company_plans",
cols: "id,company_id,plan_id,is_active,billing_cycle_start,next_billing_date,contract_start_date,contract_end_date,custom_monthly_credits,total_credits_allocated,custom_max_products,contract_reference,notes,is_trial,trial_ends_at,trial_credits,created_at,updated_at,stripe_subscription_id,stripe_price_id",
selectSQL: `SELECT id, company_id, plan_id, is_active, billing_cycle_start, next_billing_date, contract_start_date, contract_end_date, custom_monthly_credits, total_credits_allocated, custom_max_products, contract_reference, notes, is_trial, trial_ends_at, trial_credits, created_at, updated_at, stripe_subscription_id, stripe_price_id FROM company_plans WHERE company_id = $1`,
scoped: true,
},
{
name: "credit_balances",
cols: "company_id,total_credits,used_credits,updated_at",
selectSQL: `SELECT company_id, total_credits, used_credits, updated_at FROM credit_balances WHERE company_id = $1`,
scoped: true,
},
{
name: "api_keys",
cols: "id,company_id,user_id,name,key_hash,key_prefix,last_used_at,revoked_at,created_at,updated_at",
selectSQL: `SELECT id, company_id, user_id, name, key_hash, key_prefix, last_used_at, revoked_at, created_at, updated_at FROM api_keys WHERE company_id = $1`,
scoped: true,
},
{
name: "field_groups",
cols: `id,company_id,name,description,"order",is_system,created_at,updated_at`,
selectSQL: `SELECT id, company_id, name, description, "order", is_system, created_at, updated_at FROM field_groups WHERE company_id = $1`,
scoped: true,
},
{
name: "standard_fields",
cols: "id,company_id,name,key,type,group_id,is_required,description,default_value,validation,is_system,created_at,updated_at,enabled,unit,sort_order,mapping_hints",
selectSQL: `SELECT id, company_id, name, key, type, group_id, is_required, description, default_value, validation, is_system, created_at, updated_at, enabled, unit, sort_order, mapping_hints FROM standard_fields WHERE company_id = $1`,
scoped: true,
},
{
name: "structured_description_fields",
cols: "id,company_id,field_key,type,created_at,updated_at",
selectSQL: `SELECT id, company_id, field_key, type, created_at, updated_at FROM structured_description_fields WHERE company_id = $1`,
scoped: true,
},
{
name: "attributes",
cols: "id,company_id,attribute_key,name,value_type,unit,example,parent_key,created_at,updated_at",
selectSQL: `SELECT id, company_id, attribute_key, name, value_type, unit, example, parent_key, created_at, updated_at FROM attributes WHERE company_id = $1`,
scoped: true,
},
{
name: "categories",
cols: "id,company_id,name,unique_id,parent_unique_id,path,level,position,is_active,description,prompt,metadata,config,title_template,description_template,created_at,updated_at",
selectSQL: `SELECT id, company_id, name, unique_id, parent_unique_id, path, level, position, is_active, description, prompt, metadata, config, title_template, description_template, created_at, updated_at FROM categories WHERE company_id = $1`,
scoped: true,
},
{
name: "category_attributes",
cols: "id,company_id,category_unique_id,attribute_id,required,created_at,updated_at",
selectSQL: `SELECT id, company_id, category_unique_id, attribute_id, required, created_at, updated_at FROM category_attributes WHERE company_id = $1`,
scoped: true,
},
{
name: "custom_variables",
cols: "id,company_id,name,value,description,created_at,updated_at",
selectSQL: `SELECT id, company_id, name, value, description, created_at, updated_at FROM custom_variables WHERE company_id = $1`,
scoped: true,
},
{
name: "input_feeds",
cols: "id,company_id,name,url,feed_type,status,sync_interval_minutes,last_synced_at,auth_config,options,created_at,updated_at",
selectSQL: `SELECT id, company_id, name, url, feed_type, status, sync_interval_minutes, last_synced_at, auth_config, options, created_at, updated_at FROM input_feeds WHERE company_id = $1`,
scoped: true,
},
{
name: "feed_mappings",
cols: "id,feed_id,company_id,version,mappings,is_active,created_at,updated_at",
selectSQL: `SELECT id, feed_id, company_id, version, mappings, is_active, created_at, updated_at FROM feed_mappings WHERE company_id = $1`,
scoped: true,
},
{
name: "feed_tags",
cols: "id,company_id,name,color,created_at",
selectSQL: `SELECT id, company_id, name, color, created_at FROM feed_tags WHERE company_id = $1`,
scoped: true,
},
{
name: "feed_tag_mappings",
cols: "feed_id,tag_id",
selectSQL: `SELECT ftm.feed_id, ftm.tag_id FROM feed_tag_mappings ftm
JOIN feed_tags ft ON ft.id = ftm.tag_id WHERE ft.company_id = $1`,
scoped: false, // wiped via cascading / explicit join delete
},
{
name: "files",
cols: "id,company_id,user_id,name,path,content_type,size_bytes,status,metadata,created_at,updated_at",
selectSQL: `SELECT id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at FROM files WHERE company_id = $1`,
scoped: true,
},
{
name: "raw_products",
cols: "id,company_id,gtin,feed_id,feed_ids,raw_data,mapped_data,sync_job_id,is_processed,processing_status,file_id,created_at,updated_at",
// sync_job_id points at ephemeral feed_sync_jobs (not archived) — export as NULL.
// A1 seed stays catalog-clean: never archive processing state on raw rows.
selectSQL: `SELECT id, company_id, gtin, feed_id, feed_ids, raw_data, mapped_data, NULL::uuid AS sync_job_id, false AS is_processed, 'unprocessed' AS processing_status, file_id, created_at, updated_at FROM raw_products WHERE company_id = $1`,
scoped: true,
},
// processed_products / processing_jobs / processing_job_products are intentionally
// omitted from the A1 demo archive. Use -mode recover-jobs only when restoring
// legacy job history from a MySQL dump (not part of npm run seed:a1).
{
name: "export_feeds",
cols: "id,company_id,name,source_feed_id,format,public_token,template,filters,is_active,last_generated_at,created_at,updated_at",
selectSQL: `SELECT id, company_id, name, source_feed_id, format, public_token, template, filters, is_active, last_generated_at, created_at, updated_at FROM export_feeds WHERE company_id = $1`,
scoped: true,
},
{
name: "woocommerce_configs",
cols: "company_id,store_url,consumer_key,consumer_secret,is_enabled,sync_options,last_synced_at,last_test_at,last_test_status,created_at,updated_at",
selectSQL: `SELECT company_id, store_url, consumer_key, consumer_secret, is_enabled, sync_options, last_synced_at, last_test_at, last_test_status, created_at, updated_at FROM woocommerce_configs WHERE company_id = $1`,
scoped: true,
},
}
}
func exportTables(companyID uuid.UUID) []tableSpec {
return tableSpecs(companyID)
}
func exportArchive(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, outPath string) error {
var name, legacy string
err := pg.QueryRow(ctx, `
SELECT name, COALESCE(legacy_company_id::text, '')
FROM companies WHERE id = $1`, companyID).Scan(&name, &legacy)
if err != nil {
return fmt.Errorf("resolve company %s: %w (expected A1 Slovenija)", companyID, err)
}
if legacy != "" && !strings.EqualFold(legacy, billing.A1LegacyCompanyID) {
log.Printf("warning: legacy_company_id=%s (canonical MySQL A1 is %s)", legacy, billing.A1LegacyCompanyID)
}
log.Printf("exporting company %s (%q legacy=%s)", companyID, name, legacy)
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return err
}
tmp := outPath + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
gz := gzip.NewWriter(f)
bw := bufio.NewWriterSize(gz, 1<<20)
write := func(s string) error {
_, err := bw.WriteString(s)
return err
}
if err := write(fmt.Sprintf("%s\n# company_id=%s\n# company_name=%s\n# legacy_company_id=%s\n# generated_at=%s\n",
archiveMagic, companyID, sanitizeHeader(name), legacy, time.Now().UTC().Format(time.RFC3339))); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
conn, err := pg.Acquire(ctx)
if err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
defer conn.Release()
for _, t := range exportTables(companyID) {
log.Printf(" export %s…", t.name)
if err := write(fmt.Sprintf("BEGIN_TABLE %s\nCOLUMNS %s\n", t.name, t.cols)); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
// Bind company id into COPY subquery by substituting a validated uuid literal
// (companyID already parsed). ReplaceAll so multi-$1 SELECTs stay correct.
q := strings.ReplaceAll(t.selectSQL, "$1", "'"+companyID.String()+"'::uuid")
copySQL := fmt.Sprintf("COPY (%s) TO STDOUT", q)
tag, err := conn.Conn().PgConn().CopyTo(ctx, bw, copySQL)
if err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return fmt.Errorf("copy %s: %w", t.name, err)
}
rows := tag.RowsAffected()
if err := write(fmt.Sprintf("END_TABLE %s rows=%d\n", t.name, rows)); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
log.Printf(" %s rows=%d", t.name, rows)
}
if err := bw.Flush(); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := gz.Close(); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, outPath)
}
func sanitizeHeader(s string) string {
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' {
return -1
}
return r
}, s)
}
func wipeA1Tenant(ctx context.Context, tx pgx.Tx, companyID uuid.UUID) error {
// FK-safe deletes for A1 only. Users/plans/global rows are not deleted.
stmts := []string{
`DELETE FROM feed_tag_mappings WHERE tag_id IN (SELECT id FROM feed_tags WHERE company_id = $1)
OR feed_id IN (SELECT id FROM input_feeds WHERE company_id = $1)`,
// Jobs before products: job_products FK raw_products / processed_products.
`DELETE FROM processing_job_products WHERE job_id IN (SELECT id FROM processing_jobs WHERE company_id = $1)`,
`DELETE FROM processing_jobs WHERE company_id = $1`,
`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 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`,
`DELETE FROM woocommerce_configs WHERE company_id = $1`,
`DELETE FROM api_keys WHERE company_id = $1`,
`DELETE FROM company_plans WHERE company_id = $1`,
`DELETE FROM credit_balances WHERE company_id = $1`,
`DELETE FROM company_brand WHERE company_id = $1`,
`DELETE FROM company_settings WHERE company_id = $1`,
`DELETE FROM memberships WHERE company_id = $1`,
`DELETE FROM invites WHERE company_id = $1`,
`DELETE FROM billing_cycles WHERE company_id = $1`,
}
for _, q := range stmts {
if _, err := tx.Exec(ctx, q, companyID); err != nil {
return fmt.Errorf("wipe: %s: %w", q, err)
}
}
return nil
}
func reimportArchive(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, inPath string) error {
f, err := os.Open(inPath)
if err != nil {
return fmt.Errorf("open seed file: %w (expected committed scripts/seed/a1-demo-data.sql.gz; maintainers regenerate with npm run seed:a1:export)", err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("gzip: %w", err)
}
defer gz.Close()
sections, headerCompany, err := parseArchive(gz)
if err != nil {
return err
}
if headerCompany != "" && headerCompany != companyID.String() {
return fmt.Errorf("archive company_id=%s does not match -company=%s", headerCompany, companyID)
}
acq, err := pg.Acquire(ctx)
if err != nil {
return err
}
defer acq.Release()
tx, err := acq.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
log.Printf("wiping A1 tenant %s (other companies untouched)…", companyID)
if err := wipeA1Tenant(ctx, tx, companyID); err != nil {
return err
}
pgConn := acq.Conn().PgConn()
for _, sec := range sections {
if skipA1ProcessingSeedTable(sec.name) {
log.Printf(" skip %s (%d bytes) — A1 seed keeps zero processed/jobs", sec.name, len(sec.data))
continue
}
log.Printf(" load %s (%d bytes)…", sec.name, len(sec.data))
switch sec.name {
case "plans":
if err := upsertFromCopy(ctx, tx, pgConn, "plans", sec.cols, sec.data, `
INSERT INTO plans AS p (`+sec.cols+`)
SELECT `+sec.cols+` FROM tmp_seed_a1_plans
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
monthly_credits = EXCLUDED.monthly_credits,
yearly_credits = EXCLUDED.yearly_credits,
max_products = EXCLUDED.max_products,
is_custom = EXCLUDED.is_custom,
term = EXCLUDED.term,
features = EXCLUDED.features,
is_legacy = EXCLUDED.is_legacy,
updated_at = EXCLUDED.updated_at`); err != nil {
return err
}
case "companies":
if err := upsertFromCopy(ctx, tx, pgConn, "companies", sec.cols, sec.data, `
INSERT INTO companies AS c (`+sec.cols+`)
SELECT `+sec.cols+` FROM tmp_seed_a1_companies
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
language = EXCLUDED.language,
merge_products_by_gtin = EXCLUDED.merge_products_by_gtin,
legacy_company_id = EXCLUDED.legacy_company_id,
stripe_customer_id = EXCLUDED.stripe_customer_id,
updated_at = EXCLUDED.updated_at`); err != nil {
return err
}
case "users":
if err := upsertFromCopy(ctx, tx, pgConn, "users", sec.cols, sec.data, `
INSERT INTO users AS u (`+sec.cols+`)
SELECT `+sec.cols+` FROM tmp_seed_a1_users
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
name = EXCLUDED.name,
password_hash = COALESCE(EXCLUDED.password_hash, u.password_hash),
must_set_password = EXCLUDED.must_set_password,
is_platform_admin = u.is_platform_admin OR EXCLUDED.is_platform_admin,
is_active = EXCLUDED.is_active,
staff_role = COALESCE(EXCLUDED.staff_role, u.staff_role),
updated_at = EXCLUDED.updated_at`); err != nil {
return err
}
case "feed_tag_mappings":
if err := copyInto(ctx, pgConn, "feed_tag_mappings", sec.cols, sec.data); err != nil {
return err
}
default:
if err := copyInto(ctx, pgConn, sec.name, sec.cols, sec.data); err != nil {
return err
}
}
}
if err := tx.Commit(ctx); err != nil {
return err
}
if err := clearA1ProcessingArtifacts(ctx, pg, companyID); err != nil {
return err
}
var rawN, procN, feedN, mapN, jobN, jobProdN int
_ = pg.QueryRow(ctx, `SELECT count(*) FROM raw_products WHERE company_id=$1`, companyID).Scan(&rawN)
_ = pg.QueryRow(ctx, `SELECT count(*) FROM processed_products WHERE company_id=$1`, companyID).Scan(&procN)
_ = pg.QueryRow(ctx, `SELECT count(*) FROM input_feeds WHERE company_id=$1`, companyID).Scan(&feedN)
_ = pg.QueryRow(ctx, `SELECT count(*) FROM feed_mappings WHERE company_id=$1`, companyID).Scan(&mapN)
_ = pg.QueryRow(ctx, `SELECT count(*) FROM processing_jobs WHERE company_id=$1`, companyID).Scan(&jobN)
_ = pg.QueryRow(ctx, `SELECT count(*) FROM processing_job_products WHERE job_id IN (SELECT id FROM processing_jobs WHERE company_id=$1)`, companyID).Scan(&jobProdN)
log.Printf("post-import counts: raw=%d processed=%d feeds=%d mappings=%d jobs=%d job_products=%d", rawN, procN, feedN, mapN, jobN, jobProdN)
return nil
}
// skipA1ProcessingSeedTable omits processed/job history from older archives so
// npm run seed:a1 leaves A1 with a clean catalog (zero processed, zero jobs).
func skipA1ProcessingSeedTable(name string) bool {
switch name {
case "processed_products", "processing_jobs", "processing_job_products", "tasks":
return true
default:
return false
}
}
// clearA1ProcessingArtifacts deletes A1 processed products, jobs, and tasks and
// resets raw processing flags. Safe to run after reimport (other companies untouched).
func clearA1ProcessingArtifacts(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID) error {
tx, err := pg.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
stmts := []string{
`DELETE FROM processing_job_products WHERE job_id IN (SELECT id FROM processing_jobs WHERE company_id = $1)`,
`DELETE FROM processing_jobs WHERE company_id = $1`,
`DELETE FROM processed_products WHERE company_id = $1`,
`DELETE FROM tasks WHERE company_id = $1`,
`UPDATE raw_products
SET processing_status = 'unprocessed', is_processed = false, updated_at = now()
WHERE company_id = $1
AND (COALESCE(processing_status, '') NOT IN ('', 'unprocessed') OR is_processed IS TRUE)`,
}
for _, q := range stmts {
if _, err := tx.Exec(ctx, q, companyID); err != nil {
return fmt.Errorf("clear processing artifacts: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return err
}
log.Printf("A1 processing artifacts cleared for company %s (processed=0 jobs=0)", companyID)
return nil
}
type archiveSection struct {
name string
cols string
data []byte
}
func parseArchive(r io.Reader) ([]archiveSection, string, error) {
br := bufio.NewReaderSize(r, 1<<20)
var headerCompany string
var sections []archiveSection
var cur *archiveSection
var buf strings.Builder
flush := func() {
if cur == nil {
return
}
cur.data = []byte(buf.String())
sections = append(sections, *cur)
cur = nil
buf.Reset()
}
lineNo := 0
for {
line, err := br.ReadString('\n')
if len(line) > 0 {
lineNo++
trimmedRight := strings.TrimRight(line, "\r\n")
if cur == nil {
s := strings.TrimSpace(trimmedRight)
if lineNo == 1 && !strings.HasPrefix(s, "# seed-a1") {
return nil, "", fmt.Errorf("bad magic on line 1: %q", s)
}
if strings.HasPrefix(s, "# company_id=") {
headerCompany = strings.TrimPrefix(s, "# company_id=")
}
if strings.HasPrefix(s, "BEGIN_TABLE ") {
flush()
cur = &archiveSection{name: strings.TrimSpace(strings.TrimPrefix(s, "BEGIN_TABLE "))}
buf.Reset()
}
continue
}
// inside table
if strings.HasPrefix(trimmedRight, "COLUMNS ") && cur.cols == "" {
cur.cols = strings.TrimSpace(strings.TrimPrefix(trimmedRight, "COLUMNS "))
continue
}
if strings.HasPrefix(trimmedRight, "END_TABLE ") {
flush()
continue
}
buf.WriteString(line)
continue
}
if err == io.EOF {
break
}
if err != nil {
return nil, "", err
}
}
flush()
if len(sections) == 0 {
return nil, "", fmt.Errorf("archive contained no tables")
}
return sections, headerCompany, nil
}
func copyInto(ctx context.Context, pgConn *pgconn.PgConn, table, cols string, data []byte) error {
if len(strings.TrimSpace(string(data))) == 0 {
return nil
}
sql := fmt.Sprintf("COPY %s (%s) FROM STDIN", table, cols)
_, err := pgConn.CopyFrom(ctx, strings.NewReader(string(data)), sql)
if err != nil {
return fmt.Errorf("COPY %s: %w", table, err)
}
return nil
}
func upsertFromCopy(ctx context.Context, tx pgx.Tx, pgConn *pgconn.PgConn, table, cols string, data []byte, mergeSQL string) error {
tmp := "tmp_seed_a1_" + table
if _, err := tx.Exec(ctx, fmt.Sprintf(`DROP TABLE IF EXISTS %s`, tmp)); err != nil {
return err
}
if _, err := tx.Exec(ctx, fmt.Sprintf(`CREATE TEMP TABLE %s (LIKE %s INCLUDING DEFAULTS) ON COMMIT DROP`, tmp, table)); err != nil {
return err
}
if len(strings.TrimSpace(string(data))) > 0 {
sql := fmt.Sprintf("COPY %s (%s) FROM STDIN", tmp, cols)
if _, err := pgConn.CopyFrom(ctx, strings.NewReader(string(data)), sql); err != nil {
return fmt.Errorf("COPY temp %s: %w", table, err)
}
}
if _, err := tx.Exec(ctx, mergeSQL); err != nil {
return fmt.Errorf("merge %s: %w", table, err)
}
return nil
}
+569
View File
@@ -0,0 +1,569 @@
package main
import (
"bufio"
"context"
"fmt"
"io"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// recoverJobsFromMySQLDump streams a mysqldump and upserts every A1
// processing_jobs (+ joinable processing_job_products) into live Postgres.
//
// Needed because live A1 often only retains a couple of recent jobs (retention
// deletes non-migrated terminal rows after 30 days; jobs domain may never have
// been imported). Export alone cannot invent history that is missing from PG.
//
// Job products resolve raw_product_id via dump GTIN → Postgres raw_products.
func recoverJobsFromMySQLDump(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, dumpPath string) error {
legacyCompany := billing.A1LegacyCompanyID
var legacy string
_ = pg.QueryRow(ctx, `SELECT COALESCE(legacy_company_id::text, '') FROM companies WHERE id = $1`, companyID).Scan(&legacy)
if legacy != "" {
legacyCompany = legacy
}
fi, err := os.Stat(dumpPath)
if err != nil {
return fmt.Errorf("stat mysql dump %q: %w", dumpPath, err)
}
log.Printf("recover-jobs: dump=%s size=%d legacy_company=%s", dumpPath, fi.Size(), legacyCompany)
userByLegacy, err := loadUserLegacyMap(ctx, pg, companyID)
if err != nil {
return err
}
f, err := os.Open(dumpPath)
if err != nil {
return fmt.Errorf("open mysql dump: %w", err)
}
defer f.Close()
jobs, err := scanA1ProcessingJobs(f, legacyCompany)
if err != nil {
return err
}
if len(jobs) == 0 {
return fmt.Errorf("no processing_jobs for legacy company %s in dump", legacyCompany)
}
log.Printf("dump: %d A1 processing_jobs (incl. history)", len(jobs))
if _, err := f.Seek(0, io.SeekStart); err != nil {
return err
}
jobIDs := make(map[string]struct{}, len(jobs))
for _, j := range jobs {
jobIDs[j.id] = struct{}{}
}
pjpRows, rawLegacyIDs, err := scanA1JobProducts(f, jobIDs)
if err != nil {
return err
}
log.Printf("dump: %d A1 processing_job_products across %d raw ids", len(pjpRows), len(rawLegacyIDs))
if _, err := f.Seek(0, io.SeekStart); err != nil {
return err
}
gtinByLegacy, err := scanRawProductGTINs(f, rawLegacyIDs, legacyCompany)
if err != nil {
return err
}
log.Printf("dump: resolved %d/%d raw→gtin mappings", len(gtinByLegacy), len(rawLegacyIDs))
rawByGTIN, err := loadRawByGTIN(ctx, pg, companyID, gtinByLegacy)
if err != nil {
return err
}
var jobsUpserted, jobsSkipped int
for _, j := range jobs {
jobUUID, err := parseDumpJobID(j.id)
if err != nil {
jobsSkipped++
continue
}
var userID *uuid.UUID
if uid, ok := userByLegacy[j.userLegacy]; ok {
userID = &uid
}
status := normalizeProcessingJobStatus(j.status)
ptype := strings.TrimSpace(j.processingType)
if ptype == "" {
ptype = "full"
}
var errPtr *string
if j.errText != "" {
v := j.errText
errPtr = &v
}
_, err = pg.Exec(ctx, `
INSERT INTO processing_jobs (
id, company_id, user_id, status, total_products, processed_products,
error, processing_type, priority, estimated_tokens,
started_at, completed_at, created_at, updated_at,
current_step, step_progress, ai_provider_mode
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13, $14,
'', '[]'::jsonb, 'migrated'
)
ON CONFLICT (id) DO UPDATE SET
company_id = EXCLUDED.company_id,
user_id = COALESCE(EXCLUDED.user_id, processing_jobs.user_id),
status = EXCLUDED.status,
total_products = EXCLUDED.total_products,
processed_products = EXCLUDED.processed_products,
error = EXCLUDED.error,
processing_type = EXCLUDED.processing_type,
priority = EXCLUDED.priority,
estimated_tokens = EXCLUDED.estimated_tokens,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at,
created_at = EXCLUDED.created_at,
updated_at = EXCLUDED.updated_at,
ai_provider_mode = 'migrated'`,
jobUUID, companyID, userID, status,
j.totalProducts, j.processedProducts,
errPtr, ptype, j.priority, j.estimatedTokens,
j.startedAt, j.completedAt, j.createdAt, j.updatedAt,
)
if err != nil {
log.Printf("job %s: %v", j.id, err)
jobsSkipped++
continue
}
jobsUpserted++
}
var pjpUpserted, pjpSkipped int
for _, row := range pjpRows {
jobUUID, err := parseDumpJobID(row.jobID)
if err != nil {
pjpSkipped++
continue
}
gtin, ok := gtinByLegacy[row.rawLegacy]
if !ok || gtin == "" {
pjpSkipped++
continue
}
rawUUID, ok := rawByGTIN[gtin]
if !ok {
pjpSkipped++
continue
}
prodID := uuid.NewSHA1(uuid.NameSpaceOID, []byte("pjp:"+strconv.FormatInt(row.legacyID, 10)))
var errPtr *string
if row.errText != "" {
v := row.errText
errPtr = &v
}
_, err = pg.Exec(ctx, `
INSERT INTO processing_job_products (
id, job_id, raw_product_id, status, error, processed_product_id, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, NULL, $6, $7)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
error = EXCLUDED.error,
raw_product_id = EXCLUDED.raw_product_id,
updated_at = EXCLUDED.updated_at`,
prodID, jobUUID, rawUUID, normalizeJobProductStatus(row.status), errPtr,
row.createdAt, row.updatedAt,
)
if err != nil {
pjpSkipped++
continue
}
pjpUpserted++
}
var liveJobs, livePJP int
_ = pg.QueryRow(ctx, `SELECT count(*) FROM processing_jobs WHERE company_id=$1`, companyID).Scan(&liveJobs)
_ = pg.QueryRow(ctx, `SELECT count(*) FROM processing_job_products WHERE job_id IN (SELECT id FROM processing_jobs WHERE company_id=$1)`, companyID).Scan(&livePJP)
log.Printf("recover-jobs done: jobs_upserted=%d skipped=%d pjp_upserted=%d skipped=%d live_jobs=%d live_job_products=%d",
jobsUpserted, jobsSkipped, pjpUpserted, pjpSkipped, liveJobs, livePJP)
return nil
}
type dumpJob struct {
id string
userLegacy string
status, processingType, errText string
totalProducts, processedProducts int
priority, estimatedTokens int
startedAt, completedAt *time.Time
createdAt, updatedAt time.Time
}
type dumpJobProduct struct {
legacyID int64
jobID, rawLegacy string
status, errText string
createdAt, updatedAt time.Time
}
func loadUserLegacyMap(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID) (map[string]uuid.UUID, error) {
rows, err := pg.Query(ctx, `
SELECT u.id, COALESCE(u.legacy_user_id, '')
FROM users u
JOIN memberships m ON m.user_id = u.id
WHERE m.company_id = $1 AND COALESCE(u.legacy_user_id, '') <> ''`, companyID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]uuid.UUID{}
for rows.Next() {
var id uuid.UUID
var legacy string
if err := rows.Scan(&id, &legacy); err != nil {
return nil, err
}
out[legacy] = id
}
return out, rows.Err()
}
func loadRawByGTIN(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, gtinByLegacy map[string]string) (map[string]uuid.UUID, error) {
uniq := make([]string, 0, len(gtinByLegacy))
seen := map[string]struct{}{}
for _, g := range gtinByLegacy {
g = strings.TrimSpace(g)
if g == "" {
continue
}
if _, ok := seen[g]; ok {
continue
}
seen[g] = struct{}{}
uniq = append(uniq, g)
}
out := map[string]uuid.UUID{}
if len(uniq) == 0 {
return out, nil
}
rows, err := pg.Query(ctx, `
SELECT gtin, id FROM raw_products
WHERE company_id = $1 AND gtin = ANY($2)`, companyID, uniq)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var gtin string
var id uuid.UUID
if err := rows.Scan(&gtin, &id); err != nil {
return nil, err
}
out[gtin] = id
}
return out, rows.Err()
}
func scanA1ProcessingJobs(r io.Reader, legacyCompany string) ([]dumpJob, error) {
br := bufio.NewReaderSize(r, 4<<20)
mode := false
var out []dumpJob
for {
line, err := br.ReadString('\n')
if strings.HasPrefix(line, "INSERT INTO `processing_jobs`") {
mode = true
} else if mode && dumpSectionEnded(line, "processing_jobs") {
mode = false
}
if mode && strings.Contains(line, "'"+legacyCompany+"'") {
fields := parseMySQLTupleFields(line)
// id, user_id, company_id, status, total, processed, error, started, completed, created, updated, type, priority, estimated
if len(fields) >= 14 && fields[2] == legacyCompany {
j := dumpJob{
id: fields[0],
userLegacy: nullish(fields[1]),
status: fields[3],
totalProducts: atoiDefault(fields[4], 0),
processedProducts: atoiDefault(fields[5], 0),
errText: nullish(fields[6]),
startedAt: parseDumpTimePtr(fields[7]),
completedAt: parseDumpTimePtr(fields[8]),
createdAt: parseDumpTime(fields[9]),
updatedAt: parseDumpTime(fields[10]),
processingType: nullish(fields[11]),
priority: atoiDefault(fields[12], 0),
estimatedTokens: atoiDefault(fields[13], 0),
}
if j.createdAt.IsZero() {
j.createdAt = time.Now().UTC()
}
if j.updatedAt.IsZero() {
j.updatedAt = j.createdAt
}
out = append(out, j)
}
}
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
}
return out, nil
}
func scanA1JobProducts(r io.Reader, jobIDs map[string]struct{}) ([]dumpJobProduct, map[string]struct{}, error) {
br := bufio.NewReaderSize(r, 4<<20)
mode := false
var out []dumpJobProduct
rawIDs := map[string]struct{}{}
for {
line, err := br.ReadString('\n')
if strings.HasPrefix(line, "INSERT INTO `processing_job_products`") {
mode = true
} else if mode && dumpSectionEnded(line, "processing_job_products") {
mode = false
}
if mode && looksLikeTupleLine(line) {
fields := parseMySQLTupleFields(line)
// id, job_id, raw_product_id, status, error, processed_product_id, created, updated
if len(fields) >= 8 {
if _, ok := jobIDs[fields[1]]; ok {
legacyID, _ := strconv.ParseInt(fields[0], 10, 64)
row := dumpJobProduct{
legacyID: legacyID,
jobID: fields[1],
rawLegacy: fields[2],
status: fields[3],
errText: nullish(fields[4]),
createdAt: parseDumpTime(fields[6]),
updatedAt: parseDumpTime(fields[7]),
}
if row.createdAt.IsZero() {
row.createdAt = time.Now().UTC()
}
if row.updatedAt.IsZero() {
row.updatedAt = row.createdAt
}
out = append(out, row)
rawIDs[row.rawLegacy] = struct{}{}
}
}
}
if err == io.EOF {
break
}
if err != nil {
return nil, nil, err
}
}
return out, rawIDs, nil
}
func scanRawProductGTINs(r io.Reader, want map[string]struct{}, legacyCompany string) (map[string]string, error) {
br := bufio.NewReaderSize(r, 4<<20)
mode := false
out := map[string]string{}
remaining := len(want)
for remaining > 0 {
line, err := br.ReadString('\n')
if strings.HasPrefix(line, "INSERT INTO `raw_products`") {
mode = true
} else if mode && dumpSectionEnded(line, "raw_products") {
mode = false
}
if mode && looksLikeTupleLine(line) {
// Need id + gtin; company is field index 4 in this dump schema:
// id, gtin, feed_id, feed_ids, company_id, raw_data, ...
fields := parseMySQLTupleFieldsN(line, 5)
if len(fields) >= 5 {
id := fields[0]
if _, ok := want[id]; ok && fields[4] == legacyCompany {
gtin := strings.TrimSpace(nullish(fields[1]))
if gtin != "" {
out[id] = gtin
remaining--
}
}
}
}
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
}
return out, nil
}
func dumpSectionEnded(line, table string) bool {
if strings.HasPrefix(line, "CREATE TABLE") || strings.HasPrefix(line, "UNLOCK TABLES") || strings.HasPrefix(line, "LOCK TABLES") {
return true
}
if strings.HasPrefix(line, "INSERT INTO `") && !strings.HasPrefix(line, "INSERT INTO `"+table+"`") {
return true
}
if strings.HasPrefix(line, "DROP TABLE") {
return true
}
return false
}
func looksLikeTupleLine(line string) bool {
s := strings.TrimLeft(line, " \t")
return strings.HasPrefix(s, "(")
}
func parseMySQLTupleFields(line string) []string {
return parseMySQLTupleFieldsN(line, 0)
}
// parseMySQLTupleFieldsN parses up to maxFields (0 = all) from the first (...) tuple on the line.
func parseMySQLTupleFieldsN(line string, maxFields int) []string {
start := strings.Index(line, "(")
if start < 0 {
return nil
}
body := line[start+1:]
var out []string
for i := 0; i < len(body); {
if maxFields > 0 && len(out) >= maxFields {
break
}
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == ',') {
i++
}
if i >= len(body) || body[i] == ')' {
break
}
if body[i] == '\'' {
i++
var b strings.Builder
for i < len(body) {
ch := body[i]
if ch == '\\' && i+1 < len(body) {
b.WriteByte(body[i+1])
i += 2
continue
}
if ch == '\'' {
if i+1 < len(body) && body[i+1] == '\'' {
b.WriteByte('\'')
i += 2
continue
}
i++
break
}
b.WriteByte(ch)
i++
}
out = append(out, b.String())
continue
}
j := i
for j < len(body) && body[j] != ',' && body[j] != ')' {
j++
}
out = append(out, strings.TrimSpace(body[i:j]))
i = j
}
return out
}
func parseDumpJobID(raw string) (uuid.UUID, error) {
raw = strings.TrimSpace(raw)
if id, err := uuid.Parse(raw); err == nil {
return id, nil
}
// Legacy dump mixes UUID and numeric string PKs — keep remaps stable (migrator parity).
return uuid.NewSHA1(uuid.NameSpaceOID, []byte("processing_job:"+raw)), nil
}
func nullish(s string) string {
s = strings.TrimSpace(s)
if s == "" || strings.EqualFold(s, "NULL") {
return ""
}
return s
}
func atoiDefault(s string, def int) int {
s = nullish(s)
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
func parseDumpTime(s string) time.Time {
s = nullish(s)
if s == "" {
return time.Time{}
}
for _, layout := range []string{
"2006-01-02 15:04:05",
time.RFC3339,
"2006-01-02 15:04:05.000",
} {
if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil {
return t.UTC()
}
}
return time.Time{}
}
func parseDumpTimePtr(s string) *time.Time {
t := parseDumpTime(s)
if t.IsZero() {
return nil
}
return &t
}
func normalizeProcessingJobStatus(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "completed", "success", "done":
return "completed"
case "failed", "error":
return "failed"
case "cancelled", "canceled", "skipped":
return "cancelled"
case "running", "processing":
return "running"
case "pending", "queued":
return "pending"
default:
return "failed"
}
}
func normalizeJobProductStatus(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "processed", "completed", "success", "done":
return "processed"
case "failed", "error":
return "failed"
case "cancelled", "canceled", "skipped":
return "cancelled"
case "processing", "running":
return "processing"
case "pending", "queued":
return "pending"
default:
return "failed"
}
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"os"
"testing"
)
func TestScanA1ProcessingJobsSmoke(t *testing.T) {
dump := os.Getenv("SEED_A1_MYSQL_DUMP")
if dump == "" {
dump = `d:\Users\Green Eclipse\Downloads\descrybe_new.sql`
}
if _, err := os.Stat(dump); err != nil {
t.Skipf("mysql dump not available: %v", err)
}
f, err := os.Open(dump)
if err != nil {
t.Fatal(err)
}
defer f.Close()
jobs, err := scanA1ProcessingJobs(f, "97e1a309-3d23-4aa2-b518-8e8d7afdfec7")
if err != nil {
t.Fatal(err)
}
if len(jobs) < 100 {
t.Fatalf("expected many A1 jobs, got %d", len(jobs))
}
has := false
for _, j := range jobs {
if j.id == "827f0b82-3214-4c1d-bf05-2455bbd66fc6" {
has = true
if j.status != "completed" || j.totalProducts != 1 {
t.Fatalf("target job unexpected status=%s total=%d", j.status, j.totalProducts)
}
}
}
if !has {
t.Fatal("missing target job 827f0b82-…")
}
}
func TestParseMySQLTupleFields(t *testing.T) {
fields := parseMySQLTupleFields("('827f0b82-3214-4c1d-bf05-2455bbd66fc6',\t'user_x',\t'97e1a309-3d23-4aa2-b518-8e8d7afdfec7',\t'completed',\t1,\t1,\tNULL,\t'2026-07-31 08:36:45',\t'2026-07-31 08:36:58',\t'2026-07-31 08:36:45',\t'2026-07-31 08:36:58',\t'full',\tNULL,\t7500),")
if len(fields) < 14 {
t.Fatalf("fields=%d %#v", len(fields), fields)
}
if fields[0] != "827f0b82-3214-4c1d-bf05-2455bbd66fc6" || fields[2] != "97e1a309-3d23-4aa2-b518-8e8d7afdfec7" {
t.Fatalf("unexpected fields %#v", fields)
}
}
func TestParseDumpJobID(t *testing.T) {
id, err := parseDumpJobID("827f0b82-3214-4c1d-bf05-2455bbd66fc6")
if err != nil || id.String() != "827f0b82-3214-4c1d-bf05-2455bbd66fc6" {
t.Fatalf("uuid parse: %v %s", err, id)
}
sha, err := parseDumpJobID("12345")
if err != nil || sha.String() == "12345" {
t.Fatalf("sha1 remap expected, got %v %s", err, sha)
}
}