210 lines
9.2 KiB
Go
210 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
|
)
|
|
|
|
// newPromotionEvaluateHandler serves POST /promotions/evaluate: it takes a
|
|
// (possibly partial) evaluation context and returns the totals plus the
|
|
// applied/pending promotion effects, without creating or mutating a real cart.
|
|
// An empty body is treated as an empty context (evaluates against no items).
|
|
// Pure stateless — callers compose the line list themselves.
|
|
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req promotions.EvaluateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
resp := svc.Evaluate(store.Snapshot(), req)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// newPromotionEvaluateWithCartHandler serves POST /promotions/evaluate-with-cart.
|
|
// Reads the `cartid` cookie the storefront already maintains (set by
|
|
// GET /cart on first visit), fetches the actual cart from the grain pool
|
|
// (cross-pod via GetAnywhere so a cart owned by another pod is fetched
|
|
// transparently), deep-copies the grain, merges the PDP request items
|
|
// into the copy (replace-by-sku semantic so the PDP qty wins for any
|
|
// product already in the cart), and runs the canonical
|
|
// promotions.PromotionService.EvaluateAndApply pipeline the live cart
|
|
// processor uses — so the preview sees the cart's vouchers, customer
|
|
// segment, customer-lifetime-value, and computed total, not just the
|
|
// line list, and the coupon+promotion-overlap edge case is handled
|
|
// the same way on both sides. Returns the same EvaluateResponse shape
|
|
// as the stateless endpoint.
|
|
//
|
|
// Why this exists: the storefront's "I kundkorgen" preview needs the
|
|
// engine's verdict for "what would the cart look like if I add this
|
|
// product". The cart is the source of truth (it has vouchers, customer
|
|
// segment, customer-lifetime-value, total, etc. that the engine needs),
|
|
// and a serialised copy sent from the browser can race the live cart.
|
|
// Letting the backend read the cookie + fetch the grain means there is
|
|
// exactly one source of truth and no race. The grain is deep-copied via
|
|
// JSON marshal/unmarshal so the preview never mutates the live state —
|
|
// every read is purely a what-if computation.
|
|
//
|
|
// Missing/invalid cookie (new visitor, preview-only tab): the handler
|
|
// treats the request as a pure stateless evaluation of the request
|
|
// items alone — the "what would I get just for the product I'm
|
|
// viewing" answer is still useful on its own.
|
|
//
|
|
// Cart fetch failure (the grain is gone, the pool is degraded, etc.):
|
|
// the handler logs and falls through to the same stateless evaluation
|
|
// of the request items. The preview degrades gracefully rather than
|
|
// erroring the whole block.
|
|
//
|
|
// "Replace" merge semantic: if the same sku is in both the cart and
|
|
// the request items, the request item wins (with its qty). This
|
|
// matches the PDP's intent ("I am previewing adding this product
|
|
// at this qty") and avoids double-counting the same product.
|
|
func newPromotionEvaluateWithCartHandler(store *promotions.Store, svc *promotions.PromotionService, server *PoolServer) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req promotions.EvaluateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var resp promotions.EvaluateResponse
|
|
if g := cloneCartForPreview(server, r); g != nil {
|
|
mergeRequestItemsIntoGrain(g, req.Items, svc.DefaultTaxProvider)
|
|
// One call to the canonical pipeline — same code path
|
|
// the live cart processor's reg.RegisterProcessor in
|
|
// main.go uses. Going through the shared
|
|
// PromotionService.EvaluateAndApply means the bypass
|
|
// loop, the re-eval trigger, and ApplyResults stay
|
|
// in lockstep with post-add behavior. EvaluateAndApply
|
|
// populates g.EvaluatedItems via cart.MapEvaluatedItems
|
|
// on its final step, so the preview response reuses the
|
|
// same per-line projection the live cart renders — math
|
|
// and rounding stay in lockstep across both paths.
|
|
svc.EvaluateAndApply(store.Snapshot(), g, previewContextOptions(req)...)
|
|
resp = promotions.EvaluateResponse{
|
|
TotalPrice: g.TotalPrice,
|
|
TotalDiscount: g.TotalDiscount,
|
|
AppliedPromotions: g.AppliedPromotions,
|
|
Items: g.EvaluatedItems,
|
|
}
|
|
} else {
|
|
// No live cart (missing/invalid cookie, fetch failed, or
|
|
// deep-copy failed). Fall back to the pure stateless
|
|
// evaluation of the request items alone — the user still
|
|
// gets a useful preview of "what would the engine do for
|
|
// just the items I'm about to add".
|
|
resp = svc.Evaluate(store.Snapshot(), req)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// cloneCartForPreview reads the cartid cookie, fetches the live grain
|
|
// (cross-pod via GetAnywhere), and returns a deep copy safe to mutate
|
|
// for the what-if preview. Returns nil (and logs) when the cookie is
|
|
// missing/invalid OR the grain fetch fails OR the deep-copy fails —
|
|
// the caller falls back to a stateless evaluation of the request items
|
|
// alone.
|
|
//
|
|
// The deep copy is via JSON marshal/unmarshal, which is intentional: it
|
|
// produces a fully independent *cart.CartGrain (re-allocating every
|
|
// pointer — Items, Vouchers, ItemMeta, Price, etc.) so the live grain
|
|
// is never touched, even if the live grain holds non-serializable
|
|
// unexported state like an in-memory event-log channel. Unexported
|
|
// fields are silently dropped by the json package, which is exactly
|
|
// what we want here — the engine only reads the grain's exported
|
|
// state (Items, Vouchers, TotalPrice, etc.), and the live grain's
|
|
// in-memory event log / channels must not be cloned or shared.
|
|
func cloneCartForPreview(server *PoolServer, r *http.Request) *cart.CartGrain {
|
|
cookie, err := r.Cookie("cartid")
|
|
if err != nil || cookie.Value == "" {
|
|
return nil
|
|
}
|
|
id, ok := cart.ParseCartId(cookie.Value)
|
|
if !ok {
|
|
log.Printf("promotions/evaluate-with-cart: invalid cartid cookie, falling back to stateless")
|
|
return nil
|
|
}
|
|
live, err := server.GetAnywhere(r.Context(), id)
|
|
if err != nil {
|
|
log.Printf("promotions/evaluate-with-cart: cart fetch failed for %s, falling back to stateless: %v", id, err)
|
|
return nil
|
|
}
|
|
if live == nil {
|
|
return nil
|
|
}
|
|
buf, err := json.Marshal(live)
|
|
if err != nil {
|
|
log.Printf("promotions/evaluate-with-cart: cart marshal failed for %s, falling back to stateless: %v", id, err)
|
|
return nil
|
|
}
|
|
var clone *cart.CartGrain
|
|
if err := json.Unmarshal(buf, &clone); err != nil {
|
|
log.Printf("promotions/evaluate-with-cart: cart unmarshal failed for %s, falling back to stateless: %v", id, err)
|
|
return nil
|
|
}
|
|
return clone
|
|
}
|
|
|
|
// mergeRequestItemsIntoGrain merges reqItems into the grain's items with
|
|
// "replace" semantic: any cart line whose sku also appears in the
|
|
// request is dropped (the request item wins, so the PDP qty replaces
|
|
// the cart qty for that product). Remaining cart lines are kept
|
|
// verbatim, then the converted request items are appended. The grain
|
|
// is mutated in place — caller is expected to be holding a deep copy
|
|
// from cloneCartForPreview. The EvalItem→CartItem conversion uses the
|
|
// shared promotions.EvalItem.ToCartItem helper so the math (VAT rate
|
|
// rounding, qty default, ItemMeta, Price) matches the synthetic-cart
|
|
// conversion in the stateless endpoint exactly.
|
|
func mergeRequestItemsIntoGrain(g *cart.CartGrain, reqItems []promotions.EvalItem, tp cart.TaxProvider) {
|
|
requestSkus := make(map[string]struct{}, len(reqItems))
|
|
for _, it := range reqItems {
|
|
if it.Sku != "" {
|
|
requestSkus[it.Sku] = struct{}{}
|
|
}
|
|
}
|
|
merged := make([]*cart.CartItem, 0, len(g.Items)+len(reqItems))
|
|
for _, it := range g.Items {
|
|
if _, taken := requestSkus[it.Sku]; taken {
|
|
continue
|
|
}
|
|
merged = append(merged, it)
|
|
}
|
|
for _, it := range reqItems {
|
|
merged = append(merged, it.ToCartItem(tp))
|
|
}
|
|
g.Items = merged
|
|
}
|
|
|
|
// previewContextOptions builds the ContextOption list for the
|
|
// cart-aware preview. The shared EvaluateRequest.ContextOptions
|
|
// already includes WithNow when req.Now is set, so we only prepend a
|
|
// default WithNow(time.Now()) when the request didn't specify one —
|
|
// avoiding a duplicate WithNow in the opts list (the engine's
|
|
// NewContextFromCart applies options in order, but the last-one-wins
|
|
// rule for duplicates is not a contract we want to rely on). The
|
|
// other context options (CustomerSegment, CLV, OrderCount) come from
|
|
// req.ContextOptions unchanged.
|
|
func previewContextOptions(req promotions.EvaluateRequest) []promotions.ContextOption {
|
|
opts := req.ContextOptions()
|
|
if req.Now == nil {
|
|
opts = append([]promotions.ContextOption{promotions.WithNow(time.Now())}, opts...)
|
|
}
|
|
return opts
|
|
}
|