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.
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user