diff --git a/cmd/order/flow_registry.go b/cmd/order/flow_registry.go index 58ceff4..da7be87 100644 --- a/cmd/order/flow_registry.go +++ b/cmd/order/flow_registry.go @@ -19,7 +19,7 @@ func buildFlowRegistry( ) *flow.Registry { freg := flow.NewRegistry() flow.RegisterBuiltinHooks(freg) - order.RegisterFlowActions(freg, applier, provider) + order.RegisterFlowActions(freg, applier, provider, prefChecker) order.RegisterInventoryReservationActions(freg, applier, inventoryReservations) order.RegisterEmitHook(freg, emitPub) order.RegisterEmailHook(freg, applier, emailSender, emailTemplates, prefChecker) diff --git a/internal/customerauth/server.go b/internal/customerauth/server.go index 4ca6851..c799046 100644 --- a/internal/customerauth/server.go +++ b/internal/customerauth/server.go @@ -163,6 +163,13 @@ type resetCompleteRequest struct { Password string `json:"password"` } +// EmailPreferencesResponse mirrors the UCP email preferences type for the +// customer-facing API. +type EmailPreferencesResponse struct { + OrderEmails *bool `json:"orderEmails,omitempty"` + MarketingEmails *bool `json:"marketingEmails,omitempty"` +} + // CustomerResponse mirrors the UCP customer projection (kept local to avoid an // import cycle with internal/ucp). The password hash is never included. type CustomerResponse struct { @@ -176,6 +183,9 @@ type CustomerResponse struct { Addresses []AddressResponse `json:"addresses"` Orders []OrderRef `json:"orders"` + // EmailPreferences carries the customer's current email opt-in/out state. + EmailPreferences *EmailPreferencesResponse `json:"emailPreferences,omitempty"` + // EmailVerified reflects whether the email-verification flow has completed. EmailVerified bool `json:"emailVerified"` } @@ -554,6 +564,12 @@ func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse { for _, o := range g.Orders { resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status}) } + if g.EmailPreferences != nil { + resp.EmailPreferences = &EmailPreferencesResponse{ + OrderEmails: g.EmailPreferences.OrderEmails, + MarketingEmails: g.EmailPreferences.MarketingEmails, + } + } return resp } diff --git a/pkg/flow/engine.go b/pkg/flow/engine.go index 19739e3..12a55d0 100644 --- a/pkg/flow/engine.go +++ b/pkg/flow/engine.go @@ -242,6 +242,11 @@ func (e *Engine) Validate(def *Definition) error { if _, ok := e.reg.hook(h.Type); !ok { return fmt.Errorf("flow %q step %q: unknown hook %q", def.Name, s.Name, h.Type) } + if h.When != "" { + if _, ok := e.reg.predicate(h.When); !ok { + return fmt.Errorf("flow %q step %q: hook %q: unknown predicate %q", def.Name, s.Name, h.Type, h.When) + } + } } } } @@ -337,8 +342,26 @@ func (e *Engine) compensate(ctx context.Context, res *Result, executed []Step, s } // fireHooks runs every hook for a phase, logging (never propagating) failures. +// Hooks with a When predicate are evaluated first; the hook is skipped when the +// predicate returns false. func (e *Engine) fireHooks(ctx context.Context, st *State, refs []HookRef, info HookInfo) { for _, ref := range refs { + if ref.When != "" { + pred, ok := e.reg.predicate(ref.When) + if !ok { + st.Logger.Error("flow hook skipped: unknown predicate", "hook", ref.Type, "predicate", ref.When, "step", info.Step) + continue + } + run, err := pred(ctx, st) + if err != nil { + st.Logger.Error("flow hook predicate error", "hook", ref.Type, "predicate", ref.When, "step", info.Step, "err", err) + continue + } + if !run { + st.Logger.Debug("flow hook skipped by predicate", "hook", ref.Type, "predicate", ref.When, "step", info.Step) + continue + } + } h, ok := e.reg.hook(ref.Type) if !ok { st.Logger.Error("flow hook skipped: unknown type", "hook", ref.Type, "step", info.Step) diff --git a/pkg/flow/flow.go b/pkg/flow/flow.go index 0bce865..105a20d 100644 --- a/pkg/flow/flow.go +++ b/pkg/flow/flow.go @@ -51,9 +51,11 @@ type Hooks struct { OnError []HookRef `json:"onError,omitempty"` } -// HookRef names a registered hook and its params. +// HookRef names a registered hook and its params. When, if set, names a +// registered predicate; the hook is skipped when it returns false. type HookRef struct { Type string `json:"type"` + When string `json:"when,omitempty"` Params json.RawMessage `json:"params,omitempty"` } diff --git a/pkg/order/actions.go b/pkg/order/actions.go index 7fc47ab..ded751e 100644 --- a/pkg/order/actions.go +++ b/pkg/order/actions.go @@ -63,7 +63,7 @@ const PlaceOrderVar = "placeOrder" // RegisterFlowActions registers the order lifecycle actions on reg, driving the // grain through app and taking payment via provider. Compose with // flow.RegisterBuiltinHooks(reg) to get the "log"/"webhook" hooks too. -func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvider) { +func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvider, prefCheckers ...EmailPreferenceChecker) { reg.ActionWithMeta("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error { po, ok := st.Vars[PlaceOrderVar].(*messages.PlaceOrder) if !ok || po == nil { @@ -243,7 +243,11 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid ExampleParams: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"TRACK-123","trackingUri":"https://tracking.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`), }) - registerPredicates(reg, app) + var checker EmailPreferenceChecker + if len(prefCheckers) > 0 { + checker = prefCheckers[0] + } + registerPredicates(reg, app, checker) } // registerPredicates adds the order gating predicates a step can reference via @@ -251,7 +255,7 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid // at the moment the step is reached (e.g. only fire a receipt hook once the // order is captured). Predicates take no params (the engine's When is a bare // name), so each is a fixed, composable boolean. -func registerPredicates(reg *flow.Registry, app Applier) { +func registerPredicates(reg *flow.Registry, app Applier, prefChecker EmailPreferenceChecker) { get := func(ctx context.Context, st *flow.State) (*OrderGrain, error) { return app.Get(ctx, st.ID) } reg.PredicateWithMeta("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) { @@ -289,4 +293,30 @@ func registerPredicates(reg *flow.Registry, app Applier) { } return o.CapturedAmount-o.RefundedAmount > 0, nil }, flow.CapabilityMeta{Description: "Run only when captured amount remains to refund."}) + + // Email consent predicates — gate marketing or order steps when the customer + // has opted out in their profile email preferences. + if prefChecker != nil { + reg.PredicateWithMeta("has_marketing_consent", func(ctx context.Context, st *flow.State) (bool, error) { + o, err := get(ctx, st) + if err != nil { + return false, err + } + if o.CustomerEmail == "" { + return false, nil // no email address -> no consent + } + return prefChecker.Allowed(ctx, o.CustomerEmail, "marketing"), nil + }, flow.CapabilityMeta{Description: "Run only when the customer has consented to marketing emails."}) + + reg.PredicateWithMeta("has_order_email_consent", func(ctx context.Context, st *flow.State) (bool, error) { + o, err := get(ctx, st) + if err != nil { + return false, err + } + if o.CustomerEmail == "" { + return false, nil // no email address -> cannot receive order emails + } + return prefChecker.Allowed(ctx, o.CustomerEmail, "order"), nil + }, flow.CapabilityMeta{Description: "Run only when the customer has consented to order transactional emails."}) + } } diff --git a/pkg/order/flow_test.go b/pkg/order/flow_test.go index 7badb25..62d34a2 100644 --- a/pkg/order/flow_test.go +++ b/pkg/order/flow_test.go @@ -37,10 +37,15 @@ func newPool(t *testing.T) *actor.SimpleGrainPool[OrderGrain] { return pool } +// alwaysAllowChecker is a mock EmailPreferenceChecker that allows every email. +type alwaysAllowChecker struct{} + +func (alwaysAllowChecker) Allowed(_ context.Context, _ string, _ string) bool { return true } + func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry { reg := flow.NewRegistry() flow.RegisterBuiltinHooks(reg) - RegisterFlowActions(reg, pool, provider) + RegisterFlowActions(reg, pool, provider, alwaysAllowChecker{}) // 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") diff --git a/pkg/order/flows/fulfill-order.json b/pkg/order/flows/fulfill-order.json index 0dfa548..b65b049 100644 --- a/pkg/order/flows/fulfill-order.json +++ b/pkg/order/flows/fulfill-order.json @@ -8,7 +8,7 @@ "hooks": { "after": [ { "type": "log", "params": { "message": "fulfillment created" } }, - { "type": "send_email", "when": "has_customer_email", "params": { "template": "fulfillment-shipped" } }, + { "type": "send_email", "when": "has_order_email_consent", "params": { "template": "fulfillment-shipped" } }, { "type": "fulfillment_webhook", "params": { "url": "", "method": "POST" }, "continueOnError": true } ] } diff --git a/pkg/order/flows/place-and-pay.json b/pkg/order/flows/place-and-pay.json index 758e5ca..c0d06c1 100644 --- a/pkg/order/flows/place-and-pay.json +++ b/pkg/order/flows/place-and-pay.json @@ -8,7 +8,7 @@ "hooks": { "after": [ { "type": "log", "params": { "message": "order placed" } }, - { "type": "send_email", "when": "has_customer_email", "params": { "template": "order-confirmation" } } + { "type": "send_email", "when": "has_order_email_consent", "params": { "template": "order-confirmation" } } ] } }, @@ -27,7 +27,7 @@ "hooks": { "after": [ { "type": "log", "params": { "message": "payment captured" } }, - { "type": "send_email", "when": "has_customer_email", "params": { "template": "payment-receipt" } }, + { "type": "send_email", "when": "has_order_email_consent", "params": { "template": "payment-receipt" } }, { "type": "emit_order_created" } ] }