Files
descrybe/apps/api/internal/seo/service.go
T
2026-08-23 22:03:57 +02:00

314 lines
9.0 KiB
Go

package seo
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
"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 {
ctx := aiaudit.WithCall(ctx, aiaudit.CallContext{CompanyID: companyID, Role: aiaudit.RoleSEOMeta})
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
}