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 }