Files

385 lines
11 KiB
Go
Raw Permalink Normal View History

package woocommerce
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
)
type OrderListFilter struct {
Status string
Email string
Since *time.Time
Limit int
Offset int
}
type ReviewListFilter struct {
Status string
ProductID int64
MinRating int
Limit int
Offset int
}
type OrderRow struct {
ID uuid.UUID `json:"id"`
ExternalID int64 `json:"external_id"`
Status string `json:"status"`
Currency string `json:"currency"`
Total *string `json:"total,omitempty"`
CustomerID *int64 `json:"customer_id,omitempty"`
CustomerEmail *string `json:"customer_email,omitempty"`
CustomerName *string `json:"customer_name,omitempty"`
OrderedAt *time.Time `json:"ordered_at,omitempty"`
SyncedAt time.Time `json:"synced_at"`
Payload json.RawMessage `json:"payload"`
}
type ReviewRow struct {
ID uuid.UUID `json:"id"`
ExternalID int64 `json:"external_id"`
ProductID *int64 `json:"product_id,omitempty"`
ProductName string `json:"product_name"`
Status string `json:"status"`
Reviewer string `json:"reviewer"`
ReviewerEmail string `json:"reviewer_email"`
Rating *int `json:"rating,omitempty"`
Review string `json:"review"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
SyncedAt time.Time `json:"synced_at"`
Payload json.RawMessage `json:"payload"`
}
type AudienceCustomer struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
}
type AudienceResult struct {
Customers []AudienceCustomer `json:"customers"`
Total int `json:"total"`
Note string `json:"note"`
}
func normalizeOrderListFilter(f OrderListFilter) OrderListFilter {
if f.Limit <= 0 {
f.Limit = 50
}
if f.Limit > 200 {
f.Limit = 200
}
if f.Offset < 0 {
f.Offset = 0
}
return f
}
func (s *Service) ListOrders(ctx context.Context, companyID uuid.UUID, f OrderListFilter) ([]OrderRow, int, error) {
f = normalizeOrderListFilter(f)
args := []any{companyID}
where := []string{"company_id = $1"}
n := 2
if status := strings.TrimSpace(f.Status); status != "" {
where = append(where, "status = $"+itoa(n))
args = append(args, status)
n++
}
if email := strings.TrimSpace(strings.ToLower(f.Email)); email != "" {
where = append(where, "lower(customer_email) = $"+itoa(n))
args = append(args, email)
n++
}
if f.Since != nil {
where = append(where, "ordered_at >= $"+itoa(n))
args = append(args, *f.Since)
n++
}
whereSQL := strings.Join(where, " AND ")
var total int
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM woo_orders WHERE `+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, f.Limit, f.Offset)
rows, err := s.Pool.Query(ctx, `
SELECT id, external_id, status, currency, total::text, customer_id, customer_email, customer_name,
ordered_at, synced_at, payload
FROM woo_orders
WHERE `+whereSQL+`
ORDER BY ordered_at DESC NULLS LAST, external_id DESC
LIMIT $`+itoa(n)+` OFFSET $`+itoa(n+1), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]OrderRow, 0)
for rows.Next() {
var row OrderRow
if err := rows.Scan(
&row.ID, &row.ExternalID, &row.Status, &row.Currency, &row.Total, &row.CustomerID,
&row.CustomerEmail, &row.CustomerName, &row.OrderedAt, &row.SyncedAt, &row.Payload,
); err != nil {
return nil, 0, err
}
out = append(out, row)
}
return out, total, rows.Err()
}
func (s *Service) ListReviews(ctx context.Context, companyID uuid.UUID, f ReviewListFilter) ([]ReviewRow, int, error) {
if f.Limit <= 0 {
f.Limit = 50
}
if f.Limit > 200 {
f.Limit = 200
}
if f.Offset < 0 {
f.Offset = 0
}
args := []any{companyID}
where := []string{"company_id = $1"}
n := 2
if status := strings.TrimSpace(f.Status); status != "" {
where = append(where, "status = $"+itoa(n))
args = append(args, status)
n++
}
if f.ProductID > 0 {
where = append(where, "product_id = $"+itoa(n))
args = append(args, f.ProductID)
n++
}
if f.MinRating > 0 {
where = append(where, "rating >= $"+itoa(n))
args = append(args, f.MinRating)
n++
}
whereSQL := strings.Join(where, " AND ")
var total int
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM product_reviews WHERE `+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, f.Limit, f.Offset)
rows, err := s.Pool.Query(ctx, `
SELECT id, external_id, product_id, product_name, status, reviewer, reviewer_email,
rating, review, reviewed_at, synced_at, payload
FROM product_reviews
WHERE `+whereSQL+`
ORDER BY reviewed_at DESC NULLS LAST, external_id DESC
LIMIT $`+itoa(n)+` OFFSET $`+itoa(n+1), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]ReviewRow, 0)
for rows.Next() {
var row ReviewRow
if err := rows.Scan(
&row.ID, &row.ExternalID, &row.ProductID, &row.ProductName, &row.Status, &row.Reviewer,
&row.ReviewerEmail, &row.Rating, &row.Review, &row.ReviewedAt, &row.SyncedAt, &row.Payload,
); err != nil {
return nil, 0, err
}
out = append(out, row)
}
return out, total, rows.Err()
}
// AudienceBoughtCategories returns distinct customers who bought category X
// and (optionally) did not buy category Y. Best-effort from synced orders:
// matches line-item categories JSON and/or local processed_products.category by SKU/product_id.
func (s *Service) AudienceBoughtCategories(ctx context.Context, companyID uuid.UUID, boughtCategory, notBoughtCategory string, limit int) (AudienceResult, error) {
boughtCategory = strings.TrimSpace(boughtCategory)
notBoughtCategory = strings.TrimSpace(notBoughtCategory)
if boughtCategory == "" {
return AudienceResult{Customers: []AudienceCustomer{}, Note: "bought_category is required"}, nil
}
if limit <= 0 {
limit = 500
}
if limit > 5000 {
limit = 5000
}
const note = "best-effort from synced Woo orders (line-item categories + processed_products by sku/product_id)"
rows, err := s.Pool.Query(ctx, `
WITH buyers AS (
SELECT DISTINCT lower(o.customer_email) AS email, COALESCE(o.customer_name, '') AS name
FROM woo_orders o
JOIN woo_order_items i ON i.order_id = o.id AND i.company_id = o.company_id
LEFT JOIN processed_products p
ON p.company_id = o.company_id
AND (
(i.sku <> '' AND p.product_id = i.sku)
OR (i.product_id IS NOT NULL AND p.product_id = i.product_id::text)
)
WHERE o.company_id = $1
AND o.customer_email IS NOT NULL AND o.customer_email <> ''
AND o.status IN ('completed', 'processing', 'on-hold')
AND (
EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(i.categories) = 'array' THEN i.categories
ELSE '[]'::jsonb
END
) cat(val)
WHERE lower(cat.val #>> '{}') = lower($2)
)
OR lower(COALESCE(p.category, '')) = lower($2)
)
),
excluded AS (
SELECT DISTINCT lower(o.customer_email) AS email
FROM woo_orders o
JOIN woo_order_items i ON i.order_id = o.id AND i.company_id = o.company_id
LEFT JOIN processed_products p
ON p.company_id = o.company_id
AND (
(i.sku <> '' AND p.product_id = i.sku)
OR (i.product_id IS NOT NULL AND p.product_id = i.product_id::text)
)
WHERE o.company_id = $1
AND $3 <> ''
AND o.customer_email IS NOT NULL AND o.customer_email <> ''
AND o.status IN ('completed', 'processing', 'on-hold')
AND (
EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(i.categories) = 'array' THEN i.categories
ELSE '[]'::jsonb
END
) cat(val)
WHERE lower(cat.val #>> '{}') = lower($3)
)
OR lower(COALESCE(p.category, '')) = lower($3)
)
)
SELECT b.email, b.name
FROM buyers b
WHERE NOT EXISTS (SELECT 1 FROM excluded e WHERE e.email = b.email)
ORDER BY b.email
LIMIT $4`, companyID, boughtCategory, notBoughtCategory, limit)
if err != nil {
return AudienceResult{}, err
}
defer rows.Close()
out := make([]AudienceCustomer, 0)
for rows.Next() {
var c AudienceCustomer
if err := rows.Scan(&c.Email, &c.Name); err != nil {
return AudienceResult{}, err
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return AudienceResult{}, err
}
return AudienceResult{Customers: out, Total: len(out), Note: note}, nil
}
// AudienceAnyOrdersExcept returns distinct customers with any qualifying Woo order,
// optionally excluding those who bought notBoughtCategory (best-effort category match).
func (s *Service) AudienceAnyOrdersExcept(ctx context.Context, companyID uuid.UUID, notBoughtCategory string, limit int) (AudienceResult, error) {
notBoughtCategory = strings.TrimSpace(notBoughtCategory)
if limit <= 0 {
limit = 500
}
if limit > 5000 {
limit = 5000
}
const note = "best-effort from synced Woo orders (any order, optional not_bought_category exclude)"
rows, err := s.Pool.Query(ctx, `
WITH buyers AS (
SELECT DISTINCT lower(o.customer_email) AS email, COALESCE(o.customer_name, '') AS name
FROM woo_orders o
WHERE o.company_id = $1
AND o.customer_email IS NOT NULL AND o.customer_email <> ''
AND o.status IN ('completed', 'processing', 'on-hold')
),
excluded AS (
SELECT DISTINCT lower(o.customer_email) AS email
FROM woo_orders o
JOIN woo_order_items i ON i.order_id = o.id AND i.company_id = o.company_id
LEFT JOIN processed_products p
ON p.company_id = o.company_id
AND (
(i.sku <> '' AND p.product_id = i.sku)
OR (i.product_id IS NOT NULL AND p.product_id = i.product_id::text)
)
WHERE o.company_id = $1
AND $2 <> ''
AND o.customer_email IS NOT NULL AND o.customer_email <> ''
AND o.status IN ('completed', 'processing', 'on-hold')
AND (
EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(i.categories) = 'array' THEN i.categories
ELSE '[]'::jsonb
END
) cat(val)
WHERE lower(cat.val #>> '{}') = lower($2)
)
OR lower(COALESCE(p.category, '')) = lower($2)
)
)
SELECT b.email, b.name
FROM buyers b
WHERE NOT EXISTS (SELECT 1 FROM excluded e WHERE e.email = b.email)
ORDER BY b.email
LIMIT $3`, companyID, notBoughtCategory, limit)
if err != nil {
return AudienceResult{}, err
}
defer rows.Close()
out := make([]AudienceCustomer, 0)
for rows.Next() {
var c AudienceCustomer
if err := rows.Scan(&c.Email, &c.Name); err != nil {
return AudienceResult{}, err
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return AudienceResult{}, err
}
return AudienceResult{Customers: out, Total: len(out), Note: note}, nil
}
func itoa(n int) string {
if n == 0 {
return "0"
}
const digits = "0123456789"
if n < 10 {
return digits[n : n+1]
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}