package main import ( "context" "database/sql" "encoding/json" "fmt" "log" "strconv" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) func migrateCatalogAndFeeds( ctx context.Context, mysqlDB *sql.DB, pg *pgxpool.Pool, companyMap, userMap map[string]string, allow map[string]bool, domains domainSet, report map[string]int, dryRun bool, ) (categoryMap, attributeMap, feedMap, rawMap, fileMap map[string]string) { categoryMap = map[string]string{} attributeMap = map[string]string{} feedMap = map[string]string{} rawMap = map[string]string{} fileMap = map[string]string{} // Feeds before products so feed_id can be remapped when possible. if domains.has("feeds") { migrateInputFeeds(ctx, mysqlDB, pg, companyMap, feedMap, allow, report, dryRun) } if domains.has("files") { migrateFiles(ctx, mysqlDB, pg, companyMap, userMap, fileMap, allow, report, dryRun) } if domains.has("catalog") { migrateCategories(ctx, mysqlDB, pg, companyMap, categoryMap, allow, report, dryRun) migrateAttributes(ctx, mysqlDB, pg, companyMap, attributeMap, allow, report, dryRun) migrateCategoryAttributes(ctx, mysqlDB, pg, companyMap, attributeMap, allow, report, dryRun) migrateCustomVariables(ctx, mysqlDB, pg, companyMap, allow, report, dryRun) } if domains.has("products") { migrateRawProducts(ctx, mysqlDB, pg, companyMap, feedMap, rawMap, allow, report, dryRun) migrateProcessedProducts(ctx, mysqlDB, pg, companyMap, feedMap, rawMap, allow, report, dryRun) backfillProcessedDescriptionsFromMapped(ctx, pg, companyMap, allow, report, dryRun) backfillProcessedNamesFromMapped(ctx, pg, companyMap, allow, report, dryRun) } if domains.has("feeds") { migrateExportFeeds(ctx, mysqlDB, pg, companyMap, feedMap, allow, report, dryRun) } return categoryMap, attributeMap, feedMap, rawMap, fileMap } func migrateCategories( ctx context.Context, mysqlDB *sql.DB, pg *pgxpool.Pool, companyMap, categoryMap map[string]string, allow map[string]bool, report map[string]int, dryRun bool, ) { if !mysqlTableExists(ctx, mysqlDB, "categories") { log.Printf("categories skipped: table missing") return } q := mysqlSelectList( "id", "company_id", "name", "unique_id", mysqlCol(ctx, mysqlDB, "categories", "parent_id", "NULL"), mysqlCol(ctx, mysqlDB, "categories", "path", "NULL"), mysqlCoalesce(ctx, mysqlDB, "categories", "level", "0"), mysqlCoalesce(ctx, mysqlDB, "categories", "position", "0"), mysqlCoalesce(ctx, mysqlDB, "categories", "is_active", "1"), mysqlCol(ctx, mysqlDB, "categories", "description", "NULL"), mysqlCol(ctx, mysqlDB, "categories", "prompt", "NULL"), mysqlCol(ctx, mysqlDB, "categories", "metadata", "NULL"), mysqlCol(ctx, mysqlDB, "categories", "config", "NULL"), mysqlCol(ctx, mysqlDB, "categories", "title_template", "NULL"), mysqlCol(ctx, mysqlDB, "categories", "description_template", "NULL"), ) + " FROM categories WHERE 1=1" clause, cargs := mysqlCompanyFilter("company_id", allow) q += clause rows, err := mysqlDB.QueryContext(ctx, q, cargs...) if err != nil { rows, err = mysqlDB.QueryContext(ctx, ` SELECT id, company_id, name, unique_id, NULL, NULL, 0, 0, 1, NULL, NULL, NULL, NULL, NULL, NULL FROM categories WHERE 1=1`+clause, cargs...) } if err != nil { log.Printf("categories skipped: %v", err) return } defer rows.Close() for rows.Next() { var legacyID int64 var companyLegacy, name, uniqueID string var parentID, path, desc, prompt sql.NullString var level, position, isActive int var metadata, config, titleTpl, descTpl []byte if err := rows.Scan(&legacyID, &companyLegacy, &name, &uniqueID, &parentID, &path, &level, &position, &isActive, &desc, &prompt, &metadata, &config, &titleTpl, &descTpl); err != nil { log.Printf("category scan: %v", err) report["categories_skipped"]++ continue } cid, ok := companyMap[companyLegacy] if !ok { report["categories_skipped"]++ continue } newID := uuid.New() categoryMap[strconv.FormatInt(legacyID, 10)] = newID.String() if dryRun { report["categories"]++ continue } _, err = pg.Exec(ctx, ` INSERT INTO categories ( id, company_id, name, unique_id, parent_unique_id, path, level, position, is_active, description, prompt, metadata, config, title_template, description_template ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, COALESCE($12::jsonb, '{}'::jsonb), COALESCE($13::jsonb, '{}'::jsonb), $14::jsonb, $15::jsonb ) ON CONFLICT (company_id, unique_id) DO UPDATE SET name = EXCLUDED.name, parent_unique_id = EXCLUDED.parent_unique_id, title_template = EXCLUDED.title_template, description_template = EXCLUDED.description_template, updated_at = now()`, newID, cid, name, uniqueID, nullString(parentID), nullString(path), level, position, isActive == 1, nullString(desc), nullString(prompt), jsonOrNull(metadata), jsonOrNull(config), jsonOrNull(titleTpl), jsonOrNull(descTpl)) if err != nil { log.Printf("category insert %d: %v", legacyID, err) var existing uuid.UUID if err2 := pg.QueryRow(ctx, `SELECT id FROM categories WHERE company_id = $1 AND unique_id = $2`, cid, uniqueID).Scan(&existing); err2 == nil { categoryMap[strconv.FormatInt(legacyID, 10)] = existing.String() } else { report["categories_skipped"]++ continue } } else { var existing uuid.UUID _ = pg.QueryRow(ctx, `SELECT id FROM categories WHERE company_id = $1 AND unique_id = $2`, cid, uniqueID).Scan(&existing) if existing != uuid.Nil { categoryMap[strconv.FormatInt(legacyID, 10)] = existing.String() } } report["categories"]++ } } func migrateAttributes( ctx context.Context, mysqlDB *sql.DB, pg *pgxpool.Pool, companyMap, attributeMap map[string]string, allow map[string]bool, report map[string]int, dryRun bool, ) { if !mysqlTableExists(ctx, mysqlDB, "attributes") { log.Printf("attributes skipped: table missing") return } q := mysqlSelectList( "id", "company_id", "attribute_key", "name", mysqlCoalesce(ctx, mysqlDB, "attributes", "value_type", "'string'"), mysqlCol(ctx, mysqlDB, "attributes", "unit", "NULL"), mysqlCol(ctx, mysqlDB, "attributes", "example", "NULL"), mysqlCol(ctx, mysqlDB, "attributes", "parent_key", "NULL"), ) + " FROM attributes WHERE 1=1" clause, cargs := mysqlCompanyFilter("company_id", allow) q += clause rows, err := mysqlDB.QueryContext(ctx, q, cargs...) if err != nil { rows, err = mysqlDB.QueryContext(ctx, ` SELECT id, company_id, attribute_key, name, 'string', NULL, NULL, NULL FROM attributes WHERE 1=1`+clause, cargs...) } if err != nil { log.Printf("attributes skipped: %v", err) return } defer rows.Close() const batchSize = 400 type pending struct { legacyID int64 cid, key, name, valueType string unit, example, parentKey *string newID uuid.UUID } flush := func(batch []pending) { if len(batch) == 0 { return } if dryRun { report["attributes"] += len(batch) return } b := &pgx.Batch{} for _, p := range batch { b.Queue(` INSERT INTO attributes (id, company_id, attribute_key, name, value_type, unit, example, parent_key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (company_id, attribute_key) DO UPDATE SET name = EXCLUDED.name, value_type = EXCLUDED.value_type, updated_at = now()`, p.newID, p.cid, p.key, p.name, p.valueType, p.unit, p.example, p.parentKey) } br := pg.SendBatch(ctx, b) if err := br.Close(); err != nil { log.Printf("attributes batch: %v — resolving ids individually", err) for _, p := range batch { var existing uuid.UUID if err2 := pg.QueryRow(ctx, `SELECT id FROM attributes WHERE company_id = $1 AND attribute_key = $2`, p.cid, p.key).Scan(&existing); err2 == nil { attributeMap[strconv.FormatInt(p.legacyID, 10)] = existing.String() report["attributes"]++ } else { report["attributes_skipped"]++ } } return } for _, p := range batch { var existing uuid.UUID if err := pg.QueryRow(ctx, `SELECT id FROM attributes WHERE company_id = $1 AND attribute_key = $2`, p.cid, p.key).Scan(&existing); err == nil { attributeMap[strconv.FormatInt(p.legacyID, 10)] = existing.String() } report["attributes"]++ } } batch := make([]pending, 0, batchSize) for rows.Next() { var legacyID int64 var companyLegacy, key, name, valueType string var unit, example, parentKey sql.NullString if err := rows.Scan(&legacyID, &companyLegacy, &key, &name, &valueType, &unit, &example, &parentKey); err != nil { log.Printf("attribute scan: %v", err) report["attributes_skipped"]++ continue } cid, ok := companyMap[companyLegacy] if !ok { report["attributes_skipped"]++ continue } if valueType == "" { valueType = "string" } newID := uuid.New() attributeMap[strconv.FormatInt(legacyID, 10)] = newID.String() batch = append(batch, pending{ legacyID: legacyID, cid: cid, key: key, name: name, valueType: valueType, unit: nullString(unit), example: nullString(example), parentKey: nullString(parentKey), newID: newID, }) if len(batch) >= batchSize { flush(batch) batch = batch[:0] } } flush(batch) } func migrateCategoryAttributes( ctx context.Context, mysqlDB *sql.DB, pg *pgxpool.Pool, companyMap, attributeMap map[string]string, allow map[string]bool, report map[string]int, dryRun bool, ) { if !mysqlTableExists(ctx, mysqlDB, "category_attributes") { log.Printf("category_attributes skipped: table missing") return } clause, cargs := mysqlCompanyFilter("company_id", allow) rows, err := mysqlDB.QueryContext(ctx, ` SELECT company_id, category_id, attribute_id, COALESCE(required, 0) FROM category_attributes WHERE 1=1`+clause, cargs...) if err != nil { log.Printf("category_attributes skipped: %v", err) return } defer rows.Close() const batchSize = 500 batch := &pgx.Batch{} pending := 0 flush := func() { if dryRun || pending == 0 { return } br := pg.SendBatch(ctx, batch) if err := br.Close(); err != nil { log.Printf("category_attributes batch: %v", err) } batch = &pgx.Batch{} pending = 0 } for rows.Next() { var companyLegacy, categoryUnique string var attrLegacy int64 var required int if err := rows.Scan(&companyLegacy, &categoryUnique, &attrLegacy, &required); err != nil { log.Printf("category_attribute scan: %v", err) report["category_attributes_skipped"]++ continue } cid, okC := companyMap[companyLegacy] attrID, okA := attributeMap[strconv.FormatInt(attrLegacy, 10)] if !okC || !okA { report["category_attributes_skipped"]++ continue } if dryRun { report["category_attributes"]++ continue } batch.Queue(` INSERT INTO category_attributes (company_id, category_unique_id, attribute_id, required) VALUES ($1, $2, $3, $4) ON CONFLICT (company_id, category_unique_id, attribute_id) DO UPDATE SET required = EXCLUDED.required`, cid, categoryUnique, attrID, required == 1) pending++ report["category_attributes"]++ if pending >= batchSize { flush() } } flush() } func migrateCustomVariables( ctx context.Context, mysqlDB *sql.DB, pg *pgxpool.Pool, companyMap map[string]string, allow map[string]bool, report map[string]int, dryRun bool, ) { if !mysqlTableExists(ctx, mysqlDB, "custom_variables") { log.Printf("custom_variables skipped: table missing") return } // Legacy has name/label/description/example; v2 has name/value/description. clause, cargs := mysqlCompanyFilter("company_id", allow) rows, err := mysqlDB.QueryContext(ctx, ` SELECT company_id, name, COALESCE(NULLIF(label, ''), NULLIF(example, ''), ''), description FROM custom_variables WHERE 1=1`+clause, cargs...) if err != nil { rows, err = mysqlDB.QueryContext(ctx, ` SELECT company_id, name, COALESCE(value, ''), description FROM custom_variables WHERE 1=1`+clause, cargs...) } if err != nil { log.Printf("custom_variables skipped: %v", err) return } defer rows.Close() for rows.Next() { var companyLegacy, name, value string var desc sql.NullString if err := rows.Scan(&companyLegacy, &name, &value, &desc); err != nil { log.Printf("custom_variable scan: %v", err) report["custom_variables_skipped"]++ continue } cid, ok := companyMap[companyLegacy] if !ok { report["custom_variables_skipped"]++ continue } if dryRun { report["custom_variables"]++ continue } _, err = pg.Exec(ctx, ` INSERT INTO custom_variables (company_id, name, value, description) VALUES ($1, $2, $3, $4) ON CONFLICT (company_id, name) DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, updated_at = now()`, cid, name, value, nullString(desc)) if err != nil { log.Printf("custom_variable: %v", err) report["custom_variables_skipped"]++ continue } report["custom_variables"]++ } } func migrateInputFeeds( ctx context.Context, mysqlDB *sql.DB, pg *pgxpool.Pool, companyMap, feedMap map[string]string, allow map[string]bool, report map[string]int, dryRun bool, ) { if mysqlTableExists(ctx, mysqlDB, "xml_feeds") { hasMappings := mysqlColumnExists(ctx, mysqlDB, "xml_feeds", "field_mappings") hasVersion := mysqlColumnExists(ctx, mysqlDB, "xml_feeds", "mapping_version") hasSourceType := mysqlColumnExists(ctx, mysqlDB, "xml_feeds", "source_type") q := `SELECT id, company_id, name, url, COALESCE(status, 'active'), COALESCE(sync_frequency, 24)` if hasMappings { q += `, field_mappings` } else { q += `, NULL` } if hasVersion { q += `, COALESCE(mapping_version, 1)` } else { q += `, 1` } if hasSourceType { q += `, COALESCE(source_type, 'url')` } else { q += `, 'url'` } clause, cargs := mysqlCompanyFilter("company_id", allow) q += ` FROM xml_feeds WHERE 1=1` + clause rows, err := mysqlDB.QueryContext(ctx, q, cargs...) if err != nil { log.Printf("xml_feeds skipped: %v", err) } else { defer rows.Close() for rows.Next() { var legacyID int64 var companyLegacy, name string var url, status sql.NullString var syncFreq, mapVersion int var fieldMappings []byte var sourceType string if err := rows.Scan(&legacyID, &companyLegacy, &name, &url, &status, &syncFreq, &fieldMappings, &mapVersion, &sourceType); err != nil { log.Printf("xml_feed scan: %v", err) report["input_feeds_skipped"]++ continue } cid, ok := companyMap[companyLegacy] if !ok { report["input_feeds_skipped"]++ continue } st := "active" if status.Valid && status.String != "" { st = status.String } interval := syncFreq * 60 if interval <= 0 { interval = 60 } feedType := "xml" if sourceType == "csv" || sourceType == "file" { feedType = sourceType } newID := uuid.New() feedMap[strconv.FormatInt(legacyID, 10)] = newID.String() if dryRun { report["input_feeds"]++ if len(fieldMappings) > 0 { report["feed_mappings"]++ } continue } _, err = pg.Exec(ctx, ` INSERT INTO input_feeds (id, company_id, name, url, feed_type, status, sync_interval_minutes) VALUES ($1, $2, $3, $4, $5, $6, $7)`, newID, cid, name, nullString(url), feedType, st, interval) if err != nil { log.Printf("input_feed insert %d: %v", legacyID, err) report["input_feeds_skipped"]++ delete(feedMap, strconv.FormatInt(legacyID, 10)) continue } report["input_feeds"]++ if err := insertFeedMappings(ctx, pg, newID, cid, fieldMappings, mapVersion, report); err != nil { log.Printf("feed_mappings insert %d: %v", legacyID, err) } } return } } else { log.Printf("xml_feeds skipped: table missing") } if !mysqlTableExists(ctx, mysqlDB, "product_feeds") { log.Printf("product_feeds skipped: table missing") return } rows, err := mysqlDB.QueryContext(ctx, ` SELECT id, feed_name FROM product_feeds`) if err != nil { log.Printf("product_feeds skipped: %v", err) return } defer rows.Close() for rows.Next() { var legacyID int64 var feedName sql.NullString if err := rows.Scan(&legacyID, &feedName); err != nil { report["input_feeds_skipped"]++ continue } name := feedName.String if name == "" { name = fmt.Sprintf("product_feed_%d", legacyID) } // product_feeds has no company_id — attach to first mapped company when only one, else skip. if len(companyMap) != 1 { report["input_feeds_skipped"]++ continue } var cid string for _, v := range companyMap { cid = v break } newID := uuid.New() feedMap[strconv.FormatInt(legacyID, 10)] = newID.String() if dryRun { report["input_feeds"]++ continue } _, err = pg.Exec(ctx, ` INSERT INTO input_feeds (id, company_id, name, feed_type, status) VALUES ($1, $2, $3, 'xml', 'active')`, newID, cid, name) if err != nil { log.Printf("product_feed insert %d: %v", legacyID, err) delete(feedMap, strconv.FormatInt(legacyID, 10)) report["input_feeds_skipped"]++ continue } report["input_feeds"]++ } } func migrateRawProducts( ctx context.Context, mysqlDB *sql.DB, pg *pgxpool.Pool, companyMap, feedMap, rawMap map[string]string, allow map[string]bool, report map[string]int, dryRun bool, ) { if !mysqlTableExists(ctx, mysqlDB, "raw_products") { log.Printf("raw_products skipped: table missing") return } q := mysqlSelectList( "id", "company_id", "gtin", mysqlCol(ctx, mysqlDB, "raw_products", "feed_id", "NULL"), mysqlCol(ctx, mysqlDB, "raw_products", "feed_ids", "NULL"), mysqlCoalesce(ctx, mysqlDB, "raw_products", "raw_data", "'{}'"), mysqlCol(ctx, mysqlDB, "raw_products", "mapped_data", "NULL"), mysqlCoalesce(ctx, mysqlDB, "raw_products", "is_processed", "0"), mysqlCoalesce(ctx, mysqlDB, "raw_products", "processing_status", "'unprocessed'"), ) + " FROM raw_products WHERE 1=1" clause, cargs := mysqlCompanyFilter("company_id", allow) q += clause rows, err := mysqlDB.QueryContext(ctx, q, cargs...) if err != nil { rows, err = mysqlDB.QueryContext(ctx, ` SELECT id, company_id, gtin, feed_id, NULL, raw_data, NULL, 0, 'unprocessed' FROM raw_products WHERE 1=1`+clause, cargs...) } if err != nil { log.Printf("raw_products skipped: %v", err) return } defer rows.Close() const batchSize = 500 type pending struct { legacyID int64 cid, gtin, status string feedID *uuid.UUID feedIDs, rawData, mappedData []byte isProcessed bool newID uuid.UUID } flush := func(batch []pending) { if len(batch) == 0 { return } if dryRun { report["raw_products"] += len(batch) return } tx, err := pg.Begin(ctx) if err != nil { log.Printf("raw_products tx: %v", err) report["raw_products_skipped"] += len(batch) return } defer tx.Rollback(ctx) copyRows := make([][]any, 0, len(batch)) for _, p := range batch { copyRows = append(copyRows, []any{ p.newID, p.cid, p.gtin, p.feedID, jsonOrNull(p.feedIDs), string(ensureJSON(p.rawData)), string(ensureJSON(p.mappedData)), p.isProcessed, p.status, }) } _, err = tx.CopyFrom(ctx, pgx.Identifier{"raw_products"}, []string{"id", "company_id", "gtin", "feed_id", "feed_ids", "raw_data", "mapped_data", "is_processed", "processing_status"}, pgx.CopyFromRows(copyRows), ) if err != nil { log.Printf("raw_products copy: %v — falling back to per-row insert", err) for _, p := range batch { var inserted uuid.UUID err = tx.QueryRow(ctx, ` INSERT INTO raw_products ( id, company_id, gtin, feed_id, feed_ids, raw_data, mapped_data, is_processed, processing_status ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8, $9) ON CONFLICT (company_id, gtin) DO UPDATE SET feed_id = COALESCE(EXCLUDED.feed_id, raw_products.feed_id), raw_data = EXCLUDED.raw_data, mapped_data = EXCLUDED.mapped_data, is_processed = EXCLUDED.is_processed, processing_status = EXCLUDED.processing_status, updated_at = now() RETURNING id`, p.newID, p.cid, p.gtin, p.feedID, jsonOrNull(p.feedIDs), string(ensureJSON(p.rawData)), string(ensureJSON(p.mappedData)), p.isProcessed, p.status).Scan(&inserted) if err != nil { log.Printf("raw_product insert %d: %v", p.legacyID, err) report["raw_products_skipped"]++ continue } rawMap[strconv.FormatInt(p.legacyID, 10)] = inserted.String() report["raw_products"]++ } } else { report["raw_products"] += len(batch) } if err := tx.Commit(ctx); err != nil { log.Printf("raw_products commit: %v", err) } } batch := make([]pending, 0, batchSize) gtinOwner := map[string]string{} // companyUUID|gtin → new UUID string (dedupe for PG unique index) for rows.Next() { var legacyID int64 var companyLegacy string var gtin, status sql.NullString var feedID sql.NullInt64 var feedIDs, rawData, mappedData []byte var isProcessed int if err := rows.Scan(&legacyID, &companyLegacy, >in, &feedID, &feedIDs, &rawData, &mappedData, &isProcessed, &status); err != nil { log.Printf("raw_product scan: %v", err) report["raw_products_skipped"]++ continue } cid, ok := companyMap[companyLegacy] if !ok { report["raw_products_skipped"]++ continue } gtinVal := "" if gtin.Valid { gtinVal = strings.TrimSpace(gtin.String) } if gtinVal == "" { // PG requires NOT NULL gtin; synthesize a stable placeholder from legacy id. gtinVal = fmt.Sprintf("legacy-missing-%d", legacyID) report["raw_products_gtin_synthesized"]++ } statusVal := "unprocessed" if status.Valid && strings.TrimSpace(status.String) != "" { statusVal = strings.TrimSpace(status.String) } switch statusVal { case "unprocessed", "processing", "processed", "failed": default: statusVal = "unprocessed" report["raw_products_status_normalized"]++ } rawObj := map[string]any{} if len(rawData) > 0 { _ = json.Unmarshal(rawData, &rawObj) } var mappedFeed *uuid.UUID if feedID.Valid { rawObj["_legacy_feed_id"] = feedID.Int64 if fid, ok := feedMap[strconv.FormatInt(feedID.Int64, 10)]; ok { u, err := uuid.Parse(fid) if err == nil { mappedFeed = &u } } } rawBytes, _ := json.Marshal(rawObj) dedupeKey := cid + "|" + gtinVal if existing, ok := gtinOwner[dedupeKey]; ok { rawMap[strconv.FormatInt(legacyID, 10)] = existing report["raw_products_gtin_deduped"]++ continue } newID := uuid.New() gtinOwner[dedupeKey] = newID.String() rawMap[strconv.FormatInt(legacyID, 10)] = newID.String() batch = append(batch, pending{ legacyID: legacyID, cid: cid, gtin: gtinVal, status: statusVal, feedID: mappedFeed, feedIDs: feedIDs, rawData: rawBytes, mappedData: mappedData, isProcessed: isProcessed == 1, newID: newID, }) if len(batch) >= batchSize { flush(batch) batch = batch[:0] } } flush(batch) } func migrateProcessedProducts( ctx context.Context, mysqlDB *sql.DB, pg *pgxpool.Pool, companyMap, feedMap, rawMap map[string]string, allow map[string]bool, report map[string]int, dryRun bool, ) { if !mysqlTableExists(ctx, mysqlDB, "processed_products") { log.Printf("processed_products skipped: table missing") return } q := mysqlSelectList( "id", mysqlCol(ctx, mysqlDB, "processed_products", "company_id", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "product_id", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "name", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "category", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "description", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "processed_description", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "attributes", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "processed_attributes", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "status", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "gpt_response", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "total_tokens", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "feed_id", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "raw_product_id", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "processed_name", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "meta_title", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "meta_description", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "structured_description", "NULL"), mysqlCol(ctx, mysqlDB, "processed_products", "field_sources", "NULL"), ) + " FROM processed_products WHERE 1=1" clause, cargs := mysqlCompanyFilter("company_id", allow) q += clause rows, err := mysqlDB.QueryContext(ctx, q, cargs...) if err != nil { rows, err = mysqlDB.QueryContext(ctx, ` SELECT id, company_id, product_id, name, category, description, processed_description, attributes, processed_attributes, status, gpt_response, total_tokens, feed_id, raw_product_id, NULL, NULL, NULL, NULL, NULL FROM processed_products WHERE 1=1`+clause, cargs...) } if err != nil { log.Printf("processed_products skipped: %v", err) return } defer rows.Close() for rows.Next() { var legacyID int64 var companyLegacy sql.NullString var productID, name, category, desc, procDesc, status, procName, metaTitle, metaDesc sql.NullString var attrs, procAttrs, gptResp, structured, fieldSources []byte var totalTokens sql.NullInt64 var feedID, rawProductID sql.NullInt64 if err := rows.Scan(&legacyID, &companyLegacy, &productID, &name, &category, &desc, &procDesc, &attrs, &procAttrs, &status, &gptResp, &totalTokens, &feedID, &rawProductID, &procName, &metaTitle, &metaDesc, &structured, &fieldSources); err != nil { log.Printf("processed_product scan: %v", err) report["processed_products_skipped"]++ continue } if !companyLegacy.Valid || companyLegacy.String == "" { report["processed_products_skipped"]++ continue } cid, ok := companyMap[companyLegacy.String] if !ok { report["processed_products_skipped"]++ continue } var mappedFeed, mappedRaw *uuid.UUID if feedID.Valid { if fid, ok := feedMap[strconv.FormatInt(feedID.Int64, 10)]; ok { u, err := uuid.Parse(fid) if err == nil { mappedFeed = &u } } } if rawProductID.Valid { if rid, ok := rawMap[strconv.FormatInt(rawProductID.Int64, 10)]; ok { u, err := uuid.Parse(rid) if err == nil { mappedRaw = &u } } } if dryRun { report["processed_products"]++ continue } var tokens *int if totalTokens.Valid { t := int(totalTokens.Int64) tokens = &t } _, err = pg.Exec(ctx, ` INSERT INTO processed_products ( company_id, product_id, name, category, description, processed_description, attributes, processed_attributes, status, gpt_response, total_tokens, feed_id, raw_product_id, processed_name, meta_title, meta_description, structured_description, field_sources ) VALUES ( $1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10::jsonb, $11, $12, $13, $14, $15, $16, $17::jsonb, $18::jsonb )`, cid, nullString(productID), nullString(name), nullString(category), nullString(desc), nullString(procDesc), jsonOrNull(attrs), jsonOrNull(procAttrs), nullString(status), jsonOrNull(gptResp), tokens, mappedFeed, mappedRaw, nullString(procName), nullString(metaTitle), nullString(metaDesc), jsonOrNull(structured), jsonOrNull(fieldSources)) if err != nil { log.Printf("processed_product insert %d: %v", legacyID, err) report["processed_products_skipped"]++ continue } report["processed_products"]++ } } // backfillProcessedDescriptionsFromMapped fills empty processed_products.description from the // linked raw mapped_data.description. Legacy MySQL often left description blank while the feed // original lived only on raw_products.mapped_data. func backfillProcessedDescriptionsFromMapped( ctx context.Context, pg *pgxpool.Pool, companyMap map[string]string, allow map[string]bool, report map[string]int, dryRun bool, ) { if dryRun || pg == nil { return } cids := make([]uuid.UUID, 0, len(companyMap)) for legacy, cid := range companyMap { if len(allow) > 0 && !allow[legacy] { continue } u, err := uuid.Parse(cid) if err != nil { continue } cids = append(cids, u) } if len(cids) == 0 { return } ct, err := pg.Exec(ctx, ` UPDATE processed_products p SET description = NULLIF(r.mapped_data->>'description', ''), updated_at = now() FROM raw_products r WHERE p.raw_product_id = r.id AND p.company_id = ANY($1::uuid[]) AND COALESCE(p.description, '') = '' AND COALESCE(r.mapped_data->>'description', '') <> ''`, cids) if err != nil { log.Printf("processed description backfill: %v", err) return } n := int(ct.RowsAffected()) if n > 0 { report["processed_descriptions_backfilled"] = n log.Printf("backfilled %d processed_products.description from mapped_data", n) } } // backfillProcessedNamesFromMapped fills empty processed_products.name from mapped_data name/title. func backfillProcessedNamesFromMapped( ctx context.Context, pg *pgxpool.Pool, companyMap map[string]string, allow map[string]bool, report map[string]int, dryRun bool, ) { if dryRun || pg == nil { return } cids := make([]uuid.UUID, 0, len(companyMap)) for legacy, cid := range companyMap { if len(allow) > 0 && !allow[legacy] { continue } u, err := uuid.Parse(cid) if err != nil { continue } cids = append(cids, u) } if len(cids) == 0 { return } ct, err := pg.Exec(ctx, ` UPDATE processed_products p SET name = COALESCE(NULLIF(r.mapped_data->>'name', ''), NULLIF(r.mapped_data->>'title', '')), updated_at = now() FROM raw_products r WHERE p.raw_product_id = r.id AND p.company_id = ANY($1::uuid[]) AND COALESCE(p.name, '') = '' AND COALESCE(NULLIF(r.mapped_data->>'name', ''), NULLIF(r.mapped_data->>'title', ''), '') <> ''`, cids) if err != nil { log.Printf("processed name backfill: %v", err) return } n := int(ct.RowsAffected()) if n > 0 { report["processed_names_backfilled"] = n log.Printf("backfilled %d processed_products.name from mapped_data", n) } } func migrateExportFeeds( ctx context.Context, mysqlDB *sql.DB, pg *pgxpool.Pool, companyMap, feedMap map[string]string, allow map[string]bool, report map[string]int, dryRun bool, ) { if !mysqlTableExists(ctx, mysqlDB, "export_feeds") { log.Printf("export_feeds skipped: table missing") return } q := mysqlSelectList( "id", "company_id", "name", mysqlCoalesce(ctx, mysqlDB, "export_feeds", "format", "'xml'"), mysqlCol(ctx, mysqlDB, "export_feeds", "source_feed_id", "NULL"), mysqlCol(ctx, mysqlDB, "export_feeds", "mappings", "NULL"), mysqlCol(ctx, mysqlDB, "export_feeds", "structure", "NULL"), mysqlCol(ctx, mysqlDB, "export_feeds", "last_generated_at", "NULL"), ) + " FROM export_feeds WHERE 1=1" clause, cargs := mysqlCompanyFilter("company_id", allow) q += clause rows, err := mysqlDB.QueryContext(ctx, q, cargs...) if err != nil { rows, err = mysqlDB.QueryContext(ctx, ` SELECT id, company_id, name, format, source_feed_id, mappings, NULL, NULL FROM export_feeds WHERE 1=1`+clause, cargs...) } if err != nil { log.Printf("export_feeds skipped: %v", err) return } defer rows.Close() for rows.Next() { var legacyID, companyLegacy, name, format string var sourceFeed sql.NullInt64 var mappings, structure []byte var lastGen sql.NullTime if err := rows.Scan(&legacyID, &companyLegacy, &name, &format, &sourceFeed, &mappings, &structure, &lastGen); err != nil { log.Printf("export_feed scan: %v", err) report["export_feeds_skipped"]++ continue } cid, ok := companyMap[companyLegacy] if !ok { report["export_feeds_skipped"]++ continue } var src *uuid.UUID if sourceFeed.Valid { if fid, ok := feedMap[strconv.FormatInt(sourceFeed.Int64, 10)]; ok { u, err := uuid.Parse(fid) if err == nil { src = &u } } } tpl := map[string]any{} if len(mappings) > 0 { var m any if json.Unmarshal(mappings, &m) == nil { tpl["mappings"] = m } } if len(structure) > 0 { var s any if json.Unmarshal(structure, &s) == nil { tpl["structure"] = s } } tplBytes, _ := json.Marshal(tpl) if format == "" { format = "xml" } if dryRun { report["export_feeds"]++ continue } newID := uuid.New() _, err = pg.Exec(ctx, ` INSERT INTO export_feeds (id, company_id, name, source_feed_id, format, template, last_generated_at) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)`, newID, cid, name, src, format, string(tplBytes), nullTime(lastGen)) if err != nil { log.Printf("export_feed insert %s: %v", legacyID, err) report["export_feeds_skipped"]++ continue } report["export_feeds"]++ } } func insertFeedMappings( ctx context.Context, pg *pgxpool.Pool, feedID uuid.UUID, companyID string, fieldMappings []byte, version int, report map[string]int, ) error { if len(fieldMappings) == 0 { return nil } payload := ensureJSON(fieldMappings) // Normalize object maps → JSON array of entries for feed_mappings.mappings default shape. var asObj map[string]any if json.Unmarshal(payload, &asObj) == nil && len(asObj) > 0 { arr := make([]any, 0, len(asObj)) for k, v := range asObj { entry := map[string]any{"key": k, "mapping": normalizeLegacyMappingValue(v)} arr = append(arr, entry) } if b, err := json.Marshal(arr); err == nil { payload = b } } else { var asArr []any if json.Unmarshal(payload, &asArr) == nil && len(asArr) > 0 { for i, item := range asArr { m, ok := item.(map[string]any) if !ok { continue } if nested, ok := m["mapping"]; ok { m["mapping"] = normalizeLegacyMappingValue(nested) asArr[i] = m } } if b, err := json.Marshal(asArr); err == nil { payload = b } } } if version <= 0 { version = 1 } _, err := pg.Exec(ctx, ` INSERT INTO feed_mappings (feed_id, company_id, version, mappings, is_active) VALUES ($1, $2, $3, $4::jsonb, true)`, feedID, companyID, version, string(payload)) if err != nil { report["feed_mappings_skipped"]++ return err } report["feed_mappings"]++ return nil } // normalizeLegacyMappingValue rewrites nested mapping objects so fieldName uses // snake_case aliases the v2 mapping UI / ecommerce catalog understand. func normalizeLegacyMappingValue(v any) any { m, ok := v.(map[string]any) if !ok { return v } out := make(map[string]any, len(m)) for k, val := range m { out[k] = val } for _, key := range []string{"fieldName", "field", "target"} { raw, _ := out[key].(string) if strings.TrimSpace(raw) == "" { continue } out[key] = canonicalizeLegacyFieldKey(raw) break } return out } func canonicalizeLegacyFieldKey(raw string) string { compact := strings.ToLower(strings.TrimSpace(raw)) compact = strings.ReplaceAll(compact, "_", "") compact = strings.ReplaceAll(compact, "-", "") compact = strings.ReplaceAll(compact, " ", "") aliases := map[string]string{ "name": "title", "productname": "title", "title": "title", "purchaseprice": "purchase_price", "productmodel": "product_model", "moreimages": "additional_image_urls", "imageurl": "image_url", "producturl": "product_url", "officiallink": "official_link", "mainimage": "main_image", "eprelid": "eprel_id", "stockstatus": "availability", "videourl": "video_url", "netdepth": "net_depth", "netheight": "net_height", "netwidth": "net_width", "netmass": "net_mass", } if v, ok := aliases[compact]; ok { return v } return strings.TrimSpace(raw) } func mysqlTableExists(ctx context.Context, db *sql.DB, name string) bool { var n int err := db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?`, name).Scan(&n) return err == nil && n > 0 } func mysqlColumnExists(ctx context.Context, db *sql.DB, table, column string) bool { var n int err := db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, table, column).Scan(&n) return err == nil && n > 0 } func nullString(ns sql.NullString) *string { if !ns.Valid { return nil } s := ns.String return &s } func nullTime(nt sql.NullTime) any { if !nt.Valid { return nil } return nt.Time } func jsonOrNull(b []byte) *string { if len(b) == 0 || string(b) == "null" { return nil } s := string(b) if !json.Valid(b) { enc, err := json.Marshal(s) if err != nil { return nil } s = string(enc) } return &s } func ensureJSON(b []byte) []byte { if len(b) == 0 || !json.Valid(b) { return []byte("{}") } return b }