Files
descrybe/apps/api/internal/campaigns/audience.go
T
greeneclipse 8580c996c3 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.
2026-08-09 22:47:43 +02:00

247 lines
6.5 KiB
Go

package campaigns
import (
"context"
"encoding/json"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce"
"github.com/google/uuid"
)
// AudienceFilter is the structured form of email_campaigns.audience_filter.
// UI shape uses type + category_ids; API/docs also accept bought_category directly.
type AudienceFilter struct {
Type string `json:"type,omitempty"`
CategoryIDs []string `json:"category_ids,omitempty"`
BoughtCategory string `json:"bought_category,omitempty"`
NotBoughtCategory string `json:"not_bought_category,omitempty"`
BoughtCategories []string `json:"bought_categories,omitempty"`
Emails []string `json:"emails,omitempty"`
}
// ResolveAudience returns campaign recipients from explicit emails and/or Woo order history.
// Bought/not-bought category matching is best-effort over synced woo_orders / order_items.
func (s *Service) ResolveAudience(ctx context.Context, companyID uuid.UUID, filter AudienceFilter, limit int) (woocommerce.AudienceResult, error) {
if limit <= 0 {
limit = 500
}
if limit > 5000 {
limit = 5000
}
boughtList, notBought, err := s.resolveBoughtCategories(ctx, companyID, filter)
if err != nil {
return woocommerce.AudienceResult{}, err
}
if len(boughtList) > 0 {
woo := &woocommerce.Service{Pool: s.Pool}
if len(boughtList) == 1 && boughtList[0] == "__any_order__" {
res, err := woo.AudienceAnyOrdersExcept(ctx, companyID, notBought, limit)
if err != nil {
return woocommerce.AudienceResult{}, err
}
seen := map[string]struct{}{}
for _, c := range res.Customers {
seen[strings.ToLower(c.Email)] = struct{}{}
}
for _, raw := range filter.Emails {
if len(res.Customers) >= limit {
break
}
email, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[email]; ok {
continue
}
res.Customers = append(res.Customers, woocommerce.AudienceCustomer{Email: email})
seen[email] = struct{}{}
}
res.Total = len(res.Customers)
return res, nil
}
merged := woocommerce.AudienceResult{
Customers: make([]woocommerce.AudienceCustomer, 0),
Note: "best-effort from synced Woo orders (campaign audience_filter)",
}
seen := map[string]struct{}{}
for _, bought := range boughtList {
if len(merged.Customers) >= limit {
break
}
res, err := woo.AudienceBoughtCategories(ctx, companyID, bought, notBought, limit)
if err != nil {
return woocommerce.AudienceResult{}, err
}
if res.Note != "" {
merged.Note = res.Note
}
for _, c := range res.Customers {
email := strings.ToLower(strings.TrimSpace(c.Email))
if email == "" {
continue
}
if _, ok := seen[email]; ok {
continue
}
seen[email] = struct{}{}
merged.Customers = append(merged.Customers, c)
if len(merged.Customers) >= limit {
break
}
}
}
for _, raw := range filter.Emails {
if len(merged.Customers) >= limit {
break
}
email, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[email]; ok {
continue
}
merged.Customers = append(merged.Customers, woocommerce.AudienceCustomer{Email: email})
seen[email] = struct{}{}
}
merged.Total = len(merged.Customers)
return merged, nil
}
out := woocommerce.AudienceResult{
Customers: make([]woocommerce.AudienceCustomer, 0),
Note: "explicit email list (no bought_category filter)",
}
seen := map[string]struct{}{}
for _, raw := range filter.Emails {
email, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[email]; ok {
continue
}
out.Customers = append(out.Customers, woocommerce.AudienceCustomer{Email: email})
seen[email] = struct{}{}
if len(out.Customers) >= limit {
break
}
}
out.Total = len(out.Customers)
return out, nil
}
func (s *Service) resolveBoughtCategories(ctx context.Context, companyID uuid.UUID, filter AudienceFilter) ([]string, string, error) {
notBought := strings.TrimSpace(filter.NotBoughtCategory)
bought := make([]string, 0)
add := func(v string) {
v = strings.TrimSpace(v)
if v == "" {
return
}
for _, existing := range bought {
if strings.EqualFold(existing, v) {
return
}
}
bought = append(bought, v)
}
add(filter.BoughtCategory)
for _, v := range filter.BoughtCategories {
add(v)
}
typ := strings.ToLower(strings.TrimSpace(filter.Type))
names, err := s.categoryNamesByIDs(ctx, companyID, filter.CategoryIDs)
if err != nil {
return nil, "", err
}
switch typ {
case "purchased", "by_category":
for _, name := range names {
add(name)
}
case "not_purchased":
if notBought == "" && len(names) > 0 {
notBought = names[0]
}
if len(bought) == 0 {
return []string{"__any_order__"}, notBought, nil
}
default:
// Keep explicit bought_category / bought_categories when type is empty/all.
if len(bought) == 0 {
for _, name := range names {
add(name)
}
}
}
return bought, notBought, nil
}
func (s *Service) categoryNamesByIDs(ctx context.Context, companyID uuid.UUID, rawIDs []string) ([]string, error) {
ids := make([]uuid.UUID, 0, len(rawIDs))
for _, raw := range rawIDs {
id, err := uuid.Parse(strings.TrimSpace(raw))
if err != nil {
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, nil
}
rows, err := s.Pool.Query(ctx, `
SELECT name FROM categories
WHERE company_id = $1 AND id = ANY($2::uuid[]) AND is_active = true`, companyID, ids)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, len(ids))
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
name = strings.TrimSpace(name)
if name != "" {
out = append(out, name)
}
}
return out, rows.Err()
}
// ResolveAudienceMap accepts the loose map[string]any shape used by campaign Create/Update inputs.
func (s *Service) ResolveAudienceMap(ctx context.Context, companyID uuid.UUID, raw map[string]any, limit int) (woocommerce.AudienceResult, error) {
return s.ResolveAudience(ctx, companyID, AudienceFilterFromMap(raw), limit)
}
// AudienceFilterFromMap converts a JSON-object audience_filter into AudienceFilter.
func AudienceFilterFromMap(raw map[string]any) AudienceFilter {
if raw == nil {
return AudienceFilter{}
}
b, err := json.Marshal(raw)
if err != nil {
return AudienceFilter{}
}
return ParseAudienceFilter(b)
}
// ParseAudienceFilter decodes audience_filter JSONB.
func ParseAudienceFilter(raw []byte) AudienceFilter {
var f AudienceFilter
if len(raw) == 0 {
return f
}
_ = json.Unmarshal(raw, &f)
return f
}