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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+392
View File
@@ -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
}