393 lines
11 KiB
Go
393 lines
11 KiB
Go
package campaigns
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"log"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
maxCampaignProductIDs = 100
|
||
|
|
maxCampaignCategoryIDs = 50
|
||
|
|
)
|
||
|
|
|
||
|
|
// Campaign is the API representation of email_campaigns (+ latest version fields).
|
||
|
|
type Campaign struct {
|
||
|
|
ID uuid.UUID `json:"id"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
TemplateKey string `json:"template_key"`
|
||
|
|
Season string `json:"season,omitempty"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
CategoryIDs []uuid.UUID `json:"category_ids"`
|
||
|
|
ProductIDs []uuid.UUID `json:"product_ids"`
|
||
|
|
Prompt string `json:"prompt"`
|
||
|
|
UseDefaultPrompt bool `json:"use_default_prompt"`
|
||
|
|
AudienceFilter map[string]any `json:"audience_filter"`
|
||
|
|
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
|
||
|
|
SentAt *time.Time `json:"sent_at,omitempty"`
|
||
|
|
Subject string `json:"subject,omitempty"`
|
||
|
|
HTMLBody string `json:"html_body,omitempty"`
|
||
|
|
PlainBody string `json:"plain_body,omitempty"`
|
||
|
|
LatestVersion *Version `json:"latest_version,omitempty"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
UpdatedAt time.Time `json:"updated_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Version struct {
|
||
|
|
ID uuid.UUID `json:"id"`
|
||
|
|
Version int `json:"version"`
|
||
|
|
Subject string `json:"subject"`
|
||
|
|
HTMLBody string `json:"html_body"`
|
||
|
|
PlainBody string `json:"plain_body"`
|
||
|
|
GenerationMode string `json:"generation_mode"`
|
||
|
|
GeneratedAt time.Time `json:"generated_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CreateInput struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
TemplateKey string `json:"template_key"`
|
||
|
|
CategoryIDs []uuid.UUID `json:"category_ids"`
|
||
|
|
ProductIDs []uuid.UUID `json:"product_ids"`
|
||
|
|
Prompt string `json:"prompt"`
|
||
|
|
UseDefaultPrompt *bool `json:"use_default_prompt"`
|
||
|
|
AudienceFilter map[string]any `json:"audience_filter"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type UpdateInput struct {
|
||
|
|
Name *string `json:"name"`
|
||
|
|
TemplateKey *string `json:"template_key"`
|
||
|
|
Status *string `json:"status"`
|
||
|
|
CategoryIDs []uuid.UUID `json:"category_ids"`
|
||
|
|
ProductIDs []uuid.UUID `json:"product_ids"`
|
||
|
|
Prompt *string `json:"prompt"`
|
||
|
|
UseDefaultPrompt *bool `json:"use_default_prompt"`
|
||
|
|
AudienceFilter map[string]any `json:"audience_filter"`
|
||
|
|
ScheduledAt *time.Time `json:"scheduled_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type GenerateInput struct {
|
||
|
|
Mode string `json:"mode"` // template | ai
|
||
|
|
Force bool `json:"force"`
|
||
|
|
UseAI *bool `json:"use_ai"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type SendTestInput struct {
|
||
|
|
To string `json:"to"`
|
||
|
|
Email string `json:"email"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ScheduleInput struct {
|
||
|
|
ScheduledAt time.Time `json:"scheduled_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type SendInput struct {
|
||
|
|
Confirm bool `json:"confirm"`
|
||
|
|
Recipients []string `json:"recipients"`
|
||
|
|
DryRun bool `json:"dry_run"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) List(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]Campaign, int64, error) {
|
||
|
|
var total int64
|
||
|
|
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM email_campaigns WHERE company_id = $1`, companyID).Scan(&total); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
rows, err := s.Pool.Query(ctx, `
|
||
|
|
SELECT id, name, template_key, status, category_ids, product_ids, prompt, use_default_prompt,
|
||
|
|
audience_filter, scheduled_at, sent_at, created_at, updated_at
|
||
|
|
FROM email_campaigns WHERE company_id = $1
|
||
|
|
ORDER BY updated_at DESC LIMIT $2 OFFSET $3`, companyID, limit, offset)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
out := make([]Campaign, 0)
|
||
|
|
for rows.Next() {
|
||
|
|
c, err := scanCampaign(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
out = append(out, c)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
// One round-trip for the page (was N+1 via attachLatestVersion per row).
|
||
|
|
_ = s.attachLatestVersions(ctx, out)
|
||
|
|
return out, total, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) Get(ctx context.Context, companyID, id uuid.UUID) (Campaign, error) {
|
||
|
|
row := s.Pool.QueryRow(ctx, `
|
||
|
|
SELECT id, name, template_key, status, category_ids, product_ids, prompt, use_default_prompt,
|
||
|
|
audience_filter, scheduled_at, sent_at, created_at, updated_at
|
||
|
|
FROM email_campaigns WHERE company_id = $1 AND id = $2`, companyID, id)
|
||
|
|
c, err := scanCampaign(row)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return Campaign{}, ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
_ = s.attachLatestVersion(ctx, &c)
|
||
|
|
return c, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) Create(ctx context.Context, companyID uuid.UUID, createdBy *uuid.UUID, in CreateInput) (Campaign, error) {
|
||
|
|
name := strings.TrimSpace(in.Name)
|
||
|
|
if name == "" {
|
||
|
|
return Campaign{}, ErrNameRequired
|
||
|
|
}
|
||
|
|
tpl, err := GetTemplate(in.TemplateKey)
|
||
|
|
if err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
useDefault := true
|
||
|
|
if in.UseDefaultPrompt != nil {
|
||
|
|
useDefault = *in.UseDefaultPrompt
|
||
|
|
}
|
||
|
|
prompt := SanitizePrompt(in.Prompt)
|
||
|
|
if useDefault && prompt == "" {
|
||
|
|
prompt = tpl.DefaultPrompt
|
||
|
|
}
|
||
|
|
if err := ValidatePrompt(prompt); err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
af := in.AudienceFilter
|
||
|
|
if af == nil {
|
||
|
|
af = map[string]any{}
|
||
|
|
}
|
||
|
|
afBytes, err := json.Marshal(af)
|
||
|
|
if err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
cats := in.CategoryIDs
|
||
|
|
if cats == nil {
|
||
|
|
cats = []uuid.UUID{}
|
||
|
|
}
|
||
|
|
prods := in.ProductIDs
|
||
|
|
if prods == nil {
|
||
|
|
prods = []uuid.UUID{}
|
||
|
|
}
|
||
|
|
if err := validateCampaignRefs(cats, prods); err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
var id uuid.UUID
|
||
|
|
err = s.Pool.QueryRow(ctx, `
|
||
|
|
INSERT INTO email_campaigns (
|
||
|
|
company_id, name, template_key, category_ids, product_ids, prompt, use_default_prompt, audience_filter, created_by
|
||
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)
|
||
|
|
RETURNING id`,
|
||
|
|
companyID, name, tpl.Key, cats, prods, prompt, useDefault, string(afBytes), createdBy,
|
||
|
|
).Scan(&id)
|
||
|
|
if err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
return s.Get(ctx, companyID, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) Update(ctx context.Context, companyID, id uuid.UUID, in UpdateInput) (Campaign, error) {
|
||
|
|
cur, err := s.Get(ctx, companyID, id)
|
||
|
|
if err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
name := cur.Name
|
||
|
|
if in.Name != nil {
|
||
|
|
name = strings.TrimSpace(*in.Name)
|
||
|
|
if name == "" {
|
||
|
|
return Campaign{}, ErrNameRequired
|
||
|
|
}
|
||
|
|
}
|
||
|
|
tplKey := cur.TemplateKey
|
||
|
|
if in.TemplateKey != nil {
|
||
|
|
tpl, err := GetTemplate(*in.TemplateKey)
|
||
|
|
if err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
tplKey = tpl.Key
|
||
|
|
}
|
||
|
|
status := cur.Status
|
||
|
|
if in.Status != nil {
|
||
|
|
st := strings.TrimSpace(strings.ToLower(*in.Status))
|
||
|
|
switch st {
|
||
|
|
case "draft", "ready", "scheduled", "sent", "cancelled":
|
||
|
|
status = st
|
||
|
|
default:
|
||
|
|
return Campaign{}, ErrInvalidStatus
|
||
|
|
}
|
||
|
|
}
|
||
|
|
prompt := cur.Prompt
|
||
|
|
if in.Prompt != nil {
|
||
|
|
prompt = SanitizePrompt(*in.Prompt)
|
||
|
|
if err := ValidatePrompt(prompt); err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
useDefault := cur.UseDefaultPrompt
|
||
|
|
if in.UseDefaultPrompt != nil {
|
||
|
|
useDefault = *in.UseDefaultPrompt
|
||
|
|
}
|
||
|
|
cats := cur.CategoryIDs
|
||
|
|
if in.CategoryIDs != nil {
|
||
|
|
cats = in.CategoryIDs
|
||
|
|
}
|
||
|
|
prods := cur.ProductIDs
|
||
|
|
if in.ProductIDs != nil {
|
||
|
|
prods = in.ProductIDs
|
||
|
|
}
|
||
|
|
if err := validateCampaignRefs(cats, prods); err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
af := cur.AudienceFilter
|
||
|
|
if in.AudienceFilter != nil {
|
||
|
|
af = in.AudienceFilter
|
||
|
|
}
|
||
|
|
afBytes, err := json.Marshal(af)
|
||
|
|
if err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
scheduledAt := cur.ScheduledAt
|
||
|
|
if in.ScheduledAt != nil {
|
||
|
|
scheduledAt = in.ScheduledAt
|
||
|
|
}
|
||
|
|
_, err = s.Pool.Exec(ctx, `
|
||
|
|
UPDATE email_campaigns SET
|
||
|
|
name=$3, template_key=$4, status=$5, category_ids=$6, product_ids=$7,
|
||
|
|
prompt=$8, use_default_prompt=$9, audience_filter=$10::jsonb, scheduled_at=$11, updated_at=now()
|
||
|
|
WHERE company_id=$1 AND id=$2`,
|
||
|
|
companyID, id, name, tplKey, status, cats, prods, prompt, useDefault, string(afBytes), scheduledAt,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
return s.Get(ctx, companyID, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) Delete(ctx context.Context, companyID, id uuid.UUID) error {
|
||
|
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM email_campaigns WHERE company_id=$1 AND id=$2`, companyID, id)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if tag.RowsAffected() == 0 {
|
||
|
|
return ErrNotFound
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type scannable interface {
|
||
|
|
Scan(dest ...any) error
|
||
|
|
}
|
||
|
|
|
||
|
|
func scanCampaign(row scannable) (Campaign, error) {
|
||
|
|
var c Campaign
|
||
|
|
var af []byte
|
||
|
|
err := row.Scan(
|
||
|
|
&c.ID, &c.Name, &c.TemplateKey, &c.Status, &c.CategoryIDs, &c.ProductIDs, &c.Prompt, &c.UseDefaultPrompt,
|
||
|
|
&af, &c.ScheduledAt, &c.SentAt, &c.CreatedAt, &c.UpdatedAt,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return Campaign{}, err
|
||
|
|
}
|
||
|
|
if c.CategoryIDs == nil {
|
||
|
|
c.CategoryIDs = []uuid.UUID{}
|
||
|
|
}
|
||
|
|
if c.ProductIDs == nil {
|
||
|
|
c.ProductIDs = []uuid.UUID{}
|
||
|
|
}
|
||
|
|
c.AudienceFilter = map[string]any{}
|
||
|
|
if len(af) > 0 {
|
||
|
|
_ = json.Unmarshal(af, &c.AudienceFilter)
|
||
|
|
}
|
||
|
|
if tpl, err := GetTemplate(c.TemplateKey); err == nil {
|
||
|
|
c.Season = tpl.Season
|
||
|
|
}
|
||
|
|
return c, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func applyVersionFields(c *Campaign, v Version) {
|
||
|
|
c.LatestVersion = &v
|
||
|
|
c.Subject = v.Subject
|
||
|
|
c.HTMLBody = v.HTMLBody
|
||
|
|
c.PlainBody = v.PlainBody
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) attachLatestVersion(ctx context.Context, c *Campaign) error {
|
||
|
|
var v Version
|
||
|
|
err := s.Pool.QueryRow(ctx, `
|
||
|
|
SELECT id, version, subject, html_body, plain_body, generation_mode, generated_at
|
||
|
|
FROM email_campaign_versions
|
||
|
|
WHERE campaign_id=$1
|
||
|
|
ORDER BY version DESC LIMIT 1`, c.ID).Scan(
|
||
|
|
&v.ID, &v.Version, &v.Subject, &v.HTMLBody, &v.PlainBody, &v.GenerationMode, &v.GeneratedAt,
|
||
|
|
)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
log.Printf("campaigns: attach version: %v", err)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
applyVersionFields(c, v)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// latestVersionsByCampaignIDsSQL loads one latest version per campaign (batch for List).
|
||
|
|
const latestVersionsByCampaignIDsSQL = `
|
||
|
|
SELECT DISTINCT ON (campaign_id)
|
||
|
|
campaign_id, id, version, subject, html_body, plain_body, generation_mode, generated_at
|
||
|
|
FROM email_campaign_versions
|
||
|
|
WHERE campaign_id = ANY($1)
|
||
|
|
ORDER BY campaign_id, version DESC`
|
||
|
|
|
||
|
|
// attachLatestVersions fills LatestVersion/subject/body fields for a page of campaigns in one query.
|
||
|
|
func (s *Service) attachLatestVersions(ctx context.Context, campaigns []Campaign) error {
|
||
|
|
if len(campaigns) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
ids := make([]uuid.UUID, len(campaigns))
|
||
|
|
for i := range campaigns {
|
||
|
|
ids[i] = campaigns[i].ID
|
||
|
|
}
|
||
|
|
rows, err := s.Pool.Query(ctx, latestVersionsByCampaignIDsSQL, ids)
|
||
|
|
if err != nil {
|
||
|
|
log.Printf("campaigns: attach versions batch: %v", err)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
byID := make(map[uuid.UUID]Version, len(campaigns))
|
||
|
|
for rows.Next() {
|
||
|
|
var campaignID uuid.UUID
|
||
|
|
var v Version
|
||
|
|
if err := rows.Scan(
|
||
|
|
&campaignID, &v.ID, &v.Version, &v.Subject, &v.HTMLBody, &v.PlainBody, &v.GenerationMode, &v.GeneratedAt,
|
||
|
|
); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
byID[campaignID] = v
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
for i := range campaigns {
|
||
|
|
if v, ok := byID[campaigns[i].ID]; ok {
|
||
|
|
applyVersionFields(&campaigns[i], v)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateCampaignRefs(cats, prods []uuid.UUID) error {
|
||
|
|
if len(cats) > maxCampaignCategoryIDs {
|
||
|
|
return ErrTooManyCategoryIDs
|
||
|
|
}
|
||
|
|
if len(prods) > maxCampaignProductIDs {
|
||
|
|
return ErrTooManyProductIDs
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|