package seo import ( "context" "encoding/json" "errors" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts" "github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/company" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) const ( maxProductsScan = 2000 maxCategoriesScan = 500 maxRecsReturn = 200 ) // Service loads catalog rows and applies SEO fixes. type Service struct { Pool *pgxpool.Pool Billing *billing.Service Completer processing.Completer // AI optional: resolves company BYOK before falling back to Completer. AI *aiprovider.Service // Prompts optional: company-editable SEO system/user templates. Prompts *aiprompts.Service } // Recommendations returns a company-scoped SEO report. func (s *Service) Recommendations(ctx context.Context, companyID uuid.UUID) (Report, error) { products, err := s.loadProducts(ctx, companyID) if err != nil { return Report{}, err } categories, err := s.loadCategories(ctx, companyID) if err != nil { return Report{}, err } recs := Analyze(products, categories) checklist, overall := BuildChecklist(recs, len(products), len(categories)) // Cap payload size but keep checklist accurate from full analyze. limited := recs if len(limited) > maxRecsReturn { limited = prioritizeRecs(recs, maxRecsReturn) } canAI := false if s.Billing != nil { ent, err := s.Billing.EntitlementsForCompany(ctx, companyID) if err == nil { canAI = ent.CanUseAI } } return Report{ CompanyID: companyID.String(), ProductCount: len(products), CategoryCount: len(categories), OverallScore: overall, CanUseAI: canAI, Checklist: checklist, Recommendations: limited, Types: AllTypes(), }, nil } // Apply fills meta for one product (template = free; ai = paid/credits). func (s *Service) Apply(ctx context.Context, companyID uuid.UUID, productID uuid.UUID, mode string) (ApplyResult, error) { mode = strings.ToLower(strings.TrimSpace(mode)) if mode == "" { mode = ApplyModeTemplate } if mode != ApplyModeTemplate && mode != ApplyModeAI { return ApplyResult{}, ErrInvalidMode } p, err := s.loadOneProduct(ctx, companyID, productID) if err != nil { return ApplyResult{}, err } var metaTitle, metaDesc string credits := 0 tokens := 0 switch mode { case ApplyModeTemplate: metaTitle, metaDesc = FillMetaTemplate(p) case ApplyModeAI: if s.Billing == nil { return ApplyResult{}, billing.ErrAIRequiresUpgrade } if err := s.Billing.AssertFeatures(ctx, companyID, "capability.seo_ai_rewrite", "marketing.seo.ai_rewrite"); err != nil { return ApplyResult{}, err } ent, err := s.Billing.EntitlementsForCompany(ctx, companyID) if err != nil { return ApplyResult{}, err } if !ent.CanUseAI { return ApplyResult{}, billing.ErrAIRequiresUpgrade } if ent.RemainingCredits < 1 { return ApplyResult{}, billing.ErrInsufficientCredits } var completer processing.Completer if s.AI != nil { c, _, _, rerr := s.AI.ResolveCompleter(ctx, companyID) if rerr != nil { return ApplyResult{}, rerr } completer = c } else { completer = s.Completer } if completer == nil { completer = processing.HeuristicCompleter{} } if brand, berr := company.LoadBrand(ctx, s.Pool, companyID); berr == nil { p.BrandPrompt = brand.PromptBlock() } p.Language = company.LoadLanguage(ctx, s.Pool, companyID) var prompts aiprompts.Resolved if s.Prompts != nil { if resolved, perr := s.Prompts.Resolve(ctx, companyID, aiprompts.KeySEOMeta, p.Language); perr == nil { prompts = resolved } } mt, md, tok, err := FillMetaAI(ctx, completer, p, prompts) if err != nil { return ApplyResult{}, err } metaTitle, metaDesc, tokens = mt, md, tok if err := s.Billing.ConsumeCredits(ctx, companyID, tokens, "seo_meta_ai"); err != nil { return ApplyResult{}, err } credits = 1 if tokens > 0 { credits += (tokens + 999) / 1000 } } ct, err := s.Pool.Exec(ctx, ` UPDATE processed_products SET meta_title = $3, meta_description = $4, updated_at = now() WHERE id = $1 AND company_id = $2`, productID, companyID, metaTitle, metaDesc) if err != nil { return ApplyResult{}, err } if ct.RowsAffected() == 0 { return ApplyResult{}, ErrNotFound } return ApplyResult{ ProductID: productID.String(), Mode: mode, MetaTitle: metaTitle, MetaDescription: metaDesc, CreditsCharged: credits, }, nil } func (s *Service) loadProducts(ctx context.Context, companyID uuid.UUID) ([]ProductInput, error) { rows, err := s.Pool.Query(ctx, ` SELECT p.id::text, COALESCE(p.product_id, ''), COALESCE(p.name, ''), COALESCE(p.processed_name, ''), COALESCE(p.description, ''), COALESCE(p.processed_description, ''), COALESCE(p.meta_title, ''), COALESCE(p.meta_description, ''), COALESCE(p.category, ''), COALESCE(p.attributes, '{}'::jsonb), COALESCE(p.processed_attributes, '{}'::jsonb), COALESCE(r.mapped_data, '{}'::jsonb) FROM processed_products p LEFT JOIN raw_products r ON r.id = p.raw_product_id WHERE p.company_id = $1 ORDER BY p.updated_at DESC LIMIT $2`, companyID, maxProductsScan) if err != nil { return nil, err } defer rows.Close() out := make([]ProductInput, 0) for rows.Next() { var p ProductInput var attrs, procAttrs, mapped []byte if err := rows.Scan( &p.ID, &p.ProductID, &p.Name, &p.ProcessedName, &p.Description, &p.ProcessedDesc, &p.MetaTitle, &p.MetaDescription, &p.Category, &attrs, &procAttrs, &mapped, ); err != nil { return nil, err } p.Attributes = decodeMap(attrs) p.ProcessedAttrs = decodeMap(procAttrs) p.MappedData = decodeMap(mapped) out = append(out, p) } return out, rows.Err() } func (s *Service) loadOneProduct(ctx context.Context, companyID, id uuid.UUID) (ProductInput, error) { var p ProductInput var attrs, procAttrs, mapped []byte err := s.Pool.QueryRow(ctx, ` SELECT p.id::text, COALESCE(p.product_id, ''), COALESCE(p.name, ''), COALESCE(p.processed_name, ''), COALESCE(p.description, ''), COALESCE(p.processed_description, ''), COALESCE(p.meta_title, ''), COALESCE(p.meta_description, ''), COALESCE(p.category, ''), COALESCE(p.attributes, '{}'::jsonb), COALESCE(p.processed_attributes, '{}'::jsonb), COALESCE(r.mapped_data, '{}'::jsonb) FROM processed_products p LEFT JOIN raw_products r ON r.id = p.raw_product_id WHERE p.id = $1 AND p.company_id = $2`, id, companyID).Scan( &p.ID, &p.ProductID, &p.Name, &p.ProcessedName, &p.Description, &p.ProcessedDesc, &p.MetaTitle, &p.MetaDescription, &p.Category, &attrs, &procAttrs, &mapped, ) if errors.Is(err, pgx.ErrNoRows) { return ProductInput{}, ErrNotFound } if err != nil { return ProductInput{}, err } p.Attributes = decodeMap(attrs) p.ProcessedAttrs = decodeMap(procAttrs) p.MappedData = decodeMap(mapped) return p, nil } func (s *Service) loadCategories(ctx context.Context, companyID uuid.UUID) ([]CategoryInput, error) { rows, err := s.Pool.Query(ctx, ` SELECT id::text, COALESCE(unique_id, ''), COALESCE(name, ''), COALESCE(description_template, '{}'::jsonb) FROM categories WHERE company_id = $1 ORDER BY name LIMIT $2`, companyID, maxCategoriesScan) if err != nil { return nil, err } defer rows.Close() out := make([]CategoryInput, 0) for rows.Next() { var c CategoryInput var tplBytes []byte if err := rows.Scan(&c.ID, &c.UniqueID, &c.Name, &tplBytes); err != nil { return nil, err } if len(tplBytes) > 0 { _ = json.Unmarshal(tplBytes, &c.DescriptionTemplate) } out = append(out, c) } return out, rows.Err() } func decodeMap(b []byte) map[string]any { if len(b) == 0 { return map[string]any{} } var m map[string]any if err := json.Unmarshal(b, &m); err != nil || m == nil { return map[string]any{} } return m } func prioritizeRecs(recs []Recommendation, limit int) []Recommendation { severityRank := map[string]int{ SeverityCritical: 0, SeverityWarn: 1, SeverityInfo: 2, } // Stable partition by severity without full sort alloc if small. buckets := [3][]Recommendation{} for _, r := range recs { i := severityRank[r.Severity] if i < 0 || i > 2 { i = 2 } buckets[i] = append(buckets[i], r) } out := make([]Recommendation, 0, limit) for _, b := range buckets { for _, r := range b { if len(out) >= limit { return out } out = append(out, r) } } return out } // EnsureCost seeds seo_meta_ai processing cost (idempotent). func EnsureCost(ctx context.Context, pool *pgxpool.Pool) error { _, err := pool.Exec(ctx, ` INSERT INTO processing_costs (feature_name, cost_per_unit, description, is_active) VALUES ('seo_meta_ai', 1, 'Credits per SEO AI meta fill', true) ON CONFLICT (feature_name) DO NOTHING`) return err }