194 lines
4.9 KiB
Go
194 lines
4.9 KiB
Go
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
|
||
|
|
}
|