314 lines
9.0 KiB
Go
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
|
|
}
|