Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
package marketing
import (
"errors"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
)
// clientError is a validation message safe to return to API clients.
type clientError struct {
msg string
}
func (e *clientError) Error() string { return e.msg }
// ClientMsg marks a message as safe to expose in HTTP 4xx responses.
func ClientMsg(msg string) error {
return &clientError{msg: msg}
}
// ClientError reports whether err is a known client-facing marketing error.
// Feed create/update validation from PrepareCampaign is also exposed.
func ClientError(err error) (msg string, ok bool) {
if err == nil {
return "", false
}
var ce *clientError
if errors.As(err, &ce) {
return ce.msg, true
}
return feeds.ClientError(err)
}
@@ -0,0 +1,65 @@
package marketing
import (
"strings"
"testing"
)
func TestListPreparedCampaignsSQL_boundsAndFilters(t *testing.T) {
if !strings.Contains(listPreparedCampaignsSQL, "LIMIT") {
t.Fatal("expected SQL LIMIT on prepared campaigns list")
}
if !strings.Contains(listPreparedCampaignsSQL, "template ?") {
t.Fatal("expected jsonb key filter so non-campaign feeds are skipped in SQL")
}
if maxPreparedCampaigns <= 0 || maxPreparedCampaigns > 2000 {
t.Fatalf("maxPreparedCampaigns out of expected range: %d", maxPreparedCampaigns)
}
}
func TestBlackFridayDate2026(t *testing.T) {
bf := BlackFridayDate(2026)
if bf.Year() != 2026 || bf.Month() != 11 || bf.Day() != 27 {
t.Fatalf("expected 2026-11-27, got %s", bf.Format("2006-01-02"))
}
}
func TestResolveBlackFridayWindow(t *testing.T) {
p, err := ResolvePreset(PresetBlackFriday, 2026)
if err != nil {
t.Fatal(err)
}
if p.StartDate != "2026-11-20" || p.EndDate != "2026-11-30" {
t.Fatalf("unexpected window %s → %s", p.StartDate, p.EndDate)
}
}
func TestResolveChristmas(t *testing.T) {
p, err := ResolvePreset(PresetChristmas, 2026)
if err != nil {
t.Fatal(err)
}
if p.StartDate != "2026-12-01" || p.EndDate != "2026-12-26" {
t.Fatalf("unexpected christmas window %s → %s", p.StartDate, p.EndDate)
}
}
func TestComputeProductQualityScore(t *testing.T) {
empty := ComputeProductQualityScore(ProductInput{})
if empty.Score != 0 || empty.Grade != "F" {
t.Fatalf("empty expected F/0, got %s/%d", empty.Grade, empty.Score)
}
full := ComputeProductQualityScore(ProductInput{
ProcessedName: "Great Widget Pro",
ProcessedDescription: "A detailed product description that is long enough.",
MetaTitle: "Great Widget Pro | Shop",
MetaDescription: "Buy Great Widget Pro with free shipping and a two-year warranty today.",
Category: "Widgets",
ProcessedAttributes: map[string]any{"color": "red"},
MappedData: map[string]any{"image": "https://example.com/w.jpg"},
})
if full.Score != 100 || full.Grade != "A" {
t.Fatalf("full expected A/100, got %s/%d", full.Grade, full.Score)
}
}
+193
View File
@@ -0,0 +1,193 @@
package marketing
import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// PreparedCampaign is a seasonal preset linked to an export feed.
type PreparedCampaign struct {
PresetID PresetID `json:"preset_id"`
Name string `json:"name"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Year int `json:"year"`
ExportFeedID string `json:"export_feed_id"`
ExportFeedName string `json:"export_feed_name"`
Created bool `json:"created"`
}
// Service prepares content-calendar campaigns via export feeds (no new tables).
type Service struct {
Pool *pgxpool.Pool
Feeds *feeds.Service
}
// Cap matches httpapi maxPageLimit; seasonal presets over years stay well under this.
const maxPreparedCampaigns = 200
// listPreparedCampaignsSQL filters to campaign feeds in SQL (jsonb key) and caps rows.
const listPreparedCampaignsSQL = `
SELECT id, name, template
FROM export_feeds
WHERE company_id = $1
AND template ? $2
ORDER BY created_at DESC
LIMIT $3`
// ListPreparedCampaigns finds export feeds whose template contains _campaign meta.
func (s *Service) ListPreparedCampaigns(ctx context.Context, companyID uuid.UUID) ([]PreparedCampaign, error) {
rows, err := s.Pool.Query(ctx, listPreparedCampaignsSQL, companyID, CampaignStructureKey, maxPreparedCampaigns)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PreparedCampaign, 0)
for rows.Next() {
var id uuid.UUID
var name string
var tpl []byte
if err := rows.Scan(&id, &name, &tpl); err != nil {
return nil, err
}
meta, ok := readCampaignMeta(tpl)
if !ok {
continue
}
out = append(out, PreparedCampaign{
PresetID: meta.PresetID,
Name: meta.Name,
StartDate: meta.StartDate,
EndDate: meta.EndDate,
Year: meta.Year,
ExportFeedID: id.String(),
ExportFeedName: name,
Created: false,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool {
return out[i].StartDate < out[j].StartDate
})
return out, nil
}
// PrepareInput creates or reuses a seasonal export feed.
type PrepareInput struct {
PresetID PresetID
Year int
Format string
ForceNew bool
}
// PrepareCampaign creates (or reuses) a CSV/XML export feed for a seasonal preset.
func (s *Service) PrepareCampaign(ctx context.Context, companyID uuid.UUID, in PrepareInput) (PreparedCampaign, error) {
year := in.Year
if year == 0 {
year = time.Now().UTC().Year()
}
preset, err := ResolvePreset(in.PresetID, year)
if err != nil {
return PreparedCampaign{}, err
}
format := in.Format
if format == "" {
format = "csv"
}
if format != "csv" && format != "xml" {
return PreparedCampaign{}, ClientMsg("format must be csv or xml")
}
if !in.ForceNew {
existing, err := s.ListPreparedCampaigns(ctx, companyID)
if err != nil {
return PreparedCampaign{}, err
}
for _, c := range existing {
if c.PresetID == in.PresetID && c.Year == year {
c.Created = false
return c, nil
}
}
}
meta := StructureMeta{
PresetID: preset.ID,
Name: preset.Name,
StartDate: preset.StartDate,
EndDate: preset.EndDate,
Year: preset.Year,
PreparedAt: time.Now().UTC().Format(time.RFC3339),
}
template := map[string]any{
CampaignStructureKey: meta,
"mappings": DefaultCampaignMappings(),
}
if format == "xml" {
template["root"] = "rss"
template["item"] = "channel/item"
}
feedName := fmt.Sprintf("%s %d", preset.Name, year)
created, err := s.Feeds.CreateExportFeed(ctx, companyID, feeds.CreateExportInput{
Name: feedName,
Format: format,
Template: template,
Filters: map[string]any{"statuses": []string{"completed"}},
})
if err != nil {
return PreparedCampaign{}, err
}
id, _ := created["id"].(uuid.UUID)
name, _ := created["name"].(string)
if name == "" {
name = feedName
}
return PreparedCampaign{
PresetID: preset.ID,
Name: preset.Name,
StartDate: preset.StartDate,
EndDate: preset.EndDate,
Year: preset.Year,
ExportFeedID: id.String(),
ExportFeedName: name,
Created: true,
}, nil
}
func readCampaignMeta(raw []byte) (StructureMeta, bool) {
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
return StructureMeta{}, false
}
var root map[string]any
if err := json.Unmarshal(raw, &root); err != nil {
return StructureMeta{}, false
}
metaRaw, ok := root[CampaignStructureKey]
if !ok || metaRaw == nil {
return StructureMeta{}, false
}
b, err := json.Marshal(metaRaw)
if err != nil {
return StructureMeta{}, false
}
var meta StructureMeta
if err := json.Unmarshal(b, &meta); err != nil {
return StructureMeta{}, false
}
if meta.PresetID == "" || meta.StartDate == "" || meta.EndDate == "" {
return StructureMeta{}, false
}
return meta, true
}
+110
View File
@@ -0,0 +1,110 @@
package marketing
import (
"fmt"
"time"
)
// PresetID identifies a seasonal content-calendar preset.
type PresetID string
const (
PresetBlackFriday PresetID = "black_friday"
PresetChristmas PresetID = "christmas"
)
// CampaignStructureKey is stored on export_feeds.template JSON (no migration).
const CampaignStructureKey = "_campaign"
// Preset is a dated seasonal window for preparing an export feed.
type Preset struct {
ID PresetID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Year int `json:"year"`
}
// StructureMeta is persisted under template._campaign.
type StructureMeta struct {
PresetID PresetID `json:"presetId"`
Name string `json:"name"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
Year int `json:"year"`
PreparedAt string `json:"preparedAt"`
}
func pad2(n int) string {
return fmt.Sprintf("%02d", n)
}
func toISODate(t time.Time) string {
return fmt.Sprintf("%s-%s-%s", pad2(t.Year()), pad2(int(t.Month())), pad2(t.Day()))
}
// BlackFridayDate returns Black Friday (day after US Thanksgiving) in UTC date parts.
func BlackFridayDate(year int) time.Time {
nov1 := time.Date(year, time.November, 1, 0, 0, 0, 0, time.UTC)
dow := int(nov1.Weekday()) // Sunday=0
firstThursday := 1 + ((4 - dow + 7) % 7)
fourthThursday := firstThursday + 21
return time.Date(year, time.November, fourthThursday+1, 0, 0, 0, 0, time.UTC)
}
// ResolvePreset returns date window for a preset id and year.
func ResolvePreset(id PresetID, year int) (Preset, error) {
if year < 2000 || year > 2100 {
return Preset{}, ClientMsg("invalid year")
}
switch id {
case PresetBlackFriday:
bf := BlackFridayDate(year)
start := bf.AddDate(0, 0, -7)
end := bf.AddDate(0, 0, 3)
return Preset{
ID: id,
Name: "Black Friday",
Description: "Promo window around Black Friday — prepare a Google Shopping-style export feed.",
StartDate: toISODate(start),
EndDate: toISODate(end),
Year: year,
}, nil
case PresetChristmas:
return Preset{
ID: PresetChristmas,
Name: "Christmas",
Description: "Holiday catalog push from Dec 1 through Boxing Day.",
StartDate: toISODate(time.Date(year, time.December, 1, 0, 0, 0, 0, time.UTC)),
EndDate: toISODate(time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC)),
Year: year,
}, nil
default:
return Preset{}, ClientMsg("preset_id must be black_friday or christmas")
}
}
// ListPresets returns Black Friday + Christmas for the year.
func ListPresets(year int) []Preset {
bf, _ := ResolvePreset(PresetBlackFriday, year)
xmas, _ := ResolvePreset(PresetChristmas, year)
return []Preset{bf, xmas}
}
// DefaultCampaignMappings are Google Shopping-ish CSV column → product field sources.
func DefaultCampaignMappings() map[string]string {
return map[string]string{
"id": "product_id",
"title": "processed_name",
"description": "processed_description",
"link": "attr.url",
"image_link": "attr.image",
"availability": "attr.availability",
"price": "attr.price",
"brand": "attr.brand",
"gtin": "gtin",
"google_product_category": "category",
"condition": "attr.condition",
}
}
+237
View File
@@ -0,0 +1,237 @@
package marketing
import (
"encoding/json"
"strings"
)
// QualityCheckKey identifies a completeness / SEO signal.
type QualityCheckKey string
const (
CheckTitle QualityCheckKey = "title"
CheckDescription QualityCheckKey = "description"
CheckMetaTitle QualityCheckKey = "meta_title"
CheckMetaDescription QualityCheckKey = "meta_description"
CheckCategory QualityCheckKey = "category"
CheckAttributes QualityCheckKey = "attributes"
CheckImage QualityCheckKey = "image"
)
// QualityCheck is one weighted gate in the score.
type QualityCheck struct {
Passed bool `json:"passed"`
Weight int `json:"weight"`
Label string `json:"label"`
}
// QualityResult is a 0100 completeness / SEO score.
type QualityResult struct {
Score int `json:"score"`
MaxScore int `json:"max_score"`
Grade string `json:"grade"`
Checks map[QualityCheckKey]QualityCheck `json:"checks"`
}
// ProductInput is the field snapshot used for scoring (no DB column required).
type ProductInput struct {
Name string
ProcessedName string
Description string
ProcessedDescription string
MetaTitle string
MetaDescription string
Category string
Attributes any
ProcessedAttributes any
MappedData map[string]any
}
var qualityWeights = map[QualityCheckKey]int{
CheckTitle: 20,
CheckDescription: 20,
CheckMetaTitle: 15,
CheckMetaDescription: 15,
CheckCategory: 10,
CheckAttributes: 10,
CheckImage: 10,
}
var qualityLabels = map[QualityCheckKey]string{
CheckTitle: "Title",
CheckDescription: "Description",
CheckMetaTitle: "Meta title",
CheckMetaDescription: "Meta description",
CheckCategory: "Category",
CheckAttributes: "Attributes",
CheckImage: "Image",
}
var qualityOrder = []QualityCheckKey{
CheckTitle, CheckDescription, CheckMetaTitle, CheckMetaDescription,
CheckCategory, CheckAttributes, CheckImage,
}
func hasText(value string, minLen int) bool {
return len(strings.TrimSpace(value)) >= minLen
}
func countAttributes(value any) int {
if value == nil {
return 0
}
switch v := value.(type) {
case []any:
return len(v)
case map[string]any:
return len(v)
case string:
s := strings.TrimSpace(v)
if s == "" || s == "{}" || s == "[]" || s == "null" {
return 0
}
var arr []any
if err := json.Unmarshal([]byte(s), &arr); err == nil {
return len(arr)
}
var obj map[string]any
if err := json.Unmarshal([]byte(s), &obj); err == nil {
return len(obj)
}
return 0
case []byte:
return countAttributes(string(v))
default:
b, err := json.Marshal(v)
if err != nil {
return 0
}
return countAttributes(string(b))
}
}
func hasImage(mapped map[string]any) bool {
if mapped == nil {
return false
}
keys := []string{"image", "image_link", "image_url", "images", "main_image", "primary_image", "picture", "photo"}
for _, key := range keys {
raw, ok := mapped[key]
if !ok || raw == nil {
continue
}
switch v := raw.(type) {
case string:
if strings.TrimSpace(v) != "" {
return true
}
case []any:
if len(v) > 0 {
return true
}
}
}
return false
}
func gradeFromScore(score int) string {
switch {
case score >= 90:
return "A"
case score >= 75:
return "B"
case score >= 60:
return "C"
case score >= 40:
return "D"
default:
return "F"
}
}
// ComputeProductQualityScore scores completeness + SEO fields (0100).
func ComputeProductQualityScore(in ProductInput) QualityResult {
mappedName := ""
mappedDesc := ""
if in.MappedData != nil {
if s, ok := in.MappedData["name"].(string); ok {
mappedName = s
} else if s, ok := in.MappedData["title"].(string); ok {
mappedName = s
}
if s, ok := in.MappedData["description"].(string); ok {
mappedDesc = s
}
}
passed := map[QualityCheckKey]bool{
CheckTitle: hasText(in.ProcessedName, 3) || hasText(in.Name, 3) || hasText(mappedName, 3),
CheckDescription: hasText(in.ProcessedDescription, 20) || hasText(in.Description, 20) ||
hasText(mappedDesc, 20),
CheckMetaTitle: hasText(in.MetaTitle, 10),
CheckMetaDescription: hasText(in.MetaDescription, 40),
CheckCategory: hasText(in.Category, 1),
CheckAttributes: countAttributes(in.ProcessedAttributes) > 0 || countAttributes(in.Attributes) > 0,
CheckImage: hasImage(in.MappedData),
}
score := 0
checks := make(map[QualityCheckKey]QualityCheck, len(qualityOrder))
for _, key := range qualityOrder {
w := qualityWeights[key]
ok := passed[key]
if ok {
score += w
}
checks[key] = QualityCheck{Passed: ok, Weight: w, Label: qualityLabels[key]}
}
return QualityResult{
Score: score,
MaxScore: 100,
Grade: gradeFromScore(score),
Checks: checks,
}
}
// ScoreFromProductMap builds ProductInput from a catalog row map and scores it.
func ScoreFromProductMap(m map[string]any) QualityResult {
in := ProductInput{
Name: asString(m["name"]),
ProcessedName: asString(m["processed_name"]),
Description: asString(m["description"]),
ProcessedDescription: asString(m["processed_description"]),
MetaTitle: asString(m["meta_title"]),
MetaDescription: asString(m["meta_description"]),
Category: asString(m["category"]),
Attributes: m["attributes"],
ProcessedAttributes: m["processed_attributes"],
}
if md, ok := m["mapped_data"].(map[string]any); ok {
in.MappedData = md
} else if raw, ok := m["mapped_data"].([]byte); ok && len(raw) > 0 {
var obj map[string]any
if json.Unmarshal(raw, &obj) == nil {
in.MappedData = obj
}
} else if s := asString(m["mapped_data"]); s != "" {
var obj map[string]any
if json.Unmarshal([]byte(s), &obj) == nil {
in.MappedData = obj
}
}
return ComputeProductQualityScore(in)
}
func asString(v any) string {
switch t := v.(type) {
case string:
return t
case []byte:
return string(t)
case nil:
return ""
default:
return ""
}
}