Files
2026-08-23 22:03:57 +02:00

533 lines
16 KiB
Go

package campaigns
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
)
func (s *Service) Generate(ctx context.Context, companyID, id uuid.UUID, in GenerateInput) (Campaign, error) {
if !s.allowGenerate(companyID.String()) {
return Campaign{}, ErrRateLimited
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
mode := strings.ToLower(strings.TrimSpace(in.Mode))
if mode == "" {
if in.UseAI != nil && *in.UseAI {
mode = "ai"
} else {
mode = "template"
}
}
if mode != "template" && mode != "ai" {
return Campaign{}, fmt.Errorf("mode must be template or ai")
}
tpl, err := GetTemplate(c.TemplateKey)
if err != nil {
return Campaign{}, err
}
brandName := s.companyName(ctx, companyID)
brand, _ := company.LoadBrand(ctx, s.Pool, companyID)
subject := renderSubject(tpl, brandName)
products := s.loadProductSnippets(ctx, companyID, c.ProductIDs, c.CategoryIDs)
logoAbs := company.AbsoluteLogoForEmbed(s.PublicAPIURL, s.TokenSigningSecret, companyID, brand.LogoURL)
html := templateHTML(subject, defaultIntro(tpl, brandName), buildProductHTML(products), s.WebOrigin, logoAbs)
plain := subject + "\n\n" + defaultIntro(tpl, brandName) + "\n\n" + productPlainList(products)
if mode == "ai" {
if s.Billing != nil {
if err := s.Billing.AssertFeatures(ctx, companyID, "capability.campaign_ai", "marketing.campaigns.generate_ai"); err != nil {
return Campaign{}, err
}
ent, err := s.Billing.EntitlementsForCompany(ctx, companyID)
if err != nil {
return Campaign{}, err
}
// Free tier: CanUseAI is false when no credits / free plan.
// Paid with CanUseAI but empty wallet must not run AI (no silent free generate).
if !ent.CanUseAI || ent.IsFreePlan {
return Campaign{}, ErrAIRequiresUpgrade
}
if ent.RemainingCredits < 1 {
return Campaign{}, ErrInsufficientCredits
}
}
// Tag the campaign role so admin AI inspector rows are not filed as processing.
ctx := aiaudit.WithCall(ctx, aiaudit.CallContext{CompanyID: companyID, Role: aiaudit.RoleCampaign})
var completer processing.Completer
if s.AI != nil {
cplt, _, _, rerr := s.AI.ResolveCompleter(ctx, companyID)
if rerr != nil {
return Campaign{}, ErrAIUnavailable
}
completer = cplt
} else {
completer = s.Completer
}
if completer == nil {
return Campaign{}, ErrAIUnavailable
}
if en, ok := completer.(processing.EnableChecker); ok && !en.Enabled() {
return Campaign{}, ErrAIUnavailable
}
sysTpl := ""
userTpl := ""
lang := company.LoadLanguage(ctx, s.Pool, companyID)
if s.Prompts != nil {
if resolved, perr := s.Prompts.Resolve(ctx, companyID, aiprompts.KeyCampaignEmail, lang); perr == nil {
sysTpl = resolved.SystemTemplate
userTpl = resolved.UserTemplate
}
}
if def, ok := aiprompts.DefaultFor(aiprompts.KeyCampaignEmail); ok {
if strings.TrimSpace(sysTpl) == "" {
sysTpl = def.SystemTemplate
}
if strings.TrimSpace(userTpl) == "" {
userTpl = def.UserTemplate
}
}
userPrompt := c.Prompt
if c.UseDefaultPrompt || strings.TrimSpace(userPrompt) == "" {
userPrompt = tpl.DefaultPrompt
}
userPrompt = SanitizePrompt(userPrompt)
userPrompt = security.TruncateRunes(userPrompt, 600)
products = limitProductSnippets(products, processing.MaxCampaignProducts)
vars := aiprompts.Vars{
"campaign_prompt": userPrompt,
"products": productPlainList(products),
"brand": brandName,
"brand_voice": processing.CompactBrandPrompt(brand.PromptBlock()),
"language": company.LanguageLabel(company.LoadLanguage(ctx, s.Pool, companyID)),
"template_key": c.TemplateKey,
}
system := strings.TrimSpace(aiprompts.Render(sysTpl, vars))
user := strings.TrimSpace(aiprompts.Render(userTpl, vars))
if user == "" {
user = userPrompt + "\n\nProducts:\n" + productPlainList(products) + "\nBrand: " + brandName
}
comp, obj, err := processing.CompleteJSON(ctx, completer, system, user, processing.CompleteOptions{
MaxTokens: processing.MaxTokensCampaign,
Temperature: processing.DefaultStructuredTemp,
})
if err != nil && obj == nil && comp.Text == "" {
log.Printf("campaigns: ai generate failed company=%s", companyID)
return Campaign{}, fmt.Errorf("ai generation failed")
}
parsed := parseAIContent(comp.Text, subject, html, plain)
if obj != nil {
if v, ok := obj["subject"].(string); ok && strings.TrimSpace(v) != "" {
parsed.Subject = strings.TrimSpace(v)
}
if v, ok := obj["html_body"].(string); ok && strings.TrimSpace(v) != "" {
parsed.HTML = v
} else if v, ok := obj["html"].(string); ok && strings.TrimSpace(v) != "" {
parsed.HTML = v
}
if v, ok := obj["plain_body"].(string); ok && strings.TrimSpace(v) != "" {
parsed.Plain = v
} else if v, ok := obj["text"].(string); ok && strings.TrimSpace(v) != "" {
parsed.Plain = v
}
}
subject, html, plain = parsed.Subject, parsed.HTML, parsed.Plain
if s.Billing != nil {
// Always debit base feature cost (even if provider reported 0 tokens).
if err := s.Billing.ConsumeCredits(ctx, companyID, comp.TotalTokens, "campaign_copy"); err != nil {
return Campaign{}, err
}
}
}
unsubURL := s.unsubscribePlaceholderURL(companyID)
html, plain = EnsureUnsubscribeFooter(html, plain, unsubURL)
html = SanitizeHTMLBody(html)
if !HasUnsubscribeFooter(html) {
html, plain = EnsureUnsubscribeFooter(html, plain, unsubURL)
html = SanitizeHTMLBody(html)
}
if !HasUnsubscribeFooter(html) {
return Campaign{}, ErrMissingUnsubscribe
}
subject = security.TruncateRunes(subject, MaxSubjectLen)
var nextVer int
err = s.Pool.QueryRow(ctx, `
SELECT COALESCE(MAX(version), 0) + 1 FROM email_campaign_versions
WHERE company_id=$1 AND campaign_id=$2`, companyID, id).Scan(&nextVer)
if err != nil {
return Campaign{}, err
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO email_campaign_versions (
campaign_id, company_id, version, subject, html_body, plain_body, generation_mode
) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, companyID, nextVer, subject, html, plain, mode,
)
if err != nil {
return Campaign{}, err
}
_, _ = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET status='ready', updated_at=now() WHERE company_id=$1 AND id=$2`, companyID, id)
return s.Get(ctx, companyID, id)
}
func (s *Service) SendTest(ctx context.Context, companyID, id uuid.UUID, in SendTestInput) (Campaign, error) {
if !s.allowSend(companyID.String() + ":test") {
return Campaign{}, ErrRateLimited
}
to := strings.TrimSpace(in.To)
if to == "" {
to = strings.TrimSpace(in.Email)
}
addr, err := NormalizeEmail(to)
if err != nil {
return Campaign{}, ErrInvalidEmail
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
if c.LatestVersion == nil || (c.Subject == "" && c.HTMLBody == "") {
return Campaign{}, ErrMissingContent
}
if s.Email == nil {
return Campaign{}, ErrProviderNotFound
}
cfg, err := s.Email.GetConfig(ctx, companyID)
if err != nil {
return Campaign{}, err
}
if !cfg.Configured {
return Campaign{}, ErrProviderNotFound
}
if !cfg.Verified {
return Campaign{}, ErrProviderUnverified
}
cid := id.String()
_, err = s.Email.Send(ctx, companyID, email.SendRequest{
To: []string{addr},
Subject: "[TEST] " + c.Subject,
Text: c.PlainBody,
HTML: c.HTMLBody,
CampaignID: &cid,
Mode: "test",
})
if err != nil {
return Campaign{}, mapEmailErr(err)
}
return s.Get(ctx, companyID, id)
}
func (s *Service) Schedule(ctx context.Context, companyID, id uuid.UUID, in ScheduleInput) (Campaign, error) {
if in.ScheduledAt.IsZero() || in.ScheduledAt.Before(time.Now().UTC().Add(-time.Minute)) {
return Campaign{}, fmt.Errorf("scheduled_at must be in the future")
}
if s.Email == nil {
return Campaign{}, ErrProviderNotFound
}
cfg, err := s.Email.GetConfig(ctx, companyID)
if err != nil {
return Campaign{}, err
}
if !cfg.Configured {
return Campaign{}, ErrProviderNotFound
}
if !cfg.Verified || !cfg.CanSendReal {
return Campaign{}, ErrProviderUnverified
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
if c.LatestVersion == nil {
return Campaign{}, ErrMissingContent
}
_, err = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET status='scheduled', scheduled_at=$3, updated_at=now()
WHERE company_id=$1 AND id=$2`, companyID, id, in.ScheduledAt.UTC())
if err != nil {
return Campaign{}, err
}
return s.Get(ctx, companyID, id)
}
func (s *Service) Send(ctx context.Context, companyID, id uuid.UUID, in SendInput) (Campaign, error) {
if !s.allowSend(companyID.String() + ":send") {
return Campaign{}, ErrRateLimited
}
if !in.Confirm {
return Campaign{}, ErrConfirmRequired
}
if !in.DryRun && s.Billing != nil {
if err := s.Billing.AssertFeatures(ctx, companyID, "capability.email_live_send", "marketing.campaigns.send"); err != nil {
return Campaign{}, err
}
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
if c.LatestVersion == nil || c.HTMLBody == "" {
return Campaign{}, ErrMissingContent
}
if s.Email == nil {
return Campaign{}, ErrProviderNotFound
}
cfg, err := s.Email.GetConfig(ctx, companyID)
if err != nil {
return Campaign{}, err
}
if !cfg.Configured {
return Campaign{}, ErrProviderNotFound
}
if !in.DryRun && (!cfg.Verified || !cfg.CanSendReal) {
return Campaign{}, ErrProviderUnverified
}
recipients := in.Recipients
if len(recipients) == 0 {
res, err := s.ResolveAudienceMap(ctx, companyID, c.AudienceFilter, 100)
if err != nil {
return Campaign{}, err
}
for _, cust := range res.Customers {
recipients = append(recipients, cust.Email)
}
}
cleaned := make([]string, 0, len(recipients))
seen := map[string]struct{}{}
for _, raw := range recipients {
addr, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[addr]; ok {
continue
}
seen[addr] = struct{}{}
cleaned = append(cleaned, addr)
}
if len(cleaned) == 0 {
return Campaign{}, ErrNoRecipients
}
cid := id.String()
_, err = s.Email.Send(ctx, companyID, email.SendRequest{
To: cleaned,
Subject: c.Subject,
Text: c.PlainBody,
HTML: c.HTMLBody,
CampaignID: &cid,
Mode: "blast",
ConfirmUnderstood: email.ConfirmUnderstoodPhrase,
ForceDryRun: in.DryRun,
})
if err != nil {
return Campaign{}, mapEmailErr(err)
}
if !in.DryRun {
_, _ = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET status='sent', sent_at=now(), updated_at=now()
WHERE company_id=$1 AND id=$2`, companyID, id)
}
return s.Get(ctx, companyID, id)
}
func mapEmailErr(err error) error {
switch {
case errors.Is(err, email.ErrNotConfigured):
return ErrProviderNotFound
case errors.Is(err, email.ErrNotVerified), errors.Is(err, email.ErrNotEnabled):
return ErrProviderUnverified
case errors.Is(err, email.ErrRateLimited):
return ErrRateLimited
case errors.Is(err, email.ErrMissingConfirm):
return ErrConfirmRequired
case errors.Is(err, email.ErrInvalidRecipient), errors.Is(err, email.ErrInvalidFrom):
return ErrInvalidEmail
default:
return err
}
}
func (s *Service) companyName(ctx context.Context, companyID uuid.UUID) string {
var name string
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(name, '') FROM companies WHERE id=$1`, companyID).Scan(&name)
name = strings.TrimSpace(name)
if name == "" {
return "our store"
}
return name
}
type productSnippet struct {
Name string
}
func (s *Service) loadProductSnippets(ctx context.Context, companyID uuid.UUID, productIDs, categoryIDs []uuid.UUID) []productSnippet {
out := make([]productSnippet, 0, 8)
if len(productIDs) > 0 {
rows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(processed_name, ''), NULLIF(name, ''), 'Product')
FROM processed_products
WHERE company_id=$1 AND id = ANY($2)
LIMIT 12`, companyID, productIDs)
if err == nil {
defer rows.Close()
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
out = append(out, productSnippet{Name: name})
}
}
}
}
if len(out) == 0 && len(categoryIDs) > 0 {
rows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(p.processed_name, ''), NULLIF(p.name, ''), 'Product')
FROM processed_products p
JOIN categories c ON c.company_id = p.company_id
AND (c.name = p.category OR c.unique_id = p.category OR c.id::text = p.category)
WHERE p.company_id=$1 AND c.id = ANY($2::uuid[])
ORDER BY p.updated_at DESC NULLS LAST
LIMIT 12`, companyID, categoryIDs)
if err == nil {
defer rows.Close()
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
out = append(out, productSnippet{Name: name})
}
}
}
}
if len(out) == 0 {
rows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(processed_name, ''), NULLIF(name, ''), 'Product')
FROM processed_products
WHERE company_id=$1
ORDER BY updated_at DESC NULLS LAST
LIMIT 6`, companyID)
if err == nil {
defer rows.Close()
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
out = append(out, productSnippet{Name: name})
}
}
}
}
return out
}
func (s *Service) unsubscribePlaceholderURL(companyID uuid.UUID) string {
base := strings.TrimRight(s.WebOrigin, "/")
if base == "" {
base = strings.TrimRight(s.PublicAPIURL, "/")
}
if base == "" {
return "/unsubscribe"
}
return base + "/unsubscribe?company=" + companyID.String()
}
type aiParsed struct {
Subject string
HTML string
Plain string
}
func parseAIContent(text, fallbackSubject, fallbackHTML, fallbackPlain string) aiParsed {
text = strings.TrimSpace(text)
out := aiParsed{Subject: fallbackSubject, HTML: fallbackHTML, Plain: fallbackPlain}
obj, err := processing.ParseJSONObject(text)
if err == nil && obj != nil {
if v, ok := obj["subject"].(string); ok && strings.TrimSpace(v) != "" {
out.Subject = strings.TrimSpace(v)
}
if v, ok := obj["html_body"].(string); ok && strings.TrimSpace(v) != "" {
out.HTML = v
} else if v, ok := obj["html"].(string); ok && strings.TrimSpace(v) != "" {
out.HTML = v
}
if v, ok := obj["plain_body"].(string); ok && strings.TrimSpace(v) != "" {
out.Plain = v
} else if v, ok := obj["text"].(string); ok && strings.TrimSpace(v) != "" {
out.Plain = v
}
return out
}
if strings.Contains(text, "<") {
out.HTML = text
out.Plain = stripTags(text)
}
return out
}
func limitProductSnippets(products []productSnippet, max int) []productSnippet {
if max > 0 && len(products) > max {
products = products[:max]
}
out := make([]productSnippet, len(products))
copy(out, products)
for i := range out {
out[i].Name = security.TruncateRunes(out[i].Name, processing.MaxCampaignNameRunes)
}
return out
}
func defaultIntro(tpl Template, brand string) string {
switch TemplateKey(tpl.Key) {
case TemplateChristmas:
return fmt.Sprintf("Season's greetings from %s — here are a few holiday favorites we think you'll love.", brand)
case TemplateBlackFriday:
return fmt.Sprintf("Black Friday is here. %s picked standout products worth a look before they go.", brand)
case TemplateSpring:
return fmt.Sprintf("Spring refresh from %s — new energy for the season ahead.", brand)
default:
return fmt.Sprintf("A few highlights from %s, curated for you.", brand)
}
}
func buildProductHTML(products []productSnippet) string {
if len(products) == 0 {
return `<p><em>Your selected products will appear here.</em></p>`
}
var b strings.Builder
b.WriteString(`<ul style="padding-left:18px">`)
for _, p := range products {
b.WriteString("<li>" + escapeHTML(p.Name) + "</li>")
}
b.WriteString("</ul>")
return b.String()
}
func productPlainList(products []productSnippet) string {
if len(products) == 0 {
return "(no products selected)"
}
names := make([]string, 0, len(products))
for _, p := range products {
names = append(names, "- "+p.Name)
}
return strings.Join(names, "\n")
}