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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user