package catalog import ( "context" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // cloneProcessedFieldSourcesSQL copies field_sources but drops enhance_input_hash // so the destination's first process cannot hash-skip a thin prior description. const cloneProcessedFieldSourcesSQL = "COALESCE(pp.field_sources, '{}'::jsonb) - 'enhance_input_hash'" // CloneResult summarizes a company catalog clone (source unchanged). type CloneResult struct { SourceCompanyID uuid.UUID `json:"source_company_id"` DestCompanyID uuid.UUID `json:"dest_company_id"` Counts map[string]int64 `json:"counts"` } // ClearCompanyCatalog deletes operational catalog rows for a company. // Does not touch billing, memberships, api_keys, store connectors, or users. func ClearCompanyCatalog(ctx context.Context, tx pgx.Tx, companyID uuid.UUID) error { stmts := []string{ `DELETE FROM processed_products WHERE company_id = $1`, `DELETE FROM raw_products WHERE company_id = $1`, `DELETE FROM export_feeds WHERE company_id = $1`, `DELETE FROM feed_mappings WHERE company_id = $1`, `DELETE FROM feed_sync_jobs WHERE company_id = $1`, `DELETE FROM schema_extraction_tasks WHERE company_id = $1`, `DELETE FROM feed_tag_mappings WHERE feed_id IN (SELECT id FROM input_feeds WHERE company_id = $1) OR tag_id IN (SELECT id FROM feed_tags WHERE company_id = $1)`, `DELETE FROM feed_tags 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 files WHERE company_id = $1`, `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 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 ai_prompt_templates WHERE company_id = $1`, `DELETE FROM company_brand WHERE company_id = $1`, } for _, q := range stmts { if _, err := tx.Exec(ctx, q, companyID); err != nil { return fmt.Errorf("clear catalog: %s: %w", q, err) } } return nil } // CloneCompanyCatalog copies operational catalog data from src into dest. // Source rows are never updated. Dest catalog is cleared first. // Skips plan/billing, memberships, api_keys, Shopify/Woo connectors, and AI BYOK secrets. func (s *Service) CloneCompanyCatalog(ctx context.Context, src, dest uuid.UUID) (*CloneResult, error) { if s == nil || s.Pool == nil { return nil, fmt.Errorf("catalog service not configured") } if err := validateCloneCompanies(src, dest); err != nil { return nil, err } return cloneCompanyCatalog(ctx, s.Pool, src, dest) } func validateCloneCompanies(src, dest uuid.UUID) error { if src == uuid.Nil || dest == uuid.Nil { return ClientMsg("source_company_id and dest_company_id are required") } if src == dest { return ClientMsg("cannot clone a company catalog onto itself") } return nil } func cloneCompanyCatalog(ctx context.Context, pool *pgxpool.Pool, src, dest uuid.UUID) (*CloneResult, error) { tx, err := pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) // Large A1-sized catalogs need headroom inside one transaction. if _, err := tx.Exec(ctx, `SET LOCAL statement_timeout = '15min'`); err != nil { return nil, fmt.Errorf("set statement_timeout: %w", err) } var srcOK, destOK bool if err := tx.QueryRow(ctx, ` SELECT EXISTS(SELECT 1 FROM companies WHERE id = $1), EXISTS(SELECT 1 FROM companies WHERE id = $2)`, src, dest).Scan(&srcOK, &destOK); err != nil { return nil, err } if !srcOK { return nil, ClientMsg("source company not found") } if !destOK { return nil, ClientMsg("destination company not found") } if err := ClearCompanyCatalog(ctx, tx, dest); err != nil { return nil, err } if _, err := tx.Exec(ctx, ` CREATE TEMP TABLE clone_id_map ( kind TEXT NOT NULL, old_id UUID NOT NULL, new_id UUID NOT NULL, PRIMARY KEY (kind, old_id) ) ON COMMIT DROP`); err != nil { return nil, fmt.Errorf("create clone_id_map: %w", err) } counts := map[string]int64{} add := func(label string, n int64) { counts[label] = n } // --- taxonomy / attributes --- ct, err := tx.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, created_at, updated_at ) SELECT gen_random_uuid(), $2, name, unique_id, parent_unique_id, path, level, position, is_active, description, prompt, metadata, config, title_template, description_template, created_at, now() FROM categories WHERE company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("categories: %w", err) } add("categories", ct.RowsAffected()) if _, err := tx.Exec(ctx, ` INSERT INTO clone_id_map (kind, old_id, new_id) SELECT 'attribute', id, gen_random_uuid() FROM attributes WHERE company_id = $1`, src); err != nil { return nil, fmt.Errorf("map attributes: %w", err) } ct, err = tx.Exec(ctx, ` INSERT INTO attributes ( id, company_id, attribute_key, name, value_type, unit, example, parent_key, created_at, updated_at ) SELECT m.new_id, $2, a.attribute_key, a.name, a.value_type, a.unit, a.example, a.parent_key, a.created_at, now() FROM attributes a JOIN clone_id_map m ON m.kind = 'attribute' AND m.old_id = a.id WHERE a.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("attributes: %w", err) } add("attributes", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO category_attributes ( id, company_id, category_unique_id, attribute_id, required, created_at, updated_at ) SELECT gen_random_uuid(), $2, ca.category_unique_id, m.new_id, ca.required, ca.created_at, now() FROM category_attributes ca JOIN clone_id_map m ON m.kind = 'attribute' AND m.old_id = ca.attribute_id WHERE ca.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("category_attributes: %w", err) } add("category_attributes", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO custom_variables (id, company_id, name, value, description, created_at, updated_at) SELECT gen_random_uuid(), $2, name, value, description, created_at, now() FROM custom_variables WHERE company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("custom_variables: %w", err) } add("custom_variables", ct.RowsAffected()) if _, err := tx.Exec(ctx, ` INSERT INTO clone_id_map (kind, old_id, new_id) SELECT 'field_group', id, gen_random_uuid() FROM field_groups WHERE company_id = $1`, src); err != nil { return nil, fmt.Errorf("map field_groups: %w", err) } ct, err = tx.Exec(ctx, ` INSERT INTO field_groups (id, company_id, name, description, "order", is_system, created_at, updated_at) SELECT m.new_id, $2, fg.name, fg.description, fg."order", fg.is_system, fg.created_at, now() FROM field_groups fg JOIN clone_id_map m ON m.kind = 'field_group' AND m.old_id = fg.id WHERE fg.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("field_groups: %w", err) } add("field_groups", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO standard_fields ( id, company_id, name, key, type, group_id, is_required, description, default_value, validation, is_system, created_at, updated_at ) SELECT gen_random_uuid(), $2, sf.name, sf.key, sf.type, m.new_id, sf.is_required, sf.description, sf.default_value, sf.validation, sf.is_system, sf.created_at, now() FROM standard_fields sf JOIN clone_id_map m ON m.kind = 'field_group' AND m.old_id = sf.group_id WHERE sf.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("standard_fields: %w", err) } add("standard_fields", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO structured_description_fields (id, company_id, field_key, type, created_at, updated_at) SELECT gen_random_uuid(), $2, field_key, type, created_at, now() FROM structured_description_fields WHERE company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("structured_description_fields: %w", err) } add("structured_description_fields", ct.RowsAffected()) // --- files / feeds --- if _, err := tx.Exec(ctx, ` INSERT INTO clone_id_map (kind, old_id, new_id) SELECT 'file', id, gen_random_uuid() FROM files WHERE company_id = $1`, src); err != nil { return nil, fmt.Errorf("map files: %w", err) } ct, err = tx.Exec(ctx, ` INSERT INTO files ( id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at ) SELECT m.new_id, $2, NULL, f.name, f.path, f.content_type, f.size_bytes, f.status, f.metadata, f.created_at, now() FROM files f JOIN clone_id_map m ON m.kind = 'file' AND m.old_id = f.id WHERE f.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("files: %w", err) } add("files", ct.RowsAffected()) if _, err := tx.Exec(ctx, ` INSERT INTO clone_id_map (kind, old_id, new_id) SELECT 'feed', id, gen_random_uuid() FROM input_feeds WHERE company_id = $1`, src); err != nil { return nil, fmt.Errorf("map input_feeds: %w", err) } ct, err = tx.Exec(ctx, ` INSERT INTO input_feeds ( id, company_id, name, url, feed_type, status, sync_interval_minutes, last_synced_at, auth_config, options, created_at, updated_at ) SELECT m.new_id, $2, f.name, f.url, f.feed_type, f.status, f.sync_interval_minutes, f.last_synced_at, f.auth_config, f.options, f.created_at, now() FROM input_feeds f JOIN clone_id_map m ON m.kind = 'feed' AND m.old_id = f.id WHERE f.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("input_feeds: %w", err) } add("input_feeds", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO feed_mappings (id, feed_id, company_id, version, mappings, is_active, created_at, updated_at) SELECT gen_random_uuid(), m.new_id, $2, fm.version, fm.mappings, fm.is_active, fm.created_at, now() FROM feed_mappings fm JOIN clone_id_map m ON m.kind = 'feed' AND m.old_id = fm.feed_id WHERE fm.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("feed_mappings: %w", err) } add("feed_mappings", ct.RowsAffected()) if _, err := tx.Exec(ctx, ` INSERT INTO clone_id_map (kind, old_id, new_id) SELECT 'feed_tag', id, gen_random_uuid() FROM feed_tags WHERE company_id = $1`, src); err != nil { return nil, fmt.Errorf("map feed_tags: %w", err) } ct, err = tx.Exec(ctx, ` INSERT INTO feed_tags (id, company_id, name, color, created_at) SELECT m.new_id, $2, t.name, t.color, t.created_at FROM feed_tags t JOIN clone_id_map m ON m.kind = 'feed_tag' AND m.old_id = t.id WHERE t.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("feed_tags: %w", err) } add("feed_tags", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO feed_tag_mappings (feed_id, tag_id) SELECT fm.new_id, tm.new_id FROM feed_tag_mappings x JOIN clone_id_map fm ON fm.kind = 'feed' AND fm.old_id = x.feed_id JOIN clone_id_map tm ON tm.kind = 'feed_tag' AND tm.old_id = x.tag_id`) if err != nil { return nil, fmt.Errorf("feed_tag_mappings: %w", err) } add("feed_tag_mappings", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO export_feeds ( id, company_id, name, source_feed_id, format, public_token, template, filters, is_active, last_generated_at, created_at, updated_at ) SELECT gen_random_uuid(), $2, e.name, sm.new_id, e.format, encode(gen_random_bytes(16), 'hex'), e.template, e.filters, e.is_active, e.last_generated_at, e.created_at, now() FROM export_feeds e LEFT JOIN clone_id_map sm ON sm.kind = 'feed' AND sm.old_id = e.source_feed_id WHERE e.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("export_feeds: %w", err) } add("export_feeds", ct.RowsAffected()) // --- products --- if _, err := tx.Exec(ctx, ` INSERT INTO clone_id_map (kind, old_id, new_id) SELECT 'raw_product', id, gen_random_uuid() FROM raw_products WHERE company_id = $1`, src); err != nil { return nil, fmt.Errorf("map raw_products: %w", err) } ct, err = tx.Exec(ctx, ` INSERT INTO raw_products ( 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 ) SELECT rm.new_id, $2, rp.gtin, fm.new_id, CASE WHEN rp.feed_ids IS NULL THEN NULL WHEN jsonb_typeof(rp.feed_ids) <> 'array' THEN rp.feed_ids ELSE COALESCE(( SELECT jsonb_agg(to_jsonb(COALESCE(xmm.new_id::text, elem))) FROM jsonb_array_elements_text(rp.feed_ids) AS t(elem) LEFT JOIN clone_id_map xmm ON xmm.kind = 'feed' AND xmm.old_id::text = t.elem ), '[]'::jsonb) END, rp.raw_data, rp.mapped_data, NULL, rp.is_processed, rp.processing_status, filem.new_id, rp.created_at, now() FROM raw_products rp JOIN clone_id_map rm ON rm.kind = 'raw_product' AND rm.old_id = rp.id LEFT JOIN clone_id_map fm ON fm.kind = 'feed' AND fm.old_id = rp.feed_id LEFT JOIN clone_id_map filem ON filem.kind = 'file' AND filem.old_id = rp.file_id WHERE rp.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("raw_products: %w", err) } add("raw_products", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO processed_products ( id, company_id, user_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, last_transition_at, structured_description, field_sources, created_at, updated_at, ai_provider_mode, localized_content ) SELECT gen_random_uuid(), $2, NULL, pp.product_id, pp.name, pp.category, pp.description, pp.processed_description, pp.attributes, pp.processed_attributes, pp.status, pp.gpt_response, pp.total_tokens, fm.new_id, rm.new_id, pp.processed_name, pp.meta_title, pp.meta_description, pp.last_transition_at, pp.structured_description, ` + cloneProcessedFieldSourcesSQL + `, pp.created_at, now(), COALESCE(pp.ai_provider_mode, 'internal'), COALESCE(pp.localized_content, '{}'::jsonb) FROM processed_products pp LEFT JOIN clone_id_map fm ON fm.kind = 'feed' AND fm.old_id = pp.feed_id LEFT JOIN clone_id_map rm ON rm.kind = 'raw_product' AND rm.old_id = pp.raw_product_id WHERE pp.company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("processed_products: %w", err) } add("processed_products", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO ai_prompt_templates ( id, company_id, prompt_key, system_template, user_template, is_enabled, created_at, updated_at, language ) SELECT gen_random_uuid(), $2, prompt_key, system_template, user_template, is_enabled, created_at, now(), language FROM ai_prompt_templates WHERE company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("ai_prompt_templates: %w", err) } add("ai_prompt_templates", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO company_brand ( company_id, voice_tone, dos, donts, primary_color, secondary_color, logo_url, preferred_terms, updated_at ) SELECT $2, voice_tone, dos, donts, primary_color, secondary_color, logo_url, preferred_terms, now() FROM company_brand WHERE company_id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("company_brand: %w", err) } add("company_brand", ct.RowsAffected()) // Copy content language settings onto dest (not name/plan/billing). ct, err = tx.Exec(ctx, ` UPDATE companies AS d SET language = s.language, content_languages = s.content_languages, updated_at = now() FROM companies s WHERE d.id = $2 AND s.id = $1`, src, dest) if err != nil { return nil, fmt.Errorf("companies language: %w", err) } add("company_language", ct.RowsAffected()) ct, err = tx.Exec(ctx, ` INSERT INTO company_settings (company_id, settings, updated_at) SELECT $2, settings, now() FROM company_settings WHERE company_id = $1 ON CONFLICT (company_id) DO UPDATE SET settings = EXCLUDED.settings, updated_at = now()`, src, dest) if err != nil { return nil, fmt.Errorf("company_settings: %w", err) } add("company_settings", ct.RowsAffected()) // Cloned raws are ready to process on the destination (source flags may say processed). if _, err := tx.Exec(ctx, ` UPDATE raw_products SET is_processed = false, processing_status = 'unprocessed', updated_at = now() WHERE company_id = $1`, dest); err != nil { return nil, fmt.Errorf("reset raw processing flags: %w", err) } // Drop skip hashes still present on weak / title-echo localized descriptions // (field_sources hash is already stripped in the INSERT above). hashRepair, err := repairWeakEnhanceHashesTx(ctx, tx, dest) if err != nil { return nil, fmt.Errorf("repair weak enhance hashes: %w", err) } add("weak_enhance_hashes_cleared", hashRepair.Cleared) if err := tx.Commit(ctx); err != nil { return nil, err } return &CloneResult{ SourceCompanyID: src, DestCompanyID: dest, Counts: counts, }, nil }