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
+1 -1
View File
@@ -41,7 +41,7 @@ func testServer(t *testing.T) *server {
applier := &orderedApplier{pool: pool, storage: storage}
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
freg := buildFlowRegistry(applier, provider, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
freg := buildFlowRegistry(applier, provider, nil, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil)))
def, err := order.EmbeddedFlow("place-and-pay")
+1 -1
View File
@@ -26,7 +26,7 @@ func TestProjectFulfillmentIntegrationLogs(t *testing.T) {
logger := slog.New(slog.NewJSONHandler(&logs, nil))
provider := order.NewPassthroughProvider("legacy", "ref", 25000)
freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, logger)
freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, nil, logger)
engine := flow.NewEngine(freg, logger)
st := flow.NewState(801, logger)
+221
View File
@@ -0,0 +1,221 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
// emailTemplateInfo is the metadata returned by the list endpoint.
type emailTemplateInfo struct {
Name string `json:"name"`
Embedded bool `json:"embedded,omitempty"`
}
// emailTemplateHandler serves CRUD operations for email templates stored in the
// ORDER_EMAIL_TEMPLATES directory. Embedded templates are read-only; on-disk
// copies override them and can be edited via PUT.
type emailTemplateHandler struct {
dir string // the on-disk templates directory, from ORDER_EMAIL_TEMPLATES env
}
func newEmailTemplateHandler(dir string) *emailTemplateHandler {
if dir == "" {
dir = "data/order-email-templates"
}
return &emailTemplateHandler{dir: dir}
}
// list returns all known email templates (embedded + on-disk) with their source.
func (h *emailTemplateHandler) list(w http.ResponseWriter, r *http.Request) {
embedded := knownEmbeddedTemplates()
onDisk := h.listOnDisk()
seen := map[string]bool{}
var infos []emailTemplateInfo
// List on-disk first (they take precedence).
for _, name := range onDisk {
infos = append(infos, emailTemplateInfo{Name: name})
seen[name] = true
}
// Then add embedded templates not overridden by disk.
for _, name := range embedded {
if !seen[name] {
infos = append(infos, emailTemplateInfo{Name: name, Embedded: true})
seen[name] = true
}
}
if infos == nil {
infos = []emailTemplateInfo{}
}
writeJSON(w, http.StatusOK, infos)
}
// get returns the template content for the given name.
func (h *emailTemplateHandler) get(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
store, err := order.LoadEmailTemplates(h.dir)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
return
}
tmpl, err := store.Get(name)
if err != nil {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
return
}
writeJSON(w, http.StatusOK, tmpl)
}
// save persists a template to the on-disk directory. Returns the saved template.
func (h *emailTemplateHandler) save(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" || !validEmailTemplateName(name) {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template name %q", name))
return
}
var tmpl order.EmailTemplate
if err := json.NewDecoder(r.Body).Decode(&tmpl); err != nil {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template body: %w", err))
return
}
if err := tmpl.Validate(name); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := os.MkdirAll(h.dir, 0o755); err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("create templates dir: %w", err))
return
}
data, err := json.MarshalIndent(tmpl, "", " ")
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("marshal template: %w", err))
return
}
path := filepath.Join(h.dir, name+".json")
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("write template: %w", err))
return
}
writeJSON(w, http.StatusOK, tmpl)
}
// delete removes an on-disk template. Embedded templates cannot be deleted.
func (h *emailTemplateHandler) delete(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
// Check if it's embedded (read-only).
if isEmbeddedTemplate(name) {
writeErr(w, http.StatusForbidden, fmt.Errorf("template %q is built-in and cannot be deleted; save an override instead", name))
return
}
path := filepath.Join(h.dir, name+".json")
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found on disk", name))
return
}
writeErr(w, http.StatusInternalServerError, fmt.Errorf("delete template: %w", err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// preview renders a template with sample data and returns the result.
func (h *emailTemplateHandler) preview(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
store, err := order.LoadEmailTemplates(h.dir)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
return
}
tmpl, err := store.Get(name)
if err != nil {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
return
}
// Build sample data for preview.
sampleData := order.SampleTemplateData()
subject, _ := order.RenderTextTemplate("preview-subject", tmpl.Subject, sampleData)
textBody, _ := order.RenderTextTemplate("preview-text", tmpl.Text, sampleData)
htmlBody, _ := order.RenderOptionalHTMLTemplate("preview-html", tmpl.HTML, sampleData)
writeJSON(w, http.StatusOK, map[string]string{
"subject": subject,
"text": textBody,
"html": htmlBody,
})
}
// listOnDisk returns template names from the on-disk directory.
func (h *emailTemplateHandler) listOnDisk() []string {
matches, err := filepath.Glob(filepath.Join(h.dir, "*.json"))
if err != nil {
return nil
}
var names []string
for _, m := range matches {
name := strings.TrimSuffix(filepath.Base(m), ".json")
if validEmailTemplateName(name) {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
// knownEmbeddedTemplates returns the names of templates embedded in the binary.
// This must be kept in sync with the files in pkg/order/email_templates/.
func knownEmbeddedTemplates() []string {
return []string{
"fulfillment-shipped",
"order-confirmation",
"payment-receipt",
"shipment-delivered",
"return-confirmed",
"refund-issued",
}
}
// isEmbeddedTemplate reports whether name matches one of the embedded templates.
func isEmbeddedTemplate(name string) bool {
for _, e := range knownEmbeddedTemplates() {
if e == name {
return true
}
}
return false
}
// validEmailTemplateName validates a template name (alphanumeric + hyphens).
func validEmailTemplateName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
if !(r == '-' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
+2 -1
View File
@@ -14,6 +14,7 @@ func buildFlowRegistry(
emitPub order.Publisher,
emailSender order.EmailSender,
emailTemplates order.EmailTemplateStore,
prefChecker order.EmailPreferenceChecker,
logger *slog.Logger,
) *flow.Registry {
freg := flow.NewRegistry()
@@ -21,7 +22,7 @@ func buildFlowRegistry(
order.RegisterFlowActions(freg, applier, provider)
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
order.RegisterEmitHook(freg, emitPub)
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates)
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates, prefChecker)
order.RegisterFulfillmentWebhookHook(freg, applier, nil)
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
registerProjectFlowHooks(freg, applier, logger)
+1 -1
View File
@@ -120,7 +120,7 @@ func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromC
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
}
freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, nil, nil, nil, s.logger)
freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, nil, nil, nil, nil, s.logger)
engine := flow.NewEngine(freg, s.logger)
st := flow.NewState(ordID, s.logger)
st.Vars[order.PlaceOrderVar] = po
+59 -20
View File
@@ -170,14 +170,29 @@ func main() {
}
}
}
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, logger)
// Email preference checker: when a profile service URL is configured, look up
// customer email preferences before sending transactional emails.
var prefChecker order.EmailPreferenceChecker
if profileURL := os.Getenv("PROFILE_URL"); profileURL != "" {
prefChecker = order.NewPreferencesChecker(profileURL + "/ucp/v1/customers")
logger.Info("email preference checking enabled", "profile_url", profileURL)
}
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, prefChecker, logger)
engine := flow.NewEngine(freg, logger)
def, err := order.EmbeddedFlow("place-and-pay")
if err != nil {
log.Fatalf("load flow: %v", err)
}
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{def.Name: def})
fulfillDef, err := order.EmbeddedFlow("fulfill-order")
if err != nil {
log.Fatalf("load fulfill flow: %v", err)
}
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{
def.Name: def,
fulfillDef.Name: fulfillDef,
})
if err != nil {
log.Fatalf("load flows: %v", err)
}
@@ -245,6 +260,14 @@ func main() {
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
// Email template admin API (used by the backoffice Email Template Manager).
eth := newEmailTemplateHandler(config.EnvString("ORDER_EMAIL_TEMPLATES", "data/order-email-templates"))
mux.HandleFunc("GET /api/order-email-templates", eth.list)
mux.HandleFunc("GET /api/order-email-templates/{name}", eth.get)
mux.HandleFunc("PUT /api/order-email-templates/{name}", eth.save)
mux.HandleFunc("DELETE /api/order-email-templates/{name}", eth.delete)
mux.HandleFunc("POST /api/order-email-templates/{name}/preview", eth.preview)
// Ingest checkout orders from order-queue (Klarna/Adyen fallback).
if amqpURL != "" {
startOrderIngest(context.Background(), amqpURL, s)
@@ -543,9 +566,9 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider {
return order.NewMockProvider()
}
// selectEmailSender picks the flow-email transport. SMTP is the only sender
// implementation for now; when no SMTP host is configured the hook still exists
// in capabilities but errors at run time if a flow tries to use it.
// selectEmailSender picks the flow-email transport. Supports SMTP and
// MailerSend. When no sender is configured the hook still exists in
// capabilities but errors at run time if a flow tries to use it.
func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
kind := config.EnvString("ORDER_EMAIL_SENDER", "")
if kind == "" && os.Getenv("ORDER_EMAIL_SMTP_HOST") == "" {
@@ -554,9 +577,6 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
if kind == "" {
kind = "smtp"
}
if kind != "smtp" {
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q", kind)
}
fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS"))
if fromAddr == "" {
@@ -566,19 +586,38 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")),
Address: fromAddr,
}
sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{
Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""),
Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }),
Username: os.Getenv("ORDER_EMAIL_SMTP_USERNAME"),
Password: os.Getenv("ORDER_EMAIL_SMTP_PASSWORD"),
DefaultFrom: from,
Timeout: config.EnvDuration("ORDER_EMAIL_SMTP_TIMEOUT", 10*time.Second),
})
if err != nil {
return nil, err
switch kind {
case "smtp":
sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{
Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""),
Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }),
Username: os.Getenv("ORDER_EMAIL_SMTP_USERNAME"),
Password: os.Getenv("ORDER_EMAIL_SMTP_PASSWORD"),
DefaultFrom: from,
Timeout: config.EnvDuration("ORDER_EMAIL_SMTP_TIMEOUT", 10*time.Second),
})
if err != nil {
return nil, err
}
logger.Info("email sender enabled", "kind", "smtp", "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
return sender, nil
case "mailersend":
apiKey := os.Getenv("MAILERSEND_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("MAILERSEND_API_KEY is required for mailersend sender")
}
sender, err := order.NewMailerSendEmailSender(apiKey, from)
if err != nil {
return nil, err
}
logger.Info("email sender enabled", "kind", "mailersend", "from", from.Address)
return sender, nil
default:
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q (supported: smtp, mailersend)", kind)
}
logger.Info("email sender enabled", "kind", kind, "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
return sender, nil
}
func selectInventoryReservationService() (order.InventoryReservationService, error) {