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
+106
View File
@@ -0,0 +1,106 @@
package order
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ProfilePreferencesClient is the subset of the profile service's UCP customer
// API that returns email preferences for a given email address.
type ProfilePreferencesClient struct {
baseURL string
httpClient *http.Client
timeout time.Duration
}
// NewProfilePreferencesClient creates a client that talks to the profile
// service's UCP customer endpoint at baseURL (e.g. "http://profile:8080/ucp/v1/customers").
func NewProfilePreferencesClient(baseURL string) *ProfilePreferencesClient {
return &ProfilePreferencesClient{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{Timeout: 5 * time.Second},
timeout: 5 * time.Second,
}
}
// emailPreferencesResponse mirrors ucp.EmailPreferencesResponse for decoding.
type emailPreferencesResponse struct {
OrderEmails *bool `json:"orderEmails,omitempty"`
MarketingEmails *bool `json:"marketingEmails,omitempty"`
}
// customerLookupResponse mirrors the UCP customer response, but we only need
// email preferences.
type customerLookupResponse struct {
EmailPreferences *emailPreferencesResponse `json:"emailPreferences,omitempty"`
}
// fetchPreferences calls GET /by-email/{email} on the profile service and
// returns the parsed email preferences. Returns nil, nil when the profile
// is not found (unknown email -> allow all).
func (c *ProfilePreferencesClient) fetchPreferences(ctx context.Context, email string) (*emailPreferencesResponse, error) {
url := fmt.Sprintf("%s/by-email/%s", c.baseURL, email)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("profile prefs: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil // unknown email -> no preferences
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("profile prefs: unexpected status %d: %s", resp.StatusCode, string(body))
}
var data customerLookupResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("profile prefs: decode: %w", err)
}
return data.EmailPreferences, nil
}
// PreferencesChecker wraps ProfilePreferencesClient to implement
// EmailPreferenceChecker.
type PreferencesChecker struct {
client *ProfilePreferencesClient
}
// NewPreferencesChecker creates an EmailPreferenceChecker that looks up
// preferences from the profile service at the given base URL.
func NewPreferencesChecker(profileServiceURL string) *PreferencesChecker {
return &PreferencesChecker{
client: NewProfilePreferencesClient(profileServiceURL),
}
}
// Allowed returns true when the customer has not opted out of the given
// category. Categories are "order" (order confirmations, shipping updates,
// receipts) and "marketing" (promotional offers, newsletters). Returns true
// when preferences are unknown (profile not found) — the safe default.
func (c *PreferencesChecker) Allowed(ctx context.Context, email string, category string) bool {
prefs, err := c.client.fetchPreferences(ctx, email)
if err != nil || prefs == nil {
return true // safe default: allow when unknown
}
switch category {
case "order":
return prefs.OrderEmails == nil || *prefs.OrderEmails
case "marketing":
return prefs.MarketingEmails == nil || *prefs.MarketingEmails
default:
return true // unknown category -> allow
}
}
@@ -0,0 +1,5 @@
{
"subject": "Order {{ .Order.OrderReference }} confirmed",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour order {{ .Order.OrderReference }} has been placed successfully.\n\nWe'll send you an update when your payment is confirmed and your items are on their way.\n\nOrder id: {{ .OrderID }}\n\nThank you for your purchase!",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your order <strong>{{ .Order.OrderReference }}</strong> has been placed successfully.</p><p>We'll send you an update when your payment is confirmed and your items are on their way.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for your purchase!</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Payment received for order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour payment for order {{ .Order.OrderReference }} has been received and confirmed.\n\nWe'll let you know as soon as your items ship.\n\nOrder id: {{ .OrderID }}\n\nThank you for shopping with us!",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your payment for order <strong>{{ .Order.OrderReference }}</strong> has been received and confirmed.</p><p>We'll let you know as soon as your items ship.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for shopping with us!</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Refund issued for order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nA refund has been issued for order {{ .Order.OrderReference }}.\n\nThe amount should appear in your account within a few business days, depending on your payment provider.\n\nOrder id: {{ .OrderID }}\n\nThank you for your patience.",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>A refund has been issued for order <strong>{{ .Order.OrderReference }}</strong>.</p><p>The amount should appear in your account within a few business days, depending on your payment provider.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for your patience.</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Return request received — order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nWe've received your return request for order {{ .Order.OrderReference }}.\n\nWe'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.\n\nOrder id: {{ .OrderID }}\n\nIf you have any questions, please don't hesitate to contact us.",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>We've received your return request for order <strong>{{ .Order.OrderReference }}</strong>.</p><p>We'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>If you have any questions, please don't hesitate to contact us.</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Package delivered — order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour package for order {{ .Order.OrderReference }} has been delivered.\n{{ with .Fulfillment }}Carrier: {{ .Carrier }}\nTracking number: {{ .TrackingNumber }}{{ end }}\n\nWe hope you're happy with your purchase! If you have any questions, feel free to reply to this email.\n\nOrder id: {{ .OrderID }}",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your package for order <strong>{{ .Order.OrderReference }}</strong> has been delivered.</p>{{ with .Fulfillment }}<ul><li><strong>Carrier:</strong> {{ .Carrier }}</li><li><strong>Tracking number:</strong> {{ .TrackingNumber }}</li></ul>{{ end }}<p>We hope you're happy with your purchase!</p><p>Order id: <code>{{ .OrderID }}</code></p>"
}
+4
View File
@@ -44,6 +44,10 @@ func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
// place-and-pay references the emit_order_created hook; register it (nil
// publisher = no-op) so flow validation passes in tests too.
RegisterOrderCreatedEmit(reg, pool, nil, "order", "order")
// place-and-pay also references send_email hooks; register it with nil
// sender and nil templates so validation passes (runtime = error, tests
// that exercise email have their own sender/templates).
RegisterEmailHook(reg, pool, nil, nil)
return reg
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "fulfill-order",
"description": "Create a fulfillment (shipment) for an order and send the shipping notification to the customer.",
"steps": [
{
"name": "fulfill",
"action": "create_fulfillment",
"hooks": {
"after": [
{ "type": "log", "params": { "message": "fulfillment created" } },
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "fulfillment-shipped" } },
{ "type": "fulfillment_webhook", "params": { "url": "", "method": "POST" }, "continueOnError": true }
]
}
}
]
}
+6 -2
View File
@@ -1,12 +1,15 @@
{
"name": "place-and-pay",
"description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails.",
"description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails. Sends order confirmation and payment receipt emails when a customer email is present.",
"steps": [
{
"name": "place",
"action": "place_order",
"hooks": {
"after": [{ "type": "log", "params": { "message": "order placed" } }]
"after": [
{ "type": "log", "params": { "message": "order placed" } },
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "order-confirmation" } }
]
}
},
{
@@ -24,6 +27,7 @@
"hooks": {
"after": [
{ "type": "log", "params": { "message": "payment captured" } },
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "payment-receipt" } },
{ "type": "emit_order_created" }
]
}
+84
View File
@@ -0,0 +1,84 @@
package order
import (
"context"
"fmt"
"net/mail"
"strings"
"github.com/mailersend/mailersend-go"
)
// MailerSendEmailSender sends email through the MailerSend API using the
// official Go SDK. It implements the EmailSender interface.
type MailerSendEmailSender struct {
client *mailersend.Mailersend
defaultFrom mail.Address
}
// NewMailerSendEmailSender creates a new MailerSend-backed sender.
func NewMailerSendEmailSender(apiKey string, defaultFrom mail.Address) (*MailerSendEmailSender, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("mailersend sender: missing API key")
}
if strings.TrimSpace(defaultFrom.Address) == "" {
return nil, fmt.Errorf("mailersend sender: missing default from address")
}
return &MailerSendEmailSender{
client: mailersend.NewMailersend(apiKey),
defaultFrom: defaultFrom,
}, nil
}
// DefaultFrom returns the default sender address configured for this sender.
func (s *MailerSendEmailSender) DefaultFrom() mail.Address { return s.defaultFrom }
// Send delivers the rendered email message through the MailerSend API.
func (s *MailerSendEmailSender) Send(ctx context.Context, msg EmailMessage) error {
if strings.TrimSpace(msg.From.Address) == "" {
return fmt.Errorf("mailersend sender: missing from address")
}
if len(msg.To) == 0 {
return fmt.Errorf("mailersend sender: missing recipients")
}
if strings.TrimSpace(msg.Subject) == "" {
return fmt.Errorf("mailersend sender: missing subject")
}
if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" {
return fmt.Errorf("mailersend sender: missing body")
}
message := s.client.Email.NewMessage()
fromName := msg.From.Name
if fromName == "" {
fromName = s.defaultFrom.Name
}
message.SetFrom(mailersend.From{
Name: fromName,
Email: msg.From.Address,
})
recipients := make([]mailersend.Recipient, 0, len(msg.To))
for _, rcpt := range msg.To {
recipients = append(recipients, mailersend.Recipient{
Name: rcpt.Name,
Email: rcpt.Address,
})
}
message.SetRecipients(recipients)
message.SetSubject(msg.Subject)
if strings.TrimSpace(msg.HTMLBody) != "" {
message.SetHTML(msg.HTMLBody)
}
if strings.TrimSpace(msg.TextBody) != "" {
message.SetText(msg.TextBody)
}
_, err := s.client.Email.Send(ctx, message)
if err != nil {
return fmt.Errorf("mailersend sender: %w", err)
}
return nil
}
+126
View File
@@ -0,0 +1,126 @@
package profile
import (
"bytes"
"io"
"context"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"google.golang.org/protobuf/proto"
)
// emailIndexEntry captures the email preferences snapshot for a profile.
type emailIndexEntry struct {
ID uint64
EmailPreferences *EmailPreferences
}
// ProfileEmailIndex is a thread-safe in-memory map from email address to
// profile ID and email preferences snapshot. It is populated at startup by
// scanning the profile storage directory, and updated when SetProfile
// mutations change the email or preferences.
type ProfileEmailIndex struct {
mu sync.RWMutex
byID map[uint64]string // profileID → email (for cleanup on email change)
idx map[string]emailIndexEntry // email → profile info
}
// NewProfileEmailIndex returns an empty index.
func NewProfileEmailIndex() *ProfileEmailIndex {
return &ProfileEmailIndex{
byID: make(map[uint64]string),
idx: make(map[string]emailIndexEntry),
}
}
// Set records or updates the email→profile mapping. When email is empty the
// entry is removed (profile was anonymized).
func (ix *ProfileEmailIndex) Set(profileID uint64, email string, prefs *EmailPreferences) {
ix.mu.Lock()
defer ix.mu.Unlock()
// Remove any previous email for this profile.
if oldEmail, ok := ix.byID[profileID]; ok {
delete(ix.idx, oldEmail)
}
if strings.TrimSpace(email) == "" {
delete(ix.byID, profileID)
return
}
email = strings.ToLower(strings.TrimSpace(email))
ix.byID[profileID] = email
ix.idx[email] = emailIndexEntry{
ID: profileID,
EmailPreferences: prefs,
}
}
// Lookup returns the email preferences for the given email. The second return
// value is false when the email is not found.
func (ix *ProfileEmailIndex) Lookup(email string) (emailIndexEntry, bool) {
ix.mu.RLock()
defer ix.mu.RUnlock()
entry, ok := ix.idx[strings.ToLower(strings.TrimSpace(email))]
return entry, ok
}
// BuildEmailIndex populates the index by scanning the profile storage
// directory and replaying each event log into a fresh ProfileGrain. It must
// be called once at service startup after the mutation registry and storage
// are initialised.
//
// dir — the same path passed to actor.NewDiskStorage (e.g. "data/profiles").
// replayer — an *actor.StateStorage for reading event log files.
// reg — the profile mutation registry.
// ix — the (empty) index to populate.
func BuildEmailIndex(dir string, replayer interface {
Load(r io.Reader, onMessage func(msg proto.Message, timeStamp time.Time)) error
}, reg actor.MutationRegistry, ix *ProfileEmailIndex) {
entries, err := os.ReadDir(dir)
if err != nil {
log.Printf("email index: cannot read profile dir %s: %v — starting with empty index", dir, err)
return
}
var count int
ctx := context.Background()
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".events.log") {
continue
}
idStr := strings.TrimSuffix(entry.Name(), ".events.log")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
continue
}
p := NewProfileGrain(id, time.Now())
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
log.Printf("email index: read %s: %v", entry.Name(), err)
continue
}
if err := replayer.Load(bytes.NewReader(data), func(msg proto.Message, _ time.Time) { //nolint:govet
_, _ = reg.Apply(ctx, p, msg)
}); err != nil {
log.Printf("email index: replay %s: %v", entry.Name(), err)
continue
}
if p.Email != "" {
ix.Set(p.Id, p.Email, p.EmailPreferences)
count++
}
}
log.Printf("email index: built %d entries from %s", count, dir)
}
+11
View File
@@ -29,5 +29,16 @@ func HandleSetProfile(p *ProfileGrain, m *messages.SetProfile) error {
if m.AvatarUrl != nil {
p.AvatarUrl = *m.AvatarUrl
}
if m.OrderEmails != nil || m.MarketingEmails != nil {
if p.EmailPreferences == nil {
p.EmailPreferences = &EmailPreferences{}
}
if m.OrderEmails != nil {
p.EmailPreferences.OrderEmails = m.OrderEmails
}
if m.MarketingEmails != nil {
p.EmailPreferences.MarketingEmails = m.MarketingEmails
}
}
return nil
}
+15 -5
View File
@@ -29,6 +29,15 @@ type LinkedOrder struct {
Status string `json:"status,omitempty"`
}
// EmailPreferences controls which transactional emails a customer receives.
// All fields default to true (opted in) when nil/omitted.
type EmailPreferences struct {
// OrderEmails sends order confirmations, shipping updates, receipts.
OrderEmails *bool `json:"orderEmails,omitempty"`
// MarketingEmails sends promotional offers, newsletters, product recommendations.
MarketingEmails *bool `json:"marketingEmails,omitempty"`
}
// StoredAddress is a saved address in the user's address book.
type StoredAddress struct {
Id uint32 `json:"id"`
@@ -60,11 +69,12 @@ type ProfileGrain struct {
Language string `json:"language,omitempty"`
Currency string `json:"currency,omitempty"`
AvatarUrl string `json:"avatarUrl,omitempty"`
Carts []LinkedCart `json:"carts,omitempty"`
Checkouts []LinkedCheckout `json:"checkouts,omitempty"`
Orders []LinkedOrder `json:"orders,omitempty"`
Addresses []StoredAddress `json:"addresses,omitempty"`
AvatarUrl string `json:"avatarUrl,omitempty"`
Carts []LinkedCart `json:"carts,omitempty"`
Checkouts []LinkedCheckout `json:"checkouts,omitempty"`
Orders []LinkedOrder `json:"orders,omitempty"`
Addresses []StoredAddress `json:"addresses,omitempty"`
EmailPreferences *EmailPreferences `json:"emailPreferences,omitempty"`
}
// NewProfileGrain returns an empty profile grain for id.