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 }