Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
247 lines
6.5 KiB
Go
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
|
|
}
|