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 }