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,392 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user