more cart
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user