more cart

This commit is contained in:
2026-07-01 10:40:28 +02:00
parent 75db64ce75
commit b1e99891e9
30 changed files with 1058 additions and 93 deletions
+87 -3
View File
@@ -21,6 +21,7 @@ import (
"time"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/platform/money"
)
// EmailMessage is the rendered mail payload a sender delivers.
@@ -229,6 +230,12 @@ type EmailTemplate struct {
}
func (t EmailTemplate) validate(name string) error {
return t.Validate(name)
}
// Validate checks that the template has the required fields. Exported so callers
// (e.g. the email template admin API) can validate before persisting.
func (t EmailTemplate) Validate(name string) error {
if strings.TrimSpace(t.Subject) == "" {
return fmt.Errorf("email template %q: missing subject", name)
}
@@ -330,6 +337,7 @@ type emailHookParams struct {
To string `json:"to,omitempty"`
From string `json:"from,omitempty"`
Subject string `json:"subject,omitempty"`
Category string `json:"category,omitempty"` // "order" or "marketing"; checked against preferences
}
type flowTemplateData struct {
@@ -344,16 +352,30 @@ type flowTemplateData struct {
BillingAddress any
}
// EmailPreferenceChecker determines whether an email of a given category should
// be sent to a customer. Returns true when preferences are unknown (allow).
type EmailPreferenceChecker interface {
// Allowed returns true if the customer with the given email has opted in to
// the given category. category is one of "order" or "marketing".
Allowed(ctx context.Context, email string, category string) bool
}
// RegisterEmailHook registers the "send_email" flow hook. It reads the current
// order for template data, renders the named template, and delivers it through
// the configured sender. Hook params:
// the configured sender. When checker is non-nil, emails in a category are
// gated by the customer's saved preferences. Hook params:
//
// {
// "template": "fulfillment-shipped",
// "to": "{{ .Order.CustomerEmail }}",
// "from": "Warehouse <warehouse@example.com>"
// "from": "Warehouse <warehouse@example.com>",
// "category": "order"
// }
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore) {
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore, checker ...EmailPreferenceChecker) {
var prefCheck EmailPreferenceChecker
if len(checker) > 0 {
prefCheck = checker[0]
}
reg.HookWithMeta("send_email", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
if sender == nil {
return fmt.Errorf("send_email: no sender configured")
@@ -414,6 +436,14 @@ func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, temp
to = append(to, *rcpt)
}
// Check email preferences before sending.
if prefCheck != nil && p.Category != "" && len(to) > 0 {
email := to[0].Address
if !prefCheck.Allowed(ctx, email, p.Category) {
return nil // silently skip — customer opted out
}
}
from := sender.DefaultFrom()
if strings.TrimSpace(p.From) != "" {
fromRendered, err := renderTextTemplate("email-from", p.From, data)
@@ -473,6 +503,16 @@ func decodeEmailTemplateJSON(raw json.RawMessage) any {
return v
}
func RenderTextTemplate(name, src string, data any) (string, error) {
return renderTextTemplate(name, src, data)
}
// RenderOptionalHTMLTemplate renders an HTML template with the given data, or
// returns empty string when src is empty.
func RenderOptionalHTMLTemplate(name, src string, data any) (string, error) {
return renderOptionalHTMLTemplate(name, src, data)
}
func renderTextTemplate(name, src string, data any) (string, error) {
tmpl, err := texttmpl.New(name).Option("missingkey=error").Parse(src)
if err != nil {
@@ -492,6 +532,50 @@ func renderOptionalTextTemplate(name, src string, data any) (string, error) {
return renderTextTemplate(name, src, data)
}
// SampleTemplateData returns a flowTemplateData populated with sample values
// for previewing email templates in the admin UI.
func SampleTemplateData() any {
now := time.Now().UTC().Format(time.RFC3339)
return flowTemplateData{
Order: &OrderGrain{
Id: 12345,
OrderReference: "ORD-SAMPLE-001",
Currency: "SEK",
Status: StatusCaptured,
CustomerName: "Anna Svensson",
CustomerEmail: "anna@example.com",
TotalAmount: money.Cents(29900),
Lines: []Line{
{Reference: "l1", Sku: "PROD-001", Name: "Sample Product", Quantity: 1, UnitPrice: 29900, TotalAmount: 29900, TaxRate: 2500},
},
PlacedAt: now,
},
OrderID: "abc123",
Fulfillment: &Fulfillment{
ID: "f_ship001",
Carrier: "PostNord",
TrackingNumber: "SE-123456789",
TrackingURI: "https://tracking.postnord.se/123456789",
},
ShippingAddress: map[string]any{
"givenName": "Anna",
"familyName": "Svensson",
"street": "Storgatan 1",
"city": "Stockholm",
"postalCode": "111 22",
"country": "SE",
},
BillingAddress: map[string]any{
"givenName": "Anna",
"familyName": "Svensson",
"street": "Storgatan 1",
"city": "Stockholm",
"postalCode": "111 22",
"country": "SE",
},
}
}
func renderOptionalHTMLTemplate(name, src string, data any) (string, error) {
if strings.TrimSpace(src) == "" {
return "", nil