package catalog import ( "context" "fmt" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/company" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // Canonical Postgres id for the migrated A1 Slovenija tenant (seed-a1 default). const a1CompanyID = "604f23a8-b66e-4b21-8b45-0d72b68f4790" // platformDemoCompanyName matches migrator / seed-demo standalone demo tenant. const platformDemoCompanyName = "Platform Demo" // RepairCategoryEnhancePromptsResult is the dry-run / apply summary for // RepairA1DemoCategoryEnhancePrompts. type RepairCategoryEnhancePromptsResult struct { CompaniesScanned int `json:"companies_scanned"` CategoriesSeen int `json:"categories_seen"` WouldUpdate int `json:"would_update"` Updated int `json:"updated"` AlreadyOK int `json:"already_ok"` EmptySkipped int `json:"empty_skipped"` ByCompany map[string]int `json:"by_company"` DryRun bool `json:"dry_run"` } // RepairA1DemoCategoryEnhancePrompts replaces legacy HTML marketing categories.prompt // values for A1 Slovenija + Platform Demo with aiprompts.CategoryEnhanceUserTemplate // (role-sectioned title/description/meta/attributes USER overlay). // // LOCAL repair only (idempotent): // - Writes JSON-compatible user overlays under both "sl" (prompt->>'sl') and "*" // (LangPromptAny) so language stays via {{language}}, not hardcoded-only copy. // - Touches ONLY categories.prompt — never title_template / description_template // (unique name/description/meta formulas stay intact; AppendFormulaConstraints // encodes them as plain-text instructions at enhance render time). // - dryRun=true: count WouldUpdate only; dryRun=false: apply and set Updated. // // Entrypoint: go run ./cmd/repair-category-prompts (-dry-run | -apply). func RepairA1DemoCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool, dryRun bool) (RepairCategoryEnhancePromptsResult, error) { out := RepairCategoryEnhancePromptsResult{ DryRun: dryRun, ByCompany: map[string]int{}, } if pool == nil { return out, fmt.Errorf("postgres pool is required") } a1ID, err := uuid.Parse(a1CompanyID) if err != nil { return out, fmt.Errorf("a1 company id: %w", err) } companyRows, err := pool.Query(ctx, ` SELECT id, name FROM companies WHERE id = $1 OR name = $2 OR COALESCE(legacy_company_id, '') = $3 ORDER BY name`, a1ID, platformDemoCompanyName, billing.A1LegacyCompanyID) if err != nil { return out, fmt.Errorf("list target companies: %w", err) } defer companyRows.Close() type co struct { id uuid.UUID name string } companies := make([]co, 0, 2) for companyRows.Next() { var c co if err := companyRows.Scan(&c.id, &c.name); err != nil { return out, err } companies = append(companies, c) } if err := companyRows.Err(); err != nil { return out, err } out.CompaniesScanned = len(companies) if len(companies) == 0 { return out, fmt.Errorf("no A1 / Platform Demo companies found") } want, err := repairedCategoryEnhancePromptMap() if err != nil { return out, err } for _, c := range companies { n, err := repairCompanyCategoryEnhancePrompts(ctx, pool, c.id, c.name, want, dryRun, &out) if err != nil { return out, err } if n > 0 { out.ByCompany[c.name] = n } } return out, nil } // repairedCategoryEnhancePromptMap is the canonical stored shape: "sl" (prompt->>'sl' // for A1/Demo) plus LangPromptAny ("*") so PromptForLanguage resolves for any content // language. Copy stays language-agnostic via {{language}} — not hardcoded Slovenian. func repairedCategoryEnhancePromptMap() (company.LangPromptMap, error) { tpl := strings.TrimSpace(aiprompts.CategoryEnhanceUserTemplate) if tpl == "" { return nil, fmt.Errorf("CategoryEnhanceUserTemplate is empty") } return company.LangPromptMap{ "sl": tpl, company.LangPromptAny: tpl, }, nil } func categoryEnhancePromptMapOK(m company.LangPromptMap, want company.LangPromptMap) bool { if !company.HasAnyPrompt(m) || !company.HasAnyPrompt(want) { return false } tpl := strings.TrimSpace(want[company.LangPromptAny]) if tpl == "" { tpl = strings.TrimSpace(want["sl"]) } if tpl == "" { return false } // Accept already-repaired maps: every non-empty value equals the shared template // and LangPromptAny (or legacy sl-only) is present. hasKey := false for lang, p := range m { p = strings.TrimSpace(p) if p == "" { continue } if p != tpl { return false } if lang == company.LangPromptAny || lang == "sl" { hasKey = true } } return hasKey } func repairCompanyCategoryEnhancePrompts( ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID, companyName string, want company.LangPromptMap, dryRun bool, out *RepairCategoryEnhancePromptsResult, ) (int, error) { rows, err := pool.Query(ctx, ` SELECT id, COALESCE(prompt, '{}'::jsonb) FROM categories WHERE company_id = $1`, companyID) if err != nil { return 0, fmt.Errorf("list categories for %s: %w", companyName, err) } defer rows.Close() updatedHere := 0 for rows.Next() { var id uuid.UUID var raw []byte if err := rows.Scan(&id, &raw); err != nil { return updatedHere, err } out.CategoriesSeen++ m, err := company.DecodeLangPromptMap(raw) if err != nil { return updatedHere, fmt.Errorf("decode prompt category=%s company=%s: %w", id, companyName, err) } if !company.HasAnyPrompt(m) { out.EmptySkipped++ continue } if categoryEnhancePromptMapOK(m, want) { out.AlreadyOK++ continue } out.WouldUpdate++ if dryRun { updatedHere++ continue } cleaned, err := company.SanitizeLangPromptMap(want, MaxCategoryPromptRunes) if err != nil { return updatedHere, fmt.Errorf("sanitize prompt category=%s: %w", id, err) } encoded, err := company.EncodeLangPromptMap(cleaned) if err != nil { return updatedHere, err } ct, err := pool.Exec(ctx, ` UPDATE categories SET prompt = $3::jsonb, updated_at = now() WHERE id = $1 AND company_id = $2`, id, companyID, string(encoded)) if err != nil { return updatedHere, fmt.Errorf("update prompt category=%s: %w", id, err) } if ct.RowsAffected() == 0 { return updatedHere, fmt.Errorf("category %s not updated (company mismatch?)", id) } out.Updated++ updatedHere++ } return updatedHere, rows.Err() }