Compare commits
32
Commits
63b0112cc7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c150ac635c | ||
|
|
80a1e6d05e | ||
|
|
9f1eca07e9 | ||
|
|
e20793a6b3 | ||
|
|
781c1bd171 | ||
|
|
e2f835c01b | ||
|
|
eed53c96fb | ||
|
|
6180706140 | ||
|
|
31530be28f | ||
|
|
dddb5c699a | ||
|
|
8bb0a7a786 | ||
|
|
b6a67e709d | ||
|
|
cecf4abe76 | ||
|
|
f339b60351 | ||
|
|
2e2060da5c | ||
|
|
26fd6dc970 | ||
|
|
1c97a4bdb0 | ||
|
|
4a37cb479c | ||
|
|
83986e7a35 | ||
|
|
b1e99891e9 | ||
|
|
75db64ce75 | ||
|
|
9495c654fa | ||
|
|
7b657901ac | ||
|
|
2e0204a287 | ||
|
|
a469b5ea97 | ||
|
|
d7acfc422c | ||
|
|
98e2fd8d41 | ||
|
|
de47ef4f14 | ||
|
|
46be260fcc | ||
|
|
d711348863 | ||
|
|
a3eab70de8 | ||
|
|
e9e9700a09 |
@@ -0,0 +1,102 @@
|
|||||||
|
# go-cart-actor — agents map
|
||||||
|
|
||||||
|
The **commercial heart** of the platform. Orleans-style grain actors for cart,
|
||||||
|
checkout, order, profile, inventory, plus a **UCP (Universal Commerce
|
||||||
|
Protocol) HTTP adapter** at `internal/ucp/`. Deploys multiple binaries in
|
||||||
|
parallel (one per concern) coordinated via k8s and an internal actor
|
||||||
|
discovery/RPC layer. See `docs/checkout-order-handoff.md` for the role split
|
||||||
|
with `go-order-manager`.
|
||||||
|
|
||||||
|
## Where to start
|
||||||
|
|
||||||
|
- `cmd/cart/main.go` — the one most commonly run in dev.
|
||||||
|
- `pkg/actor/` — the grain framework (start with `grain.go`, then
|
||||||
|
`simple_grain_pool.go` and `grain_pool.go`).
|
||||||
|
- `pkg/cart/cart-grain.go` — a concrete grain, the cleanest example.
|
||||||
|
- `internal/ucp/handler.go` and `ucp/{cart,checkout,order,profile}.go` —
|
||||||
|
the protocol adapter over the grains.
|
||||||
|
- `go-cart-actor/README.md`, `TODO.md`, `NEW_MUTATIONS_SPEC.md` — current
|
||||||
|
intent and known gaps.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Grain model**: each domain entity (cart, order, checkout…) is a grain
|
||||||
|
keyed by an opaque ID. Per-aggregate state machine lives in the grain's
|
||||||
|
`state.go`; mutations are individual `mutation_*.go` files under
|
||||||
|
`pkg/<domain>/` and registered through `mutation_registry.go`.
|
||||||
|
- **RPC**: gRPC over [`proto/`](proto/) — `cart.proto`, `checkout.proto`,
|
||||||
|
`order.proto`, `profile.proto`, `control_plane.proto`. Pod discovery via
|
||||||
|
`pkg/discovery/` + `pkg/proxy/remotehost.go` (k8s host discovery in
|
||||||
|
`cmd/<bin>/k8s-host-discovery.go`).
|
||||||
|
- **Binaries** (`cmd/`):
|
||||||
|
- `cart/`, `checkout/`, `order/`, `profile/`, `inventory/` — one each.
|
||||||
|
- `backoffice/` — the admin UI host, plugged into `backoffice` via
|
||||||
|
`pkg/backofficeadmin/`.
|
||||||
|
- `ucp-artifacts/` — emits the protocol artefacts (signing, handler
|
||||||
|
bindings).
|
||||||
|
- **UCP**: `internal/ucp/` is the protocol adapter — it talks to the grains
|
||||||
|
via RPC, not directly. Separate from `pkg/{cart,checkout,…}`.
|
||||||
|
- **Cross-cutting**: `pkg/outbox/`, `pkg/idempotency/`, `pkg/flow/`,
|
||||||
|
`pkg/promotions/`, `pkg/voucher/`, `pkg/telemetry/`.
|
||||||
|
- **Auth**: `internal/customerauth/` — password hashing (`password.go`),
|
||||||
|
rate limit, mail notifier, redis store, opaque customer session tokens.
|
||||||
|
|
||||||
|
## Important files by area
|
||||||
|
|
||||||
|
| Area | File(s) |
|
||||||
|
| -------------------------- | ------------------------------------------------------ |
|
||||||
|
| Grain framework | `pkg/actor/{grain,grain_pool,simple_grain_pool,state,disk_storage}.go` |
|
||||||
|
| Grain RPC | `pkg/actor/{grpc_server,grpc_server_test}.go` |
|
||||||
|
| Cart mutations | `pkg/cart/mutation_*.go` (+ `cart-grain.go`) |
|
||||||
|
| Order mutations | `pkg/order/mutation_*.go` + `pkg/order/order-grain.go` |
|
||||||
|
| Order outbox / payment | `pkg/order/{emit_created,payment,stripe,klarna}_*.go` |
|
||||||
|
| Promotions / vouchers | `pkg/promotions/`, `pkg/voucher/` |
|
||||||
|
| Idempotency | `pkg/idempotency/store.go` |
|
||||||
|
| Outbox (event publish) | `pkg/outbox/outbox.go` |
|
||||||
|
| UCP protocol adapter | `internal/ucp/{handler,cart,checkout,order,customer,profile}.go`, `signing.go` |
|
||||||
|
| Backoffice admin | `pkg/backofficeadmin/{app,hub,fileserver,customer}.go` |
|
||||||
|
| Customer auth | `internal/customerauth/{server,redis,password,tokens,mail_notifier,ratelimit}.go` |
|
||||||
|
| Telemetry / OTel | `pkg/telemetry/{otel,logs}.go` |
|
||||||
|
| k8s service discovery | `pkg/discovery/discovery.go`, `pkg/proxy/remotehost.go`, `cmd/<bin>/k8s-host-discovery.go` |
|
||||||
|
| Tests integration scaffold | `cmd/cart/projection_*_test.go`, `pkg/promotions/apply_test.go` |
|
||||||
|
|
||||||
|
## Cross-project seams
|
||||||
|
|
||||||
|
- **`platform/`**: uses `platform/auth`, `platform/catalog`, `platform/event`,
|
||||||
|
`platform/inventory`, `platform/order`, `platform/tax`, `platform/rabbit`.
|
||||||
|
- **`go-order-manager`**: subscribes to order events off the RabbitMQ
|
||||||
|
topology (`docs/checkout-order-handoff.md`).
|
||||||
|
- **`go-shipping`**: pulled via HTTP from `cart` (the cart client).
|
||||||
|
- **`slask-tracking`**: independent consumer of order events for analytics.
|
||||||
|
- **`backoffice`** (commerce tag): exposes the `pkg/backofficeadmin/` host.
|
||||||
|
|
||||||
|
## Improvement suggestions
|
||||||
|
|
||||||
|
- **Mutation file explosion**: each behavior of a grain lives in its own
|
||||||
|
`mutation_*.go`. This is shallow — the deletion test says removing one just
|
||||||
|
moves the same code to its sibling file. Consider grouping related
|
||||||
|
mutations per feature slice (e.g. one `cart_subscription.go` for
|
||||||
|
add/remove/upsert subscriptiondetails), so the test surface and locality
|
||||||
|
live together.
|
||||||
|
- **`pkg/cart` vs `internal/ucp` vs `pkg/checkout`** — three layers touch the
|
||||||
|
same conceptual "cart" entity. The UCP adapter depends on the grain, the
|
||||||
|
grain depends on the protocol-specific mutations, and the checkout layer
|
||||||
|
brokers a different view of the cart. Drop the conceptual split here:
|
||||||
|
consider promoting one domain (`cart`) as the seam and treating the others
|
||||||
|
as adapters.
|
||||||
|
- **Inconsistent layout**: most binaries are at `cmd/<name>/main.go`, but
|
||||||
|
`internal/ucp/`, `internal/customerauth/`, and `pkg/voucher/`, `pkg/outbox/`
|
||||||
|
sit at different depths. A short module-layout doc would prevent the next
|
||||||
|
contributor from guessing where new helpers go.
|
||||||
|
- **`TODO.md` and `NEW_MUTATIONS_SPEC.md` live in the repo root.** Both belong
|
||||||
|
in `docs/` so the README is the only thing newcomers hit first.
|
||||||
|
- **Two proto variants**: `proto/checkout.proto` and
|
||||||
|
`proto/checkout/checkout.proto`. Likely a historical artefact (see
|
||||||
|
`proto/README` if it exists, otherwise grep `proto/` for `checkout` to see
|
||||||
|
which is actually imported).
|
||||||
|
- **Test coverage is uneven across `pkg/*`** — vendor-friendly modules like
|
||||||
|
`pkg/promotions/` have `_test.go`, others like `pkg/voucher/` rely on
|
||||||
|
`parser_test.go` only. Worth a pass to seed unit tests at each public
|
||||||
|
function entry.
|
||||||
|
- **`cookies.txt` at repo root** is not a build artefact and shouldn't be
|
||||||
|
committed.
|
||||||
@@ -20,8 +20,6 @@ func main() {
|
|||||||
app, err := backofficeadmin.New(backofficeadmin.Config{
|
app, err := backofficeadmin.New(backofficeadmin.Config{
|
||||||
DataDir: config.EnvString("CART_DIR", "data"),
|
DataDir: config.EnvString("CART_DIR", "data"),
|
||||||
CheckoutDataDir: config.EnvString("CHECKOUT_DIR", "checkout-data"),
|
CheckoutDataDir: config.EnvString("CHECKOUT_DIR", "checkout-data"),
|
||||||
RedisAddress: os.Getenv("REDIS_ADDRESS"),
|
|
||||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error creating backoffice: %v", err)
|
log.Fatalf("Error creating backoffice: %v", err)
|
||||||
|
|||||||
@@ -1,65 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetDiscovery() discovery.Discovery {
|
|
||||||
if podIp == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
config, kerr := rest.InClusterConfig()
|
|
||||||
|
|
||||||
if kerr != nil {
|
|
||||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
|
||||||
}
|
|
||||||
client, err := kubernetes.NewForConfig(config)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating client: %v\n", err)
|
|
||||||
}
|
|
||||||
timeout := int64(30)
|
|
||||||
// Scope discovery to this pod's namespace so it only needs a namespaced Role
|
|
||||||
// (pods: get/list/watch), not a cluster-wide ClusterRole.
|
|
||||||
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
|
|
||||||
LabelSelector: "actor-pool=cart",
|
|
||||||
TimeoutSeconds: &timeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||||
|
discovery.StartK8sDiscovery(podIp, "actor-pool=cart", 30, pool)
|
||||||
go func(hw discovery.Discovery) {
|
|
||||||
if hw == nil {
|
|
||||||
log.Print("No discovery service available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch, err := hw.Watch()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Discovery error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for evt := range ch {
|
|
||||||
if evt.Host == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch evt.IsReady {
|
|
||||||
case false:
|
|
||||||
if pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
|
||||||
pool.RemoveHost(evt.Host)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if !pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Discovered host %s", evt.Host)
|
|
||||||
pool.AddRemoteHost(evt.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(GetDiscovery())
|
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-194
@@ -11,13 +11,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart/recovery"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||||
@@ -27,19 +27,17 @@ import (
|
|||||||
"git.k6n.net/mats/platform/config"
|
"git.k6n.net/mats/platform/config"
|
||||||
"git.k6n.net/mats/platform/event"
|
"git.k6n.net/mats/platform/event"
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
// cartMetrics is the shared prometheus instrumentation for the
|
||||||
Name: "cart_grain_spawned_total",
|
// cart actor pool and event log. Built once at process start; nil
|
||||||
Help: "The total number of spawned grains",
|
// is not expected in production but the actor package's
|
||||||
})
|
// touch sites are nil-safe so a test binary can pass nil.
|
||||||
|
cartMetrics = actor.NewMetrics("cart")
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -105,136 +103,60 @@ type CartChangeEvent struct {
|
|||||||
Mutations []actor.ApplyResult `json:"mutations"`
|
Mutations []actor.ApplyResult `json:"mutations"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem) bool {
|
// catalogProjectionCache is the cart's per-pod, cold-start-ready catalog
|
||||||
if string(update.SKU) == item.Sku {
|
// projection cache, backed by platform/catalog.ProjectionStore. It consumes the
|
||||||
if update.LocationID == "se" && item.StoreId == nil {
|
// catalog.projection_published wire — a full snapshot (begin/chunk/end, framed by
|
||||||
return true
|
// epoch) plus incremental deltas — into its OWN local .bin and serves catalog
|
||||||
}
|
// facts by SKU (price, tax_class, inventory_tracked, image, …) at add-to-cart /
|
||||||
if item.StoreId == nil {
|
// checkout-reserve decisions. Stock is NOT cached — queried synchronously from
|
||||||
return false
|
// inventory per docs/inventory-shape.md.
|
||||||
}
|
|
||||||
if *item.StoreId == string(update.LocationID) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// catalogProjectionCache is the cart's in-process map of SKU → catalog.Projection,
|
|
||||||
// updated by reading catalog.projection_published events off the bus. It is the
|
|
||||||
// cart-side source of truth for catalog facts (price, tax_class, inventory_tracked
|
|
||||||
// flag, currency, image, brand) used at add-to-cart / checkout-reserve decisions.
|
|
||||||
// Stock state is NOT cached here — exact qty is still queried synchronously from
|
|
||||||
// the inventory service at decision points per docs/inventory-shape.md.
|
|
||||||
//
|
//
|
||||||
// Concurrency: read-mostly workload justifies sync.RWMutex.
|
// Clustering: the cart runs N replicas. Each pod has its OWN exclusive bus queue
|
||||||
|
// (rabbit.ListenToPattern declares an exclusive server-named queue, so every pod
|
||||||
|
// receives the full stream) and its OWN pod-local .bin. It's a replicated read
|
||||||
|
// cache — no leader, no shared volume; N pods writing one file would corrupt it.
|
||||||
|
// See docs/catalog-projection.md.
|
||||||
//
|
//
|
||||||
// Single-tier storage. The bus is the only writer — the cart does not separately
|
// The cart stores the full catalog.Projection (identity transform): its
|
||||||
// seed from a snapshot file at boot, because the access pattern is point lookup
|
// add-to-cart overlay (projection_overlay.go) consumes nearly every field, so
|
||||||
// of cart-active SKUs only, and coupling two sources (mmap snapshot + bus-fed
|
// there's nothing to subset. Deletes ride as tombstones in the store's overlay
|
||||||
// map) created duplicated state with subtle drift risk and an mmap-SIGBUS hazard.
|
// (IsDeleted) so a removed SKU isn't re-fetched before the next snapshot folds
|
||||||
// Cold-grace: the cache may be incomplete for an SKU the cart sees before the
|
// the delete into the base. Cold-grace: an SKU not yet in the base/overlay
|
||||||
// first projection_published arrives for it; the cart falls back to the existing
|
// resolves via the existing PRODUCT_BASE_URL HTTP fetch + overlay.
|
||||||
// PRODUCT_BASE_URL HTTP fetch and overlays the cache fields on top of it.
|
|
||||||
//
|
|
||||||
// Deletes carry a tombstone in the map (not a plain delete) so a bus delete of
|
|
||||||
// an SKU that already fits the cache rules correctly shadows it for Get /
|
|
||||||
// IsDeleted lookups — preventing an oversight from re-fetching a deleted SKU
|
|
||||||
// before the cache rebuilds. Tombstones have a per-pod lifetime; bus-driven,
|
|
||||||
// so the next sweep on the same SKU flips the entry back to live.
|
|
||||||
type catalogProjectionCache struct {
|
type catalogProjectionCache struct {
|
||||||
mu sync.RWMutex
|
store *catalog.ProjectionStore[catalog.Projection]
|
||||||
items map[string]projectionEntry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// projectionEntry is a cache slot: a live projection, or a tombstone (deleted)
|
// identityProjection is the cart's mandatory transform — it stores the full
|
||||||
// that shadows the live fallback.
|
// Projection because the overlay uses almost all of it.
|
||||||
type projectionEntry struct {
|
func identityProjection(p catalog.Projection) catalog.Projection { return p }
|
||||||
proj catalog.Projection
|
|
||||||
deleted bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newCatalogProjectionCache() *catalogProjectionCache {
|
// newCatalogProjectionCache opens the per-pod store under dir. dir MUST be
|
||||||
return &catalogProjectionCache{items: make(map[string]projectionEntry, 8192)}
|
// pod-local (container fs / an emptyDir in k8s) — never a shared volume.
|
||||||
}
|
func newCatalogProjectionCache(dir string) (*catalogProjectionCache, error) {
|
||||||
|
s, err := catalog.NewProjectionStore(dir, "cart-projection.bin", identityProjection, nil)
|
||||||
func (c *catalogProjectionCache) Apply(updates []catalog.ProjectionUpdate) (upserts, deletes int) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
for _, u := range updates {
|
|
||||||
if u.Deleted {
|
|
||||||
c.items[u.SKU] = projectionEntry{deleted: true}
|
|
||||||
deletes++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if u.SKU == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
c.items[u.SKU] = projectionEntry{proj: u.Projection}
|
|
||||||
upserts++
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *catalogProjectionCache) Get(sku string) (catalog.Projection, bool) {
|
|
||||||
c.mu.RLock()
|
|
||||||
e, ok := c.items[sku]
|
|
||||||
c.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return catalog.Projection{}, false
|
|
||||||
}
|
|
||||||
if e.deleted {
|
|
||||||
return catalog.Projection{}, false
|
|
||||||
}
|
|
||||||
return e.proj, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsDeleted reports whether a tombstone exists for sku — used by HTTP-fetch
|
|
||||||
// call sites to skip the fallback for an SKU the bus has explicitly deleted,
|
|
||||||
// avoiding a wasteful round-trip on items the writer has just removed.
|
|
||||||
func (c *catalogProjectionCache) IsDeleted(sku string) bool {
|
|
||||||
c.mu.RLock()
|
|
||||||
e, ok := c.items[sku]
|
|
||||||
c.mu.RUnlock()
|
|
||||||
return ok && e.deleted
|
|
||||||
}
|
|
||||||
|
|
||||||
// Len reports the number of map entries (bus-fed upserts + tombstones). Used
|
|
||||||
// for log gauges only.
|
|
||||||
func (c *catalogProjectionCache) Len() int {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return len(c.items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// projectionDeliveryHandler is the AMQP delivery handler for
|
|
||||||
// `catalog.projection_published` on the `catalog` topic exchange. Decodes the
|
|
||||||
// []ProjectionUpdate batch and merges it into cache. Returns nil on a batch
|
|
||||||
// that doesn't apply locally so the AMQP delivery is acked; returns an error
|
|
||||||
// on a decode failure so the broker knows to redeliver / quarantine.
|
|
||||||
//
|
|
||||||
// Recovery: any panic in a single event is caught so the goroutine keeps
|
|
||||||
// draining the queue; the bus stays live across malformed events.
|
|
||||||
func projectionDeliveryHandler(cache *catalogProjectionCache) func(amqp.Delivery) error {
|
|
||||||
return func(d amqp.Delivery) (err error) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
err = fmt.Errorf("projection handler panic recovered: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
var ev event.Event
|
|
||||||
if err := json.Unmarshal(d.Body, &ev); err != nil {
|
|
||||||
return fmt.Errorf("decode event envelope: %w", err)
|
|
||||||
}
|
|
||||||
updates, err := catalog.DecodeUpdates(ev.Payload)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("decode projection updates: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
upserts, deletes := cache.Apply(updates)
|
return &catalogProjectionCache{store: s}, nil
|
||||||
log.Printf("cart: catalog projection batch applied: upserts=%d deletes=%d total=%d eventID=%s source=%q",
|
|
||||||
upserts, deletes, cache.Len(), ev.ID, ev.Source)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleFrame feeds one catalog.projection_published event (snapshot frame or
|
||||||
|
// delta) into the store; framing/epoch ordering live in platform/catalog.
|
||||||
|
func (c *catalogProjectionCache) HandleFrame(meta map[string]string, payload []byte) error {
|
||||||
|
return c.store.HandleFrame(meta, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *catalogProjectionCache) Get(sku string) (catalog.Projection, bool) { return c.store.Get(sku) }
|
||||||
|
|
||||||
|
// IsDeleted reports whether the bus has explicitly deleted sku (overlay
|
||||||
|
// tombstone) — call sites skip the HTTP fallback for it.
|
||||||
|
func (c *catalogProjectionCache) IsDeleted(sku string) bool { return c.store.IsDeleted(sku) }
|
||||||
|
|
||||||
|
// Len reports cached entries (base + overlay) — debug/log gauge only.
|
||||||
|
func (c *catalogProjectionCache) Len() int {
|
||||||
|
_, base, overlay := c.store.Stats()
|
||||||
|
return base + overlay
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -249,7 +171,11 @@ func main() {
|
|||||||
|
|
||||||
controlPlaneConfig := actor.DefaultServerConfig()
|
controlPlaneConfig := actor.DefaultServerConfig()
|
||||||
|
|
||||||
promotionStore, err := promotions.NewStore("data/promotions.json")
|
promotionsPath := os.Getenv("PROMOTIONS_FILE")
|
||||||
|
if promotionsPath == "" {
|
||||||
|
promotionsPath = "data/promotions.json"
|
||||||
|
}
|
||||||
|
promotionStore, err := promotions.NewStore(promotionsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error loading promotions: %v\n", err)
|
log.Fatalf("Error loading promotions: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -265,88 +191,42 @@ func main() {
|
|||||||
Password: redisPassword,
|
Password: redisPassword,
|
||||||
DB: 0,
|
DB: 0,
|
||||||
})
|
})
|
||||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
|
||||||
}
|
|
||||||
_ = inventoryService
|
|
||||||
|
|
||||||
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
||||||
}
|
}
|
||||||
|
reservationPolicy := inventory.NewReservationPolicyAdapter(inventoryReservationService)
|
||||||
|
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(reservationPolicy))
|
||||||
reg.RegisterProcessor(
|
reg.RegisterProcessor(
|
||||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
// Canonical promotion pipeline — same call site as the
|
||||||
// Clear bypass flags first
|
// /promotions/evaluate-with-cart preview handler in
|
||||||
for _, v := range g.Vouchers {
|
// promotions_evaluate.go. Going through the shared
|
||||||
if v != nil {
|
// PromotionService.EvaluateAndApply means the bypass
|
||||||
v.BypassedByPromotions = false
|
// loop, the re-eval trigger, and ApplyResults stay in
|
||||||
}
|
// lockstep with the preview so a what-if always matches
|
||||||
}
|
// post-add behavior (including the coupon+promotion-
|
||||||
|
// overlap edge case). If you find yourself adding
|
||||||
g.UpdateTotals()
|
// promotion logic here, add it to EvaluateAndApply
|
||||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
// instead — the preview must follow.
|
||||||
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
promotionService.EvaluateAndApply(promotionStore.Snapshot(), g, promotions.WithNow(time.Now()))
|
||||||
|
|
||||||
// Check if any qualified promotion rule has a coupon_code condition matching our vouchers
|
|
||||||
hasBypassed := false
|
|
||||||
for _, res := range results {
|
|
||||||
if res.Applicable {
|
|
||||||
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
|
|
||||||
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
|
|
||||||
var codes []string
|
|
||||||
if s, ok := bc.Value.AsString(); ok {
|
|
||||||
codes = append(codes, strings.ToLower(s))
|
|
||||||
} else if arr, ok := bc.Value.AsStringSlice(); ok {
|
|
||||||
for _, s := range arr {
|
|
||||||
codes = append(codes, strings.ToLower(s))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, code := range codes {
|
|
||||||
for _, v := range g.Vouchers {
|
|
||||||
if v != nil && strings.ToLower(v.Code) == code {
|
|
||||||
if !v.BypassedByPromotions {
|
|
||||||
v.BypassedByPromotions = true
|
|
||||||
hasBypassed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasBypassed {
|
|
||||||
// Re-evaluate with bypassed vouchers
|
|
||||||
g.UpdateTotals()
|
|
||||||
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
|
||||||
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyResults applies qualifying actions in priority order and records
|
|
||||||
// every effect — both applied discounts and pending "spend X more for ..."
|
|
||||||
// nudges with their progress — in g.AppliedPromotions.
|
|
||||||
promotionService.ApplyResults(g, results, promotionCtx)
|
|
||||||
g.Version++
|
g.Version++
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](config.EnvString("CART_DIR", "data"), reg)
|
diskStorage := actor.NewDiskStorage[cart.CartGrain](config.EnvString("CART_DIR", "data"), reg)
|
||||||
|
diskStorage.SetMetrics(cartMetrics)
|
||||||
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
||||||
MutationRegistry: reg,
|
MutationRegistry: reg,
|
||||||
Storage: diskStorage,
|
Storage: diskStorage,
|
||||||
|
Metrics: cartMetrics,
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
||||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
|
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
|
||||||
defer span.End()
|
defer span.End()
|
||||||
grainSpawns.Inc()
|
|
||||||
ret := cart.NewCartGrain(id, time.Now())
|
ret := cart.NewCartGrain(id, time.Now())
|
||||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||||
|
|
||||||
@@ -423,7 +303,13 @@ func main() {
|
|||||||
// connection keeps the cart's mutation-feed lifecycle decoupled from a
|
// connection keeps the cart's mutation-feed lifecycle decoupled from a
|
||||||
// read-write-consume connection that could grow event handlers later
|
// read-write-consume connection that could grow event handlers later
|
||||||
// (e.g. promotions reacting to a price drop on a watched SKU).
|
// (e.g. promotions reacting to a price drop on a watched SKU).
|
||||||
projectionCache := newCatalogProjectionCache()
|
// Pod-local store dir — container fs / an emptyDir in k8s. NEVER a shared
|
||||||
|
// volume: each replica owns its own .bin (replicated read cache, no leader).
|
||||||
|
projectionDir := config.EnvString("CART_PROJECTION_DIR", "data/projection")
|
||||||
|
projectionCache, err := newCatalogProjectionCache(projectionDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error opening catalog projection cache: %v", err)
|
||||||
|
}
|
||||||
if amqpUrl != "" {
|
if amqpUrl != "" {
|
||||||
if pConn, perr := rabbit.Dial(amqpUrl, "cart-catalog-projection"); perr != nil {
|
if pConn, perr := rabbit.Dial(amqpUrl, "cart-catalog-projection"); perr != nil {
|
||||||
log.Printf("cart: catalog projection consumer DISABLED (dial failed): %v", perr)
|
log.Printf("cart: catalog projection consumer DISABLED (dial failed): %v", perr)
|
||||||
@@ -434,7 +320,7 @@ func main() {
|
|||||||
log.Printf("cart: channel on projection reconnect: %v", err)
|
log.Printf("cart: channel on projection reconnect: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), projectionDeliveryHandler(projectionCache)); err != nil {
|
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), catalog.MakeAmqpHandler(projectionCache.store)); err != nil {
|
||||||
log.Printf("cart: bind catalog.projection_published on reconnect: %v", err)
|
log.Printf("cart: bind catalog.projection_published on reconnect: %v", err)
|
||||||
_ = ch.Close()
|
_ = ch.Close()
|
||||||
}
|
}
|
||||||
@@ -460,12 +346,71 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer grpcSrv.GracefulStop()
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
// go diskStorage.SaveLoop(10 * time.Second)
|
go diskStorage.SaveLoop(20 * time.Second)
|
||||||
UseDiscovery(pool)
|
UseDiscovery(pool)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// Data Retention (C5) GDPR Purge
|
||||||
|
retentionDays := config.EnvInt("CART_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
|
||||||
|
if retentionDays > 0 {
|
||||||
|
purgeInterval := config.EnvDuration("CART_PURGE_INTERVAL", 24*time.Hour)
|
||||||
|
log.Printf("cart: data retention enabled. Retaining carts for %d days, purging every %v", retentionDays, purgeInterval)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(purgeInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run immediately on startup
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
log.Print("cart: data retention / GDPR purge disabled (CART_RETENTION_DAYS not set or <= 0)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abandoned-cart recovery check (C3-notifications seam, dry-run by default).
|
||||||
|
// Scans local pod event-log files for carts whose modtime falls into the
|
||||||
|
// "[now - delay - window, now - delay]" window and that have items + a
|
||||||
|
// contact (email and/or push tokens). Hands each candidate to the
|
||||||
|
// configured Notifier (LoggingNotifier v0). The scanner reads local shard
|
||||||
|
// files only — the control plane doesn't fan out between pods on purpose
|
||||||
|
// (each pod owns the carts it is the owner of). Idempotency is the next
|
||||||
|
// pass; the v0 window size keeps duplicate notifications minute-scoped.
|
||||||
|
abandonedDelay := config.EnvDuration("CART_ABANDONED_DELAY", time.Hour)
|
||||||
|
if abandonedDelay > 0 {
|
||||||
|
abandonedWindow := config.EnvDuration("CART_ABANDONED_WINDOW", time.Hour)
|
||||||
|
abandonedInterval := config.EnvDuration("CART_ABANDONED_SCAN_INTERVAL", 15*time.Minute)
|
||||||
|
notifier := recovery.NewLoggingNotifier()
|
||||||
|
scanner := recovery.NewScanner(config.EnvString("CART_DIR", "data"), diskStorage)
|
||||||
|
log.Printf("cart: abandonment recovery ENABLED. Delay=%v window=%v scan_interval=%v (notifier=LoggingNotifier dry-run)", abandonedDelay, abandonedWindow, abandonedInterval)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(abandonedInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
log.Print("cart: abandonment recovery DISABLED (CART_ABANDONED_DELAY not set or <= 0)")
|
||||||
|
}
|
||||||
|
|
||||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
@@ -503,6 +448,19 @@ func main() {
|
|||||||
// without creating or mutating a real cart.
|
// without creating or mutating a real cart.
|
||||||
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
|
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
|
||||||
|
|
||||||
|
// Cart-aware promotion evaluation: reads the cartid cookie the storefront
|
||||||
|
// already maintains, fetches the actual cart grain (cross-pod via
|
||||||
|
// GetAnywhere), merges with the request's items (the items the user is
|
||||||
|
// considering adding), and runs the engine on the merged line list. The
|
||||||
|
// cart is the source of truth (vouchers, customer segment, customer
|
||||||
|
// lifetime value, total) so a serialised copy from the browser can race
|
||||||
|
// the live cart — letting the backend read the cookie + fetch the grain
|
||||||
|
// means exactly one source of truth and no race. Missing cookie or fetch
|
||||||
|
// failure gracefully degrades to a stateless evaluation of the request
|
||||||
|
// items alone. See newPromotionEvaluateWithCartHandler in
|
||||||
|
// promotions_evaluate.go for the full contract.
|
||||||
|
mux.HandleFunc("POST /promotions/evaluate-with-cart", newPromotionEvaluateWithCartHandler(promotionStore, promotionService, syncedServer))
|
||||||
|
|
||||||
// only for local
|
// only for local
|
||||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
pool.AddRemote(r.PathValue("host"))
|
pool.AddRemote(r.PathValue("host"))
|
||||||
@@ -612,3 +570,47 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[cart.CartGrain], pool *actor.SimpleGrainPool[cart.CartGrain], retentionDays int) {
|
||||||
|
log.Printf("cart: starting data retention purge...")
|
||||||
|
retention := time.Duration(retentionDays) * 24 * time.Hour
|
||||||
|
|
||||||
|
localIds := make(map[uint64]bool)
|
||||||
|
for _, id := range pool.GetLocalIds() {
|
||||||
|
localIds[id] = true
|
||||||
|
}
|
||||||
|
isGrainActive := func(id uint64) bool {
|
||||||
|
return localIds[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cart: data retention purge failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("cart: data retention purge completed. Purged %d expired cart logs", purged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRecovery is the abandoned-cart scanner pass. It finds carts that crossed
|
||||||
|
// the abandonment threshold in the recent window and hands each to the
|
||||||
|
// configured Notifier. Each pass is independent; idempotency across ticks is
|
||||||
|
// the next pass (logged duplicates are not catastrophic for the dry-run
|
||||||
|
// notifier, but a real MailerSend/FCM impl will need a SETNX side-key).
|
||||||
|
func runRecovery(ctx context.Context, scanner *recovery.Scanner, notifier recovery.Notifier, delay, window time.Duration) {
|
||||||
|
candidates, err := scanner.Scan(ctx, delay, window, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cart: recovery scan failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
log.Printf("cart: recovery scan complete, 0 candidates (window delay=%v width=%v)", delay, window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("cart: recovery scan complete, %d candidates", len(candidates))
|
||||||
|
for _, c := range candidates {
|
||||||
|
if err := notifier.NotifyAbandoned(ctx, c); err != nil {
|
||||||
|
log.Printf("cart: recovery notify (cart %d) failed: %v", c.CartID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+113
-16
@@ -16,8 +16,6 @@ import (
|
|||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
"google.golang.org/protobuf/types/known/anypb"
|
||||||
@@ -28,16 +26,13 @@ import (
|
|||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total,
|
||||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
// cart_mutations_total, cart_remote_negotiation_total,
|
||||||
Name: "cart_grain_mutations_total",
|
// cart_connected_remotes, …) are bumped centrally by SimpleGrainPool
|
||||||
Help: "The total number of mutations",
|
// — see pkg/actor/simple_grain_pool.go. The cart Metrics is
|
||||||
})
|
// registered once in cmd/cart/main.go and shared with DiskStorage
|
||||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
// via SetMetrics, so the per-handler counters this file used to
|
||||||
Name: "cart_grain_lookups_total",
|
// maintain are no longer needed.
|
||||||
Help: "The total number of lookups",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
type PoolServer struct {
|
type PoolServer struct {
|
||||||
actor.GrainPool[cart.CartGrain]
|
actor.GrainPool[cart.CartGrain]
|
||||||
@@ -75,6 +70,10 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id c
|
|||||||
|
|
||||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||||
sku := r.PathValue("sku")
|
sku := r.PathValue("sku")
|
||||||
|
country := getCountryFromHost(r.Host)
|
||||||
|
if s.idx == nil {
|
||||||
|
log.Printf("No index present")
|
||||||
|
}
|
||||||
if s.idx != nil && s.idx.IsDeleted(sku) {
|
if s.idx != nil && s.idx.IsDeleted(sku) {
|
||||||
// Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the
|
// Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the
|
||||||
// product-fetcher's "product service returned %d for sku %s" shape so
|
// product-fetcher's "product service returned %d for sku %s" shape so
|
||||||
@@ -86,7 +85,24 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
|
|||||||
http.Error(w, fmt.Sprintf("product service returned %d for sku %s (bus-deleted)", 404, sku), http.StatusNotFound)
|
http.Error(w, fmt.Sprintf("product service returned %d for sku %s (bus-deleted)", 404, sku), http.StatusNotFound)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
|
|
||||||
|
// Cache-only fast path: AddSkuToCartHandler is a SINGLE top-level add
|
||||||
|
// (qty=1, no children). When the projection carries full authoritative
|
||||||
|
// fields, we skip PRODUCT_BASE_URL entirely and build the AddItem
|
||||||
|
// directly from the cache. This eliminates one HTTP round-trip per
|
||||||
|
// add-to-cart under steady-state bus-fed conditions.
|
||||||
|
var msg *messages.AddItem
|
||||||
|
if s.idx != nil {
|
||||||
|
if p, ok := s.idx.Get(sku); ok && HasRequiredFields(p) {
|
||||||
|
msg = BuildAddItemFromCache(sku, 1, country, nil, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
if s.idx == nil {
|
||||||
|
log.Printf("No item found in index %s", sku)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
msg, err = GetItemAddMessage(r.Context(), sku, 1, country, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -95,15 +111,23 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
|
|||||||
ApplyProjectionOverlay(msg, p)
|
ApplyProjectionOverlay(msg, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data, err := s.ApplyLocal(r.Context(), id, msg)
|
data, err := s.ApplyLocal(r.Context(), id, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
grainMutations.Add(float64(len(data.Mutations)))
|
|
||||||
return s.WriteResult(w, data)
|
return s.WriteResult(w, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total,
|
||||||
|
// cart_mutations_total, cart_remote_negotiation_total,
|
||||||
|
// cart_connected_remotes, …) are bumped centrally by SimpleGrainPool
|
||||||
|
// — see pkg/actor/simple_grain_pool.go. The cart Metrics is
|
||||||
|
// registered once in cmd/cart/main.go and shared with DiskStorage
|
||||||
|
// via SetMetrics, so the per-handler counters this file used to
|
||||||
|
// maintain are no longer needed.
|
||||||
|
|
||||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
@@ -185,6 +209,19 @@ type itemGroup struct {
|
|||||||
// become authoritative for what the projection carries; HTTP stays for
|
// become authoritative for what the projection carries; HTTP stays for
|
||||||
// dimensions (parent width/height for child pricing), seller/orgPrice and the
|
// dimensions (parent width/height for child pricing), seller/orgPrice and the
|
||||||
// dynamic ExtraJson product data. Skipped cleanly when idx is nil.
|
// dynamic ExtraJson product data. Skipped cleanly when idx is nil.
|
||||||
|
//
|
||||||
|
// Cache-only fast path: a parent with NO children AND a cache hit that
|
||||||
|
// satisfies HasRequiredFields skips PRODUCT_BASE_URL entirely — BuildAddItem-
|
||||||
|
// FromCache constructs the line directly from the Projection. This eliminates
|
||||||
|
// the per-add HTTP round-trip under steady-state bus-fed conditions for the
|
||||||
|
// common top-level add case. The HTTP path continues to handle:
|
||||||
|
// - cache miss (cold-grace; a SKU not yet bus-fed)
|
||||||
|
// - HasRequiredFields==false (partial bus delivery, e.g. TaxClass missing
|
||||||
|
// because the producer didn't classify)
|
||||||
|
// - parents WITH children (configurator behavior preservation — child
|
||||||
|
// price = child per-m² rate × parent area, and parent area comes from
|
||||||
|
// HTTP-fetched width/height that the Projection doesn't carry yet; a
|
||||||
|
// future schema extension can unblock a child cache-only path).
|
||||||
func buildItemGroups(ctx context.Context, items []Item, country string, idx *catalogProjectionCache) []itemGroup {
|
func buildItemGroups(ctx context.Context, items []Item, country string, idx *catalogProjectionCache) []itemGroup {
|
||||||
groups := make([]itemGroup, len(items))
|
groups := make([]itemGroup, len(items))
|
||||||
wg := sync.WaitGroup{}
|
wg := sync.WaitGroup{}
|
||||||
@@ -194,7 +231,19 @@ func buildItemGroups(ctx context.Context, items []Item, country string, idx *cat
|
|||||||
log.Printf("error adding item %s: bus-deleted (skipping)", itm.Sku)
|
log.Printf("error adding item %s: bus-deleted (skipping)", itm.Sku)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
parentMsg, parentProduct, err := BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil)
|
|
||||||
|
// Cache-only fast path: see package doc above for the eligibility
|
||||||
|
// contract.
|
||||||
|
var parentMsg *messages.AddItem
|
||||||
|
var parentProduct *ProductItem
|
||||||
|
if len(itm.Children) == 0 && idx != nil {
|
||||||
|
if p, ok := idx.Get(itm.Sku); ok && HasRequiredFields(p) {
|
||||||
|
parentMsg = BuildAddItemFromCache(itm.Sku, itm.Quantity, country, itm.StoreId, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parentMsg == nil {
|
||||||
|
var err error
|
||||||
|
parentMsg, parentProduct, err = BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||||
return
|
return
|
||||||
@@ -204,6 +253,7 @@ func buildItemGroups(ctx context.Context, items []Item, country string, idx *cat
|
|||||||
ApplyProjectionOverlay(parentMsg, p)
|
ApplyProjectionOverlay(parentMsg, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
parentMsg.CustomFields = itm.CustomFields
|
parentMsg.CustomFields = itm.CustomFields
|
||||||
groups[i].parent = parentMsg
|
groups[i].parent = parentMsg
|
||||||
|
|
||||||
@@ -450,7 +500,6 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
|
|||||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
||||||
|
|
||||||
grainLookups.Inc()
|
|
||||||
if err == nil && handled {
|
if err == nil && handled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -557,6 +606,49 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRecoveryContactRequest is the wire shape clients send to
|
||||||
|
// PUT /cart/recovery-contact. It mirrors proto SetRecoveryContact with
|
||||||
|
// JSON-friendly types; sending an empty JSON object clears all contact info.
|
||||||
|
type SetRecoveryContactRequest struct {
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
PushTokens []cart.PushToken `json:"pushTokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toMessage builds the proto message from the JSON request body.
|
||||||
|
func (s *SetRecoveryContactRequest) toMessage() *messages.SetRecoveryContact {
|
||||||
|
out := &messages.SetRecoveryContact{Email: s.Email}
|
||||||
|
if len(s.PushTokens) > 0 {
|
||||||
|
out.PushTokens = make([]*messages.PushToken, 0, len(s.PushTokens))
|
||||||
|
for _, t := range s.PushTokens {
|
||||||
|
out.PushTokens = append(out.PushTokens, &messages.PushToken{
|
||||||
|
Platform: t.Platform,
|
||||||
|
Token: t.Token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) SetRecoveryContactHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||||
|
if r.Method != http.MethodPut {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req := SetRecoveryContactRequest{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.ApplyLocal(r.Context(), cartId, req.toMessage())
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||||
setUserId := messages.SetUserId{}
|
setUserId := messages.SetUserId{}
|
||||||
err := json.NewDecoder(r.Body).Decode(&setUserId)
|
err := json.NewDecoder(r.Body).Decode(&setUserId)
|
||||||
@@ -699,6 +791,10 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
|
|||||||
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
||||||
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||||
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
||||||
|
// PUT only — SetRecoveryContact is PUT/replace semantics. We deliberately
|
||||||
|
// do NOT allow POST here to avoid conflicts with any future POST→create
|
||||||
|
// semantics on /cart paths.
|
||||||
|
handleFunc("PUT /cart/recovery-contact", CookieCartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler)))
|
||||||
|
|
||||||
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
||||||
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
||||||
@@ -715,6 +811,7 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
|
|||||||
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||||
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||||
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
||||||
|
handleFunc("PUT /cart/byid/{id}/recovery-contact", CartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler)))
|
||||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
||||||
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
||||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
|
handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWriteResult_CartGrainCarriesEvaluatedItems verifies that the per-line
|
||||||
|
// promotion breakdown lives on CartGrain.EvaluatedItems (so the canonical
|
||||||
|
// promotion pipeline populates it once per mutation, not at every read
|
||||||
|
// path) and naturally serialises through WriteResult alongside the rest
|
||||||
|
// of the grain's tagged fields. There is no envelope wrapper anymore —
|
||||||
|
// the grain's own JSON tag carries the field. See pkg/cart/evaluated_item.go
|
||||||
|
// for EvaluatedItem semantics and pkg/cart/cart-grain.go for the EvaluatedItems
|
||||||
|
// field declaration.
|
||||||
|
func TestWriteResult_CartGrainCarriesEvaluatedItems(t *testing.T) {
|
||||||
|
grain := &cart.CartGrain{
|
||||||
|
Id: 1,
|
||||||
|
Currency: "SEK",
|
||||||
|
Items: []*cart.CartItem{
|
||||||
|
{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Quantity: 2,
|
||||||
|
Price: *cart.NewPriceFromIncVat(10000, 2500),
|
||||||
|
Discount: cart.NewPriceFromIncVat(2000, 500),
|
||||||
|
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TotalPrice: cart.NewPriceFromIncVat(20000, 5000),
|
||||||
|
TotalDiscount: cart.NewPriceFromIncVat(4000, 1000),
|
||||||
|
EvaluatedItems: []cart.EvaluatedItem{{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Name: "Promo Item",
|
||||||
|
Quantity: 2,
|
||||||
|
PriceIncVat: 10000,
|
||||||
|
OrgPriceIncVat: 12000,
|
||||||
|
DiscountIncVat: 2000,
|
||||||
|
EffectivePriceIncVat: 9000,
|
||||||
|
EffectiveTotalIncVat: 18000,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &PoolServer{}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
if err := s.WriteResult(w, grain); err != nil {
|
||||||
|
t.Fatalf("WriteResult: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v (body=%q)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{"id", "currency", "items", "totalPrice", "totalDiscount", "evaluatedItems"} {
|
||||||
|
if _, ok := resp[key]; !ok {
|
||||||
|
t.Fatalf("expected top-level %q in grain serialisation, got keys %v", key, mapKeys(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ev []struct {
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
DiscountIncVat int64 `json:"discountIncVat"`
|
||||||
|
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"`
|
||||||
|
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp["evaluatedItems"], &ev); err != nil {
|
||||||
|
t.Fatalf("evaluatedItems parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(ev) != 1 || ev[0].Sku != "promo-1" {
|
||||||
|
t.Fatalf("expected one promo-1 evaluated item, got %+v", ev)
|
||||||
|
}
|
||||||
|
if ev[0].DiscountIncVat != 2000 || ev[0].EffectiveTotalIncVat != 18000 || ev[0].EffectivePriceIncVat != 9000 {
|
||||||
|
t.Fatalf("evaluatedItem math off: %+v", ev[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteResult_MutationResultNestsEvaluatedItems verifies the natural
|
||||||
|
// post-mutation wire shape: actor.MutationResult[cart.CartGrain] serialises
|
||||||
|
// Result (the grain) and Mutations (applied-change metadata) at the existing
|
||||||
|
// top-level positions, and CartGrain's EvaluatedItems rides inside Result
|
||||||
|
// because the grain owns the field. Previously the envelope wrapper pulled
|
||||||
|
// evaluatedItems out as a sibling of {result, mutations}; the natural
|
||||||
|
// nesting is what the user wanted ("no strange patterns on a global level").
|
||||||
|
// Internal/admin tools that read response.evaluatedItems will need to read
|
||||||
|
// response.result.evaluatedItems instead. Wrap-around breakage acknowledged.
|
||||||
|
func TestWriteResult_MutationResultNestsEvaluatedItems(t *testing.T) {
|
||||||
|
grain := &cart.CartGrain{
|
||||||
|
Id: 1,
|
||||||
|
Currency: "SEK",
|
||||||
|
Items: []*cart.CartItem{
|
||||||
|
{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Quantity: 1,
|
||||||
|
Price: *cart.NewPriceFromIncVat(10000, 2500),
|
||||||
|
Discount: cart.NewPriceFromIncVat(3000, 750),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EvaluatedItems: []cart.EvaluatedItem{{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Quantity: 1,
|
||||||
|
PriceIncVat: 10000,
|
||||||
|
DiscountIncVat: 3000,
|
||||||
|
EffectiveTotalIncVat: 7000,
|
||||||
|
EffectivePriceIncVat: 7000,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
mr := &actor.MutationResult[cart.CartGrain]{
|
||||||
|
Result: *grain,
|
||||||
|
Mutations: []actor.ApplyResult{{Type: "cart.AddItem"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &PoolServer{}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
if err := s.WriteResult(w, mr); err != nil {
|
||||||
|
t.Fatalf("WriteResult: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// {result, mutations} are the existing envelope — preserved.
|
||||||
|
for _, key := range []string{"result", "mutations"} {
|
||||||
|
if _, ok := resp[key]; !ok {
|
||||||
|
t.Fatalf("expected top-level %q in mutation response, got %v", key, mapKeys(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// evaluatedItems no longer rises to the top level (the envelope is
|
||||||
|
// gone). Confirm absence at the top — it's now nested under result.
|
||||||
|
if _, hasTop := resp["evaluatedItems"]; hasTop {
|
||||||
|
t.Fatalf("evaluatedItems leaked to top level — envelope wrapper returned somehow")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result nests the grain, including EvaluatedItems naturally.
|
||||||
|
var inner map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(resp["result"], &inner); err != nil {
|
||||||
|
t.Fatalf("result parse: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := inner["items"]; !ok {
|
||||||
|
t.Fatalf("result.items missing — Result did not carry the grain")
|
||||||
|
}
|
||||||
|
evRaw, ok := inner["evaluatedItems"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("result.evaluatedItems missing — grain field should nest under result")
|
||||||
|
}
|
||||||
|
var ev []struct {
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
DiscountIncVat int64 `json:"discountIncVat"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(evRaw, &ev); err != nil {
|
||||||
|
t.Fatalf("result.evaluatedItems parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(ev) != 1 || ev[0].Sku != "promo-1" || ev[0].DiscountIncVat != 3000 {
|
||||||
|
t.Fatalf("result.evaluatedItems wrong: %+v", ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutations are still a sibling of result.
|
||||||
|
var muts []map[string]any
|
||||||
|
if err := json.Unmarshal(resp["mutations"], &muts); err != nil {
|
||||||
|
t.Fatalf("mutations parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(muts) != 1 || muts[0]["type"] != "cart.AddItem" {
|
||||||
|
t.Fatalf("mutations wrong: %+v", muts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteResult_NoEvaluatedItemsOmitsField verifies the EvaluatedItems
|
||||||
|
// omitempty on CartGrain drops the field entirely when the canonical
|
||||||
|
// pipeline hasn't run (synthetic grain in a fixture, fresh spawn before
|
||||||
|
// any promotion-aware mutation). Clients reading evaluatedItems can
|
||||||
|
// rely on absence to mean "no promotion compute yet".
|
||||||
|
func TestWriteResult_NoEvaluatedItemsOmitsField(t *testing.T) {
|
||||||
|
grain := &cart.CartGrain{
|
||||||
|
Id: 1,
|
||||||
|
Currency: "SEK",
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &PoolServer{}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
if err := s.WriteResult(w, grain); err != nil {
|
||||||
|
t.Fatalf("WriteResult: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if _, has := resp["evaluatedItems"]; has {
|
||||||
|
t.Fatalf("expected evaluatedItems omitted on a grain with nil breakdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapKeys returns the sorted set of top-level keys for nicer failure output.
|
||||||
|
func mapKeys(m map[string]json.RawMessage) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -139,7 +139,7 @@ const minGlassArea = 0.4 // m²
|
|||||||
// areaFromItem derives the main article's *visible glass* surface in m² from a
|
// areaFromItem derives the main article's *visible glass* surface in m² from a
|
||||||
// resolved configurator selection. The catalog width/height are facet codes;
|
// resolved configurator selection. The catalog width/height are facet codes;
|
||||||
// the outer (karmytter) size is `code × 100 − margin` mm and the visible glass
|
// the outer (karmytter) size is `code × 100 − margin` mm and the visible glass
|
||||||
// is that minus the frame border per axis, e.g. width 4, widthMargin 20,
|
// is that minus the frame border per axis, e.g. width 4, marginWidth 20,
|
||||||
// borderWidth 194 → 400 − 20 − 194 = 186 mm. Area = glassW(mm) × glassH(mm) /
|
// borderWidth 194 → 400 − 20 − 194 = 186 mm. Area = glassW(mm) × glassH(mm) /
|
||||||
// 1e6, clamped up to the minGlassArea billing floor. Returns 0 when a dimension
|
// 1e6, clamped up to the minGlassArea billing floor. Returns 0 when a dimension
|
||||||
// is missing/non-numeric (non-window PDP, or no product resolved yet).
|
// is missing/non-numeric (non-window PDP, or no product resolved yet).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
// TestProjectionCache_ApplyUpserts covers the happy path: a batch of N
|
// TestProjectionCache_ApplyUpserts covers the happy path: a batch of N
|
||||||
// projections reaches the cache via Apply, all are visible via Get.
|
// projections reaches the cache via Apply, all are visible via Get.
|
||||||
func TestProjectionCache_ApplyUpserts(t *testing.T) {
|
func TestProjectionCache_ApplyUpserts(t *testing.T) {
|
||||||
c := newCatalogProjectionCache()
|
c := mustCache(t)
|
||||||
updates := []catalog.ProjectionUpdate{
|
updates := []catalog.ProjectionUpdate{
|
||||||
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard"}},
|
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard"}},
|
||||||
{Projection: catalog.Projection{ID: "id-B", SKU: "B", PriceIncVat: money.Cents(150_00), TaxClass: "reduced"}},
|
{Projection: catalog.Projection{ID: "id-B", SKU: "B", PriceIncVat: money.Cents(150_00), TaxClass: "reduced"}},
|
||||||
@@ -48,7 +48,7 @@ func TestProjectionCache_ApplyUpserts(t *testing.T) {
|
|||||||
// - IsDeleted returns true
|
// - IsDeleted returns true
|
||||||
// - the same entry continues to count toward Len (size accounting)
|
// - the same entry continues to count toward Len (size accounting)
|
||||||
func TestProjectionCache_DeleteTombstone(t *testing.T) {
|
func TestProjectionCache_DeleteTombstone(t *testing.T) {
|
||||||
c := newCatalogProjectionCache()
|
c := mustCache(t)
|
||||||
c.Apply([]catalog.ProjectionUpdate{
|
c.Apply([]catalog.ProjectionUpdate{
|
||||||
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00)}},
|
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00)}},
|
||||||
})
|
})
|
||||||
@@ -81,7 +81,7 @@ func TestProjectionCache_DeleteTombstone(t *testing.T) {
|
|||||||
// clears the tombstone (live entry replaces it): Get returns the projection,
|
// clears the tombstone (live entry replaces it): Get returns the projection,
|
||||||
// IsDeleted flips to false. Mirrors how the live catalog keeps flipping state.
|
// IsDeleted flips to false. Mirrors how the live catalog keeps flipping state.
|
||||||
func TestProjectionCache_BusResurrect(t *testing.T) {
|
func TestProjectionCache_BusResurrect(t *testing.T) {
|
||||||
c := newCatalogProjectionCache()
|
c := mustCache(t)
|
||||||
c.Apply([]catalog.ProjectionUpdate{
|
c.Apply([]catalog.ProjectionUpdate{
|
||||||
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}},
|
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}},
|
||||||
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
|
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
|
||||||
@@ -110,7 +110,7 @@ func TestProjectionCache_BusResurrect(t *testing.T) {
|
|||||||
// SKU is a no-op (defensive — bus should never publish "" but cheaper to log
|
// SKU is a no-op (defensive — bus should never publish "" but cheaper to log
|
||||||
// + drop than to populate an ambiguous cache slot).
|
// + drop than to populate an ambiguous cache slot).
|
||||||
func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
|
func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
|
||||||
c := newCatalogProjectionCache()
|
c := mustCache(t)
|
||||||
upserts, _ := c.Apply([]catalog.ProjectionUpdate{
|
upserts, _ := c.Apply([]catalog.ProjectionUpdate{
|
||||||
{Projection: catalog.Projection{SKU: "", PriceIncVat: money.Cents(100_00)}},
|
{Projection: catalog.Projection{SKU: "", PriceIncVat: money.Cents(100_00)}},
|
||||||
})
|
})
|
||||||
@@ -125,7 +125,7 @@ func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
|
|||||||
// TestProjectionCache_BusRaceSafe runs Apply + Get concurrently under -race
|
// TestProjectionCache_BusRaceSafe runs Apply + Get concurrently under -race
|
||||||
// to lock down the lock order. Run via `go test -race ./cmd/cart/...`.
|
// to lock down the lock order. Run via `go test -race ./cmd/cart/...`.
|
||||||
func TestProjectionCache_BusRaceSafe(t *testing.T) {
|
func TestProjectionCache_BusRaceSafe(t *testing.T) {
|
||||||
c := newCatalogProjectionCache()
|
c := mustCache(t)
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
@@ -151,7 +151,7 @@ func TestProjectionCache_BusRaceSafe(t *testing.T) {
|
|||||||
// the per-batch counters report the right split. Without this the cache could
|
// the per-batch counters report the right split. Without this the cache could
|
||||||
// drift on count semantics across batched events.
|
// drift on count semantics across batched events.
|
||||||
func TestApply_MixedUpsertDelete(t *testing.T) {
|
func TestApply_MixedUpsertDelete(t *testing.T) {
|
||||||
c := newCatalogProjectionCache()
|
c := mustCache(t)
|
||||||
// Order matters: prior in-cache state for A is upserted twice (latest wins),
|
// Order matters: prior in-cache state for A is upserted twice (latest wins),
|
||||||
// B is tombstoned, C cold-upserted, D cold-tombstoned.
|
// B is tombstoned, C cold-upserted, D cold-tombstoned.
|
||||||
updates := []catalog.ProjectionUpdate{
|
updates := []catalog.ProjectionUpdate{
|
||||||
|
|||||||
@@ -34,6 +34,82 @@ func resolveTaxBp(class string) int {
|
|||||||
return 2500
|
return 2500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasRequiredFields asserts the catalog Projection carries enough authoritative
|
||||||
|
// fields for the cart to build a complete AddItem WITHOUT an HTTP fallback to
|
||||||
|
// PRODUCT_BASE_URL. Required:
|
||||||
|
//
|
||||||
|
// PriceIncVat > 0 (treats zero as "unbus-fed", not "free"; positive-only
|
||||||
|
// matches every other cache-authoritative field)
|
||||||
|
// TaxClass != "" (string identifier; numeric rate resolved via
|
||||||
|
// resolveTaxBp at the same scale as the HTTP path)
|
||||||
|
// ItemID != 0 (uint32 dedup key; zero means the producer didn't
|
||||||
|
// publish one, so we don't claim we know the id)
|
||||||
|
//
|
||||||
|
// Display fields (Title, Image) and fulfillment flags (InventoryTracked,
|
||||||
|
// DropShip) are optional — when present they populate the line, when absent
|
||||||
|
// the proto-zero value is acceptable.
|
||||||
|
func HasRequiredFields(p catalog.Projection) bool {
|
||||||
|
return p.PriceIncVat.Int64() > 0 &&
|
||||||
|
p.TaxClass != "" &&
|
||||||
|
p.ItemID != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAddItemFromCache constructs a complete AddItem message directly from a
|
||||||
|
// cache hit that satisfies HasRequiredFields. The caller has already confirmed
|
||||||
|
// the projection is well-formed; we map every cache-visible carrier onto the
|
||||||
|
// proto fields the existing HTTP path would have constructed.
|
||||||
|
//
|
||||||
|
// Fields mapped:
|
||||||
|
//
|
||||||
|
// Sku ← input
|
||||||
|
// ItemId ← p.ItemID (uint32 dedup key)
|
||||||
|
// Quantity ← input
|
||||||
|
// Country ← input
|
||||||
|
// StoreId ← input
|
||||||
|
// Price ← p.PriceIncVat.Int64()
|
||||||
|
// Tax ← int32(resolveTaxBp(p.TaxClass))
|
||||||
|
// Name ← p.Title (may be empty)
|
||||||
|
// Image ← p.Image (may be empty)
|
||||||
|
// InventoryTracked ← p.InventoryTracked
|
||||||
|
// DropShip ← p.DropShip
|
||||||
|
//
|
||||||
|
// Fields intentionally left blank (cache-only gap; the schema doesn't yet
|
||||||
|
// carry them and the audience for those fields is downstream of the cart line
|
||||||
|
// — they are NOT required for line dedup or pricing):
|
||||||
|
//
|
||||||
|
// SellerId / SellerName — marketplace split; not on Projection yet.
|
||||||
|
// Stock — queried synchronously from inventory at
|
||||||
|
// decision points (per docs/inventory-shape.md).
|
||||||
|
// OrgPrice / Discount — pre-discount price; a future Projection
|
||||||
|
// extension when promotion previews need it.
|
||||||
|
// ExtraJson — dynamic product data (configurator
|
||||||
|
// width/height etc.); a future schema extension
|
||||||
|
// for cache-only-skip-HTTP for groups with
|
||||||
|
// configurator children.
|
||||||
|
//
|
||||||
|
// IMPORTANT: this helper is for TOP-LEVEL lines only. Configurator children
|
||||||
|
// re-price based on parent dimensions (ToItemAddMessage's parent != nil
|
||||||
|
// path); since the Projection does not yet carry width/height, callers must
|
||||||
|
// keep the existing HTTP-fetch flow for groups with children.
|
||||||
|
//
|
||||||
|
// Tombstoned SKUs never reach this helper; callers check idx.IsDeleted(sku)
|
||||||
|
// first and short-circuit.
|
||||||
|
func BuildAddItemFromCache(sku string, qty int, country string, storeId *string, p catalog.Projection) *messages.AddItem {
|
||||||
|
return &messages.AddItem{
|
||||||
|
Sku: sku,
|
||||||
|
ItemId: p.ItemID,
|
||||||
|
Quantity: int32(qty),
|
||||||
|
Country: country,
|
||||||
|
StoreId: storeId,
|
||||||
|
Price: p.PriceIncVat.Int64(),
|
||||||
|
Tax: int32(resolveTaxBp(p.TaxClass)),
|
||||||
|
Name: p.Title,
|
||||||
|
Image: p.Image,
|
||||||
|
InventoryTracked: p.InventoryTracked,
|
||||||
|
DropShip: p.DropShip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto
|
// ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto
|
||||||
// an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch.
|
// an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch.
|
||||||
//
|
//
|
||||||
@@ -61,10 +137,12 @@ func resolveTaxBp(class string) int {
|
|||||||
// ExtraJson product data (configurator options — width/height used by
|
// ExtraJson product data (configurator options — width/height used by
|
||||||
// accessory child-pricing).
|
// accessory child-pricing).
|
||||||
//
|
//
|
||||||
// Now that ItemID round-trips from the bus, a future "cache-only" build path
|
// SKUs that satisfy HasRequiredFields AND have no configurator children
|
||||||
// (skipping PRODUCT_BASE_URL entirely for known SKUs) is unblocked — the
|
// route through BuildAddItemFromCache instead — see the cache-only fast path
|
||||||
// only fields left HTTP-only in that future path are Sellers, Stock, OrgPrice
|
// in buildItemGroups / AddSkuToCartHandler. ApplyProjectionOverlay remains
|
||||||
// and configurator extras, none of which affect line dedup.
|
// for the cold-grace case (unbus-fed cache entry) AND for groups with
|
||||||
|
// children (the parent's HTTP fetch is required for child dimension-driven
|
||||||
|
// pricing; Overlay is applied after the fetch).
|
||||||
//
|
//
|
||||||
// Tombstoned SKUs (catalog.projection_published with deleted=true) → the
|
// Tombstoned SKUs (catalog.projection_published with deleted=true) → the
|
||||||
// caller checks idx.IsDeleted(sku) before this helper and short-circuits.
|
// caller checks idx.IsDeleted(sku) before this helper and short-circuits.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -140,7 +142,7 @@ func TestApplyProjectionOverlay_NilMsgSafe(t *testing.T) {
|
|||||||
// bypass the tombstone branch and fall through to the HTTP fetch — failing
|
// bypass the tombstone branch and fall through to the HTTP fetch — failing
|
||||||
// the test for a wiring reason rather than the contract under test.
|
// the test for a wiring reason rather than the contract under test.
|
||||||
func TestAddSkuToCartHandler_TombstoneReturns404(t *testing.T) {
|
func TestAddSkuToCartHandler_TombstoneReturns404(t *testing.T) {
|
||||||
idx := newCatalogProjectionCache()
|
idx := mustCache(t)
|
||||||
idx.Apply([]catalog.ProjectionUpdate{
|
idx.Apply([]catalog.ProjectionUpdate{
|
||||||
{Projection: catalog.Projection{SKU: "DEL", PriceIncVat: money.Cents(10_00)}},
|
{Projection: catalog.Projection{SKU: "DEL", PriceIncVat: money.Cents(10_00)}},
|
||||||
{Projection: catalog.Projection{SKU: "DEL"}, Deleted: true},
|
{Projection: catalog.Projection{SKU: "DEL"}, Deleted: true},
|
||||||
@@ -179,7 +181,7 @@ func TestAddSkuToCartHandler_TombstoneReturns404(t *testing.T) {
|
|||||||
// TestIsDeletedVsGet asserts the two methods don't conflict: a tombstoned SKU
|
// TestIsDeletedVsGet asserts the two methods don't conflict: a tombstoned SKU
|
||||||
// is reported by both IsDeleted (true) and Get (miss).
|
// is reported by both IsDeleted (true) and Get (miss).
|
||||||
func TestIsDeletedVsGet(t *testing.T) {
|
func TestIsDeletedVsGet(t *testing.T) {
|
||||||
c := newCatalogProjectionCache()
|
c := mustCache(t)
|
||||||
c.Apply([]catalog.ProjectionUpdate{
|
c.Apply([]catalog.ProjectionUpdate{
|
||||||
{Projection: catalog.Projection{SKU: "DL", PriceIncVat: money.Cents(10_00)}},
|
{Projection: catalog.Projection{SKU: "DL", PriceIncVat: money.Cents(10_00)}},
|
||||||
{Projection: catalog.Projection{SKU: "DL"}, Deleted: true},
|
{Projection: catalog.Projection{SKU: "DL"}, Deleted: true},
|
||||||
@@ -222,3 +224,378 @@ func TestApplyProjectionOverlay_ItemIdPositiveOnly(t *testing.T) {
|
|||||||
t.Errorf("zero cache ItemID wiped HTTP id: got %d, want 12345", m.ItemId)
|
t.Errorf("zero cache ItemID wiped HTTP id: got %d, want 12345", m.ItemId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cache-only-skip-HTTP path tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestHasRequiredFields covers the eligibility contract for the cache-only
|
||||||
|
// fast path: positive PriceIncVat, non-empty TaxClass, non-zero ItemID. Any
|
||||||
|
// one missing → HTTP fallback.
|
||||||
|
func TestHasRequiredFields(t *testing.T) {
|
||||||
|
full := catalog.Projection{
|
||||||
|
SKU: "S", PriceIncVat: money.Cents(100), TaxClass: "standard", ItemID: 1,
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
p catalog.Projection
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"all required fields set", full, true},
|
||||||
|
{"zero price → ineligible", catalog.Projection{SKU: "S", TaxClass: "standard", ItemID: 1}, false},
|
||||||
|
{"empty tax → ineligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(100), ItemID: 1}, false},
|
||||||
|
{"zero id → ineligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(100), TaxClass: "standard"}, false},
|
||||||
|
{"only sku set", catalog.Projection{SKU: "S"}, false},
|
||||||
|
{"empty zero value", catalog.Projection{}, false},
|
||||||
|
{"reduced-rate still eligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(99), TaxClass: "reduced", ItemID: 7}, true},
|
||||||
|
{"zero-rate still eligible (zero TaxClass not required, only non-empty)",
|
||||||
|
catalog.Projection{SKU: "S", PriceIncVat: money.Cents(0), TaxClass: "zero", ItemID: 7}, false}, // price=0 still ineligible
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := HasRequiredFields(tc.p); got != tc.want {
|
||||||
|
t.Errorf("%s: HasRequiredFields=%v, want %v (p=%+v)", tc.name, got, tc.want, tc.p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAddItemFromCache verifies the cache-only fast-path builder maps
|
||||||
|
// every cache-visible carrier onto the proto fields. ItemID, Price, Tax
|
||||||
|
// (via TaxClass resolution), Title/Image, quantity/country/storeId all
|
||||||
|
// populated; SellerId/SellerName/Stock/OrgPrice/ExtraJson left blank (those
|
||||||
|
// fields live outside the Projection for now).
|
||||||
|
func TestBuildAddItemFromCache(t *testing.T) {
|
||||||
|
p := catalog.Projection{
|
||||||
|
SKU: "X",
|
||||||
|
Title: "Cache Title",
|
||||||
|
Image: "cache.jpg",
|
||||||
|
PriceIncVat: money.Cents(123_45),
|
||||||
|
TaxClass: "reduced",
|
||||||
|
ItemID: 42,
|
||||||
|
InventoryTracked: true,
|
||||||
|
DropShip: true,
|
||||||
|
}
|
||||||
|
msg := BuildAddItemFromCache("X", 2, "no", nil, p)
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf("BuildAddItemFromCache returned nil")
|
||||||
|
}
|
||||||
|
if msg.Sku != "X" {
|
||||||
|
t.Errorf("Sku: got %q, want %q", msg.Sku, "X")
|
||||||
|
}
|
||||||
|
if msg.ItemId != 42 {
|
||||||
|
t.Errorf("ItemId: got %d, want 42", msg.ItemId)
|
||||||
|
}
|
||||||
|
if msg.Quantity != 2 {
|
||||||
|
t.Errorf("Quantity: got %d, want 2", msg.Quantity)
|
||||||
|
}
|
||||||
|
if msg.Country != "no" {
|
||||||
|
t.Errorf("Country: got %q, want %q", msg.Country, "no")
|
||||||
|
}
|
||||||
|
if msg.StoreId != nil {
|
||||||
|
t.Errorf("StoreId: got %v, want nil", msg.StoreId)
|
||||||
|
}
|
||||||
|
if msg.Price != 123_45 {
|
||||||
|
t.Errorf("Price: got %d, want 12345", msg.Price)
|
||||||
|
}
|
||||||
|
if msg.Tax != 1200 {
|
||||||
|
t.Errorf("Tax: got %d, want 1200 (reduced)", msg.Tax)
|
||||||
|
}
|
||||||
|
if msg.Name != "Cache Title" {
|
||||||
|
t.Errorf("Name: got %q, want %q", msg.Name, "Cache Title")
|
||||||
|
}
|
||||||
|
if msg.Image != "cache.jpg" {
|
||||||
|
t.Errorf("Image: got %q, want %q", msg.Image, "cache.jpg")
|
||||||
|
}
|
||||||
|
if !msg.InventoryTracked {
|
||||||
|
t.Errorf("InventoryTracked: got false, want true")
|
||||||
|
}
|
||||||
|
if !msg.DropShip {
|
||||||
|
t.Errorf("DropShip: got false, want true")
|
||||||
|
}
|
||||||
|
// Cache-only gaps (intentionally blank):
|
||||||
|
if msg.SellerId != "" || msg.SellerName != "" {
|
||||||
|
t.Errorf("SellerId/SellerName should be blank: %q/%q", msg.SellerId, msg.SellerName)
|
||||||
|
}
|
||||||
|
if msg.Stock != 0 {
|
||||||
|
t.Errorf("Stock should be 0 (queried sync from inventory): got %d", msg.Stock)
|
||||||
|
}
|
||||||
|
if msg.OrgPrice != 0 {
|
||||||
|
t.Errorf("OrgPrice should be 0 (not on Projection): got %d", msg.OrgPrice)
|
||||||
|
}
|
||||||
|
if len(msg.ExtraJson) != 0 {
|
||||||
|
t.Errorf("ExtraJson should be empty (not on Projection): got %s", msg.ExtraJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAddItemFromCache_WithStoreId covers the *string storeId path.
|
||||||
|
// nil pointer is the default; non-nil must propagate through.
|
||||||
|
func TestBuildAddItemFromCache_WithStoreId(t *testing.T) {
|
||||||
|
sid := "store-42"
|
||||||
|
p := catalog.Projection{
|
||||||
|
SKU: "X", PriceIncVat: money.Cents(100),
|
||||||
|
TaxClass: "standard", ItemID: 1,
|
||||||
|
}
|
||||||
|
msg := BuildAddItemFromCache("X", 1, "se", &sid, p)
|
||||||
|
if msg.StoreId == nil {
|
||||||
|
t.Fatalf("StoreId is nil; want propagation")
|
||||||
|
}
|
||||||
|
if *msg.StoreId != "store-42" {
|
||||||
|
t.Errorf("StoreId: got %q, want %q", *msg.StoreId, "store-42")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// productServer is a fixture that returns a minimal product document so
|
||||||
|
// BuildItemMessage / FetchItem can complete without depending on a real
|
||||||
|
// product service. Each invocation records the SKU path so tests can assert
|
||||||
|
// "the cache-only path did NOT call the product service".
|
||||||
|
type productProduct struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Img string `json:"img"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Vat int `json:"vat"`
|
||||||
|
InStock int32 `json:"inStock"`
|
||||||
|
SupplierId int `json:"supplierId"`
|
||||||
|
SupplierName string `json:"supplierName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeProductServer is a goroutine-safe mock that records each call.
|
||||||
|
type fakeProductServer struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
called chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeProductServer(t *testing.T) *fakeProductServer {
|
||||||
|
t.Helper()
|
||||||
|
called := make(chan string, 16)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case called <- r.URL.Path:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
sku := r.URL.Path
|
||||||
|
_ = json.NewEncoder(w).Encode(productProduct{
|
||||||
|
Id: 100, Sku: sku, Title: "http-name",
|
||||||
|
Img: "http.jpg", Price: 50.0, Vat: 25,
|
||||||
|
InStock: 5, SupplierId: 1, SupplierName: "test",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(func() { srv.Close() })
|
||||||
|
return &fakeProductServer{srv: srv, called: called}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildItemGroups_CacheOnlySkipsHTTP locks the core contract of the
|
||||||
|
// cache-only fast path: a parent with NO children AND a cache hit with
|
||||||
|
// HasRequiredFields returns an AddItem populated from the Projection
|
||||||
|
// WITHOUT making any HTTP round-trip. The mock product server's handler
|
||||||
|
// calls t.Errorf if invoked, so a non-empty channel would also fail the
|
||||||
|
// test.
|
||||||
|
func TestBuildItemGroups_CacheOnlySkipsHTTP(t *testing.T) {
|
||||||
|
prod := newFakeProductServer(t)
|
||||||
|
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
|
||||||
|
|
||||||
|
idx := mustCache(t)
|
||||||
|
idx.Apply([]catalog.ProjectionUpdate{
|
||||||
|
{Projection: catalog.Projection{
|
||||||
|
SKU: "TOP",
|
||||||
|
PriceIncVat: money.Cents(99_00),
|
||||||
|
TaxClass: "standard",
|
||||||
|
ItemID: 7,
|
||||||
|
Title: "Cache Top",
|
||||||
|
Image: "cache.jpg",
|
||||||
|
InventoryTracked: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
groups := buildItemGroups(context.Background(), []Item{
|
||||||
|
{Sku: "TOP", Quantity: 1},
|
||||||
|
}, "se", idx)
|
||||||
|
|
||||||
|
if len(groups) != 1 {
|
||||||
|
t.Fatalf("len(groups) = %d, want 1", len(groups))
|
||||||
|
}
|
||||||
|
g := groups[0]
|
||||||
|
if g.parent == nil {
|
||||||
|
t.Fatalf("parent nil; want a BuildAddItemFromCache-built message")
|
||||||
|
}
|
||||||
|
if g.parent.Sku != "TOP" {
|
||||||
|
t.Errorf("Sku: got %q, want %q", g.parent.Sku, "TOP")
|
||||||
|
}
|
||||||
|
if g.parent.Price != 99_00 {
|
||||||
|
t.Errorf("Price: got %d, want 9900", g.parent.Price)
|
||||||
|
}
|
||||||
|
if g.parent.Tax != 2500 {
|
||||||
|
t.Errorf("Tax: got %d, want 2500", g.parent.Tax)
|
||||||
|
}
|
||||||
|
if g.parent.ItemId != 7 {
|
||||||
|
t.Errorf("ItemId: got %d, want 7", g.parent.ItemId)
|
||||||
|
}
|
||||||
|
if g.parent.Name != "Cache Top" {
|
||||||
|
t.Errorf("Name: got %q, want %q", g.parent.Name, "Cache Top")
|
||||||
|
}
|
||||||
|
if g.parent.Image != "cache.jpg" {
|
||||||
|
t.Errorf("Image: got %q, want %q", g.parent.Image, "cache.jpg")
|
||||||
|
}
|
||||||
|
if !g.parent.InventoryTracked {
|
||||||
|
t.Errorf("InventoryTracked: got false (cache said true)")
|
||||||
|
}
|
||||||
|
if len(g.children) != 0 {
|
||||||
|
t.Errorf("children: got %d, want 0", len(g.children))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crucially: the mock product service must NOT have been called.
|
||||||
|
select {
|
||||||
|
case path := <-prod.called:
|
||||||
|
t.Errorf("product service called at %s; cache-only path should skip HTTP", path)
|
||||||
|
default:
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildItemGroups_HTTPFallbackWhenChildrenPresent locks the configurator
|
||||||
|
// safety: a parent with children CANNOT take the cache-only fast path
|
||||||
|
// because price derivation in ToItemAddMessage's parent != nil branch uses
|
||||||
|
// areaFromItem(parent) which reads width/height from the HTTP-fetched
|
||||||
|
// ProductItem.Extra — not on Projection yet. So the HTTP path must run for
|
||||||
|
// BOTH the parent and the child.
|
||||||
|
func TestBuildItemGroups_HTTPFallbackWhenChildrenPresent(t *testing.T) {
|
||||||
|
prod := newFakeProductServer(t)
|
||||||
|
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
|
||||||
|
|
||||||
|
idx := mustCache(t)
|
||||||
|
idx.Apply([]catalog.ProjectionUpdate{
|
||||||
|
{Projection: catalog.Projection{
|
||||||
|
SKU: "PARENT", PriceIncVat: money.Cents(99_00),
|
||||||
|
TaxClass: "standard", ItemID: 7,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
groups := buildItemGroups(context.Background(), []Item{
|
||||||
|
{Sku: "PARENT", Quantity: 1, Children: []Item{
|
||||||
|
{Sku: "CHILD", Quantity: 1},
|
||||||
|
}},
|
||||||
|
}, "se", idx)
|
||||||
|
|
||||||
|
if len(groups) != 1 {
|
||||||
|
t.Fatalf("len(groups) = %d, want 1", len(groups))
|
||||||
|
}
|
||||||
|
if groups[0].parent == nil {
|
||||||
|
t.Fatalf("parent nil; expect HTTP-built message")
|
||||||
|
}
|
||||||
|
// Drain the channel and pin BOTH parent and child fetches. A regression
|
||||||
|
// that fetches the parent twice or skips the child will surface loudly:
|
||||||
|
// we capture the SKU paths the mock sees, so missing/duplicate calls
|
||||||
|
// fail the assertion.
|
||||||
|
close(prod.called)
|
||||||
|
paths := make(map[string]int)
|
||||||
|
for path := range prod.called {
|
||||||
|
paths[path]++
|
||||||
|
}
|
||||||
|
if paths["/api/get/PARENT"] < 1 {
|
||||||
|
t.Errorf("expected /api/get/PARENT fetch (HTTP fallback for parent); got %v", paths)
|
||||||
|
}
|
||||||
|
if paths["/api/get/CHILD"] < 1 {
|
||||||
|
t.Errorf("expected /api/get/CHILD fetch (HTTP fallback for child); got %v", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddSkuToCartHandler_CacheOnlySkipsHTTP exercises the AddSkuToCartHandler
|
||||||
|
// cache-only path end-to-end through the mux. The same fail-on-call mock
|
||||||
|
// product service proves no HTTP round-trip happens on the cache-only fast
|
||||||
|
// path. ApplyLocal will fail on a nil grain pool — that's fine, we recover()
|
||||||
|
// inside the mux wrapper; the only assertion that matters is "the mock was
|
||||||
|
// not called".
|
||||||
|
//
|
||||||
|
// Why a real mux: PathValue("sku") only resolves when the mux has registered
|
||||||
|
// the path pattern (see TestAddSkuToCartHandler_TombstoneReturns404 for the
|
||||||
|
// same rationale).
|
||||||
|
func TestAddSkuToCartHandler_CacheOnlySkipsHTTP(t *testing.T) {
|
||||||
|
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Errorf("product service called at %s on cache-only path; want zero HTTP fetches", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer failSrv.Close()
|
||||||
|
t.Setenv("PRODUCT_BASE_URL", failSrv.URL)
|
||||||
|
|
||||||
|
idx := mustCache(t)
|
||||||
|
idx.Apply([]catalog.ProjectionUpdate{
|
||||||
|
{Projection: catalog.Projection{
|
||||||
|
SKU: "OK", PriceIncVat: money.Cents(99_00),
|
||||||
|
TaxClass: "standard", ItemID: 7,
|
||||||
|
Title: "Cache OK", Image: "cache.jpg",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
s := &PoolServer{pod_name: "test", idx: idx}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /cart/add/{sku}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// ApplyLocal calls into the embedded GrainPool which is nil here;
|
||||||
|
// the cache-only build-the-message path will reach ApplyLocal and
|
||||||
|
// then panic on a nil deref. Recover so the test framework can
|
||||||
|
// observe a clean pass; what matters is the cache-only short-circuit
|
||||||
|
// RAN (the mock product service would have been hit otherwise).
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
_ = s.AddSkuToCartHandler(w, r, cart.CartId(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/cart/add/OK", nil))
|
||||||
|
// No explicit assertion needed: failSrv's t.Errorf would already be
|
||||||
|
// recorded as a test failure if the cache-only short-circuit missed.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildItemGroups_HTTPFallbackWhenCacheMiss locks the cold-grace path:
|
||||||
|
// when the cache has NO entry for the SKU, the HTTP fetch must run.
|
||||||
|
func TestBuildItemGroups_HTTPFallbackWhenCacheMiss(t *testing.T) {
|
||||||
|
prod := newFakeProductServer(t)
|
||||||
|
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
|
||||||
|
|
||||||
|
idx := mustCache(t) // empty
|
||||||
|
groups := buildItemGroups(context.Background(), []Item{
|
||||||
|
{Sku: "MISS", Quantity: 1},
|
||||||
|
}, "se", idx)
|
||||||
|
if groups[0].parent == nil {
|
||||||
|
t.Fatalf("parent nil; cold-grace should have HTTP-fetched")
|
||||||
|
}
|
||||||
|
close(prod.called)
|
||||||
|
count := 0
|
||||||
|
for range prod.called {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected exactly 1 product service call; got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildItemGroups_HTTPFallbackWhenHasRequiredFieldsFalse locks the
|
||||||
|
// partial-cache case: a cache entry with PriceIncVat > 0 but TaxClass=""
|
||||||
|
// fails HasRequiredFields → HTTP fallback. This keeps the existing cold-grace
|
||||||
|
// behavior for SKUs whose producer hasn't published a TaxClass yet.
|
||||||
|
func TestBuildItemGroups_HTTPFallbackWhenHasRequiredFieldsFalse(t *testing.T) {
|
||||||
|
prod := newFakeProductServer(t)
|
||||||
|
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
|
||||||
|
|
||||||
|
idx := mustCache(t)
|
||||||
|
idx.Apply([]catalog.ProjectionUpdate{
|
||||||
|
{Projection: catalog.Projection{
|
||||||
|
SKU: "PARTIAL", PriceIncVat: money.Cents(99_00),
|
||||||
|
TaxClass: "" /* missing → HasRequiredFields=false */,
|
||||||
|
ItemID: 7,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
groups := buildItemGroups(context.Background(), []Item{
|
||||||
|
{Sku: "PARTIAL", Quantity: 1},
|
||||||
|
}, "se", idx)
|
||||||
|
if groups[0].parent == nil {
|
||||||
|
t.Fatalf("parent nil; partial cache should have HTTP-fetched")
|
||||||
|
}
|
||||||
|
close(prod.called)
|
||||||
|
count := 0
|
||||||
|
for range prod.called {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected exactly 1 product service call; got %d (HasRequiredFields=false should fail the cache-only gate)", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/catalog"
|
||||||
|
"git.k6n.net/mats/platform/money"
|
||||||
|
)
|
||||||
|
|
||||||
|
// frameMeta builds the event.Meta a producer stamps for a snapshot frame / delta.
|
||||||
|
func frameMeta(phase string, epoch int64) map[string]string {
|
||||||
|
m := map[string]string{catalog.MetaEpoch: strconv.FormatInt(epoch, 10), catalog.MetaTenant: "se"}
|
||||||
|
if phase != "" {
|
||||||
|
m[catalog.MetaSnapshotPhase] = phase
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func enc(t *testing.T, ups []catalog.ProjectionUpdate) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := catalog.EncodeUpdates(ups)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCartCache_SnapshotThenDelta exercises the cart's cache through the real bus
|
||||||
|
// framing: a full begin→chunk→end snapshot makes SKUs Get-able cold (no Apply),
|
||||||
|
// then an epoch-matched delta overrides one and tombstones another.
|
||||||
|
func TestCartCache_SnapshotThenDelta(t *testing.T) {
|
||||||
|
c := mustCache(t)
|
||||||
|
|
||||||
|
const epoch = int64(1000)
|
||||||
|
if err := c.HandleFrame(frameMeta(catalog.SnapshotBegin, epoch), enc(t, nil)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.HandleFrame(frameMeta(catalog.SnapshotChunk, epoch), enc(t, []catalog.ProjectionUpdate{
|
||||||
|
{Projection: catalog.Projection{ID: "sku:A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard", ItemID: 1}},
|
||||||
|
{Projection: catalog.Projection{ID: "sku:B", SKU: "B", PriceIncVat: money.Cents(50_00), TaxClass: "reduced", ItemID: 2}},
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// end carries the total count for the completeness guard.
|
||||||
|
endMeta := frameMeta(catalog.SnapshotEnd, epoch)
|
||||||
|
endMeta[catalog.MetaCount] = "2"
|
||||||
|
if err := c.HandleFrame(endMeta, enc(t, nil)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, ok := c.Get("A"); !ok || got.PriceIncVat != 100_00 {
|
||||||
|
t.Fatalf("A from snapshot = %+v ok=%v, want 10000", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Epoch-matched delta: bump A's price, delete B.
|
||||||
|
if err := c.HandleFrame(frameMeta("", epoch), enc(t, []catalog.ProjectionUpdate{
|
||||||
|
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(90_00), TaxClass: "standard", ItemID: 1}},
|
||||||
|
{Projection: catalog.Projection{SKU: "B"}, Deleted: true},
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := c.Get("A"); got.PriceIncVat != 90_00 {
|
||||||
|
t.Fatalf("A after delta = %d, want 9000", got.PriceIncVat)
|
||||||
|
}
|
||||||
|
if _, ok := c.Get("B"); ok {
|
||||||
|
t.Fatalf("B after delete should miss")
|
||||||
|
}
|
||||||
|
if !c.IsDeleted("B") {
|
||||||
|
t.Fatalf("B should be tombstoned (skip HTTP fetch)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/catalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mustCache opens a per-pod projection cache backed by a temp dir (pod-local).
|
||||||
|
func mustCache(t *testing.T) *catalogProjectionCache {
|
||||||
|
t.Helper()
|
||||||
|
c, err := newCatalogProjectionCache(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newCatalogProjectionCache: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = c.store.Close() })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply is a TEST-ONLY convenience that routes a delta batch through the store at
|
||||||
|
// the initial epoch (0), reproducing the pre-ProjectionStore cache API so the
|
||||||
|
// existing cart tests keep exercising Get/IsDeleted/Len. Production code feeds the
|
||||||
|
// store via HandleFrame with real bus framing (snapshot + epoch-stamped deltas).
|
||||||
|
func (c *catalogProjectionCache) Apply(updates []catalog.ProjectionUpdate) (upserts, deletes int) {
|
||||||
|
for _, u := range updates {
|
||||||
|
if u.Deleted {
|
||||||
|
deletes++
|
||||||
|
} else if u.SKU != "" {
|
||||||
|
upserts++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload, err := catalog.EncodeUpdates(updates)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
// No MetaSnapshotPhase ⇒ delta; epoch 0 matches a fresh store's base epoch.
|
||||||
|
_ = c.store.HandleFrame(map[string]string{catalog.MetaEpoch: "0"}, payload)
|
||||||
|
return upserts, deletes
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +16,7 @@ import (
|
|||||||
// (possibly partial) evaluation context and returns the totals plus the
|
// (possibly partial) evaluation context and returns the totals plus the
|
||||||
// applied/pending promotion effects, without creating or mutating a real cart.
|
// applied/pending promotion effects, without creating or mutating a real cart.
|
||||||
// An empty body is treated as an empty context (evaluates against no items).
|
// 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 {
|
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req promotions.EvaluateRequest
|
var req promotions.EvaluateRequest
|
||||||
@@ -27,3 +31,179 @@ func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.Promot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,6 +151,33 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflected applied promotions as negative discount lines
|
||||||
|
if grain.CartState != nil {
|
||||||
|
for _, ap := range grain.CartState.AppliedPromotions {
|
||||||
|
if ap.Pending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
discountVal := int64(0)
|
||||||
|
if ap.Discount != nil {
|
||||||
|
discountVal = ap.Discount.IncVat.Int64()
|
||||||
|
}
|
||||||
|
if discountVal <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, &Line{
|
||||||
|
Type: "discount",
|
||||||
|
Reference: "promo-" + ap.PromotionId,
|
||||||
|
Name: "Promotion: " + ap.Name,
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: int(-discountVal),
|
||||||
|
QuantityUnit: "st",
|
||||||
|
TotalAmount: int(-discountVal),
|
||||||
|
TaxRate: 2500,
|
||||||
|
TotalTaxAmount: int(-discountVal * 2500 / 12500),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
order := &CheckoutOrder{
|
order := &CheckoutOrder{
|
||||||
PurchaseCountry: country,
|
PurchaseCountry: country,
|
||||||
PurchaseCurrency: currency,
|
PurchaseCurrency: currency,
|
||||||
@@ -285,6 +312,30 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflected applied promotions as negative discount lines
|
||||||
|
if grain.CartState != nil {
|
||||||
|
for _, ap := range grain.CartState.AppliedPromotions {
|
||||||
|
if ap.Pending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
discountVal := int64(0)
|
||||||
|
if ap.Discount != nil {
|
||||||
|
discountVal = ap.Discount.IncVat.Int64()
|
||||||
|
}
|
||||||
|
if discountVal <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||||
|
Quantity: common.PtrInt64(1),
|
||||||
|
AmountIncludingTax: common.PtrInt64(-discountVal),
|
||||||
|
Description: common.PtrString("Promotion: " + ap.Name),
|
||||||
|
AmountExcludingTax: common.PtrInt64(-discountVal * 10000 / 12500),
|
||||||
|
TaxAmount: common.PtrInt64(-discountVal * 2500 / 12500),
|
||||||
|
TaxPercentage: common.PtrInt64(2500),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &adyenCheckout.CreateCheckoutSessionRequest{
|
return &adyenCheckout.CreateCheckoutSessionRequest{
|
||||||
Reference: grain.Id.String(),
|
Reference: grain.Id.String(),
|
||||||
Amount: adyenCheckout.Amount{
|
Amount: adyenCheckout.Amount{
|
||||||
|
|||||||
@@ -1,65 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetDiscovery() discovery.Discovery {
|
|
||||||
if podIp == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
config, kerr := rest.InClusterConfig()
|
|
||||||
|
|
||||||
if kerr != nil {
|
|
||||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
|
||||||
}
|
|
||||||
client, err := kubernetes.NewForConfig(config)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating client: %v\n", err)
|
|
||||||
}
|
|
||||||
timeout := int64(30)
|
|
||||||
// Scope discovery to this pod's namespace so it only needs a namespaced Role
|
|
||||||
// (pods: get/list/watch), not a cluster-wide ClusterRole.
|
|
||||||
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
|
|
||||||
LabelSelector: "actor-pool=checkout",
|
|
||||||
TimeoutSeconds: &timeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||||
|
discovery.StartK8sDiscovery(podIp, "actor-pool=checkout", 30, pool)
|
||||||
go func(hw discovery.Discovery) {
|
|
||||||
if hw == nil {
|
|
||||||
log.Print("No discovery service available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch, err := hw.Watch()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Discovery error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for evt := range ch {
|
|
||||||
if evt.Host == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch evt.IsReady {
|
|
||||||
case false:
|
|
||||||
if pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
|
||||||
pool.RemoveHost(evt.Host)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if !pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Discovered host %s", evt.Host)
|
|
||||||
pool.AddRemoteHost(evt.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(GetDiscovery())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/platform/inventory"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *
|
|||||||
|
|
||||||
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
||||||
if item.StoreId == nil || *item.StoreId == "" {
|
if item.StoreId == nil || *item.StoreId == "" {
|
||||||
return "se"
|
return inventory.LocationID("se")
|
||||||
}
|
}
|
||||||
return inventory.LocationID(*item.StoreId)
|
return inventory.LocationID(*item.StoreId)
|
||||||
}
|
}
|
||||||
@@ -259,23 +259,6 @@ func shouldTrackInventory(item *cart.CartItem) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
|
||||||
var requests []inventory.ReserveRequest
|
|
||||||
for _, item := range items {
|
|
||||||
if item == nil || !shouldTrackInventory(item) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
requests = append(requests, inventory.ReserveRequest{
|
|
||||||
InventoryReference: &inventory.InventoryReference{
|
|
||||||
SKU: inventory.SKU(item.Sku),
|
|
||||||
LocationID: getLocationId(item),
|
|
||||||
},
|
|
||||||
Quantity: uint32(item.Quantity),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return requests
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
|
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
|
||||||
cartId, ok := cart.ParseCartId(order.MerchantReference1)
|
cartId, ok := cart.ParseCartId(order.MerchantReference1)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+57
-15
@@ -15,23 +15,22 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
redisinv "git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
"git.k6n.net/mats/platform/config"
|
"git.k6n.net/mats/platform/config"
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
"git.k6n.net/mats/platform/tax"
|
"git.k6n.net/mats/platform/tax"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
// checkoutMetrics is the shared prometheus instrumentation for the
|
||||||
Name: "checkout_grain_spawned_total",
|
// checkout actor pool and event log. Built once at process start;
|
||||||
Help: "The total number of spawned checkout grains",
|
// the actor package's touch sites are nil-safe so a test binary
|
||||||
})
|
// could pass nil.
|
||||||
|
checkoutMetrics = actor.NewMetrics("checkout")
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -102,29 +101,27 @@ func main() {
|
|||||||
Password: redisPassword,
|
Password: redisPassword,
|
||||||
DB: 0,
|
DB: 0,
|
||||||
})
|
})
|
||||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reservationService, err := inventory.NewRedisCartReservationService(rdb)
|
reservationService, err := redisinv.NewRedisCartReservationService(rdb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error creating reservation service: %v\n", err)
|
log.Fatalf("Error creating reservation service: %v\n", err)
|
||||||
}
|
}
|
||||||
|
reservationPolicy := redisinv.NewReservationPolicyAdapter(reservationService)
|
||||||
|
|
||||||
checkoutDir := os.Getenv("CHECKOUT_DIR")
|
checkoutDir := os.Getenv("CHECKOUT_DIR")
|
||||||
if checkoutDir == "" {
|
if checkoutDir == "" {
|
||||||
checkoutDir = "data"
|
checkoutDir = "data"
|
||||||
}
|
}
|
||||||
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
|
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
|
||||||
|
diskStorage.SetMetrics(checkoutMetrics)
|
||||||
var syncedServer *CheckoutPoolServer
|
var syncedServer *CheckoutPoolServer
|
||||||
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
||||||
MutationRegistry: reg,
|
MutationRegistry: reg,
|
||||||
Storage: diskStorage,
|
Storage: diskStorage,
|
||||||
|
Metrics: checkoutMetrics,
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
|
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
|
||||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
|
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
|
||||||
defer span.End()
|
defer span.End()
|
||||||
grainSpawns.Inc()
|
|
||||||
|
|
||||||
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
|
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
|
||||||
// Load persisted events/state for this checkout if present
|
// Load persisted events/state for this checkout if present
|
||||||
@@ -194,8 +191,7 @@ func main() {
|
|||||||
pool.AddListener(checkoutFeed)
|
pool.AddListener(checkoutFeed)
|
||||||
|
|
||||||
syncedServer = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
|
syncedServer = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
|
||||||
syncedServer.inventoryService = inventoryService
|
syncedServer.reservationPolicy = reservationPolicy
|
||||||
syncedServer.reservationService = reservationService
|
|
||||||
syncedServer.taxProvider = selectTaxProvider()
|
syncedServer.taxProvider = selectTaxProvider()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -216,6 +212,31 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// Data Retention (C5) GDPR Purge
|
||||||
|
retentionDays := config.EnvInt("CHECKOUT_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
|
||||||
|
if retentionDays > 0 {
|
||||||
|
purgeInterval := config.EnvDuration("CHECKOUT_PURGE_INTERVAL", 24*time.Hour)
|
||||||
|
log.Printf("checkout: data retention enabled. Retaining checkouts for %d days, purging every %v", retentionDays, purgeInterval)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(purgeInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run immediately on startup
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
log.Print("checkout: data retention / GDPR purge disabled (CHECKOUT_RETENTION_DAYS not set or <= 0)")
|
||||||
|
}
|
||||||
|
|
||||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
@@ -303,3 +324,24 @@ func main() {
|
|||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[checkout.CheckoutGrain], pool *actor.SimpleGrainPool[checkout.CheckoutGrain], retentionDays int) {
|
||||||
|
log.Printf("checkout: starting data retention purge...")
|
||||||
|
retention := time.Duration(retentionDays) * 24 * time.Hour
|
||||||
|
|
||||||
|
localIds := make(map[uint64]bool)
|
||||||
|
for _, id := range pool.GetLocalIds() {
|
||||||
|
localIds[id] = true
|
||||||
|
}
|
||||||
|
isGrainActive := func(id uint64) bool {
|
||||||
|
return localIds[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("checkout: data retention purge failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("checkout: data retention purge completed. Purged %d expired checkout logs", purged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type OrderLine struct {
|
|||||||
Quantity int32 `json:"quantity"`
|
Quantity int32 `json:"quantity"`
|
||||||
UnitPrice int64 `json:"unitPrice"`
|
UnitPrice int64 `json:"unitPrice"`
|
||||||
TaxRate int32 `json:"taxRate"`
|
TaxRate int32 `json:"taxRate"`
|
||||||
|
DropShip bool `json:"drop_ship,omitempty"`
|
||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
|
profile "git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||||
|
profileMessages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
|||||||
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
|
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
|
||||||
// (2500 = 25%), so the rate passes through unchanged.
|
// (2500 = 25%), so the rate passes through unchanged.
|
||||||
TaxRate: int32(it.Tax),
|
TaxRate: int32(it.Tax),
|
||||||
|
DropShip: it.DropShip,
|
||||||
Location: location,
|
Location: location,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -159,6 +162,15 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer,
|
|||||||
Status: "completed",
|
Status: "completed",
|
||||||
CreatedAt: timestamppb.Now(),
|
CreatedAt: timestamppb.Now(),
|
||||||
})
|
})
|
||||||
|
if grain.CartState != nil && grain.CartState.UserId != "" {
|
||||||
|
if pid, ok := profile.ParseProfileId(grain.CartState.UserId); ok {
|
||||||
|
_ = s.ApplyAnywhere(ctx, checkout.CheckoutId(pid), &profileMessages.LinkOrder{
|
||||||
|
OrderReference: result.OrderId,
|
||||||
|
CartId: uint64(grain.CartId),
|
||||||
|
Status: "completed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
createErr = err
|
createErr = err
|
||||||
|
|||||||
+11
-17
@@ -16,11 +16,9 @@ import (
|
|||||||
"git.k6n.net/mats/platform/tax"
|
"git.k6n.net/mats/platform/tax"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/inventory"
|
||||||
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
@@ -33,16 +31,13 @@ import (
|
|||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// Pool metrics (checkout_grain_spawned_total, checkout_grain_lookups_total,
|
||||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
// checkout_mutations_total, checkout_remote_negotiation_total,
|
||||||
Name: "checkout_grain_mutations_total",
|
// checkout_connected_remotes, …) are bumped centrally by
|
||||||
Help: "The total number of mutations",
|
// SimpleGrainPool — see pkg/actor/simple_grain_pool.go. The checkout
|
||||||
})
|
// Metrics is registered once in cmd/checkout/main.go and shared with
|
||||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
// DiskStorage via SetMetrics, so the per-handler counters this file
|
||||||
Name: "checkout_grain_lookups_total",
|
// used to maintain are no longer needed.
|
||||||
Help: "The total number of lookups",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
type CheckoutPoolServer struct {
|
type CheckoutPoolServer struct {
|
||||||
actor.GrainPool[checkout.CheckoutGrain]
|
actor.GrainPool[checkout.CheckoutGrain]
|
||||||
@@ -52,8 +47,7 @@ type CheckoutPoolServer struct {
|
|||||||
cartClient *CartClient
|
cartClient *CartClient
|
||||||
orderClient *OrderClient
|
orderClient *OrderClient
|
||||||
orderHandler *AmqpOrderHandler
|
orderHandler *AmqpOrderHandler
|
||||||
inventoryService *inventory.RedisInventoryService
|
reservationPolicy inventory.ReservationPolicy
|
||||||
reservationService *inventory.RedisCartReservationService
|
|
||||||
taxProvider tax.Provider
|
taxProvider tax.Provider
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,14 +550,14 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) releaseCartReservations(ctx context.Context, grain *checkout.CheckoutGrain) {
|
func (s *CheckoutPoolServer) releaseCartReservations(ctx context.Context, grain *checkout.CheckoutGrain) {
|
||||||
if s.reservationService == nil || grain.CartState == nil {
|
if s.reservationPolicy == nil || grain.CartState == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, item := range grain.CartState.Items {
|
for _, item := range grain.CartState.Items {
|
||||||
if item == nil || !shouldTrackInventory(item) {
|
if item == nil || !shouldTrackInventory(item) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
|
if err := s.reservationPolicy.Release(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
|
||||||
logger.WarnContext(ctx, "failed to release cart reservation", "sku", item.Sku, "err", err)
|
logger.WarnContext(ctx, "failed to release cart reservation", "sku", item.Sku, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-6
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
|
"git.k6n.net/mats/platform/inventory"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/metric"
|
"go.opentelemetry.io/otel/metric"
|
||||||
)
|
)
|
||||||
@@ -77,12 +79,23 @@ func getCountryFromHost(host string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
|
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
|
||||||
if a.inventoryService != nil {
|
if a.reservationPolicy == nil {
|
||||||
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
return nil
|
||||||
_, err := a.inventoryService.ReservationCheck(ctx, inventoryRequests...)
|
}
|
||||||
|
for _, item := range grain.CartState.Items {
|
||||||
|
if item == nil || !shouldTrackInventory(item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loc := inventory.LocationID("se")
|
||||||
|
if item.StoreId != nil && *item.StoreId != "" {
|
||||||
|
loc = inventory.LocationID(*item.StoreId)
|
||||||
|
}
|
||||||
|
avail, err := a.reservationPolicy.Available(ctx, inventory.SKU(item.Sku), loc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnContext(ctx, "placeorder inventory check failed")
|
return fmt.Errorf("inventory check failed for SKU %s: %w", item.Sku, err)
|
||||||
return err
|
}
|
||||||
|
if int64(item.Quantity) > avail {
|
||||||
|
return fmt.Errorf("insufficient inventory for SKU %s: have %d, need %d", item.Sku, avail, item.Quantity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -154,7 +167,6 @@ func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http
|
|||||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||||
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
|
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
|
||||||
|
|
||||||
grainLookups.Inc()
|
|
||||||
if err == nil && handled {
|
if err == nil && handled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// batchItem is one stock set in a batch request. LocationID is optional; an
|
||||||
|
// empty value defaults to the service's country location (the same location the
|
||||||
|
// catalog-feed listener writes to).
|
||||||
|
type batchItem struct {
|
||||||
|
SKU string `json:"sku"`
|
||||||
|
LocationID string `json:"locationId,omitempty"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchResult mirrors one input item with the location actually written and an
|
||||||
|
// optional per-item error (empty on success).
|
||||||
|
type batchResult struct {
|
||||||
|
SKU string `json:"sku"`
|
||||||
|
LocationID string `json:"locationId"`
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchInventoryHandler SETS stock to absolute values for many SKUs in one
|
||||||
|
// pipelined call — for re-seeding/import and for testing the inventory + reserve
|
||||||
|
// paths directly. Same write as the catalog-feed listener (UpdateInventory +
|
||||||
|
// level crossing), so a value set here is authoritative until the next
|
||||||
|
// catalog.item_changed for that SKU overwrites it (catalog is the source of
|
||||||
|
// truth). Body is a JSON array of {sku, locationId?, quantity}.
|
||||||
|
func (srv *Server) batchInventoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20)) // 16 MiB
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var items []batchItem
|
||||||
|
if err := json.Unmarshal(body, &items); err != nil {
|
||||||
|
http.Error(w, "body must be a JSON array of {sku, locationId?, quantity}: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
http.Error(w, "empty batch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
results := make([]batchResult, len(items))
|
||||||
|
pipe := srv.rdb.Pipeline()
|
||||||
|
for i, it := range items {
|
||||||
|
loc := it.LocationID
|
||||||
|
if loc == "" {
|
||||||
|
loc = country
|
||||||
|
}
|
||||||
|
results[i] = batchResult{SKU: it.SKU, LocationID: loc, Quantity: it.Quantity}
|
||||||
|
if it.SKU == "" {
|
||||||
|
results[i].Error = "sku is required"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
srv.inventoryService.UpdateInventory(ctx, pipe, inventory.SKU(it.SKU), inventory.LocationID(loc), it.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
hadError := false
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
// Exec reports the first failing command; flag the whole batch rather than
|
||||||
|
// guess which items committed.
|
||||||
|
hadError = true
|
||||||
|
for i := range results {
|
||||||
|
if results[i].Error == "" {
|
||||||
|
results[i].Error = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Emit level crossings for the items that were actually written, so the
|
||||||
|
// finder/level listeners react the same way they do for catalog feeds.
|
||||||
|
for i := range results {
|
||||||
|
if results[i].Error == "" {
|
||||||
|
emitLevelIfChanged(ctx, srv.rdb, srv.conn, country, lowWatermark, results[i].SKU, results[i].LocationID, results[i].Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range results {
|
||||||
|
if results[i].Error != "" {
|
||||||
|
hadError = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := http.StatusOK
|
||||||
|
if hadError {
|
||||||
|
status = http.StatusMultiStatus // 207
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results})
|
||||||
|
}
|
||||||
@@ -40,6 +40,11 @@ func commitOrder(ctx context.Context, rdb *redis.Client, svc *inventory.RedisInv
|
|||||||
if line.SKU == "" || line.Quantity <= 0 {
|
if line.SKU == "" || line.Quantity <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Drop-ship items are fulfilled by the supplier — never decrement
|
||||||
|
// local stock for them.
|
||||||
|
if line.DropShip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
loc := inventory.LocationID(defaultLoc)
|
loc := inventory.LocationID(defaultLoc)
|
||||||
if line.Location != "" {
|
if line.Location != "" {
|
||||||
loc = inventory.LocationID(line.Location)
|
loc = inventory.LocationID(line.Location)
|
||||||
|
|||||||
+12
-1
@@ -24,6 +24,11 @@ import (
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
inventoryService *inventory.RedisInventoryService
|
inventoryService *inventory.RedisInventoryService
|
||||||
reservationService *inventory.RedisCartReservationService
|
reservationService *inventory.RedisCartReservationService
|
||||||
|
|
||||||
|
// rdb + conn back the batch write path (pipelined UpdateInventory + level
|
||||||
|
// crossing). conn is nil when RABBIT_HOST is unset; emit is then skipped.
|
||||||
|
rdb *redis.Client
|
||||||
|
conn *rabbit.Conn
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
|
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -124,13 +129,16 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
server := &Server{inventoryService: s, reservationService: r}
|
server := &Server{inventoryService: s, reservationService: r, rdb: rdb}
|
||||||
|
|
||||||
// Set up HTTP routes
|
// Set up HTTP routes
|
||||||
http.HandleFunc("/livez", server.livezHandler)
|
http.HandleFunc("/livez", server.livezHandler)
|
||||||
http.HandleFunc("/readyz", server.readyzHandler)
|
http.HandleFunc("/readyz", server.readyzHandler)
|
||||||
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
|
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
|
||||||
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
|
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
|
||||||
|
http.HandleFunc("POST /reservations", server.reserveInventoryHandler)
|
||||||
|
http.HandleFunc("POST /reservations/release", server.releaseReservationHandler)
|
||||||
|
http.HandleFunc("POST /inventory/batch", server.batchInventoryHandler)
|
||||||
|
|
||||||
stockhandler := &StockHandler{
|
stockhandler := &StockHandler{
|
||||||
MainStockLocationID: inventory.LocationID(country),
|
MainStockLocationID: inventory.LocationID(country),
|
||||||
@@ -151,6 +159,9 @@ func main() {
|
|||||||
// The catalog-feed handler emits inventory.level_changed crossings
|
// The catalog-feed handler emits inventory.level_changed crossings
|
||||||
// directly on this connection as it writes stock.
|
// directly on this connection as it writes stock.
|
||||||
stockhandler.conn = conn
|
stockhandler.conn = conn
|
||||||
|
// Share the bus connection with the batch handler so its writes emit
|
||||||
|
// inventory.level_changed crossings too.
|
||||||
|
server.conn = conn
|
||||||
// Reconnecting consumer: re-listen on reconnect.
|
// Reconnecting consumer: re-listen on reconnect.
|
||||||
conn.NotifyOnReconnect(func() {
|
conn.NotifyOnReconnect(func() {
|
||||||
ch, err := conn.Channel()
|
ch, err := conn.Channel()
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
type reservationLineReq struct {
|
||||||
|
SKU string `json:"sku"`
|
||||||
|
LocationID string `json:"locationId"`
|
||||||
|
Quantity uint32 `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reservationBatchReq struct {
|
||||||
|
HolderID string `json:"holderId"`
|
||||||
|
TTLSeconds int64 `json:"ttlSeconds,omitempty"`
|
||||||
|
Lines []reservationLineReq `json:"lines"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) reserveInventoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req reservationBatchReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.HolderID == "" {
|
||||||
|
http.Error(w, "missing holderId", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Lines) == 0 {
|
||||||
|
http.Error(w, "missing lines", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ttl := 15 * time.Minute
|
||||||
|
if req.TTLSeconds > 0 {
|
||||||
|
ttl = time.Duration(req.TTLSeconds) * time.Second
|
||||||
|
}
|
||||||
|
for _, line := range req.Lines {
|
||||||
|
if line.SKU == "" || line.LocationID == "" || line.Quantity == 0 {
|
||||||
|
http.Error(w, "each line must include sku, locationId, and quantity", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := srv.reservationService.ReserveForCart(r.Context(), inventory.CartReserveRequest{
|
||||||
|
InventoryReference: &inventory.InventoryReference{
|
||||||
|
SKU: inventory.SKU(line.SKU),
|
||||||
|
LocationID: inventory.LocationID(line.LocationID),
|
||||||
|
},
|
||||||
|
CartID: inventory.CartID(req.HolderID),
|
||||||
|
Quantity: line.Quantity,
|
||||||
|
TTL: ttl,
|
||||||
|
}); err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if errors.Is(err, inventory.ErrInsufficientInventory) {
|
||||||
|
status = http.StatusConflict
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "reserved", "holderId": req.HolderID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) releaseReservationHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req reservationBatchReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.HolderID == "" {
|
||||||
|
http.Error(w, "missing holderId", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Lines) == 0 {
|
||||||
|
http.Error(w, "missing lines", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range req.Lines {
|
||||||
|
if line.SKU == "" || line.LocationID == "" {
|
||||||
|
http.Error(w, "each line must include sku and locationId", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := srv.reservationService.ReleaseForCart(r.Context(),
|
||||||
|
inventory.SKU(line.SKU),
|
||||||
|
inventory.LocationID(line.LocationID),
|
||||||
|
inventory.CartID(req.HolderID),
|
||||||
|
); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "released", "holderId": req.HolderID})
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,10 +41,7 @@ func testServer(t *testing.T) *server {
|
|||||||
applier := &orderedApplier{pool: pool, storage: storage}
|
applier := &orderedApplier{pool: pool, storage: storage}
|
||||||
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
|
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
|
||||||
|
|
||||||
freg := flow.NewRegistry()
|
freg := buildFlowRegistry(applier, provider, nil, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
flow.RegisterBuiltinHooks(freg)
|
|
||||||
order.RegisterFlowActions(freg, applier, provider)
|
|
||||||
order.RegisterEmitHook(freg, nil)
|
|
||||||
engine := flow.NewEngine(freg, 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")
|
def, err := order.EmbeddedFlow("place-and-pay")
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
|
)
|
||||||
|
|
||||||
|
type projectFulfillmentHookParams struct {
|
||||||
|
Integration string `json:"integration,omitempty"`
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
Dropship bool `json:"dropship,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerProjectFlowHooks is the project-layer seam for customer-specific flow
|
||||||
|
// integrations. These belong here rather than pkg/order so core order logic
|
||||||
|
// stays generic while deployments can register their own fulfillment tools.
|
||||||
|
func registerProjectFlowHooks(reg *flow.Registry, app *orderedApplier, logger *slog.Logger) {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
reg.HookWithMeta("project_fulfillment_integration", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
|
||||||
|
var p projectFulfillmentHookParams
|
||||||
|
if len(params) > 0 {
|
||||||
|
if err := json.Unmarshal(params, &p); err != nil {
|
||||||
|
return fmt.Errorf("project_fulfillment_integration: bad params: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Integration == "" {
|
||||||
|
p.Integration = "order-integration"
|
||||||
|
}
|
||||||
|
|
||||||
|
o, err := app.Get(ctx, st.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(o.Fulfillments) == 0 {
|
||||||
|
return fmt.Errorf("project_fulfillment_integration: order %s has no fulfillment yet", order.OrderId(st.ID).String())
|
||||||
|
}
|
||||||
|
last := o.Fulfillments[len(o.Fulfillments)-1]
|
||||||
|
|
||||||
|
log := st.Logger
|
||||||
|
if log == nil {
|
||||||
|
log = logger
|
||||||
|
}
|
||||||
|
log.Info("project fulfillment integration",
|
||||||
|
"orderId", order.OrderId(st.ID).String(),
|
||||||
|
"orderReference", o.OrderReference,
|
||||||
|
"customerEmail", o.CustomerEmail,
|
||||||
|
"integration", p.Integration,
|
||||||
|
"target", p.Target,
|
||||||
|
"dropship", p.Dropship,
|
||||||
|
"step", info.Step,
|
||||||
|
"phase", info.Phase,
|
||||||
|
"fulfillmentId", last.ID,
|
||||||
|
"carrier", last.Carrier,
|
||||||
|
"trackingNumber", last.TrackingNumber,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}, flow.CapabilityMeta{
|
||||||
|
Description: "Project-specific fulfillment integration seam for local or testing adapters.",
|
||||||
|
ExampleParams: json.RawMessage(`{"integration":"dropship-test","target":"erp-sandbox","dropship":true}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectFulfillmentIntegrationHookIsRegistered(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
if !strings.Contains(strings.Join(s.freg.Capabilities().Hooks, ","), "project_fulfillment_integration") {
|
||||||
|
t.Fatalf("hooks = %v, want project_fulfillment_integration", s.freg.Capabilities().Hooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectFulfillmentIntegrationLogs(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
var logs bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&logs, nil))
|
||||||
|
|
||||||
|
provider := order.NewPassthroughProvider("legacy", "ref", 25000)
|
||||||
|
freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, nil, logger)
|
||||||
|
engine := flow.NewEngine(freg, logger)
|
||||||
|
|
||||||
|
st := flow.NewState(801, logger)
|
||||||
|
po := orderTestPlaceMsg()
|
||||||
|
po.CustomerEmail = "dropship@example.com"
|
||||||
|
st.Vars[order.PlaceOrderVar] = po
|
||||||
|
|
||||||
|
def, err := order.EmbeddedFlow("place-and-pay")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(context.Background(), def, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{
|
||||||
|
Name: "fulfill",
|
||||||
|
Action: "create_fulfillment",
|
||||||
|
Params: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"DS-123","lines":[{"reference":"l1","quantity":1}]}`),
|
||||||
|
Hooks: flow.Hooks{After: []flow.HookRef{{
|
||||||
|
Type: "project_fulfillment_integration",
|
||||||
|
Params: json.RawMessage(`{"integration":"dropship-test","target":"erp-sandbox","dropship":true}`),
|
||||||
|
}}},
|
||||||
|
}}}
|
||||||
|
if _, err := engine.Run(context.Background(), ship, flow.NewState(801, logger)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := logs.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`"msg":"project fulfillment integration"`,
|
||||||
|
`"integration":"dropship-test"`,
|
||||||
|
`"target":"erp-sandbox"`,
|
||||||
|
`"dropship":true`,
|
||||||
|
`"trackingNumber":"DS-123"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("log output missing %s: %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderTestPlaceMsg() *messages.PlaceOrder {
|
||||||
|
return &messages.PlaceOrder{
|
||||||
|
OrderReference: "ref-1",
|
||||||
|
CartId: "cart-1",
|
||||||
|
Currency: "SEK",
|
||||||
|
Country: "SE",
|
||||||
|
CustomerName: "Dropship Customer",
|
||||||
|
TotalAmount: 25000,
|
||||||
|
TotalTax: 5000,
|
||||||
|
PlacedAtMs: 1718877600000,
|
||||||
|
Lines: []*messages.OrderLine{
|
||||||
|
{Reference: "l1", Sku: "ABC", Name: "Widget", Quantity: 2, UnitPrice: 10000, TaxRate: 25, TotalAmount: 20000},
|
||||||
|
{Reference: "l2", Sku: "DEF", Name: "Gizmo", Quantity: 1, UnitPrice: 5000, TaxRate: 25, TotalAmount: 5000},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildFlowRegistry(
|
||||||
|
applier *orderedApplier,
|
||||||
|
provider order.PaymentProvider,
|
||||||
|
inventoryReservations order.InventoryReservationService,
|
||||||
|
emitPub order.Publisher,
|
||||||
|
emailSender order.EmailSender,
|
||||||
|
emailTemplates order.EmailTemplateStore,
|
||||||
|
prefChecker order.EmailPreferenceChecker,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) *flow.Registry {
|
||||||
|
freg := flow.NewRegistry()
|
||||||
|
flow.RegisterBuiltinHooks(freg)
|
||||||
|
order.RegisterFlowActions(freg, applier, provider, prefChecker)
|
||||||
|
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
|
||||||
|
order.RegisterEmitHook(freg, emitPub)
|
||||||
|
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates, prefChecker)
|
||||||
|
order.RegisterFulfillmentWebhookHook(freg, applier, nil)
|
||||||
|
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
|
||||||
|
registerProjectFlowHooks(freg, applier, logger)
|
||||||
|
return freg
|
||||||
|
}
|
||||||
@@ -120,11 +120,7 @@ func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromC
|
|||||||
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
|
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
|
||||||
}
|
}
|
||||||
|
|
||||||
freg := flow.NewRegistry()
|
freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, nil, nil, nil, nil, s.logger)
|
||||||
flow.RegisterBuiltinHooks(freg)
|
|
||||||
order.RegisterFlowActions(freg, s.applier, provider)
|
|
||||||
order.RegisterEmitHook(freg, nil)
|
|
||||||
|
|
||||||
engine := flow.NewEngine(freg, s.logger)
|
engine := flow.NewEngine(freg, s.logger)
|
||||||
st := flow.NewState(ordID, s.logger)
|
st := flow.NewState(ordID, s.logger)
|
||||||
st.Vars[order.PlaceOrderVar] = po
|
st.Vars[order.PlaceOrderVar] = po
|
||||||
@@ -178,6 +174,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
|
|||||||
TotalAmount: lineTotal,
|
TotalAmount: lineTotal,
|
||||||
TotalTax: lineTax,
|
TotalTax: lineTax,
|
||||||
Location: l.Location,
|
Location: l.Location,
|
||||||
|
DropShip: l.DropShip,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
po.TotalAmount = total
|
po.TotalAmount = total
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||||
|
discovery.StartK8sDiscovery(podIp, "actor-pool=order", 30, pool)
|
||||||
|
}
|
||||||
+278
-13
@@ -12,10 +12,12 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
ordermcp "git.k6n.net/mats/go-cart-actor/pkg/order/mcp"
|
ordermcp "git.k6n.net/mats/go-cart-actor/pkg/order/mcp"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
|
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||||
"git.k6n.net/mats/platform/config"
|
"git.k6n.net/mats/platform/config"
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
@@ -35,6 +38,9 @@ import (
|
|||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var podIp = os.Getenv("POD_IP")
|
||||||
|
var name = os.Getenv("POD_NAME")
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
pool *actor.SimpleGrainPool[order.OrderGrain]
|
pool *actor.SimpleGrainPool[order.OrderGrain]
|
||||||
applier *orderedApplier
|
applier *orderedApplier
|
||||||
@@ -49,13 +55,14 @@ type server struct {
|
|||||||
dataDir string
|
dataDir string
|
||||||
idem *idempotency.Store
|
idem *idempotency.Store
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
inventoryReservations order.InventoryReservationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := config.EnvString("ORDER_ADDR", ":8092") // :8090 cart, :8091 redirector
|
addr := config.EnvString("ORDER_ADDR", ":8092") // :8090 cart, :8091 redirector
|
||||||
dataDir := config.EnvString("ORDER_DATA", "data/orders")
|
dataDir := config.EnvString("ORDER_DATA", "data/orders")
|
||||||
flowsDir := config.EnvString("ORDER_FLOWS", "data/order-flows")
|
flowsDir := config.EnvString("ORDER_FLOWS", "data/order-flows")
|
||||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
log.Fatalf("create data dir: %v", err)
|
log.Fatalf("create data dir: %v", err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||||
@@ -64,8 +71,13 @@ func main() {
|
|||||||
order.RegisterMutations(reg)
|
order.RegisterMutations(reg)
|
||||||
storage := actor.NewDiskStorage[order.OrderGrain](dataDir, reg)
|
storage := actor.NewDiskStorage[order.OrderGrain](dataDir, reg)
|
||||||
|
|
||||||
|
hostname := podIp
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = "order-1"
|
||||||
|
}
|
||||||
|
|
||||||
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
|
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
|
||||||
Hostname: "order-1",
|
Hostname: hostname,
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
|
Spawn: func(ctx context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
|
||||||
g := order.NewOrderGrain(id, time.Now())
|
g := order.NewOrderGrain(id, time.Now())
|
||||||
// Replay any persisted history so the resident grain is current.
|
// Replay any persisted history so the resident grain is current.
|
||||||
@@ -74,8 +86,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
return g, nil
|
return g, nil
|
||||||
},
|
},
|
||||||
SpawnHost: func(string) (actor.Host[order.OrderGrain], error) {
|
SpawnHost: func(host string) (actor.Host[order.OrderGrain], error) {
|
||||||
return nil, fmt.Errorf("order service is single-node")
|
return proxy.NewRemoteHost[order.OrderGrain](host)
|
||||||
},
|
},
|
||||||
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
|
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
|
||||||
TTL: time.Hour,
|
TTL: time.Hour,
|
||||||
@@ -93,6 +105,15 @@ func main() {
|
|||||||
log.Fatalf("create pool: %v", err)
|
log.Fatalf("create pool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
controlPlaneConfig := actor.DefaultServerConfig()
|
||||||
|
grpcSrv, err := actor.NewControlServer[order.OrderGrain](controlPlaneConfig, pool)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||||
|
}
|
||||||
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
|
UseDiscovery(pool)
|
||||||
|
|
||||||
applier := &orderedApplier{pool: pool, storage: storage}
|
applier := &orderedApplier{pool: pool, storage: storage}
|
||||||
provider := selectProvider(logger)
|
provider := selectProvider(logger)
|
||||||
|
|
||||||
@@ -101,6 +122,18 @@ func main() {
|
|||||||
// the broker (publisher is nil without AMQP — the hook then errors at run
|
// the broker (publisher is nil without AMQP — the hook then errors at run
|
||||||
// time, which is logged, not fatal).
|
// time, which is logged, not fatal).
|
||||||
amqpURL := os.Getenv("AMQP_URL")
|
amqpURL := os.Getenv("AMQP_URL")
|
||||||
|
emailTemplates, err := order.LoadEmailTemplates(config.EnvString("ORDER_EMAIL_TEMPLATES", "data/order-email-templates"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load email templates: %v", err)
|
||||||
|
}
|
||||||
|
emailSender, err := selectEmailSender(logger)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("configure email sender: %v", err)
|
||||||
|
}
|
||||||
|
inventoryReservations, err := selectInventoryReservationService()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("configure inventory reservation service: %v", err)
|
||||||
|
}
|
||||||
var emitPub order.Publisher
|
var emitPub order.Publisher
|
||||||
if amqpURL != "" {
|
if amqpURL != "" {
|
||||||
// The amqp_emit hook writes to a durable outbox rather than the broker
|
// The amqp_emit hook writes to a durable outbox rather than the broker
|
||||||
@@ -138,20 +171,29 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
freg := flow.NewRegistry()
|
// Email preference checker: when a profile service URL is configured, look up
|
||||||
flow.RegisterBuiltinHooks(freg)
|
// customer email preferences before sending transactional emails.
|
||||||
order.RegisterFlowActions(freg, applier, provider)
|
var prefChecker order.EmailPreferenceChecker
|
||||||
order.RegisterEmitHook(freg, emitPub)
|
if profileURL := os.Getenv("PROFILE_URL"); profileURL != "" {
|
||||||
// order.created domain event → durable outbox → "order" topic exchange.
|
prefChecker = order.NewPreferencesChecker(profileURL + "/ucp/v1/customers")
|
||||||
// Reactors: inventory commit, fulfillment allocation.
|
logger.Info("email preference checking enabled", "profile_url", profileURL)
|
||||||
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
|
}
|
||||||
|
|
||||||
|
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, prefChecker, logger)
|
||||||
engine := flow.NewEngine(freg, logger)
|
engine := flow.NewEngine(freg, logger)
|
||||||
|
|
||||||
def, err := order.EmbeddedFlow("place-and-pay")
|
def, err := order.EmbeddedFlow("place-and-pay")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("load flow: %v", err)
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("load flows: %v", err)
|
log.Fatalf("load flows: %v", err)
|
||||||
}
|
}
|
||||||
@@ -171,6 +213,7 @@ func main() {
|
|||||||
engine: engine, freg: freg, flows: flows, provider: provider,
|
engine: engine, freg: freg, flows: flows, provider: provider,
|
||||||
taxProvider: taxProvider,
|
taxProvider: taxProvider,
|
||||||
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
|
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
|
||||||
|
inventoryReservations: inventoryReservations,
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -180,6 +223,7 @@ func main() {
|
|||||||
mux.HandleFunc("POST /checkout", s.handleCheckout)
|
mux.HandleFunc("POST /checkout", s.handleCheckout)
|
||||||
mux.HandleFunc("POST /api/orders/from-checkout", s.handleFromCheckout)
|
mux.HandleFunc("POST /api/orders/from-checkout", s.handleFromCheckout)
|
||||||
mux.HandleFunc("GET /api/orders", s.handleList)
|
mux.HandleFunc("GET /api/orders", s.handleList)
|
||||||
|
mux.HandleFunc("GET /api/orders/stats", s.handleStats)
|
||||||
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
|
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
|
||||||
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
|
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
|
||||||
|
|
||||||
@@ -218,6 +262,14 @@ func main() {
|
|||||||
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
|
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
|
||||||
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
|
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).
|
// Ingest checkout orders from order-queue (Klarna/Adyen fallback).
|
||||||
if amqpURL != "" {
|
if amqpURL != "" {
|
||||||
startOrderIngest(context.Background(), amqpURL, s)
|
startOrderIngest(context.Background(), amqpURL, s)
|
||||||
@@ -278,6 +330,7 @@ type lineReq struct {
|
|||||||
Quantity int32 `json:"quantity"`
|
Quantity int32 `json:"quantity"`
|
||||||
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
|
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
|
||||||
TaxRate int32 `json:"taxRate"` // basis points (2500 = 25%)
|
TaxRate int32 `json:"taxRate"` // basis points (2500 = 25%)
|
||||||
|
DropShip bool `json:"drop_ship,omitempty"`
|
||||||
Location string `json:"location,omitempty"` // inventory commit location / store id
|
Location string `json:"location,omitempty"` // inventory commit location / store id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +437,7 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
|
|||||||
TaxRate: l.TaxRate,
|
TaxRate: l.TaxRate,
|
||||||
TotalAmount: lineTotal,
|
TotalAmount: lineTotal,
|
||||||
TotalTax: lineTax,
|
TotalTax: lineTax,
|
||||||
|
DropShip: l.DropShip,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
po.TotalAmount = total
|
po.TotalAmount = total
|
||||||
@@ -391,6 +445,160 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
|
|||||||
return po
|
return po
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- dashboard stats ------------------------------------------------------
|
||||||
|
|
||||||
|
type orderAlert struct {
|
||||||
|
Severity string `json:"severity"` // warn | error
|
||||||
|
Message string `json:"message"`
|
||||||
|
OrderId string `json:"orderId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusBucket struct {
|
||||||
|
Status order.Status `json:"status"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type revenueSnapshot struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Captured int64 `json:"captured"`
|
||||||
|
Refunded int64 `json:"refunded"`
|
||||||
|
Pending int64 `json:"pending"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyRevenue struct {
|
||||||
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderStatsResponse struct {
|
||||||
|
OrdersTotal int `json:"ordersTotal"`
|
||||||
|
Revenue revenueSnapshot `json:"revenue"`
|
||||||
|
ByStatus []statusBucket `json:"byStatus"`
|
||||||
|
RecentOrders []orderSummary `json:"recentOrders"`
|
||||||
|
DailyRevenue30 []dailyRevenue `json:"dailyRevenue30,omitempty"`
|
||||||
|
Alerts []orderAlert `json:"alerts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStats scans all order logs and returns aggregated dashboard stats.
|
||||||
|
func (s *server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log"))
|
||||||
|
now := time.Now()
|
||||||
|
var totalOrders int
|
||||||
|
var totalRevenue, capturedRevenue, refundedRevenue int64
|
||||||
|
byStatus := map[order.Status]int{}
|
||||||
|
statusTotal := map[order.Status]int64{} // total amount per status
|
||||||
|
|
||||||
|
// Daily revenue for the last 30 days (keys are "2026-06-01" sortable strings).
|
||||||
|
dailyByDay := map[string]int64{}
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||||
|
dailyByDay[day] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all order summaries, then sort by placedAt and take top 10.
|
||||||
|
var allOrders []orderSummary
|
||||||
|
var alerts []orderAlert
|
||||||
|
|
||||||
|
for _, m := range matches {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(m), ".events.log")
|
||||||
|
raw, err := strconv.ParseUint(base, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g, err := s.pool.Get(r.Context(), raw)
|
||||||
|
if err != nil || g.Status == order.StatusNew {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalOrders++
|
||||||
|
byStatus[g.Status]++
|
||||||
|
statusTotal[g.Status] += g.TotalAmount.Int64()
|
||||||
|
|
||||||
|
totalRevenue += g.TotalAmount.Int64()
|
||||||
|
capturedRevenue += g.CapturedAmount.Int64()
|
||||||
|
refundedRevenue += g.RefundedAmount.Int64()
|
||||||
|
|
||||||
|
// Daily revenue — parse placedAt to extract date.
|
||||||
|
if g.PlacedAt != "" {
|
||||||
|
if placed, parseErr := time.Parse(time.RFC3339, g.PlacedAt); parseErr == nil {
|
||||||
|
dayKey := placed.Format("2006-01-02")
|
||||||
|
if _, ok := dailyByDay[dayKey]; ok {
|
||||||
|
dailyByDay[dayKey] += g.TotalAmount.Int64()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allOrders = append(allOrders, orderSummary{
|
||||||
|
OrderId: order.OrderId(raw).String(),
|
||||||
|
Reference: g.OrderReference,
|
||||||
|
Status: g.Status,
|
||||||
|
TotalAmount: g.TotalAmount.Int64(),
|
||||||
|
Currency: g.Currency,
|
||||||
|
PlacedAt: g.PlacedAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Alerts.
|
||||||
|
if g.Status == order.StatusPending {
|
||||||
|
alerts = append(alerts, orderAlert{
|
||||||
|
Severity: "warn",
|
||||||
|
Message: "Payment still pending",
|
||||||
|
OrderId: order.OrderId(raw).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if g.Status == order.StatusCancelled {
|
||||||
|
alerts = append(alerts, orderAlert{
|
||||||
|
Severity: "warn",
|
||||||
|
Message: "Order was cancelled",
|
||||||
|
OrderId: order.OrderId(raw).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort ALL orders by placedAt descending, take top 10 as "recent".
|
||||||
|
sort.Slice(allOrders, func(i, j int) bool {
|
||||||
|
return allOrders[i].PlacedAt > allOrders[j].PlacedAt
|
||||||
|
})
|
||||||
|
recent := allOrders
|
||||||
|
if len(recent) > 10 {
|
||||||
|
recent = recent[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap alerts
|
||||||
|
if len(alerts) > 50 {
|
||||||
|
alerts = alerts[:50]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build status buckets array with totals.
|
||||||
|
buckets := make([]statusBucket, 0, len(byStatus))
|
||||||
|
for st, cnt := range byStatus {
|
||||||
|
buckets = append(buckets, statusBucket{Status: st, Count: cnt, Total: statusTotal[st]})
|
||||||
|
}
|
||||||
|
sort.Slice(buckets, func(i, j int) bool {
|
||||||
|
return buckets[i].Count > buckets[j].Count
|
||||||
|
})
|
||||||
|
|
||||||
|
// Daily revenue as an ordered slice.
|
||||||
|
var dailyRev []dailyRevenue
|
||||||
|
for i := 29; i >= 0; i-- {
|
||||||
|
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||||
|
dailyRev = append(dailyRev, dailyRevenue{Date: day, Total: dailyByDay[day]})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, orderStatsResponse{
|
||||||
|
OrdersTotal: totalOrders,
|
||||||
|
Revenue: revenueSnapshot{
|
||||||
|
Total: totalRevenue,
|
||||||
|
Captured: capturedRevenue,
|
||||||
|
Refunded: refundedRevenue,
|
||||||
|
Pending: totalRevenue - capturedRevenue - refundedRevenue,
|
||||||
|
},
|
||||||
|
ByStatus: buckets,
|
||||||
|
RecentOrders: recent,
|
||||||
|
DailyRevenue30: dailyRev,
|
||||||
|
Alerts: alerts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- reads ----------------------------------------------------------------
|
// --- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -502,7 +710,6 @@ func selectTaxProvider() tax.Provider {
|
|||||||
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
|
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
|
||||||
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
|
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
|
||||||
func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
||||||
|
|
||||||
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
|
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
|
||||||
key := os.Getenv("STRIPE_SECRET_KEY")
|
key := os.Getenv("STRIPE_SECRET_KEY")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
@@ -515,6 +722,64 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
|||||||
return order.NewMockProvider()
|
return order.NewMockProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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") == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if kind == "" {
|
||||||
|
kind = "smtp"
|
||||||
|
}
|
||||||
|
|
||||||
|
fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS"))
|
||||||
|
if fromAddr == "" {
|
||||||
|
return nil, fmt.Errorf("ORDER_EMAIL_FROM_ADDRESS is required when email sender is configured")
|
||||||
|
}
|
||||||
|
from := mail.Address{
|
||||||
|
Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")),
|
||||||
|
Address: fromAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectInventoryReservationService() (order.InventoryReservationService, error) {
|
||||||
|
return order.NewHTTPInventoryReservationService(config.EnvString("INVENTORY_URL", "http://localhost:8080"), nil)
|
||||||
|
}
|
||||||
|
|
||||||
// loadUCPOrderSigner loads the UCP ECDSA signing key from the path specified in
|
// loadUCPOrderSigner loads the UCP ECDSA signing key from the path specified in
|
||||||
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||||
func loadUCPOrderSigner() *ucp.SigningConfig {
|
func loadUCPOrderSigner() *ucp.SigningConfig {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// clusterAwareApplier is a ProfileApplier that chooses between an
|
||||||
|
// in-process pool call and a remote forward based on which replica
|
||||||
|
// currently owns the grain. It is the single seam at which the auth
|
||||||
|
// server (`customerauth.Server`) and the UCP customer handler
|
||||||
|
// (`ucp.CustomerServer`) ever speak to the grain pool — keeping the
|
||||||
|
// decision of "go to the authoritative owner" vs "spawn locally" in
|
||||||
|
// one place avoids the split-brain hazard introduced when multiple
|
||||||
|
// code paths (HTTP middleware + the handlers' own pool.Get) could each
|
||||||
|
// independently decide to spawn a stale or empty grain and broadcast
|
||||||
|
// conflicting ownership for it.
|
||||||
|
//
|
||||||
|
// The decision rule:
|
||||||
|
//
|
||||||
|
// - pool.OwnerHost(id) returns a remote host → forward Get/Apply
|
||||||
|
// to that host. The host holds the authoritative in-memory state.
|
||||||
|
// - pool.OwnerHost(id) returns no host → delegate to
|
||||||
|
// pool.Get / pool.Apply. On a local cache miss pool.Get spawns
|
||||||
|
// the grain from disk on this pod, broadcasts the new ownership,
|
||||||
|
// and returns the grain; this is the only code path that ever
|
||||||
|
// claims a grain on this pod.
|
||||||
|
//
|
||||||
|
// pool.Get's spawn path is unchanged on purpose: the disk-backed event
|
||||||
|
// log is the source of truth, and the local cache is just a projection
|
||||||
|
// rebuilt from it. The risk surface that the applicr layer removes is
|
||||||
|
// the HTTP-middleware fall-through that re-entered the same pool.Get
|
||||||
|
// from a different code path and duplicated the decision.
|
||||||
|
type clusterAwareApplier struct {
|
||||||
|
pool *actor.SimpleGrainPool[profile.ProfileGrain]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the current state of grain id, preferring the
|
||||||
|
// authoritative remote owner when one is registered. With no remote
|
||||||
|
// owner the pool spawns the grain locally (from the disk-backed event
|
||||||
|
// log), takes ownership, and returns the grain.
|
||||||
|
func (a *clusterAwareApplier) Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error) {
|
||||||
|
if owner, ok := a.pool.OwnerHost(id); ok {
|
||||||
|
return owner.Get(ctx, id)
|
||||||
|
}
|
||||||
|
return a.pool.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply mutates grain id with messages, forwarding to the authoritative
|
||||||
|
// remote owner when one is registered. With no remote owner the pool
|
||||||
|
// spawns the grain locally, applies the mutation, takes ownership, and
|
||||||
|
// returns the mutated state — its listeners (including the AMQP
|
||||||
|
// mutation feed) fire from this pod.
|
||||||
|
func (a *clusterAwareApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) {
|
||||||
|
if owner, ok := a.pool.OwnerHost(id); ok {
|
||||||
|
return owner.Apply(ctx, id, msgs...)
|
||||||
|
}
|
||||||
|
return a.pool.Apply(ctx, id, msgs...)
|
||||||
|
}
|
||||||
@@ -1,68 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetDiscovery returns the k8s pod-watcher that finds peer profile replicas, or
|
|
||||||
// nil when POD_IP is unset (single-node / local dev — clustering disabled). The
|
|
||||||
// watch is scoped to this pod's own namespace so it only needs a namespaced Role
|
|
||||||
// (pods: get/list/watch), not a cluster-wide role.
|
|
||||||
func GetDiscovery() discovery.Discovery {
|
|
||||||
if podIp == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
config, kerr := rest.InClusterConfig()
|
|
||||||
if kerr != nil {
|
|
||||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
|
||||||
}
|
|
||||||
client, err := kubernetes.NewForConfig(config)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating client: %v\n", err)
|
|
||||||
}
|
|
||||||
timeout := int64(30)
|
|
||||||
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
|
|
||||||
LabelSelector: "actor-pool=profile",
|
|
||||||
TimeoutSeconds: &timeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UseDiscovery starts the watch loop that adds/removes peer hosts from the pool
|
|
||||||
// as profile replicas become ready or go away. A nil discovery (POD_IP unset)
|
|
||||||
// logs and returns, leaving the pool in single-node mode.
|
|
||||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||||
go func(hw discovery.Discovery) {
|
discovery.StartK8sDiscovery(podIp, "actor-pool=profile", 30, pool)
|
||||||
if hw == nil {
|
|
||||||
log.Print("No discovery service available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch, err := hw.Watch()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Discovery error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for evt := range ch {
|
|
||||||
if evt.Host == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch evt.IsReady {
|
|
||||||
case false:
|
|
||||||
if pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
|
||||||
pool.RemoveHost(evt.Host)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if !pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Discovered host %s", evt.Host)
|
|
||||||
pool.AddRemoteHost(evt.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(GetDiscovery())
|
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-4
@@ -8,9 +8,11 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/internal/customerauth"
|
"git.k6n.net/mats/go-cart-actor/internal/customerauth"
|
||||||
@@ -55,6 +57,32 @@ func buildAuthStorage(ctx context.Context, profileDir string) (customerauth.Cred
|
|||||||
// authSecret returns the HMAC key for customer session cookies. It reads
|
// authSecret returns the HMAC key for customer session cookies. It reads
|
||||||
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
|
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
|
||||||
// warns that issued sessions will not survive a restart (fine for dev).
|
// warns that issued sessions will not survive a restart (fine for dev).
|
||||||
|
// selectAuthNotifier picks the transactional auth-message sender. When
|
||||||
|
// MAILERSEND_API_KEY is set it returns a MailerSend-backed notifier; otherwise
|
||||||
|
// it returns nil (the server defaults to LogNotifier, which is fine for dev).
|
||||||
|
func selectAuthNotifier() customerauth.Notifier {
|
||||||
|
apiKey := os.Getenv("MAILERSEND_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fromAddr := strings.TrimSpace(os.Getenv("PROFILE_EMAIL_FROM_ADDRESS"))
|
||||||
|
if fromAddr == "" {
|
||||||
|
log.Print("warning: MAILERSEND_API_KEY set but PROFILE_EMAIL_FROM_ADDRESS is empty — falling back to LogNotifier")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
from := mail.Address{
|
||||||
|
Name: strings.TrimSpace(os.Getenv("PROFILE_EMAIL_FROM_NAME")),
|
||||||
|
Address: fromAddr,
|
||||||
|
}
|
||||||
|
n, err := customerauth.NewMailerSendNotifier(apiKey, from)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: mailersend notifier: %v — falling back to LogNotifier", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Print("customer-auth: using MailerSend notifier for verification and reset emails")
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func authSecret() []byte {
|
func authSecret() []byte {
|
||||||
if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" {
|
if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" {
|
||||||
return []byte(v)
|
return []byte(v)
|
||||||
@@ -70,6 +98,13 @@ func authSecret() []byte {
|
|||||||
var podIp = os.Getenv("POD_IP")
|
var podIp = os.Getenv("POD_IP")
|
||||||
var name = os.Getenv("POD_NAME")
|
var name = os.Getenv("POD_NAME")
|
||||||
|
|
||||||
|
// profileMetrics is the shared prometheus instrumentation for the
|
||||||
|
// profile actor pool and event log. Built once at process start; the
|
||||||
|
// actor package's touch sites are nil-safe so a test binary could pass
|
||||||
|
// nil. Package-level so tests in this package can construct a pool
|
||||||
|
// without re-registering (cart/checkout use the same pattern).
|
||||||
|
var profileMetrics = actor.NewMetrics("profile")
|
||||||
|
|
||||||
// loadUCPProfileSigner loads the UCP ECDSA signing key from the path specified in
|
// loadUCPProfileSigner loads the UCP ECDSA signing key from the path specified in
|
||||||
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||||
func loadUCPProfileSigner() *ucp.SigningConfig {
|
func loadUCPProfileSigner() *ucp.SigningConfig {
|
||||||
@@ -98,10 +133,12 @@ func main() {
|
|||||||
log.Printf("warning: could not create profile data directory %s: %v", profileDir, err)
|
log.Printf("warning: could not create profile data directory %s: %v", profileDir, err)
|
||||||
}
|
}
|
||||||
diskStorage := actor.NewDiskStorage[profile.ProfileGrain](profileDir, reg)
|
diskStorage := actor.NewDiskStorage[profile.ProfileGrain](profileDir, reg)
|
||||||
|
diskStorage.SetMetrics(profileMetrics)
|
||||||
|
|
||||||
poolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{
|
poolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{
|
||||||
MutationRegistry: reg,
|
MutationRegistry: reg,
|
||||||
Storage: diskStorage,
|
Storage: diskStorage,
|
||||||
|
Metrics: profileMetrics,
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) {
|
Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) {
|
||||||
ret := profile.NewProfileGrain(id, time.Now())
|
ret := profile.NewProfileGrain(id, time.Now())
|
||||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
err := diskStorage.LoadEvents(ctx, id, ret)
|
||||||
@@ -159,6 +196,31 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// Data Retention (C5) GDPR Purge
|
||||||
|
retentionDays := config.EnvInt("PROFILE_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
|
||||||
|
if retentionDays > 0 {
|
||||||
|
purgeInterval := config.EnvDuration("PROFILE_PURGE_INTERVAL", 24*time.Hour)
|
||||||
|
log.Printf("profile: data retention enabled. Retaining profiles for %d days, purging every %v", retentionDays, purgeInterval)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(purgeInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run immediately on startup
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
log.Print("profile: data retention / GDPR purge disabled (PROFILE_RETENTION_DAYS not set or <= 0)")
|
||||||
|
}
|
||||||
|
|
||||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
@@ -172,11 +234,37 @@ func main() {
|
|||||||
// CUSTOMER_AUTH_SECRET is all they need to work across replicas.
|
// CUSTOMER_AUTH_SECRET is all they need to work across replicas.
|
||||||
credStore, limiter := buildAuthStorage(ctx, profileDir)
|
credStore, limiter := buildAuthStorage(ctx, profileDir)
|
||||||
|
|
||||||
|
// Email index: in-memory email→profileID map so the order service can
|
||||||
|
// look up email preferences without scanning all profiles. Built from
|
||||||
|
// the event log on disk at startup, then kept live by the UCP handlers.
|
||||||
|
emailIndex := profile.NewProfileEmailIndex()
|
||||||
|
stateStorage := actor.NewState(reg)
|
||||||
|
profile.BuildEmailIndex(profileDir, stateStorage, reg, emailIndex)
|
||||||
|
|
||||||
// UCP Customer REST adapter
|
// UCP Customer REST adapter
|
||||||
auditLogPath := filepath.Join(profileDir, "audit.log")
|
auditLogPath := filepath.Join(profileDir, "audit.log")
|
||||||
customerUCP := ucp.CustomerHandler(pool, auditLogPath, credStore)
|
// Session signer is shared by the auth server (HMAC verifier for the
|
||||||
if signer := loadUCPProfileSigner(); signer != nil {
|
// "sid" cookie); NewSigner is stateless once built so a single
|
||||||
customerUCP = ucp.WithSigning(customerUCP, signer)
|
// instance can be reused across the auth server's NewServer call.
|
||||||
|
sessionSigner := customerauth.NewSigner(authSecret())
|
||||||
|
|
||||||
|
// Cluster-aware ProfileApplier: routes Get/Apply to the replica that
|
||||||
|
// currently owns the grain. With a remote owner we forward there so
|
||||||
|
// the request reads the authoritative in-memory state; with no owner
|
||||||
|
// we let pool.Get / pool.Apply spawn the grain locally from the
|
||||||
|
// disk-backed event log and broadcast ownership. Centralising this
|
||||||
|
// choice in the applicr (rather than HTTP middleware) prevents the
|
||||||
|
// split-brain hazard where a non-owner pod reads a stale or empty
|
||||||
|
// grain from disk and caches it under its own (conflicting)
|
||||||
|
// ownership.
|
||||||
|
applier := &clusterAwareApplier{pool: pool}
|
||||||
|
|
||||||
|
customerUCP := ucp.CustomerHandler(applier, auditLogPath,
|
||||||
|
ucp.WithEmailIndex(emailIndex),
|
||||||
|
ucp.WithCredentialDeleter(credStore),
|
||||||
|
)
|
||||||
|
if ucpSigner := loadUCPProfileSigner(); ucpSigner != nil {
|
||||||
|
customerUCP = ucp.WithSigning(customerUCP, ucpSigner)
|
||||||
log.Print("ucp customer signing enabled")
|
log.Print("ucp customer signing enabled")
|
||||||
}
|
}
|
||||||
// StripPrefix is required because the sub-mux (CustomerHandler) registers
|
// StripPrefix is required because the sub-mux (CustomerHandler) registers
|
||||||
@@ -185,7 +273,9 @@ func main() {
|
|||||||
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
|
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
|
||||||
|
|
||||||
// Customer auth: password signup/login + session cookies + identity linking.
|
// Customer auth: password signup/login + session cookies + identity linking.
|
||||||
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{
|
authNotifier := selectAuthNotifier()
|
||||||
|
authHandler := customerauth.NewServer(credStore, applier, sessionSigner, 0, customerauth.Options{
|
||||||
|
Notifier: authNotifier,
|
||||||
Limiter: limiter,
|
Limiter: limiter,
|
||||||
BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"),
|
BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"),
|
||||||
RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true",
|
RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true",
|
||||||
@@ -252,3 +342,24 @@ func main() {
|
|||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[profile.ProfileGrain], pool *actor.SimpleGrainPool[profile.ProfileGrain], retentionDays int) {
|
||||||
|
log.Printf("profile: starting data retention purge...")
|
||||||
|
retention := time.Duration(retentionDays) * 24 * time.Hour
|
||||||
|
|
||||||
|
localIds := make(map[uint64]bool)
|
||||||
|
for _, id := range pool.GetLocalIds() {
|
||||||
|
localIds[id] = true
|
||||||
|
}
|
||||||
|
isGrainActive := func(id uint64) bool {
|
||||||
|
return localIds[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("profile: data retention purge failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("profile: data retention purge completed. Purged %d expired profile logs", purged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ func buildProfile() ucp.ProfileData {
|
|||||||
}},
|
}},
|
||||||
"dev.ucp.shopping.authentication": {{
|
"dev.ucp.shopping.authentication": {{
|
||||||
Version: ucpVersion,
|
Version: ucpVersion,
|
||||||
Spec: "https://ucp.dev/2026-04-08/specification/authentication",
|
Spec: "https://ucp.dev/2026-04-08/specification/identity-linking",
|
||||||
Schema: authURL,
|
Schema: authURL,
|
||||||
Description: "Customer authentication — email+password registration and login with HMAC-signed session cookies, logout, current-customer lookup, email verification, and password reset. Failed logins and reset requests are rate-limited.",
|
Description: "Customer authentication — email+password registration and login with HMAC-signed session cookies, logout, current-customer lookup, email verification, and password reset. Failed logins and reset requests are rate-limited.",
|
||||||
Operations: []ucp.OpDef{
|
Operations: []ucp.OpDef{
|
||||||
|
|||||||
+102
-3
@@ -5,7 +5,7 @@
|
|||||||
{
|
{
|
||||||
"id": "volymrabatt",
|
"id": "volymrabatt",
|
||||||
"name": "Volymrabatt",
|
"name": "Volymrabatt",
|
||||||
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). Belopp i öre: 20 000–40 000 kr → 5%, 40 000–60 000 kr → 9%, 60 000–90 000 kr → 14%.",
|
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). 20 000+ kr → 5%, 40 000+ kr → 9%, 60 000+ kr → 14%.",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"startDate": "2024-01-01",
|
"startDate": "2024-01-01",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"tiers": [
|
"tiers": [
|
||||||
{ "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 },
|
{ "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 },
|
||||||
{ "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 },
|
{ "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 },
|
||||||
{ "minTotal": 6000000, "maxTotal": 9000000, "percent": 14 }
|
{ "minTotal": 6000000, "maxTotal": 0, "percent": 14 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"label": "Volymrabatt 5–14%"
|
"label": "Volymrabatt 5–14%"
|
||||||
@@ -38,7 +38,106 @@
|
|||||||
"createdAt": "2024-01-01T00:00:00Z",
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
"updatedAt": "2024-01-01T00:00:00Z",
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
"createdBy": "mats.tornberg@gmail.com",
|
"createdBy": "mats.tornberg@gmail.com",
|
||||||
"tags": ["volume", "volymrabatt", "test"]
|
"tags": ["volume", "volymrabatt"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fri-frakt",
|
||||||
|
"name": "Fri frakt över 10 000 kr",
|
||||||
|
"description": "Fri frakt när varukorgens totalsumma (inkl. moms) överstiger 10 000 kr.",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 50,
|
||||||
|
"startDate": "2024-01-01",
|
||||||
|
"endDate": null,
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "min-cart-total",
|
||||||
|
"type": "cart_total",
|
||||||
|
"operator": ">=",
|
||||||
|
"value": 1000000,
|
||||||
|
"label": "Minst 10 000 kr i varukorgen"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "fri-frakt-action",
|
||||||
|
"type": "free_shipping",
|
||||||
|
"value": 0,
|
||||||
|
"label": "Fri frakt"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageCount": 0,
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"createdBy": "mats.tornberg@gmail.com",
|
||||||
|
"tags": ["shipping", "free-shipping"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fonster-10-pct",
|
||||||
|
"name": "10% rabatt på fönster",
|
||||||
|
"description": "10% rabatt på alla fönster i varukorgen.",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 200,
|
||||||
|
"startDate": "2024-01-01",
|
||||||
|
"endDate": null,
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "cat-windows",
|
||||||
|
"type": "product_category",
|
||||||
|
"operator": "in",
|
||||||
|
"value": ["Fönster"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "fonster-pct",
|
||||||
|
"type": "percentage_discount",
|
||||||
|
"value": 10,
|
||||||
|
"label": "10% rabatt på fönster"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageCount": 0,
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"createdBy": "mats.tornberg@gmail.com",
|
||||||
|
"tags": ["windows", "category"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sommar15",
|
||||||
|
"name": "15% rabatt med kod SOMMAR15",
|
||||||
|
"description": "15% rabatt på hela köpet när rabattkoden SOMMAR15 används. Gäller vid köp över 5 000 kr under sommaren.",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 150,
|
||||||
|
"startDate": "2024-06-01",
|
||||||
|
"endDate": null,
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "min-cart-total",
|
||||||
|
"type": "cart_total",
|
||||||
|
"operator": ">=",
|
||||||
|
"value": 500000,
|
||||||
|
"label": "Minst 5 000 kr i varukorgen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "coupon-code",
|
||||||
|
"type": "coupon_code",
|
||||||
|
"operator": "=",
|
||||||
|
"value": "SOMMAR15",
|
||||||
|
"label": "Ange koden SOMMAR15"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "sommar15-pct",
|
||||||
|
"type": "percentage_discount",
|
||||||
|
"value": 15,
|
||||||
|
"label": "15% rabatt med kod"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageCount": 0,
|
||||||
|
"createdAt": "2024-06-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-06-01T00:00:00Z",
|
||||||
|
"createdBy": "mats.tornberg@gmail.com",
|
||||||
|
"tags": ["seasonal", "coupon", "summer"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: CART_DIR
|
- name: CART_DIR
|
||||||
value: "/data/cart-actor"
|
value: "/data/cart-actor"
|
||||||
|
- name: PROMOTIONS_FILE
|
||||||
|
value: "/data/cart-actor/promotions.json"
|
||||||
- name: CHECKOUT_DIR
|
- name: CHECKOUT_DIR
|
||||||
value: "/data/checkout-actor"
|
value: "/data/checkout-actor"
|
||||||
- name: TZ
|
- name: TZ
|
||||||
@@ -186,6 +188,8 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: CART_DIR
|
- name: CART_DIR
|
||||||
value: "/data/cart-actor"
|
value: "/data/cart-actor"
|
||||||
|
- name: PROMOTIONS_FILE
|
||||||
|
value: "/data/promotions.json"
|
||||||
- name: CHECKOUT_DIR
|
- name: CHECKOUT_DIR
|
||||||
value: "/data/checkout-actor"
|
value: "/data/checkout-actor"
|
||||||
- name: TZ
|
- name: TZ
|
||||||
@@ -230,6 +234,10 @@ spec:
|
|||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: metadata.name
|
fieldPath: metadata.name
|
||||||
|
- name: CART_RETENTION_DAYS
|
||||||
|
value: "30"
|
||||||
|
- name: CART_PURGE_INTERVAL
|
||||||
|
value: "24h"
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
@@ -308,6 +316,8 @@ spec:
|
|||||||
memory: "70Mi"
|
memory: "70Mi"
|
||||||
cpu: "1200m"
|
cpu: "1200m"
|
||||||
env:
|
env:
|
||||||
|
- name: PROMOTIONS_FILE
|
||||||
|
value: "/data/promotions.json"
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: "Europe/Stockholm"
|
value: "Europe/Stockholm"
|
||||||
- name: REDIS_ADDRESS
|
- name: REDIS_ADDRESS
|
||||||
@@ -350,6 +360,10 @@ spec:
|
|||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: metadata.name
|
fieldPath: metadata.name
|
||||||
|
- name: CART_RETENTION_DAYS
|
||||||
|
value: "30"
|
||||||
|
- name: CART_PURGE_INTERVAL
|
||||||
|
value: "24h"
|
||||||
---
|
---
|
||||||
kind: Service
|
kind: Service
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
@@ -484,6 +498,10 @@ spec:
|
|||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: metadata.name
|
fieldPath: metadata.name
|
||||||
|
- name: CHECKOUT_RETENTION_DAYS
|
||||||
|
value: "30"
|
||||||
|
- name: CHECKOUT_PURGE_INTERVAL
|
||||||
|
value: "24h"
|
||||||
---
|
---
|
||||||
kind: Service
|
kind: Service
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ require (
|
|||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/mailersend/mailersend-go v1.4.0
|
||||||
github.com/mailru/easyjson v0.9.1 // indirect
|
github.com/mailru/easyjson v0.9.1 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
|
|||||||
+11
@@ -127,7 +127,10 @@ github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwm
|
|||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
|
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
|
||||||
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
@@ -137,6 +140,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
|||||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||||
github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
|
github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
|
||||||
|
github.com/mailersend/mailersend-go v1.4.0 h1:8IXN3HXoyKh6qG0IyTESedEjlaYsZfT0XnV2k7XzjvE=
|
||||||
|
github.com/mailersend/mailersend-go v1.4.0/go.mod h1:4MeiOnzmjWCsXRNdjg6NGzsijsVrmQ8E/T003/ystQU=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mdempsky/unconvert v0.0.0-20250216222326-4a038b3d31f5/go.mod h1:mVCHGHs8r8jnrZ2ammcv8ySbhG2+rEPXegFmdNA51GI=
|
github.com/mdempsky/unconvert v0.0.0-20250216222326-4a038b3d31f5/go.mod h1:mVCHGHs8r8jnrZ2ammcv8ySbhG2+rEPXegFmdNA51GI=
|
||||||
@@ -152,8 +157,14 @@ github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtX
|
|||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
|
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
|
||||||
|
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
|
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
|
||||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
|
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "refId": "A", "expr": "connected_remotes" }
|
{ "refId": "A", "expr": "cart_connected_remotes" }
|
||||||
],
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"colorMode": "value",
|
"colorMode": "value",
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package customerauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mailersend/mailersend-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailerSendNotifier delivers transactional auth messages (email verification
|
||||||
|
// and password reset) through the MailerSend API. It implements the Notifier
|
||||||
|
// interface and is a drop-in replacement for LogNotifier in production.
|
||||||
|
type MailerSendNotifier struct {
|
||||||
|
client *mailersend.Mailersend
|
||||||
|
from mail.Address
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMailerSendNotifier creates a new MailerSend-backed notifier. The apiKey
|
||||||
|
// is the MailerSend API token (MAILERSEND_API_KEY env var). The from address
|
||||||
|
// must include at least an email; the name portion is optional.
|
||||||
|
func NewMailerSendNotifier(apiKey string, from mail.Address) (*MailerSendNotifier, error) {
|
||||||
|
if strings.TrimSpace(apiKey) == "" {
|
||||||
|
return nil, fmt.Errorf("mailersend notifier: missing API key")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(from.Address) == "" {
|
||||||
|
return nil, fmt.Errorf("mailersend notifier: missing from address")
|
||||||
|
}
|
||||||
|
return &MailerSendNotifier{
|
||||||
|
client: mailersend.NewMailersend(apiKey),
|
||||||
|
from: from,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendEmailVerification sends the verification link to the customer's email
|
||||||
|
// address. Errors are logged but not returned (best-effort delivery matches the
|
||||||
|
// signature of the Notifier interface).
|
||||||
|
func (n *MailerSendNotifier) SendEmailVerification(email, link string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := n.send(ctx, email,
|
||||||
|
"Verify your email address",
|
||||||
|
fmt.Sprintf("Click the link to verify your email address:\n\n%s", link),
|
||||||
|
fmt.Sprintf(`<p>Click <a href="%s">here</a> to verify your email address.</p>`, link),
|
||||||
|
); err != nil {
|
||||||
|
log.Printf("customerauth: send verification to %s: %v", email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPasswordReset sends the password-reset link to the customer's email.
|
||||||
|
// Errors are logged but not returned.
|
||||||
|
func (n *MailerSendNotifier) SendPasswordReset(email, link string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := n.send(ctx, email,
|
||||||
|
"Reset your password",
|
||||||
|
fmt.Sprintf("Click the link to reset your password:\n\n%s\n\nIf you didn't request this, ignore this email.", link),
|
||||||
|
fmt.Sprintf(`<p>Click <a href="%s">here</a> to reset your password.</p><p>If you didn't request this, ignore this email.</p>`, link),
|
||||||
|
); err != nil {
|
||||||
|
log.Printf("customerauth: send password reset to %s: %v", email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *MailerSendNotifier) send(ctx context.Context, to, subject, textBody, htmlBody string) error {
|
||||||
|
message := n.client.Email.NewMessage()
|
||||||
|
message.SetFrom(mailersend.From{
|
||||||
|
Name: n.from.Name,
|
||||||
|
Email: n.from.Address,
|
||||||
|
})
|
||||||
|
message.SetRecipients([]mailersend.Recipient{
|
||||||
|
{Email: to},
|
||||||
|
})
|
||||||
|
message.SetSubject(subject)
|
||||||
|
message.SetText(textBody)
|
||||||
|
if htmlBody != "" {
|
||||||
|
message.SetHTML(htmlBody)
|
||||||
|
}
|
||||||
|
_, err := n.client.Email.Send(ctx, message)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mailersend: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -163,6 +163,13 @@ type resetCompleteRequest struct {
|
|||||||
Password string `json:"password"`
|
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
|
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
|
||||||
// import cycle with internal/ucp). The password hash is never included.
|
// import cycle with internal/ucp). The password hash is never included.
|
||||||
type CustomerResponse struct {
|
type CustomerResponse struct {
|
||||||
@@ -176,6 +183,9 @@ type CustomerResponse struct {
|
|||||||
Addresses []AddressResponse `json:"addresses"`
|
Addresses []AddressResponse `json:"addresses"`
|
||||||
Orders []OrderRef `json:"orders"`
|
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 reflects whether the email-verification flow has completed.
|
||||||
EmailVerified bool `json:"emailVerified"`
|
EmailVerified bool `json:"emailVerified"`
|
||||||
}
|
}
|
||||||
@@ -554,6 +564,12 @@ func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
|
|||||||
for _, o := range g.Orders {
|
for _, o := range g.Orders {
|
||||||
resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status})
|
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
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,11 @@ func (s *CredentialStore) persistLocked() error {
|
|||||||
return fmt.Errorf("customerauth: temp file: %w", err)
|
return fmt.Errorf("customerauth: temp file: %w", err)
|
||||||
}
|
}
|
||||||
tmpName := tmp.Name()
|
tmpName := tmp.Name()
|
||||||
|
if err := tmp.Chmod(0644); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return fmt.Errorf("customerauth: chmod temp: %w", err)
|
||||||
|
}
|
||||||
if _, err := tmp.Write(data); err != nil {
|
if _, err := tmp.Write(data); err != nil {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
os.Remove(tmpName)
|
os.Remove(tmpName)
|
||||||
|
|||||||
@@ -189,11 +189,22 @@ func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse {
|
|||||||
Items: make([]CartItem, 0, len(g.Items)),
|
Items: make([]CartItem, 0, len(g.Items)),
|
||||||
Totals: Totals{Currency: g.Currency},
|
Totals: Totals{Currency: g.Currency},
|
||||||
Status: "active",
|
Status: "active",
|
||||||
|
AppliedPromotions: g.AppliedPromotions,
|
||||||
}
|
}
|
||||||
if g.CheckoutStatus != nil && *g.CheckoutStatus != "" {
|
if g.CheckoutStatus != nil && *g.CheckoutStatus != "" {
|
||||||
resp.Status = string(*g.CheckoutStatus)
|
resp.Status = string(*g.CheckoutStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-line evaluation breakdown. The canonical promotion pipeline
|
||||||
|
// (pkg/promotions.PromotionService.EvaluateAndApply) populates this
|
||||||
|
// field on the grain itself after every successful mutation, so the
|
||||||
|
// conversion just reads g.EvaluatedItems verbatim — no recompute at
|
||||||
|
// response time. Same shape /promotions/evaluate-with-cart returns
|
||||||
|
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems),
|
||||||
|
// so a line verified via GET /ucp/v1/carts/{id} byte-matches the
|
||||||
|
// preview from /promotions/evaluate-with-cart.
|
||||||
|
resp.EvaluatedItems = g.EvaluatedItems
|
||||||
|
|
||||||
for _, it := range g.Items {
|
for _, it := range g.Items {
|
||||||
if it == nil {
|
if it == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -229,6 +229,106 @@ func TestCartResponse_Conversion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCartResponse_EvaluatedItems verifies that the per-line breakdown the
|
||||||
|
// canonical promotion pipeline populates on CartGrain.EvaluatedItems flows
|
||||||
|
// verbatim into the UCP CartResponse's EvaluatedItems field. Conversion is a
|
||||||
|
// straight pass-through now — we hand-construct the grain's EvaluatedItems
|
||||||
|
// field with the expected values to lock down the wire shape independent of
|
||||||
|
// the math MapEvaluatedItems applies (which has its own tests in pkg/cart).
|
||||||
|
func TestCartResponse_EvaluatedItems(t *testing.T) {
|
||||||
|
id := mustParseID("ABCD")
|
||||||
|
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
|
||||||
|
g.Currency = "SEK"
|
||||||
|
g.Items = append(g.Items, &cart.CartItem{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Quantity: 2,
|
||||||
|
Price: *mustPrice(10000, 2500),
|
||||||
|
TotalPrice: *mustPrice(20000, 5000),
|
||||||
|
Tax: 2500,
|
||||||
|
OrgPrice: mustPrice(12000, 3000),
|
||||||
|
Discount: mustPrice(2000, 500),
|
||||||
|
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||||
|
})
|
||||||
|
g.TotalPrice = mustPrice(20000, 5000)
|
||||||
|
|
||||||
|
// In the live cart, pkg/promotions.PromotionService.EvaluateAndApply
|
||||||
|
// populates g.EvaluatedItems via cart.MapEvaluatedItems after every
|
||||||
|
// successful mutation. The conversion test only cares that whatever
|
||||||
|
// the grain carries projects into resp.EvaluatedItems unchanged — so
|
||||||
|
// the input is the canonical shape, not the math's output.
|
||||||
|
g.EvaluatedItems = []cart.EvaluatedItem{{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Name: "Promo Item",
|
||||||
|
Quantity: 2,
|
||||||
|
PriceIncVat: 10000,
|
||||||
|
OrgPriceIncVat: 12000,
|
||||||
|
DiscountIncVat: 2000,
|
||||||
|
EffectivePriceIncVat: 9000,
|
||||||
|
EffectiveTotalIncVat: 18000,
|
||||||
|
}}
|
||||||
|
|
||||||
|
resp := cartGrainToResponse(g.Id.String(), g)
|
||||||
|
if len(resp.EvaluatedItems) != 1 {
|
||||||
|
t.Fatalf("expected 1 evaluated item, got %d", len(resp.EvaluatedItems))
|
||||||
|
}
|
||||||
|
ev := resp.EvaluatedItems[0]
|
||||||
|
if ev.Sku != "promo-1" {
|
||||||
|
t.Fatalf("expected sku 'promo-1', got %q", ev.Sku)
|
||||||
|
}
|
||||||
|
if ev.Name != "Promo Item" {
|
||||||
|
t.Fatalf("expected name 'Promo Item', got %q", ev.Name)
|
||||||
|
}
|
||||||
|
if ev.Quantity != 2 {
|
||||||
|
t.Fatalf("expected qty 2, got %d", ev.Quantity)
|
||||||
|
}
|
||||||
|
if ev.PriceIncVat != 10000 {
|
||||||
|
t.Fatalf("expected priceIncVat 10000, got %d", ev.PriceIncVat)
|
||||||
|
}
|
||||||
|
if ev.OrgPriceIncVat != 12000 {
|
||||||
|
t.Fatalf("expected orgPriceIncVat 12000, got %d", ev.OrgPriceIncVat)
|
||||||
|
}
|
||||||
|
if ev.DiscountIncVat != 2000 {
|
||||||
|
t.Fatalf("expected discountIncVat 2000, got %d", ev.DiscountIncVat)
|
||||||
|
}
|
||||||
|
if ev.EffectiveTotalIncVat != 18000 {
|
||||||
|
t.Fatalf("expected effectiveTotalIncVat 18000, got %d", ev.EffectiveTotalIncVat)
|
||||||
|
}
|
||||||
|
if ev.EffectivePriceIncVat != 9000 {
|
||||||
|
t.Fatalf("expected effectivePriceIncVat 9000, got %d", ev.EffectivePriceIncVat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCartResponse_EvaluatedItems_MapEquivalence confirms that
|
||||||
|
// cart.MapEvaluatedItems produces the same shape the test's hand-built
|
||||||
|
// []cart.EvaluatedItem entries contain, given matching inputs. This locks
|
||||||
|
// the conversion path's output to the canonical pipeline's output — if
|
||||||
|
// either side starts omitting or renaming a field, this fails.
|
||||||
|
func TestCartResponse_EvaluatedItems_MapEquivalence(t *testing.T) {
|
||||||
|
id := mustParseID("ABCD")
|
||||||
|
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
|
||||||
|
g.Currency = "SEK"
|
||||||
|
g.Items = append(g.Items, &cart.CartItem{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Quantity: 2,
|
||||||
|
Price: *mustPrice(10000, 2500),
|
||||||
|
TotalPrice: *mustPrice(20000, 5000),
|
||||||
|
Tax: 2500,
|
||||||
|
OrgPrice: mustPrice(12000, 3000),
|
||||||
|
Discount: mustPrice(2000, 500),
|
||||||
|
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||||
|
})
|
||||||
|
g.TotalPrice = mustPrice(20000, 5000)
|
||||||
|
|
||||||
|
canonical := cart.MapEvaluatedItems(g)
|
||||||
|
if len(canonical) != 1 {
|
||||||
|
t.Fatalf("expected 1 evaluated item from MapEvaluatedItems, got %d", len(canonical))
|
||||||
|
}
|
||||||
|
e := canonical[0]
|
||||||
|
if e.DiscountIncVat != 2000 || e.EffectiveTotalIncVat != 18000 || e.EffectivePriceIncVat != 9000 {
|
||||||
|
t.Fatalf("MapEvaluatedItems math off: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustPrice(incVat int64, totalVat int64) *cart.Price {
|
func mustPrice(incVat int64, totalVat int64) *cart.Price {
|
||||||
return &cart.Price{
|
return &cart.Price{
|
||||||
IncVat: money.Cents(incVat),
|
IncVat: money.Cents(incVat),
|
||||||
|
|||||||
@@ -451,6 +451,8 @@ type orderLine struct {
|
|||||||
Quantity int32 `json:"quantity"`
|
Quantity int32 `json:"quantity"`
|
||||||
UnitPrice money.Cents `json:"unitPrice"`
|
UnitPrice money.Cents `json:"unitPrice"`
|
||||||
TaxRate int32 `json:"taxRate"`
|
TaxRate int32 `json:"taxRate"`
|
||||||
|
DropShip bool `json:"drop_ship,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type orderPayment struct {
|
type orderPayment struct {
|
||||||
@@ -540,6 +542,16 @@ func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g
|
|||||||
// buildOrderLines converts a checkout grain's cart items and delivery selections
|
// buildOrderLines converts a checkout grain's cart items and delivery selections
|
||||||
// into order lines, matching the format expected by the order service.
|
// into order lines, matching the format expected by the order service.
|
||||||
func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
||||||
|
hasFreeShipping := false
|
||||||
|
if g.CartState != nil {
|
||||||
|
for _, ap := range g.CartState.AppliedPromotions {
|
||||||
|
if ap.Type == "free_shipping" && !ap.Pending {
|
||||||
|
hasFreeShipping = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries))
|
lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries))
|
||||||
for _, it := range g.CartState.Items {
|
for _, it := range g.CartState.Items {
|
||||||
if it == nil {
|
if it == nil {
|
||||||
@@ -549,6 +561,10 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
|||||||
if it.Meta != nil {
|
if it.Meta != nil {
|
||||||
name = it.Meta.Name
|
name = it.Meta.Name
|
||||||
}
|
}
|
||||||
|
location := ""
|
||||||
|
if it.StoreId != nil {
|
||||||
|
location = *it.StoreId
|
||||||
|
}
|
||||||
lines = append(lines, orderLine{
|
lines = append(lines, orderLine{
|
||||||
Reference: it.Sku,
|
Reference: it.Sku,
|
||||||
Sku: it.Sku,
|
Sku: it.Sku,
|
||||||
@@ -558,10 +574,19 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
|||||||
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
|
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
|
||||||
// (2500 = 25%), so the rate passes through unchanged.
|
// (2500 = 25%), so the rate passes through unchanged.
|
||||||
TaxRate: int32(it.Tax),
|
TaxRate: int32(it.Tax),
|
||||||
|
DropShip: it.DropShip,
|
||||||
|
Location: location,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, d := range g.Deliveries {
|
for _, d := range g.Deliveries {
|
||||||
if d == nil || d.Price.IncVat <= 0 {
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unitPrice := d.Price.IncVat
|
||||||
|
if hasFreeShipping {
|
||||||
|
unitPrice = 0
|
||||||
|
}
|
||||||
|
if unitPrice <= 0 && !hasFreeShipping {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lines = append(lines, orderLine{
|
lines = append(lines, orderLine{
|
||||||
@@ -569,10 +594,35 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
|||||||
Sku: d.Provider,
|
Sku: d.Provider,
|
||||||
Name: "Delivery",
|
Name: "Delivery",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: d.Price.IncVat,
|
UnitPrice: unitPrice,
|
||||||
TaxRate: 2500, // 25% in basis points
|
TaxRate: 2500, // 25% in basis points
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflected applied promotions as negative discount lines
|
||||||
|
if g.CartState != nil {
|
||||||
|
for _, ap := range g.CartState.AppliedPromotions {
|
||||||
|
if ap.Pending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
discountVal := int64(0)
|
||||||
|
if ap.Discount != nil {
|
||||||
|
discountVal = ap.Discount.IncVat.Int64()
|
||||||
|
}
|
||||||
|
if discountVal <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, orderLine{
|
||||||
|
Reference: "promo-" + ap.PromotionId,
|
||||||
|
Sku: "promo-" + ap.PromotionId,
|
||||||
|
Name: "Promotion: " + ap.Name,
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: money.Cents(-discountVal),
|
||||||
|
TaxRate: 2500, // default standard tax rate (25%) in basis points
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type CustomerServer struct {
|
|||||||
applier ProfileApplier
|
applier ProfileApplier
|
||||||
deleter CredentialDeleter
|
deleter CredentialDeleter
|
||||||
auditLogPath string
|
auditLogPath string
|
||||||
|
emailIndex *profile.ProfileEmailIndex // optional, maintained by mutations
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
|
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
|
||||||
@@ -30,6 +31,33 @@ func NewCustomerServer(applier ProfileApplier, auditLogPath string, deleter ...C
|
|||||||
return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath}
|
return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetEmailIndex attaches a ProfileEmailIndex that is updated by every
|
||||||
|
// customer mutation (create, update, delete).
|
||||||
|
func (s *CustomerServer) SetEmailIndex(ix *profile.ProfileEmailIndex) {
|
||||||
|
s.emailIndex = ix
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexProfileAfterMutation reads the grain and updates the email index.
|
||||||
|
func (s *CustomerServer) indexProfileAfterMutation(ctx context.Context, id uint64) {
|
||||||
|
if s.emailIndex == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g, err := s.applier.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs := g.EmailPreferences
|
||||||
|
// Copy so the index holds its own snapshot (the grain is pooled).
|
||||||
|
if prefs != nil {
|
||||||
|
cp := &profile.EmailPreferences{
|
||||||
|
OrderEmails: prefs.OrderEmails,
|
||||||
|
MarketingEmails: prefs.MarketingEmails,
|
||||||
|
}
|
||||||
|
prefs = cp
|
||||||
|
}
|
||||||
|
s.emailIndex.Set(id, g.Email, prefs)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// UCP Customer endpoints
|
// UCP Customer endpoints
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -61,6 +89,11 @@ func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Req
|
|||||||
AvatarUrl: req.AvatarUrl,
|
AvatarUrl: req.AvatarUrl,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.EmailPreferences != nil {
|
||||||
|
msg.OrderEmails = req.EmailPreferences.OrderEmails
|
||||||
|
msg.MarketingEmails = req.EmailPreferences.MarketingEmails
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error())
|
writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -72,6 +105,8 @@ func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.indexProfileAfterMutation(r.Context(), id)
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, customerGrainToResponse(rawId.String(), g))
|
writeJSON(w, http.StatusCreated, customerGrainToResponse(rawId.String(), g))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +167,11 @@ func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Req
|
|||||||
AvatarUrl: req.AvatarUrl,
|
AvatarUrl: req.AvatarUrl,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.EmailPreferences != nil {
|
||||||
|
msg.OrderEmails = req.EmailPreferences.OrderEmails
|
||||||
|
msg.MarketingEmails = req.EmailPreferences.MarketingEmails
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error())
|
writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -143,9 +183,36 @@ func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.indexProfileAfterMutation(r.Context(), id)
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetCustomerByEmail handles GET /customers/by-email/{email} — looks up
|
||||||
|
// the customer profile from the email index and returns the customer response.
|
||||||
|
// Returns 404 when the email is not found.
|
||||||
|
func (s *CustomerServer) handleGetCustomerByEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
email := r.PathValue("email")
|
||||||
|
if email == "" || s.emailIndex == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "customer not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, ok := s.emailIndex.Lookup(email)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "customer not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := s.applier.Get(r.Context(), entry.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "customer not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, customerGrainToResponse(profile.ProfileId(entry.ID).String(), g))
|
||||||
|
}
|
||||||
|
|
||||||
// handleAddAddress handles POST /customers/{id}/addresses.
|
// handleAddAddress handles POST /customers/{id}/addresses.
|
||||||
func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) {
|
func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := parseCustomerID(r)
|
id, ok := parseCustomerID(r)
|
||||||
@@ -333,6 +400,8 @@ func (s *CustomerServer) handleDeleteCustomer(w http.ResponseWriter, r *http.Req
|
|||||||
// Log audit trail to stdout (GDPR requirement)
|
// Log audit trail to stdout (GDPR requirement)
|
||||||
fmt.Printf("AUDIT: GDPR right to erasure executed for customer profile id %d\n", id)
|
fmt.Printf("AUDIT: GDPR right to erasure executed for customer profile id %d\n", id)
|
||||||
|
|
||||||
|
s.indexProfileAfterMutation(r.Context(), id)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,5 +546,13 @@ func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerRespons
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if g.EmailPreferences != nil {
|
||||||
|
prefs := &EmailPreferencesResponse{
|
||||||
|
OrderEmails: g.EmailPreferences.OrderEmails,
|
||||||
|
MarketingEmails: g.EmailPreferences.MarketingEmails,
|
||||||
|
}
|
||||||
|
resp.EmailPreferences = prefs
|
||||||
|
}
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ func TestDeleteCustomer(t *testing.T) {
|
|||||||
id := testProfileID(t, applier)
|
id := testProfileID(t, applier)
|
||||||
deleter := &testCredentialDeleter{}
|
deleter := &testCredentialDeleter{}
|
||||||
auditPath := filepath.Join(t.TempDir(), "audit.log")
|
auditPath := filepath.Join(t.TempDir(), "audit.log")
|
||||||
handler := CustomerHandler(applier, auditPath, deleter)
|
handler := CustomerHandler(applier, auditPath, WithCredentialDeleter(deleter))
|
||||||
|
|
||||||
// Set up some profile data
|
// Set up some profile data
|
||||||
pid, _ := profile.ParseProfileId(id)
|
pid, _ := profile.ParseProfileId(id)
|
||||||
|
|||||||
+30
-3
@@ -1,6 +1,10 @@
|
|||||||
package ucp
|
package ucp
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Order HTTP handler mounting
|
// Order HTTP handler mounting
|
||||||
@@ -86,11 +90,33 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
|
|||||||
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
|
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
|
||||||
//
|
//
|
||||||
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
|
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
|
||||||
func CustomerHandler(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) http.Handler {
|
// CustomerHandlerOption configures a CustomerHandler with optional dependencies.
|
||||||
s := NewCustomerServer(applier, auditLogPath, deleter...)
|
type CustomerHandlerOption func(s *CustomerServer)
|
||||||
|
|
||||||
|
// WithEmailIndex attaches a ProfileEmailIndex that the customer handler
|
||||||
|
// updates on every mutation and uses for the GET /by-email/{email} route.
|
||||||
|
func WithEmailIndex(ix *profile.ProfileEmailIndex) CustomerHandlerOption {
|
||||||
|
return func(s *CustomerServer) {
|
||||||
|
s.SetEmailIndex(ix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCredentialDeleter attaches a credential deleter for GDPR erasure.
|
||||||
|
func WithCredentialDeleter(deleter CredentialDeleter) CustomerHandlerOption {
|
||||||
|
return func(s *CustomerServer) {
|
||||||
|
s.deleter = deleter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CustomerHandler(applier ProfileApplier, auditLogPath string, opts ...CustomerHandlerOption) http.Handler {
|
||||||
|
s := NewCustomerServer(applier, auditLogPath)
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(s)
|
||||||
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /", s.handleCreateCustomer)
|
mux.HandleFunc("POST /", s.handleCreateCustomer)
|
||||||
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
||||||
|
mux.HandleFunc("GET /by-email/{email}", s.handleGetCustomerByEmail)
|
||||||
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
|
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
|
||||||
mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer)
|
mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer)
|
||||||
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
|
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
|
||||||
@@ -154,6 +180,7 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
|||||||
customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter)
|
customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter)
|
||||||
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
|
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
|
||||||
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
||||||
|
mux.HandleFunc("GET /customers/by-email/{email}", customerSrv.handleGetCustomerByEmail)
|
||||||
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
|
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
|
||||||
mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer)
|
mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer)
|
||||||
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
|
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
|
||||||
|
|||||||
@@ -121,11 +121,27 @@ type VoucherInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CartResponse is the UCP cart response body.
|
// CartResponse is the UCP cart response body.
|
||||||
|
//
|
||||||
|
// EvaluatedItems mirrors the per-line breakdown the canonical promotion
|
||||||
|
// pipeline (pkg/promotions.PromotionService.EvaluateAndApply) populates
|
||||||
|
// on the grain after every successful mutation — see
|
||||||
|
// pkg/cart/evaluated_item.go for the EvaluatedItem type and the rounding
|
||||||
|
// rules. The shape and field order match the live cart grain's own
|
||||||
|
// EvaluatedItems field and the response from /promotions/evaluate-with-cart
|
||||||
|
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems), so a
|
||||||
|
// line verified via GET /ucp/v1/carts/{id} byte-matches the preview.
|
||||||
|
//
|
||||||
|
// Kept parallel to Items rather than merged into CartItem to preserve
|
||||||
|
// the UCP wire contract — existing UCP clients keep working unchanged
|
||||||
|
// and the per-line breakdown can grow on the EvaluatedItem type without
|
||||||
|
// churning the standard item shape.
|
||||||
type CartResponse struct {
|
type CartResponse struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Items []CartItem `json:"items,omitempty"`
|
Items []CartItem `json:"items,omitempty"`
|
||||||
|
EvaluatedItems []cart.EvaluatedItem `json:"evaluatedItems,omitempty"`
|
||||||
Totals Totals `json:"totals"`
|
Totals Totals `json:"totals"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CartItem is the UCP wire format for a cart line item in responses.
|
// CartItem is the UCP wire format for a cart line item in responses.
|
||||||
@@ -335,6 +351,12 @@ type OrderLineEntry struct {
|
|||||||
// UCP Customer types
|
// UCP Customer types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// EmailPreferencesResponse is the UCP wire format for email preference toggles.
|
||||||
|
type EmailPreferencesResponse struct {
|
||||||
|
OrderEmails *bool `json:"orderEmails,omitempty"`
|
||||||
|
MarketingEmails *bool `json:"marketingEmails,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// CustomerResponse is the UCP customer profile response body.
|
// CustomerResponse is the UCP customer profile response body.
|
||||||
type CustomerResponse struct {
|
type CustomerResponse struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
@@ -345,6 +367,7 @@ type CustomerResponse struct {
|
|||||||
Currency string `json:"currency,omitempty"`
|
Currency string `json:"currency,omitempty"`
|
||||||
AvatarUrl string `json:"avatarUrl,omitempty"`
|
AvatarUrl string `json:"avatarUrl,omitempty"`
|
||||||
Addresses []AddressResponse `json:"addresses,omitempty"`
|
Addresses []AddressResponse `json:"addresses,omitempty"`
|
||||||
|
EmailPreferences *EmailPreferencesResponse `json:"emailPreferences,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddressResponse is the UCP wire format for an address.
|
// AddressResponse is the UCP wire format for an address.
|
||||||
@@ -363,6 +386,12 @@ type AddressResponse struct {
|
|||||||
IsDefaultBilling bool `json:"isDefaultBilling"`
|
IsDefaultBilling bool `json:"isDefaultBilling"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmailPreferencesInput is the body for updating a customer's email preferences.
|
||||||
|
type EmailPreferencesInput struct {
|
||||||
|
OrderEmails *bool `json:"orderEmails,omitempty"`
|
||||||
|
MarketingEmails *bool `json:"marketingEmails,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// CustomerUpdateRequest is the body for PUT /customers/{id}.
|
// CustomerUpdateRequest is the body for PUT /customers/{id}.
|
||||||
type CustomerUpdateRequest struct {
|
type CustomerUpdateRequest struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
@@ -371,6 +400,7 @@ type CustomerUpdateRequest struct {
|
|||||||
Language *string `json:"language,omitempty"`
|
Language *string `json:"language,omitempty"`
|
||||||
Currency *string `json:"currency,omitempty"`
|
Currency *string `json:"currency,omitempty"`
|
||||||
AvatarUrl *string `json:"avatarUrl,omitempty"`
|
AvatarUrl *string `json:"avatarUrl,omitempty"`
|
||||||
|
EmailPreferences *EmailPreferencesInput `json:"emailPreferences,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAddressRequest is the body for POST /customers/{id}/addresses.
|
// AddAddressRequest is the body for POST /customers/{id}/addresses.
|
||||||
|
|||||||
+363
-36
@@ -7,22 +7,38 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultWriterIdleTTL = 5 * time.Minute
|
||||||
|
|
||||||
type QueueEvent struct {
|
type QueueEvent struct {
|
||||||
TimeStamp time.Time
|
TimeStamp time.Time
|
||||||
Message proto.Message
|
Message proto.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// diskLogWriter holds one append-only log file kept open between writes.
|
||||||
|
type diskLogWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
file *os.File
|
||||||
|
lastUsed time.Time
|
||||||
|
purged bool // set by purgeIdleWriters before closing; getWriter re-checks after acquiring mu
|
||||||
|
}
|
||||||
|
|
||||||
type DiskStorage[V any] struct {
|
type DiskStorage[V any] struct {
|
||||||
*StateStorage
|
*StateStorage
|
||||||
path string
|
path string
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
queue *sync.Map // map[uint64][]QueueEvent
|
queue *sync.Map // map[uint64][]QueueEvent
|
||||||
|
dirOnce sync.Once
|
||||||
|
dirErr error
|
||||||
|
writersMu sync.Mutex
|
||||||
|
writers map[uint64]*diskLogWriter
|
||||||
|
writerIdleTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogStorage[V any] interface {
|
type LogStorage[V any] interface {
|
||||||
@@ -32,10 +48,219 @@ type LogStorage[V any] interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
||||||
return &DiskStorage[V]{
|
s := &DiskStorage[V]{
|
||||||
StateStorage: NewState(registry),
|
StateStorage: NewState(registry),
|
||||||
path: path,
|
path: path,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
writers: make(map[uint64]*diskLogWriter),
|
||||||
|
writerIdleTTL: defaultWriterIdleTTL,
|
||||||
|
}
|
||||||
|
go s.purgeLoop()
|
||||||
|
go s.filesExistingLoop()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// filesExistingLoop refreshes the EventLogFilesExisting gauge every
|
||||||
|
// 30 seconds. The gauge represents the count of .events.log files on
|
||||||
|
// disk, which diverges from the open-writer count whenever the idle
|
||||||
|
// purge closes a writer but the file is still kept (the .log file is
|
||||||
|
// not deleted by the idle purge). Periodic re-listing is cheap (one
|
||||||
|
// os.ReadDir per tick) and is the only way the gauge can pick up files
|
||||||
|
// created or removed out of band (e.g. GDPR purge, or the init-time
|
||||||
|
// rebuild of a grain that already has a log).
|
||||||
|
func (s *DiskStorage[V]) filesExistingLoop() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.refreshFilesExisting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshFilesExisting counts .events.log files in the storage dir
|
||||||
|
// and writes the value to the EventLogFilesExisting gauge. Safe to
|
||||||
|
// call concurrently — os.ReadDir is atomic for our purposes (the dir
|
||||||
|
// is owned by this pod, mutations go through the writersMu lock).
|
||||||
|
// No-op when the dir does not exist yet (cold start) or when metrics
|
||||||
|
// are disabled (s.StateStorage.metrics == nil).
|
||||||
|
func (s *DiskStorage[V]) refreshFilesExisting() {
|
||||||
|
if s.StateStorage.metrics == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(s.path)
|
||||||
|
if err != nil {
|
||||||
|
// Dir may not exist yet on a cold start, or briefly during a
|
||||||
|
// retention-driven directory recreation. Leave the gauge
|
||||||
|
// untouched in that case — the next tick will catch up.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".events.log") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.StateStorage.metrics.EventLogFilesExisting.Set(float64(count))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) ensureDir() error {
|
||||||
|
s.dirOnce.Do(func() {
|
||||||
|
s.dirErr = os.MkdirAll(s.path, 0o755)
|
||||||
|
})
|
||||||
|
return s.dirErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWriter returns an open append handle for id. The caller must unlock w.mu
|
||||||
|
// when finished writing.
|
||||||
|
|
||||||
|
|
||||||
|
// SetMetrics attaches a Metrics instance to the embedded StateStorage
|
||||||
|
// so every append / replay / unknown-type path has access to the
|
||||||
|
// same event-log counters. The metrics struct is shared by reference;
|
||||||
|
// do not mutate its fields from outside. Pass nil to disable
|
||||||
|
// instrumentation (used by tests that do not care about Prometheus).
|
||||||
|
func (s *DiskStorage[V]) SetMetrics(m *Metrics) {
|
||||||
|
s.StateStorage.metrics = m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) recordAppend(n int) {
|
||||||
|
m := s.StateStorage.metrics
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.EventLogAppends.Inc()
|
||||||
|
if n > 0 {
|
||||||
|
m.EventLogBytesWritten.Add(float64(n))
|
||||||
|
}
|
||||||
|
m.EventLogLastAppendUnix.Set(float64(time.Now().Unix()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) writeEvents(id uint64, events []QueueEvent) error {
|
||||||
|
if len(events) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w, err := s.getWriter(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
for _, evt := range events {
|
||||||
|
n, err := s.Append(w.file, evt.Message, evt.TimeStamp)
|
||||||
|
if err != nil {
|
||||||
|
// A partial write still counts the bytes that DID land on
|
||||||
|
// disk, so the throughput gauge is honest about a mid-write
|
||||||
|
// failure.
|
||||||
|
s.recordAppend(n)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.recordAppend(n)
|
||||||
|
}
|
||||||
|
w.lastUsed = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) writeMutations(id uint64, msgs ...proto.Message) error {
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w, err := s.getWriter(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, m := range msgs {
|
||||||
|
n, err := s.Append(w.file, m, now)
|
||||||
|
if err != nil {
|
||||||
|
s.recordAppend(n)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.recordAppend(n)
|
||||||
|
}
|
||||||
|
w.lastUsed = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) purgeLoop() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.purgeIdleWriters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) purgeIdleWriters() {
|
||||||
|
cutoff := time.Now().Add(-s.writerIdleTTL)
|
||||||
|
|
||||||
|
s.writersMu.Lock()
|
||||||
|
defer s.writersMu.Unlock()
|
||||||
|
|
||||||
|
for id, w := range s.writers {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.lastUsed.Before(cutoff) {
|
||||||
|
w.purged = true
|
||||||
|
delete(s.writers, id)
|
||||||
|
if err := w.file.Close(); err != nil {
|
||||||
|
log.Printf("failed to close idle event log %d: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) getWriter(id uint64) (*diskLogWriter, error) {
|
||||||
|
if err := s.ensureDir(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
s.writersMu.Lock()
|
||||||
|
w, ok := s.writers[id]
|
||||||
|
if !ok {
|
||||||
|
fh, err := os.OpenFile(s.logPath(id), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
s.writersMu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w = &diskLogWriter{file: fh, lastUsed: time.Now()}
|
||||||
|
s.writers[id] = w
|
||||||
|
}
|
||||||
|
s.writersMu.Unlock()
|
||||||
|
|
||||||
|
w.mu.Lock()
|
||||||
|
if !w.purged {
|
||||||
|
w.lastUsed = time.Now()
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
// Writer was purged by purgeIdleWriters between our map lookup and acquiring w.mu.
|
||||||
|
// Release it and retry — the map no longer contains this entry, so the next
|
||||||
|
// iteration will create a fresh writer.
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) closeWriters() {
|
||||||
|
s.writersMu.Lock()
|
||||||
|
defer s.writersMu.Unlock()
|
||||||
|
for id, w := range s.writers {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.purged = true
|
||||||
|
if err := w.file.Close(); err != nil {
|
||||||
|
log.Printf("failed to close event log %d: %v", id, err)
|
||||||
|
}
|
||||||
|
delete(s.writers, id)
|
||||||
|
w.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,26 +280,23 @@ func (s *DiskStorage[V]) SaveLoop(duration time.Duration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *DiskStorage[V]) save() {
|
func (s *DiskStorage[V]) save() {
|
||||||
|
if s.queue == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
carts := 0
|
carts := 0
|
||||||
lines := 0
|
lines := 0
|
||||||
s.queue.Range(func(key, value any) bool {
|
s.queue.Range(func(key, value any) bool {
|
||||||
id := key.(uint64)
|
id := key.(uint64)
|
||||||
path := s.logPath(id)
|
qe, ok := value.([]QueueEvent)
|
||||||
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
if !ok {
|
||||||
if err != nil {
|
s.queue.Delete(id)
|
||||||
log.Printf("failed to open event log file: %v", err)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
defer fh.Close()
|
if err := s.writeEvents(id, qe); err != nil {
|
||||||
|
log.Printf("failed to append events for grain %d: %v", id, err)
|
||||||
if qe, ok := value.([]QueueEvent); ok {
|
return true
|
||||||
for _, msg := range qe {
|
|
||||||
if err := s.Append(fh, msg.Message, msg.TimeStamp); err != nil {
|
|
||||||
log.Printf("failed to append event to log file: %v", err)
|
|
||||||
}
|
|
||||||
lines++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
lines += len(qe)
|
||||||
carts++
|
carts++
|
||||||
s.queue.Delete(id)
|
s.queue.Delete(id)
|
||||||
return true
|
return true
|
||||||
@@ -88,6 +310,43 @@ func (s *DiskStorage[V]) logPath(id uint64) string {
|
|||||||
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
|
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadReplayMetrics times the replay, records duration, and tracks
|
||||||
|
// handler errors. It is shared by LoadEvents and LoadEventsFunc. The
|
||||||
|
// returned `onMessage` closure bumps EventLogMutationErrors on any
|
||||||
|
// non-nil error from registry.Apply, and the deferred function bumps
|
||||||
|
// EventLogReplayTotal / Failures / Duration on the overall return.
|
||||||
|
func (s *DiskStorage[V]) loadReplayMetrics() (onMessage func(err error), done func(err error)) {
|
||||||
|
m := s.StateStorage.metrics
|
||||||
|
if m == nil {
|
||||||
|
noop := func(error) {}
|
||||||
|
return noop, noop
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
onMessage = func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
m.EventLogMutationErrors.Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done = func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
m.EventLogReplayFailures.Inc()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.EventLogReplayTotal.Inc()
|
||||||
|
m.EventLogReplayDuration.Observe(time.Since(start).Seconds())
|
||||||
|
}
|
||||||
|
return onMessage, done
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordReplayFailure bumps EventLogReplayFailures when the open
|
||||||
|
// step fails before the Load loop runs (and therefore before the
|
||||||
|
// loadReplayMetrics closure is in scope). Nil-safe.
|
||||||
|
func (s *DiskStorage[V]) recordReplayFailure() {
|
||||||
|
if m := s.StateStorage.metrics; m != nil {
|
||||||
|
m.EventLogReplayFailures.Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error {
|
func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error {
|
||||||
path := s.logPath(id)
|
path := s.logPath(id)
|
||||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -97,16 +356,25 @@ func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Gr
|
|||||||
|
|
||||||
fh, err := os.Open(path)
|
fh, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.recordReplayFailure()
|
||||||
return fmt.Errorf("open replay file: %w", err)
|
return fmt.Errorf("open replay file: %w", err)
|
||||||
}
|
}
|
||||||
defer fh.Close()
|
defer fh.Close()
|
||||||
|
trackApply, done := s.loadReplayMetrics()
|
||||||
index := 0
|
index := 0
|
||||||
return s.Load(fh, func(msg proto.Message, when time.Time) {
|
loadErr := s.Load(fh, func(msg proto.Message, when time.Time) {
|
||||||
if condition(msg, index, when) {
|
if condition(msg, index, when) {
|
||||||
s.registry.Apply(ctx, grain, msg)
|
results, _ := s.registry.Apply(ctx, grain, msg)
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Error != nil {
|
||||||
|
trackApply(r.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
index++
|
index++
|
||||||
})
|
})
|
||||||
|
done(loadErr)
|
||||||
|
return loadErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error {
|
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error {
|
||||||
@@ -118,18 +386,28 @@ func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[
|
|||||||
|
|
||||||
fh, err := os.Open(path)
|
fh, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.recordReplayFailure()
|
||||||
return fmt.Errorf("open replay file: %w", err)
|
return fmt.Errorf("open replay file: %w", err)
|
||||||
}
|
}
|
||||||
defer fh.Close()
|
defer fh.Close()
|
||||||
return s.Load(fh, func(msg proto.Message, _ time.Time) {
|
trackApply, done := s.loadReplayMetrics()
|
||||||
s.registry.Apply(ctx, grain, msg)
|
loadErr := s.Load(fh, func(msg proto.Message, _ time.Time) {
|
||||||
|
results, _ := s.registry.Apply(ctx, grain, msg)
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Error != nil {
|
||||||
|
trackApply(r.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
done(loadErr)
|
||||||
|
return loadErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DiskStorage[V]) Close() {
|
func (s *DiskStorage[V]) Close() {
|
||||||
if s.queue != nil {
|
if s.queue != nil {
|
||||||
s.save()
|
s.save()
|
||||||
}
|
}
|
||||||
|
s.closeWriters()
|
||||||
close(s.done)
|
close(s.done)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,25 +423,74 @@ func (s *DiskStorage[V]) AppendMutations(id uint64, msg ...proto.Message) error
|
|||||||
}
|
}
|
||||||
s.queue.Store(id, queue)
|
s.queue.Store(id, queue)
|
||||||
return nil
|
return nil
|
||||||
} else {
|
|
||||||
path := s.logPath(id)
|
|
||||||
// Ensure the parent directory exists (e.g. first write to a new PVC mount).
|
|
||||||
// MkdirAll is a no-op when the directory already exists, so it's safe to
|
|
||||||
// call on every AppendMutations without extra stat overhead.
|
|
||||||
if err := os.MkdirAll(s.path, 0755); err != nil {
|
|
||||||
log.Printf("failed to create event log directory %s: %v", s.path, err)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
return s.writeMutations(id, msg...)
|
||||||
if err != nil {
|
|
||||||
log.Printf("failed to open event log file: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer fh.Close()
|
|
||||||
for _, m := range msg {
|
|
||||||
err = s.Append(fh, m, time.Now())
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) openWriterCount() int {
|
||||||
|
s.writersMu.Lock()
|
||||||
|
defer s.writersMu.Unlock()
|
||||||
|
return len(s.writers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) PurgeOlderThan(ctx context.Context, retention time.Duration, isGrainActive func(id uint64) bool) (int, error) {
|
||||||
|
if err := s.ensureDir(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := os.ReadDir(s.path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read storage dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-retention)
|
||||||
|
purgedCount := 0
|
||||||
|
|
||||||
|
for _, entry := range files {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(name, ".events.log") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var id uint64
|
||||||
|
_, err := fmt.Sscanf(name, "%d.events.log", &id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to get file info for %s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.ModTime().Before(cutoff) {
|
||||||
|
// Check if grain is active in memory
|
||||||
|
if isGrainActive != nil && isGrainActive(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have an active writer open
|
||||||
|
s.writersMu.Lock()
|
||||||
|
_, hasWriter := s.writers[id]
|
||||||
|
s.writersMu.Unlock()
|
||||||
|
if hasWriter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
fullPath := filepath.Join(s.path, name)
|
||||||
|
if err := os.Remove(fullPath); err != nil {
|
||||||
|
log.Printf("failed to delete expired cart log %s: %v", fullPath, err)
|
||||||
|
} else {
|
||||||
|
purgedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return purgedCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type diskTestGrain struct {
|
||||||
|
testState
|
||||||
|
id uint64
|
||||||
|
accessed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *diskTestGrain) GetId() uint64 { return g.id }
|
||||||
|
func (g *diskTestGrain) GetLastAccess() time.Time { return g.accessed }
|
||||||
|
func (g *diskTestGrain) GetLastChange() time.Time { return g.accessed }
|
||||||
|
func (g *diskTestGrain) GetCurrentState() (*testState, error) {
|
||||||
|
return &g.testState, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func diskTestRegistry(t *testing.T) MutationRegistry {
|
||||||
|
t.Helper()
|
||||||
|
reg := NewMutationRegistry()
|
||||||
|
reg.RegisterMutations(NewMutation(
|
||||||
|
func(_ *diskTestGrain, _ *cart_messages.AddItem) error { return nil },
|
||||||
|
))
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStorageAppendAndReplay(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
const id = uint64(99)
|
||||||
|
events := []proto.Message{
|
||||||
|
&cart_messages.AddItem{ItemId: 1, Quantity: 2},
|
||||||
|
&cart_messages.AddItem{ItemId: 2, Quantity: 1},
|
||||||
|
}
|
||||||
|
for _, evt := range events {
|
||||||
|
if err := storage.AppendMutations(id, evt); err != nil {
|
||||||
|
t.Fatalf("append: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||||
|
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
|
||||||
|
t.Fatalf("replay: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStorageReusesOpenWriter(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
const id = uint64(7)
|
||||||
|
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
|
||||||
|
if err := storage.AppendMutations(id, msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := storage.openWriterCount(); got != 1 {
|
||||||
|
t.Fatalf("open writers after first append = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if err := storage.AppendMutations(id, msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := storage.openWriterCount(); got != 1 {
|
||||||
|
t.Fatalf("open writers after second append = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStorageIdleWriterClosed(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
storage.writerIdleTTL = 20 * time.Millisecond
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
if err := storage.AppendMutations(1, &cart_messages.AddItem{ItemId: 1, Quantity: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if storage.openWriterCount() != 1 {
|
||||||
|
t.Fatal("expected one open writer")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
storage.purgeIdleWriters()
|
||||||
|
if storage.openWriterCount() != 0 {
|
||||||
|
t.Fatalf("expected idle writer to be closed, got %d open", storage.openWriterCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStorageSaveLoopUsesWriterCache(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
go storage.SaveLoop(20 * time.Millisecond)
|
||||||
|
|
||||||
|
const id = uint64(55)
|
||||||
|
if err := storage.AppendMutations(id, &cart_messages.AddItem{ItemId: 3, Quantity: 4}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.After(500 * time.Millisecond)
|
||||||
|
for {
|
||||||
|
if storage.openWriterCount() >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("timed out waiting for SaveLoop flush")
|
||||||
|
default:
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||||
|
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
|
||||||
|
t.Fatalf("replay after SaveLoop: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStorageConcurrentAppendSameID(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
const id = uint64(88)
|
||||||
|
const workers = 16
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(workers)
|
||||||
|
for i := range workers {
|
||||||
|
go func(itemID uint32) {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = storage.AppendMutations(id, &cart_messages.AddItem{ItemId: itemID, Quantity: 1})
|
||||||
|
}(uint32(i + 1))
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||||
|
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
|
||||||
|
t.Fatalf("replay: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStorageCloseFlushesSaveLoop(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
|
||||||
|
// Start the buffered SaveLoop but give it a long interval so it does NOT
|
||||||
|
// flush during the test — only Close() should trigger the flush. A brief
|
||||||
|
// sleep lets the goroutine schedule and set s.queue before we append.
|
||||||
|
go storage.SaveLoop(10 * time.Minute)
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
|
||||||
|
const id = uint64(101)
|
||||||
|
if err := storage.AppendMutations(id, &cart_messages.AddItem{ItemId: 7, Quantity: 3}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify event is queued but NOT yet on disk (no writer should be open
|
||||||
|
// because the buffered path only opens files inside SaveLoop → save()).
|
||||||
|
if got := storage.openWriterCount(); got != 0 {
|
||||||
|
t.Fatalf("expected 0 open writers before flush, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close flushes the queue then closes writers.
|
||||||
|
storage.Close()
|
||||||
|
|
||||||
|
// After Close(), the event should be persisted. Open a fresh storage and
|
||||||
|
// replay to verify.
|
||||||
|
fresh := NewDiskStorage[testState](storage.path, reg)
|
||||||
|
t.Cleanup(fresh.Close)
|
||||||
|
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||||
|
if err := fresh.LoadEvents(context.Background(), id, grain); err != nil {
|
||||||
|
t.Fatalf("replay after Close flush: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStorageMultipleGrainsIndependent(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
ids := []uint64{10, 20, 30}
|
||||||
|
for i, id := range ids {
|
||||||
|
evt := &cart_messages.AddItem{ItemId: uint32(i + 1), Quantity: int32(i + 1)}
|
||||||
|
if err := storage.AppendMutations(id, evt); err != nil {
|
||||||
|
t.Fatalf("append id=%d: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each grain has exactly one writer open (shared per id).
|
||||||
|
if got := storage.openWriterCount(); got != len(ids) {
|
||||||
|
t.Fatalf("open writers = %d, want %d", got, len(ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay each grain and verify the correct event was stored.
|
||||||
|
for _, id := range ids {
|
||||||
|
ctx := context.Background()
|
||||||
|
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||||
|
if err := storage.LoadEvents(ctx, id, grain); err != nil {
|
||||||
|
t.Fatalf("replay id=%d: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStorageLoadEventsNoFile(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
// No AppendMutations — no file should exist.
|
||||||
|
grain := &diskTestGrain{id: 999, accessed: time.Now()}
|
||||||
|
if err := storage.LoadEvents(context.Background(), 999, grain); err != nil {
|
||||||
|
t.Fatalf("LoadEvents on non-existent file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStorageWriteAfterIdlePurge(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
// Set a very short idle TTL so the writer is evicted quickly.
|
||||||
|
storage.writerIdleTTL = 10 * time.Millisecond
|
||||||
|
|
||||||
|
const id = uint64(202)
|
||||||
|
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
|
||||||
|
|
||||||
|
// First write opens a writer.
|
||||||
|
if err := storage.AppendMutations(id, msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := storage.openWriterCount(); got != 1 {
|
||||||
|
t.Fatalf("open writers after first write = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for idle TTL to expire and purge.
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
storage.purgeIdleWriters()
|
||||||
|
|
||||||
|
if got := storage.openWriterCount(); got != 0 {
|
||||||
|
t.Fatalf("open writers after purge = %d, want 0", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second write must create a new writer through the retry loop in getWriter.
|
||||||
|
if err := storage.AppendMutations(id, msg); err != nil {
|
||||||
|
t.Fatalf("write after purge: %v", err)
|
||||||
|
}
|
||||||
|
if got := storage.openWriterCount(); got != 1 {
|
||||||
|
t.Fatalf("open writers after write-before-close = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay must see both events.
|
||||||
|
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||||
|
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
|
||||||
|
t.Fatalf("replay after purge: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStorageCloseWithoutSaveLoop(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
|
||||||
|
// Verify Close() on a storage that never started SaveLoop works cleanly.
|
||||||
|
const id = uint64(303)
|
||||||
|
if err := storage.AppendMutations(id, &cart_messages.AddItem{ItemId: 1, Quantity: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close() — queue is nil so it skips save(), goes straight to closeWriters().
|
||||||
|
storage.Close()
|
||||||
|
|
||||||
|
// A fresh storage should be able to replay the persisted event.
|
||||||
|
fresh := NewDiskStorage[testState](storage.path, reg)
|
||||||
|
t.Cleanup(fresh.Close)
|
||||||
|
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||||
|
if err := fresh.LoadEvents(context.Background(), id, grain); err != nil {
|
||||||
|
t.Fatalf("replay after close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiskStorageConcurrentWritesDifferentIDs verifies that concurrent writes to
|
||||||
|
// different grains don't interfere with each other, and all events are persisted.
|
||||||
|
func TestDiskStorageConcurrentWritesDifferentIDs(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
const grainCount = 32
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(grainCount)
|
||||||
|
for id := range uint64(grainCount) {
|
||||||
|
go func(grainID uint64) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := range 5 {
|
||||||
|
_ = storage.AppendMutations(grainID, &cart_messages.AddItem{ItemId: uint32(i + 1), Quantity: 1})
|
||||||
|
}
|
||||||
|
}(id + 1)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Replay each grain and verify.
|
||||||
|
for id := range uint64(grainCount) {
|
||||||
|
grainID := id + 1
|
||||||
|
ctx := context.Background()
|
||||||
|
grain := &diskTestGrain{id: grainID, accessed: time.Now()}
|
||||||
|
if err := storage.LoadEvents(ctx, grainID, grain); err != nil {
|
||||||
|
t.Fatalf("replay grain %d: %v", grainID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskStoragePurge(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
storage := NewDiskStorage[testState](dir, reg)
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
|
||||||
|
|
||||||
|
// Write mutations to create event logs for 1, 2, 3, 4
|
||||||
|
ids := []uint64{1, 2, 3, 4}
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := storage.AppendMutations(id, msg); err != nil {
|
||||||
|
t.Fatalf("append %d: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force save to disk
|
||||||
|
storage.save()
|
||||||
|
|
||||||
|
// 1. Grain 1: Old modification time, inactive, no writer -> should be purged
|
||||||
|
path1 := storage.logPath(1)
|
||||||
|
oldTime := time.Now().Add(-10 * 24 * time.Hour)
|
||||||
|
if err := os.Chtimes(path1, oldTime, oldTime); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We must close the writer for grain 1, 2, 4 to make them eligible for purging (no open writer)
|
||||||
|
storage.writersMu.Lock()
|
||||||
|
for _, id := range []uint64{1, 2, 4} {
|
||||||
|
if w, ok := storage.writers[id]; ok {
|
||||||
|
w.purged = true
|
||||||
|
w.file.Close()
|
||||||
|
delete(storage.writers, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storage.writersMu.Unlock()
|
||||||
|
|
||||||
|
// 2. Grain 2: Old mod time, but marked active -> should NOT be purged
|
||||||
|
path2 := storage.logPath(2)
|
||||||
|
if err := os.Chtimes(path2, oldTime, oldTime); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Grain 3: Old mod time, but has open writer -> should NOT be purged
|
||||||
|
path3 := storage.logPath(3)
|
||||||
|
if err := os.Chtimes(path3, oldTime, oldTime); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Grain 4: Recent mod time -> should NOT be purged
|
||||||
|
path4 := storage.logPath(4)
|
||||||
|
|
||||||
|
activeGrains := map[uint64]bool{2: true}
|
||||||
|
isGrainActive := func(id uint64) bool {
|
||||||
|
return activeGrains[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
purged, err := storage.PurgeOlderThan(ctx, 5*24*time.Hour, isGrainActive)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PurgeOlderThan failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if purged != 1 {
|
||||||
|
t.Errorf("Expected exactly 1 purged file, got %d", purged)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file status
|
||||||
|
if _, err := os.Stat(path1); !os.IsNotExist(err) {
|
||||||
|
t.Error("Expected grain 1 log to be deleted")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path2); err != nil {
|
||||||
|
t.Error("Expected grain 2 log to exist (active)")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path3); err != nil {
|
||||||
|
t.Error("Expected grain 3 log to exist (active writer)")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path4); err != nil {
|
||||||
|
t.Error("Expected grain 4 log to exist (recent)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+24
-4
@@ -19,8 +19,13 @@ type GrainPool[V any] interface {
|
|||||||
OwnerHost(id uint64) (Host[V], bool)
|
OwnerHost(id uint64) (Host[V], bool)
|
||||||
Hostname() string
|
Hostname() string
|
||||||
TakeOwnership(id uint64)
|
TakeOwnership(id uint64)
|
||||||
HandleOwnershipChange(host string, ids []uint64) error
|
// HandleOwnershipChange processes a remote first-touch ownership
|
||||||
HandleRemoteExpiry(host string, ids []uint64) error
|
// claim. lastChanges is the parallel []int64 of UnixNano stamps from
|
||||||
|
// the announcer; -1 entries fall back to the pre-arbitration
|
||||||
|
// "always accept remote" behaviour so mixed-version rollouts
|
||||||
|
// remain safe.
|
||||||
|
HandleOwnershipChange(host string, ids []uint64, lastChanges []int64) error
|
||||||
|
HandleRemoteExpiry(host string, ids []uint64, lastChanges []int64) error
|
||||||
Negotiate(otherHosts []string)
|
Negotiate(otherHosts []string)
|
||||||
GetLocalIds() []uint64
|
GetLocalIds() []uint64
|
||||||
IsHealthy() bool
|
IsHealthy() bool
|
||||||
@@ -32,7 +37,13 @@ type GrainPool[V any] interface {
|
|||||||
|
|
||||||
// Host abstracts a remote node capable of proxying cart requests.
|
// Host abstracts a remote node capable of proxying cart requests.
|
||||||
type Host[V any] interface {
|
type Host[V any] interface {
|
||||||
AnnounceExpiry(ids []uint64)
|
// AnnounceExpiry broadcasts per-id eviction decisions. lastChanges is
|
||||||
|
// a parallel []int64 of UnixNano stamps taken at the moment the
|
||||||
|
// grain was dropped from the local cache; it is informational on
|
||||||
|
// this path (the receiver just removes the id from its remoteOwners
|
||||||
|
// map) but kept in the wire signature for symmetry with
|
||||||
|
// AnnounceOwnership.
|
||||||
|
AnnounceExpiry(ids []uint64, lastChanges []int64)
|
||||||
Negotiate(otherHosts []string) ([]string, error)
|
Negotiate(otherHosts []string) ([]string, error)
|
||||||
Name() string
|
Name() string
|
||||||
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
|
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
|
||||||
@@ -42,5 +53,14 @@ type Host[V any] interface {
|
|||||||
Close() error
|
Close() error
|
||||||
Ping() bool
|
Ping() bool
|
||||||
IsHealthy() bool
|
IsHealthy() bool
|
||||||
AnnounceOwnership(ownerHost string, ids []uint64)
|
// AnnounceOwnership broadcasts a first-touch ownership claim.
|
||||||
|
// lastChanges is a parallel []int64 of UnixNano stamps of the
|
||||||
|
// announcing pod's local grain's GetLastChange() at the moment of
|
||||||
|
// broadcast; receivers use it as the first-spawn-wins oracle for
|
||||||
|
// concurrent cold-cache first-touch (smaller stamp = older spawn =
|
||||||
|
// owns the grain; on equal stamps the lexicographically smaller
|
||||||
|
// hostname wins). A stamp of -1 means "no local grain to back this
|
||||||
|
// id" — receivers treat that as legacy/no-arbitration and accept
|
||||||
|
// the remote claim as before.
|
||||||
|
AnnounceOwnership(ownerHost string, ids []uint64, lastChanges []int64)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
|
|||||||
)
|
)
|
||||||
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||||
|
|
||||||
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
err := s.pool.HandleOwnershipChange(req.Host, req.Ids, req.LastChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
return &messages.OwnerChangeAck{
|
return &messages.OwnerChangeAck{
|
||||||
@@ -138,7 +138,7 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
|
|||||||
)
|
)
|
||||||
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||||
|
|
||||||
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids, req.LastChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ func (m *mockGrainPool) TakeOwnership(id uint64) {}
|
|||||||
|
|
||||||
func (m *mockGrainPool) Hostname() string { return "test-host" }
|
func (m *mockGrainPool) Hostname() string { return "test-host" }
|
||||||
|
|
||||||
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64) error { return nil }
|
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64, _ []int64) error { return nil }
|
||||||
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64) error { return nil }
|
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64, _ []int64) error { return nil }
|
||||||
func (m *mockGrainPool) Negotiate(hosts []string) {}
|
func (m *mockGrainPool) Negotiate(hosts []string) {}
|
||||||
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
|
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
|
||||||
func (m *mockGrainPool) RemoveHost(host string) {}
|
func (m *mockGrainPool) RemoveHost(host string) {}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metrics bundles every prometheus metric the actor package emits. It is
|
||||||
|
// constructed per service (cart / checkout / profile / order) with a
|
||||||
|
// service-specific prefix so the metric names do not collide when more
|
||||||
|
// than one actor-pool service is scraped into the same Prometheus.
|
||||||
|
//
|
||||||
|
// All touch sites are nil-safe: the pool / storage / registry code wraps
|
||||||
|
// each counter / histogram call in a nil-check on the *Metrics pointer,
|
||||||
|
// so the existing tests (which construct a pool or storage without
|
||||||
|
// metrics) keep working without a forced dependency on this struct.
|
||||||
|
//
|
||||||
|
// Metric names match the panels in grafana_dashboard_cart.json exactly.
|
||||||
|
// The dashboard had `connected_remotes` (no prefix) as a typo; the
|
||||||
|
// source-of-truth metric exported here is `<prefix>_connected_remotes`
|
||||||
|
// and the dashboard JSON is updated in the same change to match.
|
||||||
|
type Metrics struct {
|
||||||
|
// Pool — set by SimpleGrainPool.
|
||||||
|
ActiveGrains prometheus.Gauge
|
||||||
|
GrainsInPool prometheus.Gauge
|
||||||
|
PoolUsage prometheus.Gauge
|
||||||
|
ConnectedRemotes prometheus.Gauge
|
||||||
|
GrainSpawnedTotal prometheus.Counter
|
||||||
|
GrainLookupsTotal prometheus.Counter
|
||||||
|
MutationsTotal prometheus.Counter
|
||||||
|
MutationFailuresTotal prometheus.Counter
|
||||||
|
MutationLatency prometheus.Histogram
|
||||||
|
RemoteNegotiationTotal prometheus.Counter
|
||||||
|
|
||||||
|
// Event log — set by DiskStorage and StateStorage.
|
||||||
|
EventLogAppends prometheus.Counter
|
||||||
|
EventLogBytesWritten prometheus.Counter
|
||||||
|
EventLogFilesExisting prometheus.Gauge
|
||||||
|
EventLogLastAppendUnix prometheus.Gauge
|
||||||
|
EventLogReplayTotal prometheus.Counter
|
||||||
|
EventLogReplayFailures prometheus.Counter
|
||||||
|
EventLogReplayDuration prometheus.Histogram
|
||||||
|
EventLogUnknownTypes prometheus.Counter
|
||||||
|
EventLogMutationErrors prometheus.Counter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMetrics registers every actor metric in the default Prometheus
|
||||||
|
// registry (the one promhttp.Handler() exposes on /metrics) with the
|
||||||
|
// service-specific prefix. The prefix is the same string the binary uses
|
||||||
|
// as its service name (e.g. "cart", "checkout", "profile", "order"),
|
||||||
|
// which keeps the metric names aligned with the per-service labels in
|
||||||
|
// grafana_dashboard_cart.json.
|
||||||
|
func NewMetrics(prefix string) *Metrics {
|
||||||
|
return &Metrics{
|
||||||
|
ActiveGrains: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_active_grains",
|
||||||
|
Help: "Number of grains currently held in the local pool.",
|
||||||
|
}),
|
||||||
|
GrainsInPool: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_grains_in_pool",
|
||||||
|
Help: "Alias of active_grains: the dashboard shows both as a sanity check.",
|
||||||
|
}),
|
||||||
|
PoolUsage: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_grain_pool_usage",
|
||||||
|
Help: "Local pool fill ratio: len(grains) / poolSize (range 0..1).",
|
||||||
|
}),
|
||||||
|
ConnectedRemotes: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_connected_remotes",
|
||||||
|
Help: "Number of remote actor-pool hosts currently connected via gRPC.",
|
||||||
|
}),
|
||||||
|
GrainSpawnedTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_grain_spawned_total",
|
||||||
|
Help: "Total number of grains spawned (loaded from disk or freshly created).",
|
||||||
|
}),
|
||||||
|
GrainLookupsTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_grain_lookups_total",
|
||||||
|
Help: "Total number of pool.Get calls (state reads).",
|
||||||
|
}),
|
||||||
|
MutationsTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_mutations_total",
|
||||||
|
Help: "Total number of mutations applied across all grains (one per proto message, not per call).",
|
||||||
|
}),
|
||||||
|
MutationFailuresTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_mutation_failures_total",
|
||||||
|
Help: "Total number of pool.Apply calls that returned an error.",
|
||||||
|
}),
|
||||||
|
// Latency buckets cover the typical pool.Apply (handler + registry
|
||||||
|
// apply + storage enqueue): sub-ms in the hot path, hundreds of ms
|
||||||
|
// under load. Tweak if a hot mutation type moves outside this range.
|
||||||
|
MutationLatency: promauto.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Name: prefix + "_mutation_latency_seconds",
|
||||||
|
Help: "Wall-clock duration of a pool.Apply call (lock + spawn + handler + storage enqueue).",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(0.0005, 2, 14), // 0.5ms ... ~8s
|
||||||
|
}),
|
||||||
|
RemoteNegotiationTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_remote_negotiation_total",
|
||||||
|
Help: "Total number of cluster-negotiation rounds initiated by this pod.",
|
||||||
|
}),
|
||||||
|
|
||||||
|
EventLogAppends: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_appends_total",
|
||||||
|
Help: "Total number of event-log line appends (one per persisted mutation message).",
|
||||||
|
}),
|
||||||
|
EventLogBytesWritten: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_bytes_written_total",
|
||||||
|
Help: "Total bytes written to per-grain .events.log files.",
|
||||||
|
}),
|
||||||
|
EventLogFilesExisting: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_event_log_files_existing",
|
||||||
|
Help: "Number of per-grain .events.log files currently on disk in the storage directory.",
|
||||||
|
}),
|
||||||
|
EventLogLastAppendUnix: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_event_log_last_append_unix",
|
||||||
|
Help: "Unix timestamp of the last successful event-log append.",
|
||||||
|
}),
|
||||||
|
EventLogReplayTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_replay_total",
|
||||||
|
Help: "Total number of successful event-log replays (one per grain loaded from disk).",
|
||||||
|
}),
|
||||||
|
EventLogReplayFailures: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_replay_failures_total",
|
||||||
|
Help: "Total number of failed event-log replays (open error, JSON parse error, or handler error).",
|
||||||
|
}),
|
||||||
|
EventLogReplayDuration: promauto.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Name: prefix + "_event_log_replay_duration_seconds",
|
||||||
|
Help: "Wall-clock time to replay a single grain's event log from disk.",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(0.001, 2, 14), // 1ms ... ~16s
|
||||||
|
}),
|
||||||
|
EventLogUnknownTypes: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_unknown_types_total",
|
||||||
|
Help: "Total number of event-log entries with a type the registry does not recognise.",
|
||||||
|
}),
|
||||||
|
EventLogMutationErrors: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_mutation_errors_total",
|
||||||
|
Help: "Total number of mutation-handler errors observed during event-log replay.",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MutationStarted returns a closure to be deferred at the top of
|
||||||
|
// pool.Apply so the latency observation / counters fire exactly once
|
||||||
|
// per call regardless of which return path is taken. mutationCount
|
||||||
|
// is the number of proto messages in the Apply call (a single call
|
||||||
|
// can batch several messages — SetCartItems, for example, sends
|
||||||
|
// ClearCart + N AddItem together); the counter is incremented by
|
||||||
|
// that count so the dashboard's `rate(cart_mutations_total[1m])`
|
||||||
|
// reports "messages per second" rather than "calls per second",
|
||||||
|
// matching the original `grainMutations.Add(len(data.Mutations))`
|
||||||
|
// semantics. The returned closure is nil-safe — pass a nil *Metrics
|
||||||
|
// in and the call is a no-op.
|
||||||
|
func (m *Metrics) MutationStarted(mutationCount int) func(err error) {
|
||||||
|
if m == nil {
|
||||||
|
return func(error) {}
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
return func(err error) {
|
||||||
|
if mutationCount > 0 {
|
||||||
|
m.MutationsTotal.Add(float64(mutationCount))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
m.MutationFailuresTotal.Inc()
|
||||||
|
}
|
||||||
|
m.MutationLatency.Observe(time.Since(start).Seconds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPoolStats updates the three pool-level gauges (grains_in_pool,
|
||||||
|
// active_grains, pool_usage) in one call. Safe to invoke under the
|
||||||
|
// pool's localMu — it does not take any lock itself, only prometheus
|
||||||
|
// atomic counters.
|
||||||
|
func (m *Metrics) SetPoolStats(grains, poolSize int) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.GrainsInPool.Set(float64(grains))
|
||||||
|
m.ActiveGrains.Set(float64(grains))
|
||||||
|
if poolSize > 0 {
|
||||||
|
m.PoolUsage.Set(float64(grains) / float64(poolSize))
|
||||||
|
} else {
|
||||||
|
m.PoolUsage.Set(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncSpawn bumps the grain-spawned counter. Nil-safe.
|
||||||
|
func (m *Metrics) IncSpawn() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.GrainSpawnedTotal.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncLookup bumps the grain-lookups counter. Nil-safe.
|
||||||
|
func (m *Metrics) IncLookup() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.GrainLookupsTotal.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncNegotiation bumps the remote-negotiation counter. Nil-safe.
|
||||||
|
func (m *Metrics) IncNegotiation() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.RemoteNegotiationTotal.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConnectedRemotes updates the connected-remotes gauge. Nil-safe.
|
||||||
|
func (m *Metrics) SetConnectedRemotes(n int) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.ConnectedRemotes.Set(float64(n))
|
||||||
|
}
|
||||||
+162
-19
@@ -28,6 +28,12 @@ type SimpleGrainPool[V any] struct {
|
|||||||
// grain processes a single message at a time (the actor guarantee).
|
// grain processes a single message at a time (the actor guarantee).
|
||||||
grainLocks *keyedMutex
|
grainLocks *keyedMutex
|
||||||
|
|
||||||
|
// metrics is the prometheus instrumentation wired in by NewMetrics
|
||||||
|
// at composition time. Nil is fine — every touch site nil-checks
|
||||||
|
// first, so the existing tests (which construct a pool without
|
||||||
|
// metrics) keep working.
|
||||||
|
metrics *Metrics
|
||||||
|
|
||||||
// Cluster coordination --------------------------------------------------
|
// Cluster coordination --------------------------------------------------
|
||||||
hostname string
|
hostname string
|
||||||
remoteMu sync.RWMutex
|
remoteMu sync.RWMutex
|
||||||
@@ -39,6 +45,16 @@ type SimpleGrainPool[V any] struct {
|
|||||||
purgeTicker *time.Ticker
|
purgeTicker *time.Ticker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noLocalStamp is the sentinel value used in the parallel []int64
|
||||||
|
// lastChanges slice to mark an id for which the broadcaster has no
|
||||||
|
// local grain (e.g. TakeOwnership called before anyone has loaded the
|
||||||
|
// grain, or a custom Host implementation that doesn't carry a stamp).
|
||||||
|
// Receivers treat this sentinel as "no arbitration possible" and fall
|
||||||
|
// back to the pre-arbitration "always accept remote" verdict so the
|
||||||
|
// behaviour is identical to running an older binary that didn't carry
|
||||||
|
// stamps at all.
|
||||||
|
const noLocalStamp int64 = -1
|
||||||
|
|
||||||
type GrainPoolConfig[V any] struct {
|
type GrainPoolConfig[V any] struct {
|
||||||
Hostname string
|
Hostname string
|
||||||
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
||||||
@@ -51,6 +67,13 @@ type GrainPoolConfig[V any] struct {
|
|||||||
PoolSize int
|
PoolSize int
|
||||||
MutationRegistry MutationRegistry
|
MutationRegistry MutationRegistry
|
||||||
Storage LogStorage[V]
|
Storage LogStorage[V]
|
||||||
|
// Metrics is the prometheus instrumentation to register pool-level
|
||||||
|
// counters, gauges, and histograms with. Nil-safe: when unset, all
|
||||||
|
// touch sites are no-ops. Use actor.NewMetrics("cart") (or the
|
||||||
|
// matching service prefix) to construct the per-service metrics
|
||||||
|
// struct, and pair it with DiskStorage.SetMetrics so the same
|
||||||
|
// Metrics instance covers both pool and event-log metrics.
|
||||||
|
Metrics *Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
|
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
|
||||||
@@ -64,6 +87,7 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
|||||||
ttl: config.TTL,
|
ttl: config.TTL,
|
||||||
poolSize: config.PoolSize,
|
poolSize: config.PoolSize,
|
||||||
hostname: config.Hostname,
|
hostname: config.Hostname,
|
||||||
|
metrics: config.Metrics,
|
||||||
remoteOwners: make(map[uint64]Host[V]),
|
remoteOwners: make(map[uint64]Host[V]),
|
||||||
remoteHosts: make(map[string]Host[V]),
|
remoteHosts: make(map[string]Host[V]),
|
||||||
grainLocks: newKeyedMutex(),
|
grainLocks: newKeyedMutex(),
|
||||||
@@ -76,6 +100,11 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Publish the initial pool stats so the dashboard has a number to
|
||||||
|
// draw on first scrape, rather than waiting for the first grain
|
||||||
|
// spawn / eviction to bump the gauges.
|
||||||
|
p.metrics.SetPoolStats(0, p.poolSize)
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,22 +124,31 @@ func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
|
|||||||
func (p *SimpleGrainPool[V]) purge() {
|
func (p *SimpleGrainPool[V]) purge() {
|
||||||
purgeLimit := time.Now().Add(-p.ttl)
|
purgeLimit := time.Now().Add(-p.ttl)
|
||||||
purgedIds := make([]uint64, 0, len(p.grains))
|
purgedIds := make([]uint64, 0, len(p.grains))
|
||||||
|
purgedStamps := make([]int64, 0, len(p.grains))
|
||||||
p.localMu.Lock()
|
p.localMu.Lock()
|
||||||
|
evicted := 0
|
||||||
for id, grain := range p.grains {
|
for id, grain := range p.grains {
|
||||||
if grain.GetLastAccess().Before(purgeLimit) {
|
if grain.GetLastAccess().Before(purgeLimit) {
|
||||||
purgedIds = append(purgedIds, id)
|
|
||||||
if p.destroy != nil {
|
if p.destroy != nil {
|
||||||
if err := p.destroy(grain); err != nil {
|
if err := p.destroy(grain); err != nil {
|
||||||
log.Printf("failed to destroy grain %d: %v", id, err)
|
log.Printf("failed to destroy grain %d: %v", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Capture the lastChange stamp BEFORE delete so peers
|
||||||
|
// receive an honest per-id eviction record on the wire.
|
||||||
|
purgedIds = append(purgedIds, id)
|
||||||
|
purgedStamps = append(purgedStamps, grain.GetLastChange().UnixNano())
|
||||||
delete(p.grains, id)
|
delete(p.grains, id)
|
||||||
|
evicted++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
remaining := len(p.grains)
|
||||||
p.localMu.Unlock()
|
p.localMu.Unlock()
|
||||||
|
if evicted > 0 {
|
||||||
|
p.metrics.SetPoolStats(remaining, p.poolSize)
|
||||||
|
}
|
||||||
p.forAllHosts(func(remote Host[V]) {
|
p.forAllHosts(func(remote Host[V]) {
|
||||||
remote.AnnounceExpiry(purgedIds)
|
remote.AnnounceExpiry(purgedIds, purgedStamps)
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -136,7 +174,14 @@ func (p *SimpleGrainPool[V]) GetLocalIds() []uint64 {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error {
|
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64, _ []int64) error {
|
||||||
|
// lastChanges is informational on this path; expiry is unilateral
|
||||||
|
// from the announcer's perspective — we just drop the id from any
|
||||||
|
// remoteOwners mapping so future cross-pod forwards stop hitting a
|
||||||
|
// dead peer. The parallel stamp slice is kept in the signature for
|
||||||
|
// wire symmetry with AnnounceOwnership and so a future hardening
|
||||||
|
// pass can use stamps to detect ghost-evictions (e.g. peer evicted
|
||||||
|
// with a stamp newer than our last seen Apply result).
|
||||||
p.remoteMu.Lock()
|
p.remoteMu.Lock()
|
||||||
defer p.remoteMu.Unlock()
|
defer p.remoteMu.Unlock()
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
@@ -145,7 +190,7 @@ func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
|
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64, lastChanges []int64) error {
|
||||||
p.remoteMu.RLock()
|
p.remoteMu.RLock()
|
||||||
remoteHost, exists := p.remoteHosts[host]
|
remoteHost, exists := p.remoteHosts[host]
|
||||||
p.remoteMu.RUnlock()
|
p.remoteMu.RUnlock()
|
||||||
@@ -156,14 +201,75 @@ func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) er
|
|||||||
}
|
}
|
||||||
remoteHost = createdHost
|
remoteHost = createdHost
|
||||||
}
|
}
|
||||||
|
// Lock order: remoteMu before localMu. Unlock in reverse order so
|
||||||
|
// the localMu gauge read below cannot deadlock against a concurrent
|
||||||
|
// p.localMu holder.
|
||||||
p.remoteMu.Lock()
|
p.remoteMu.Lock()
|
||||||
defer p.remoteMu.Unlock()
|
|
||||||
p.localMu.Lock()
|
p.localMu.Lock()
|
||||||
defer p.localMu.Unlock()
|
var accepted, kept, legacy int
|
||||||
for _, id := range ids {
|
for i, id := range ids {
|
||||||
log.Printf("Handling ownership change for cart %d to host %s", id, host)
|
// remoteStamp is the announcer's lastChange at broadcast time,
|
||||||
|
// or noLocalStamp (-1) if the announcer didn't have a local
|
||||||
|
// grain (legacy / TakeOwnership path). The
|
||||||
|
// 0..len(lastChanges)-1 slice is indexed parallel to ids; out
|
||||||
|
// of range falls back to noLocalStamp (= "no arbitration").
|
||||||
|
var remoteStamp int64 = noLocalStamp
|
||||||
|
if i < len(lastChanges) {
|
||||||
|
remoteStamp = lastChanges[i]
|
||||||
|
}
|
||||||
|
var localStamp int64 = noLocalStamp
|
||||||
|
var hasLocal bool
|
||||||
|
if g, ok := p.grains[id]; ok {
|
||||||
|
localStamp = g.GetLastChange().UnixNano()
|
||||||
|
hasLocal = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-spawn-wins arbitration. Five distinct cases, ordered:
|
||||||
|
// 1) remoteStamp == noLocalStamp → announcer didn't carry a
|
||||||
|
// real stamp (legacy or TakeOwnership). Keep current
|
||||||
|
// "always accept remote" behaviour so mixed-version
|
||||||
|
// rollouts and unbroadcast-advertised TakeOwnership don't
|
||||||
|
// regress.
|
||||||
|
// 2) localStamp == noLocalStamp → we have no grain at all.
|
||||||
|
// Accept remote so the next read forwards to the right
|
||||||
|
// owner instead of us spawning a parallel projection.
|
||||||
|
// 3) stamps differ → first-spawn-wins. The pod that spawned
|
||||||
|
// the grain earlier (smaller stamp) owns it; its broadcast
|
||||||
|
// is authoritative. The newer pod's projection is
|
||||||
|
// redundant — we drop our local copy and defer to remote
|
||||||
|
// so future OwnerHost lookups route correctly.
|
||||||
|
// 4) stamps equal → deterministic tie-break: lexicographically
|
||||||
|
// smaller hostname wins (both pods compute the same
|
||||||
|
// verdict, so the cold-cache first-touch cannot flip-flop).
|
||||||
|
var keepLocal bool
|
||||||
|
switch {
|
||||||
|
case remoteStamp == noLocalStamp:
|
||||||
|
keepLocal = false
|
||||||
|
if hasLocal {
|
||||||
|
legacy++
|
||||||
|
}
|
||||||
|
case localStamp == noLocalStamp:
|
||||||
|
keepLocal = false
|
||||||
|
case localStamp != remoteStamp:
|
||||||
|
keepLocal = localStamp < remoteStamp
|
||||||
|
default:
|
||||||
|
keepLocal = p.hostname < host
|
||||||
|
}
|
||||||
|
if keepLocal {
|
||||||
|
kept++
|
||||||
|
continue
|
||||||
|
}
|
||||||
delete(p.grains, id)
|
delete(p.grains, id)
|
||||||
p.remoteOwners[id] = remoteHost
|
p.remoteOwners[id] = remoteHost
|
||||||
|
accepted++
|
||||||
|
}
|
||||||
|
remaining := len(p.grains)
|
||||||
|
p.localMu.Unlock()
|
||||||
|
p.remoteMu.Unlock()
|
||||||
|
p.metrics.SetPoolStats(remaining, p.poolSize)
|
||||||
|
if kept > 0 || accepted > 0 || legacy > 0 {
|
||||||
|
log.Printf("HandleOwnershipChange from %s: accepted_remote=%d kept_local=%d legacy_no_stamp=%d",
|
||||||
|
host, accepted, kept, legacy)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -209,8 +315,9 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
|
|||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
p.remoteHosts[host] = remote
|
p.remoteHosts[host] = remote
|
||||||
|
count := len(p.remoteHosts)
|
||||||
p.remoteMu.Unlock()
|
p.remoteMu.Unlock()
|
||||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
p.metrics.SetConnectedRemotes(count)
|
||||||
|
|
||||||
log.Printf("Connected to remote host %s", host)
|
log.Printf("Connected to remote host %s", host)
|
||||||
go p.pingLoop(remote)
|
go p.pingLoop(remote)
|
||||||
@@ -250,6 +357,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
|||||||
delete(p.remoteOwners, id)
|
delete(p.remoteOwners, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
remoteCount := len(p.remoteHosts)
|
||||||
log.Printf("Removing host %s, grains: %d", host, count)
|
log.Printf("Removing host %s, grains: %d", host, count)
|
||||||
p.remoteMu.Unlock()
|
p.remoteMu.Unlock()
|
||||||
|
|
||||||
@@ -257,7 +365,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
|||||||
if exists {
|
if exists {
|
||||||
remote.Close()
|
remote.Close()
|
||||||
}
|
}
|
||||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
p.metrics.SetConnectedRemotes(remoteCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) RemoteCount() int {
|
func (p *SimpleGrainPool[V]) RemoteCount() int {
|
||||||
@@ -332,7 +440,7 @@ func (p *SimpleGrainPool[V]) Negotiate(otherHosts []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) SendNegotiation() {
|
func (p *SimpleGrainPool[V]) SendNegotiation() {
|
||||||
//negotiationCount.Inc()
|
p.metrics.IncNegotiation()
|
||||||
|
|
||||||
p.remoteMu.RLock()
|
p.remoteMu.RLock()
|
||||||
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
||||||
@@ -366,10 +474,9 @@ func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host[V])) {
|
|||||||
|
|
||||||
wg := sync.WaitGroup{}
|
wg := sync.WaitGroup{}
|
||||||
for _, host := range rh {
|
for _, host := range rh {
|
||||||
|
|
||||||
wg.Go(func() { fn(host) })
|
wg.Go(func() { fn(host) })
|
||||||
|
|
||||||
}
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
for name, host := range rh {
|
for name, host := range rh {
|
||||||
if !host.IsHealthy() {
|
if !host.IsHealthy() {
|
||||||
@@ -387,8 +494,27 @@ func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture each id's grain.lastChange UnixNano stamp under the local
|
||||||
|
// read-lock so peers receive an honest oracle for the
|
||||||
|
// first-spawn-wins arbitration. For ids that don't have a local
|
||||||
|
// grain (e.g. TakeOwnership called before anyone has loaded the
|
||||||
|
// grain, or a caller pushing ids into the pool from outside the
|
||||||
|
// spawn path), substitute the noLocalStamp sentinel — receivers see
|
||||||
|
// -1 and fall back to "always accept remote", preserving the
|
||||||
|
// pre-arbitration semantics for that case.
|
||||||
|
p.localMu.RLock()
|
||||||
|
stamps := make([]int64, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if g, ok := p.grains[id]; ok {
|
||||||
|
stamps = append(stamps, g.GetLastChange().UnixNano())
|
||||||
|
} else {
|
||||||
|
stamps = append(stamps, noLocalStamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.localMu.RUnlock()
|
||||||
|
|
||||||
p.forAllHosts(func(rh Host[V]) {
|
p.forAllHosts(func(rh Host[V]) {
|
||||||
rh.AnnounceOwnership(p.hostname, ids)
|
rh.AnnounceOwnership(p.hostname, ids, stamps)
|
||||||
})
|
})
|
||||||
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
|
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
|
||||||
// go p.statsUpdate()
|
// go p.statsUpdate()
|
||||||
@@ -406,10 +532,13 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.metrics.IncSpawn()
|
||||||
go p.broadcastOwnership([]uint64{id})
|
go p.broadcastOwnership([]uint64{id})
|
||||||
p.localMu.Lock()
|
p.localMu.Lock()
|
||||||
p.grains[id] = grain
|
p.grains[id] = grain
|
||||||
|
remaining := len(p.grains)
|
||||||
p.localMu.Unlock()
|
p.localMu.Unlock()
|
||||||
|
p.metrics.SetPoolStats(remaining, p.poolSize)
|
||||||
|
|
||||||
return grain, nil
|
return grain, nil
|
||||||
}
|
}
|
||||||
@@ -418,7 +547,20 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
|
|||||||
// var ErrNotOwner = fmt.Errorf("not owner")
|
// var ErrNotOwner = fmt.Errorf("not owner")
|
||||||
|
|
||||||
// Apply applies a mutation to a grain.
|
// Apply applies a mutation to a grain.
|
||||||
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) {
|
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (result *MutationResult[V], err error) {
|
||||||
|
// The metric closure fires exactly once on every return path: the
|
||||||
|
// latency / counters cover the full Apply (lock acquire + spawn +
|
||||||
|
// handler + storage enqueue), and the failure counter only bumps
|
||||||
|
// when the call returned a non-nil error. `len(mutation)` is the
|
||||||
|
// number of proto messages in this Apply call — a single call can
|
||||||
|
// batch several messages (SetCartItems sends ClearCart + N AddItem
|
||||||
|
// together), and the counter is incremented by that count so the
|
||||||
|
// dashboard's `rate(cart_mutations_total[1m])` reports messages
|
||||||
|
// per second rather than calls per second, matching the original
|
||||||
|
// `grainMutations.Add(len(data.Mutations))` semantics.
|
||||||
|
done := p.metrics.MutationStarted(len(mutation))
|
||||||
|
defer func() { done(err) }()
|
||||||
|
|
||||||
// Serialize all access to this grain: spawn, mutation handlers and the
|
// Serialize all access to this grain: spawn, mutation handlers and the
|
||||||
// final state read happen atomically with respect to other callers of the
|
// final state read happen atomically with respect to other callers of the
|
||||||
// same id. Different ids never contend.
|
// same id. Different ids never contend.
|
||||||
@@ -444,19 +586,20 @@ func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...p
|
|||||||
for _, listener := range p.listeners {
|
for _, listener := range p.listeners {
|
||||||
go listener.AppendMutations(id, mutations...)
|
go listener.AppendMutations(id, mutations...)
|
||||||
}
|
}
|
||||||
result, err := grain.GetCurrentState()
|
state, err := grain.GetCurrentState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &MutationResult[V]{
|
return &MutationResult[V]{
|
||||||
Result: *result,
|
Result: *state,
|
||||||
Mutations: mutations,
|
Mutations: mutations,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the current state of a grain.
|
// Get returns the current state of a grain.
|
||||||
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
|
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (result *V, err error) {
|
||||||
|
p.metrics.IncLookup()
|
||||||
unlock := p.grainLocks.lock(id)
|
unlock := p.grainLocks.lock(id)
|
||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testState struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type testGrain struct {
|
||||||
|
id uint64
|
||||||
|
accessed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *testGrain) GetId() uint64 { return g.id }
|
||||||
|
func (g *testGrain) GetLastAccess() time.Time { return g.accessed }
|
||||||
|
func (g *testGrain) GetLastChange() time.Time { return g.accessed }
|
||||||
|
func (g *testGrain) GetCurrentState() (*testState, error) {
|
||||||
|
return &testState{Value: int(g.id)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockHost struct {
|
||||||
|
name string
|
||||||
|
healthy bool
|
||||||
|
closed atomic.Bool
|
||||||
|
closeCalls atomic.Int32
|
||||||
|
actorIDs []uint64
|
||||||
|
negotiateFn func([]string) ([]string, error)
|
||||||
|
mu sync.Mutex
|
||||||
|
ownership []uint64
|
||||||
|
expiry []uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockHost(name string) *mockHost {
|
||||||
|
return &mockHost{name: name, healthy: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHost) AnnounceExpiry(ids []uint64, _ []int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.expiry = append(m.expiry, ids...)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHost) Negotiate(otherHosts []string) ([]string, error) {
|
||||||
|
if m.negotiateFn != nil {
|
||||||
|
return m.negotiateFn(otherHosts)
|
||||||
|
}
|
||||||
|
return otherHosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHost) Name() string { return m.name }
|
||||||
|
|
||||||
|
func (m *mockHost) Proxy(_ uint64, _ http.ResponseWriter, _ *http.Request, _ io.Reader) (bool, error) {
|
||||||
|
return false, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHost) Apply(_ context.Context, id uint64, _ ...proto.Message) (*MutationResult[testState], error) {
|
||||||
|
return &MutationResult[testState]{Result: testState{Value: int(id)}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHost) Get(_ context.Context, id uint64) (*testState, error) {
|
||||||
|
return &testState{Value: int(id)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHost) GetActorIds() []uint64 { return m.actorIDs }
|
||||||
|
|
||||||
|
func (m *mockHost) Close() error {
|
||||||
|
m.closeCalls.Add(1)
|
||||||
|
m.closed.Store(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHost) Ping() bool { return m.healthy }
|
||||||
|
|
||||||
|
func (m *mockHost) IsHealthy() bool { return m.healthy }
|
||||||
|
|
||||||
|
func (m *mockHost) AnnounceOwnership(_ string, ids []uint64, _ []int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.ownership = append(m.ownership, ids...)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestPool(t *testing.T, spawnHost func(string) (Host[testState], error)) *SimpleGrainPool[testState] {
|
||||||
|
t.Helper()
|
||||||
|
reg := NewMutationRegistry()
|
||||||
|
pool, err := NewSimpleGrainPool(GrainPoolConfig[testState]{
|
||||||
|
Hostname: "local",
|
||||||
|
Spawn: func(_ context.Context, id uint64) (Grain[testState], error) {
|
||||||
|
return &testGrain{id: id, accessed: time.Now()}, nil
|
||||||
|
},
|
||||||
|
SpawnHost: spawnHost,
|
||||||
|
TTL: time.Hour,
|
||||||
|
PoolSize: 100,
|
||||||
|
MutationRegistry: reg,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleGrainPoolGetAndApply(t *testing.T) {
|
||||||
|
pool := newTestPool(t, func(string) (Host[testState], error) {
|
||||||
|
return nil, fmt.Errorf("no remotes")
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := pool.Get(context.Background(), 42)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Value != 42 {
|
||||||
|
t.Fatalf("Get value = %d, want 42", got.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := pool.GetLocalIds()
|
||||||
|
if len(ids) != 1 || ids[0] != 42 {
|
||||||
|
t.Fatalf("GetLocalIds = %v, want [42]", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleGrainPoolAddRemoteDedup(t *testing.T) {
|
||||||
|
var spawnCalls atomic.Int32
|
||||||
|
host := newMockHost("peer-a")
|
||||||
|
|
||||||
|
pool := newTestPool(t, func(name string) (Host[testState], error) {
|
||||||
|
spawnCalls.Add(1)
|
||||||
|
if name != host.name {
|
||||||
|
t.Fatalf("spawn host %q, want %q", name, host.name)
|
||||||
|
}
|
||||||
|
return host, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
const workers = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(workers)
|
||||||
|
for range workers {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := pool.AddRemote(host.name); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if pool.RemoteCount() != 1 {
|
||||||
|
t.Fatalf("RemoteCount = %d, want 1", pool.RemoteCount())
|
||||||
|
}
|
||||||
|
if spawnCalls.Load() != 1 {
|
||||||
|
t.Fatalf("spawn calls = %d, want 1", spawnCalls.Load())
|
||||||
|
}
|
||||||
|
if host.closeCalls.Load() != 0 {
|
||||||
|
t.Fatalf("duplicate remote closed = %d, want 0", host.closeCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleGrainPoolAddRemoteRaceClosesDuplicate(t *testing.T) {
|
||||||
|
start := make(chan struct{})
|
||||||
|
var spawnCalls atomic.Int32
|
||||||
|
var spawned []*mockHost
|
||||||
|
|
||||||
|
pool := newTestPool(t, func(name string) (Host[testState], error) {
|
||||||
|
spawnCalls.Add(1)
|
||||||
|
h := newMockHost(name)
|
||||||
|
spawned = append(spawned, h)
|
||||||
|
if spawnCalls.Load() == 1 {
|
||||||
|
<-start
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if _, err := pool.AddRemote("peer-b"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
close(start)
|
||||||
|
if _, err := pool.AddRemote("peer-b"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pool.RemoteCount() != 1 {
|
||||||
|
t.Fatalf("RemoteCount = %d, want 1", pool.RemoteCount())
|
||||||
|
}
|
||||||
|
if spawnCalls.Load() != 2 {
|
||||||
|
t.Fatalf("spawn calls = %d, want 2", spawnCalls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
closed := 0
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
for closed < 1 {
|
||||||
|
closed = 0
|
||||||
|
for _, h := range spawned {
|
||||||
|
if h.closeCalls.Load() > 0 {
|
||||||
|
closed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if closed >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("timed out waiting for duplicate remote host Close")
|
||||||
|
default:
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleGrainPoolRemoveHost(t *testing.T) {
|
||||||
|
host := newMockHost("peer-c")
|
||||||
|
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
||||||
|
|
||||||
|
if _, err := pool.AddRemote(host.name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool.remoteMu.Lock()
|
||||||
|
pool.remoteOwners[99] = host
|
||||||
|
pool.remoteMu.Unlock()
|
||||||
|
|
||||||
|
pool.RemoveHost(host.name)
|
||||||
|
|
||||||
|
if pool.RemoteCount() != 0 {
|
||||||
|
t.Fatalf("RemoteCount = %d, want 0", pool.RemoteCount())
|
||||||
|
}
|
||||||
|
if owner, ok := pool.OwnerHost(99); ok || owner != nil {
|
||||||
|
t.Fatalf("OwnerHost still mapped: %#v ok=%v", owner, ok)
|
||||||
|
}
|
||||||
|
if host.closeCalls.Load() != 1 {
|
||||||
|
t.Fatalf("Close calls = %d, want 1", host.closeCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleGrainPoolHandleOwnershipChange(t *testing.T) {
|
||||||
|
host := newMockHost("peer-d")
|
||||||
|
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
||||||
|
|
||||||
|
// First-spawn-wins arbitration: a remote broadcast wins only when its
|
||||||
|
// lastChange stamp is strictly earlier than the local grain's stamp
|
||||||
|
// (or the local grain is absent). Capture the local spawn time once
|
||||||
|
// and pass a stamp 1ns earlier to the remote so the test continues to
|
||||||
|
// assert "external owner takes over given an earlier remote spawn"
|
||||||
|
// under the new arbitration rule.
|
||||||
|
spawnTime := time.Now()
|
||||||
|
pool.localMu.Lock()
|
||||||
|
pool.grains[7] = &testGrain{id: 7, accessed: spawnTime}
|
||||||
|
pool.localMu.Unlock()
|
||||||
|
|
||||||
|
remoteStamp := spawnTime.UnixNano() - 1
|
||||||
|
if err := pool.HandleOwnershipChange(host.name, []uint64{7}, []int64{remoteStamp}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.localMu.RLock()
|
||||||
|
_, local := pool.grains[7]
|
||||||
|
pool.localMu.RUnlock()
|
||||||
|
if local {
|
||||||
|
t.Fatal("local grain should be removed after ownership change")
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, ok := pool.OwnerHost(7)
|
||||||
|
if !ok || owner.Name() != host.name {
|
||||||
|
t.Fatalf("OwnerHost = (%v, %v), want (%q, true)", owner, ok, host.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSimpleGrainPoolHandleOwnershipChangeArbitration verifies the
|
||||||
|
// first-spawn-wins arbitration rules for HandleOwnershipChange:
|
||||||
|
//
|
||||||
|
// - Remote with earlier lastChange than local → accept remote.
|
||||||
|
// - Remote with later lastChange than local → keep local.
|
||||||
|
// - Equal lastChange → lower hostname wins.
|
||||||
|
// - Remote stamp == -1 (legacy path) → accept remote (pre-arbitration).
|
||||||
|
// - Local grain absent → accept remote.
|
||||||
|
func TestSimpleGrainPoolHandleOwnershipChangeArbitration(t *testing.T) {
|
||||||
|
host := newMockHost("peer-arbitrate")
|
||||||
|
// Each table case constructs a fresh pool inside t.Run — we don't
|
||||||
|
// share state across cases because per-case hostname differs
|
||||||
|
// (hostname is the tie-break oracle on equal lastChange stamps).
|
||||||
|
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
name_local string // pool hostname for ordering check
|
||||||
|
id uint64
|
||||||
|
localStamp int64 // UnixNano of the local grain; -1 = no local grain
|
||||||
|
remoteStamp int64 // UnixNano stamped on the broadcast
|
||||||
|
expectLocal bool // true → local grain still resident
|
||||||
|
expectRemote bool // true → remoteOwners[id] = host
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "remote older stamp wins",
|
||||||
|
name_local: "zzz", // pool above "peer-arbitrate" lex
|
||||||
|
id: 10,
|
||||||
|
localStamp: now + 100,
|
||||||
|
remoteStamp: now,
|
||||||
|
expectLocal: false,
|
||||||
|
expectRemote: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "local earlier stamp keeps",
|
||||||
|
name_local: "aaa", // pool below "peer-arbitrate" lex but stamp decides
|
||||||
|
id: 11,
|
||||||
|
localStamp: now,
|
||||||
|
remoteStamp: now + 100,
|
||||||
|
expectLocal: true,
|
||||||
|
expectRemote: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "equal stamp + lower local hostname keeps",
|
||||||
|
name_local: "a-peer", // below host
|
||||||
|
id: 12,
|
||||||
|
localStamp: now,
|
||||||
|
remoteStamp: now,
|
||||||
|
expectLocal: true,
|
||||||
|
expectRemote: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "equal stamp + higher local hostname defers",
|
||||||
|
name_local: "zzz-peer", // above host
|
||||||
|
id: 13,
|
||||||
|
localStamp: now,
|
||||||
|
remoteStamp: now,
|
||||||
|
expectLocal: false,
|
||||||
|
expectRemote: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy (no remote stamp) accepts remote",
|
||||||
|
name_local: "zzz", // we'd otherwise keep local
|
||||||
|
id: 14,
|
||||||
|
localStamp: now + 100,
|
||||||
|
remoteStamp: -1,
|
||||||
|
expectLocal: false,
|
||||||
|
expectRemote: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no local grain accepts remote",
|
||||||
|
name_local: "aaa",
|
||||||
|
id: 15,
|
||||||
|
localStamp: -1,
|
||||||
|
remoteStamp: now,
|
||||||
|
expectLocal: false,
|
||||||
|
expectRemote: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Fresh pool per case so hostname differences don't leak.
|
||||||
|
p := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
||||||
|
p.hostname = tc.name_local
|
||||||
|
|
||||||
|
p.localMu.Lock()
|
||||||
|
if tc.localStamp != -1 {
|
||||||
|
p.grains[tc.id] = &testGrain{id: tc.id, accessed: time.Unix(0, tc.localStamp)}
|
||||||
|
}
|
||||||
|
p.localMu.Unlock()
|
||||||
|
|
||||||
|
if err := p.HandleOwnershipChange(host.name, []uint64{tc.id}, []int64{tc.remoteStamp}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.localMu.RLock()
|
||||||
|
_, hasLocal := p.grains[tc.id]
|
||||||
|
p.localMu.RUnlock()
|
||||||
|
if hasLocal != tc.expectLocal {
|
||||||
|
t.Fatalf("local grain presence = %v, want %v", hasLocal, tc.expectLocal)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, ok := p.OwnerHost(tc.id)
|
||||||
|
if ok != tc.expectRemote {
|
||||||
|
t.Fatalf("OwnerHost ok = %v, want %v", ok, tc.expectRemote)
|
||||||
|
}
|
||||||
|
if tc.expectRemote && (owner == nil || owner.Name() != host.name) {
|
||||||
|
t.Fatalf("Owner = %v, want host %q", owner, host.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleGrainPoolPurgeEvictsStaleGrains(t *testing.T) {
|
||||||
|
pool := newTestPool(t, func(string) (Host[testState], error) {
|
||||||
|
return nil, fmt.Errorf("no remotes")
|
||||||
|
})
|
||||||
|
pool.ttl = time.Minute
|
||||||
|
|
||||||
|
stale := &testGrain{id: 1, accessed: time.Now().Add(-2 * time.Minute)}
|
||||||
|
fresh := &testGrain{id: 2, accessed: time.Now()}
|
||||||
|
|
||||||
|
pool.localMu.Lock()
|
||||||
|
pool.grains[1] = stale
|
||||||
|
pool.grains[2] = fresh
|
||||||
|
pool.localMu.Unlock()
|
||||||
|
|
||||||
|
pool.purge()
|
||||||
|
|
||||||
|
pool.localMu.RLock()
|
||||||
|
defer pool.localMu.RUnlock()
|
||||||
|
if _, ok := pool.grains[1]; ok {
|
||||||
|
t.Fatal("stale grain should be purged")
|
||||||
|
}
|
||||||
|
if _, ok := pool.grains[2]; !ok {
|
||||||
|
t.Fatal("fresh grain should remain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleGrainPoolBroadcastOwnership(t *testing.T) {
|
||||||
|
host := newMockHost("peer-e")
|
||||||
|
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
||||||
|
if _, err := pool.AddRemote(host.name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.broadcastOwnership([]uint64{11, 12})
|
||||||
|
|
||||||
|
host.mu.Lock()
|
||||||
|
defer host.mu.Unlock()
|
||||||
|
if len(host.ownership) != 2 {
|
||||||
|
t.Fatalf("ownership announces = %v, want 2 ids", host.ownership)
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-7
@@ -12,6 +12,12 @@ import (
|
|||||||
|
|
||||||
type StateStorage struct {
|
type StateStorage struct {
|
||||||
registry MutationRegistry
|
registry MutationRegistry
|
||||||
|
// metrics is set by the owning DiskStorage via SetMetrics. Nil is fine —
|
||||||
|
// every method that touches it nil-checks first. The pointer is
|
||||||
|
// intentionally unexported: callers set it through DiskStorage.SetMetrics
|
||||||
|
// so the embedded StateStorage always tracks the parent disk storage's
|
||||||
|
// metrics, even if the embedded value is replaced.
|
||||||
|
metrics *Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
type StorageEvent struct {
|
type StorageEvent struct {
|
||||||
@@ -50,10 +56,20 @@ func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message, timeS
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) error {
|
// Append serialises a mutation as a StorageEvent and writes it (plus a
|
||||||
|
// trailing newline) to io. The first return value is the number of
|
||||||
|
// bytes written, which the caller (DiskStorage) adds to the
|
||||||
|
// event_log_bytes_written_total counter; the second is the underlying
|
||||||
|
// write error, if any. ErrUnknownType is returned (with 0 bytes) when
|
||||||
|
// the mutation's type is not registered, and is also counted in
|
||||||
|
// event_log_unknown_types_total.
|
||||||
|
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) (int, error) {
|
||||||
typeName, ok := s.registry.GetTypeName(mutation)
|
typeName, ok := s.registry.GetTypeName(mutation)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrUnknownType
|
if s.metrics != nil {
|
||||||
|
s.metrics.EventLogUnknownTypes.Inc()
|
||||||
|
}
|
||||||
|
return 0, ErrUnknownType
|
||||||
}
|
}
|
||||||
event := &StorageEvent{
|
event := &StorageEvent{
|
||||||
Type: typeName,
|
Type: typeName,
|
||||||
@@ -62,13 +78,16 @@ func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp ti
|
|||||||
}
|
}
|
||||||
jsonBytes, err := json.Marshal(event)
|
jsonBytes, err := json.Marshal(event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
if _, err := io.Write(jsonBytes); err != nil {
|
n, err := io.Write(jsonBytes)
|
||||||
return err
|
total := n
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
}
|
}
|
||||||
io.Write([]byte("\n"))
|
n, err = io.Write([]byte("\n"))
|
||||||
return nil
|
total += n
|
||||||
|
return total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
||||||
@@ -83,6 +102,9 @@ func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
|||||||
typeName := event.Type
|
typeName := event.Type
|
||||||
mutation, ok := s.registry.Create(typeName)
|
mutation, ok := s.registry.Create(typeName)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if s.metrics != nil {
|
||||||
|
s.metrics.EventLogUnknownTypes.Inc()
|
||||||
|
}
|
||||||
return nil, ErrUnknownType
|
return nil, ErrUnknownType
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
|
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
|
||||||
|
|||||||
+83
-51
@@ -12,20 +12,19 @@ package backofficeadmin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
actor "git.k6n.net/mats/go-cart-actor/pkg/actor"
|
actor "git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CartFileInfo is the metadata returned by the cart listing endpoint.
|
// CartFileInfo is the metadata returned by the cart listing endpoint.
|
||||||
@@ -52,9 +51,8 @@ type Config struct {
|
|||||||
CheckoutDataDir string
|
CheckoutDataDir string
|
||||||
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
|
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
|
||||||
ProfileDataDir string
|
ProfileDataDir string
|
||||||
// RedisAddress / RedisPassword reach the inventory store.
|
// PromotionsFile holds the path to promotions.json (optional).
|
||||||
RedisAddress string
|
PromotionsFile string
|
||||||
RedisPassword string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// App is the commerce admin control plane. Construct with New, register its
|
// App is the commerce admin control plane. Construct with New, register its
|
||||||
@@ -62,15 +60,16 @@ type Config struct {
|
|||||||
type App struct {
|
type App struct {
|
||||||
fs *FileServer
|
fs *FileServer
|
||||||
hub *Hub
|
hub *Hub
|
||||||
rdb *redis.Client
|
|
||||||
inv *inventory.RedisInventoryService
|
|
||||||
cs *CustomerServer
|
cs *CustomerServer
|
||||||
|
OnVouchersChange func(codes []string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs the commerce admin: it opens the Redis inventory service, the
|
// New constructs the commerce admin: it opens the cart/checkout disk event-log
|
||||||
// cart/checkout disk event-log stores, the file server, and the WebSocket hub.
|
// stores, the file server, and the WebSocket hub. It does not register routes
|
||||||
// It does not register routes (RegisterRoutes), start background work (Start),
|
// (RegisterRoutes), start background work (Start), or open a listener.
|
||||||
// or open a listener.
|
//
|
||||||
|
// Inventory writes no longer live here — they go through the standalone inventory
|
||||||
|
// service's HTTP API (/api/inventory/batch), reverse-proxied by the host.
|
||||||
func New(cfg Config) (*App, error) {
|
func New(cfg Config) (*App, error) {
|
||||||
if cfg.DataDir == "" {
|
if cfg.DataDir == "" {
|
||||||
cfg.DataDir = "data"
|
cfg.DataDir = "data"
|
||||||
@@ -79,18 +78,74 @@ func New(cfg Config) (*App, error) {
|
|||||||
cfg.CheckoutDataDir = "checkout-data"
|
cfg.CheckoutDataDir = "checkout-data"
|
||||||
}
|
}
|
||||||
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
|
||||||
Addr: cfg.RedisAddress,
|
|
||||||
Password: cfg.RedisPassword,
|
|
||||||
DB: 0,
|
|
||||||
})
|
|
||||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = os.MkdirAll(cfg.DataDir, 0755)
|
_ = os.MkdirAll(cfg.DataDir, 0755)
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
|
||||||
|
promotionsPath := cfg.PromotionsFile
|
||||||
|
if promotionsPath == "" {
|
||||||
|
promotionsPath = os.Getenv("PROMOTIONS_FILE")
|
||||||
|
}
|
||||||
|
if promotionsPath == "" {
|
||||||
|
promotionsPath = "data/promotions.json"
|
||||||
|
}
|
||||||
|
if promotionStore, err := promotions.NewStore(promotionsPath); err == nil {
|
||||||
|
promotionService := promotions.NewPromotionService(nil)
|
||||||
|
reg.RegisterProcessor(
|
||||||
|
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||||
|
for _, v := range g.Vouchers {
|
||||||
|
if v != nil {
|
||||||
|
v.BypassedByPromotions = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.UpdateTotals()
|
||||||
|
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||||
|
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||||
|
|
||||||
|
hasBypassed := false
|
||||||
|
for _, res := range results {
|
||||||
|
if res.Applicable {
|
||||||
|
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
|
||||||
|
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
|
||||||
|
var codes []string
|
||||||
|
if s, ok := bc.Value.AsString(); ok {
|
||||||
|
codes = append(codes, strings.ToLower(s))
|
||||||
|
} else if arr, ok := bc.Value.AsStringSlice(); ok {
|
||||||
|
for _, s := range arr {
|
||||||
|
codes = append(codes, strings.ToLower(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, code := range codes {
|
||||||
|
for _, v := range g.Vouchers {
|
||||||
|
if v != nil && strings.ToLower(v.Code) == code {
|
||||||
|
if !v.BypassedByPromotions {
|
||||||
|
v.BypassedByPromotions = true
|
||||||
|
hasBypassed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasBypassed {
|
||||||
|
g.UpdateTotals()
|
||||||
|
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||||
|
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
promotionService.ApplyResults(g, results, promotionCtx)
|
||||||
|
g.Version++
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log.Printf("backofficeadmin: unable to load promotions store from %s: %v", promotionsPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](cfg.DataDir, reg)
|
diskStorage := actor.NewDiskStorage[cart.CartGrain](cfg.DataDir, reg)
|
||||||
|
|
||||||
_ = os.MkdirAll(cfg.CheckoutDataDir, 0755)
|
_ = os.MkdirAll(cfg.CheckoutDataDir, 0755)
|
||||||
@@ -107,10 +162,8 @@ func New(cfg Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout),
|
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, promotionsPath, diskStorage, diskStorageCheckout),
|
||||||
hub: NewHub(),
|
hub: NewHub(),
|
||||||
rdb: rdb,
|
|
||||||
inv: inventoryService,
|
|
||||||
cs: customerSrv,
|
cs: customerSrv,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -119,11 +172,11 @@ func New(cfg Config) (*App, error) {
|
|||||||
// apply auth or CORS — the composing host (or standalone main) wraps the mux
|
// apply auth or CORS — the composing host (or standalone main) wraps the mux
|
||||||
// with those.
|
// with those.
|
||||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
a.fs.OnVouchersChange = a.OnVouchersChange
|
||||||
mux.HandleFunc("GET /carts", a.fs.CartsHandler)
|
mux.HandleFunc("GET /carts", a.fs.CartsHandler)
|
||||||
mux.HandleFunc("GET /cart/{id}", a.fs.CartHandler)
|
mux.HandleFunc("GET /cart/{id}", a.fs.CartHandler)
|
||||||
mux.HandleFunc("GET /checkouts", a.fs.CheckoutsHandler)
|
mux.HandleFunc("GET /checkouts", a.fs.CheckoutsHandler)
|
||||||
mux.HandleFunc("GET /checkout/{id}", a.fs.CheckoutHandler)
|
mux.HandleFunc("GET /checkout/{id}", a.fs.CheckoutHandler)
|
||||||
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", a.updateInventory)
|
|
||||||
mux.HandleFunc("/promotions", a.fs.PromotionsHandler)
|
mux.HandleFunc("/promotions", a.fs.PromotionsHandler)
|
||||||
mux.HandleFunc("/vouchers", a.fs.VoucherHandler)
|
mux.HandleFunc("/vouchers", a.fs.VoucherHandler)
|
||||||
mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler)
|
mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler)
|
||||||
@@ -134,28 +187,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) updateInventory(w http.ResponseWriter, r *http.Request) {
|
|
||||||
locationID := inventory.LocationID(r.PathValue("locationId"))
|
|
||||||
sku := inventory.SKU(r.PathValue("sku"))
|
|
||||||
pipe := a.rdb.Pipeline()
|
|
||||||
var payload struct {
|
|
||||||
Quantity int64 `json:"quantity"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
||||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.inv.UpdateInventory(r.Context(), pipe, sku, locationID, payload.Quantity)
|
|
||||||
if _, err := pipe.Exec(r.Context()); err != nil {
|
|
||||||
http.Error(w, "failed to update inventory", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := a.inv.SendInventoryChanged(r.Context(), sku, locationID); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start runs the WebSocket hub loop and, when conn is non-nil, the RabbitMQ
|
// Start runs the WebSocket hub loop and, when conn is non-nil, the RabbitMQ
|
||||||
// mutation consumer that broadcasts cart mutations to connected clients. The
|
// mutation consumer that broadcasts cart mutations to connected clients. The
|
||||||
@@ -169,10 +201,10 @@ func (a *App) Start(ctx context.Context, conn *rabbit.Conn) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown releases the App's resources (the Redis client). The background
|
// Shutdown releases the App's resources. The background goroutines are stopped
|
||||||
// goroutines are stopped by cancelling the ctx passed to Start.
|
// by cancelling the ctx passed to Start.
|
||||||
func (a *App) Shutdown(_ context.Context) error {
|
func (a *App) Shutdown(_ context.Context) error {
|
||||||
return a.rdb.Close()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startMutationConsumer subscribes to the cart "mutation" topic and forwards
|
// startMutationConsumer subscribes to the cart "mutation" topic and forwards
|
||||||
|
|||||||
@@ -26,14 +26,17 @@ type FileServer struct {
|
|||||||
// Define fields here
|
// Define fields here
|
||||||
dataDir string
|
dataDir string
|
||||||
checkoutDataDir string
|
checkoutDataDir string
|
||||||
|
promotionsFile string
|
||||||
storage actor.LogStorage[cart.CartGrain]
|
storage actor.LogStorage[cart.CartGrain]
|
||||||
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
|
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
|
||||||
|
OnVouchersChange func(codes []string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileServer(dataDir string, checkoutDataDir string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
|
func NewFileServer(dataDir string, checkoutDataDir string, promotionsFile string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
|
||||||
return &FileServer{
|
return &FileServer{
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
checkoutDataDir: checkoutDataDir,
|
checkoutDataDir: checkoutDataDir,
|
||||||
|
promotionsFile: promotionsFile,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
checkoutStorage: checkoutStorage,
|
checkoutStorage: checkoutStorage,
|
||||||
}
|
}
|
||||||
@@ -215,7 +218,10 @@ func (fs *FileServer) CheckoutsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
fileName := filepath.Join(fs.dataDir, "promotions.json")
|
fileName := fs.promotionsFile
|
||||||
|
if fileName == "" {
|
||||||
|
fileName = filepath.Join(fs.dataDir, "promotions.json")
|
||||||
|
}
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
file, err := os.Open(fileName)
|
file, err := os.Open(fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -271,15 +277,37 @@ func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
|
body, err := io.ReadAll(r.Body)
|
||||||
file, err := os.Create(fileName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
io.Copy(file, r.Body)
|
var parsed struct {
|
||||||
|
State struct {
|
||||||
|
Vouchers []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"vouchers"`
|
||||||
|
} `json:"state"`
|
||||||
|
}
|
||||||
|
var codes []string
|
||||||
|
if err := json.Unmarshal(body, &parsed); err == nil {
|
||||||
|
for _, v := range parsed.State.Vouchers {
|
||||||
|
if v.Code != "" {
|
||||||
|
codes = append(codes, v.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
|
||||||
|
if err := os.WriteFile(fileName, body, 0644); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if fs.OnVouchersChange != nil {
|
||||||
|
fs.OnVouchersChange(codes)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# `pkg/cart` — mutation inventory
|
||||||
|
|
||||||
|
Every mutation file under `pkg/cart/mutation_*.go` plus the slice of cart
|
||||||
|
state it touches. The grouping is **subjective** ("Subscription lifecycle",
|
||||||
|
"Discount surface", etc.) — used as a starting point for collapsing the
|
||||||
|
file-per-mutation pattern flagged in `agents.md`. Move two related
|
||||||
|
mutations into one file first; if the test surface stays smaller than the
|
||||||
|
two-file version, the grouping is real.
|
||||||
|
|
||||||
|
| File | Concern | State it READS | State it WRITES |
|
||||||
|
| --------------------------------------------------- | -------------------- | ----------------------------- | ---------------------------------------- |
|
||||||
|
| `mutation_items.go` (`mutation_add_item.go` and `mutation_remove_item.go` merged; tests stay in `mutation_add_item_test.go` + `mutation_remove_item_test.go`) | Line item add/remove | `state.Items` | `state.Items`, `state.UpdatedAt` |
|
||||||
|
| `mutation_change_quantity.go` | Line item quantity | `state.Items[idx]` | `state.Items[idx]`, `state.UpdatedAt` |
|
||||||
|
| `mutation_clear_cart.go` | Wholesale clear | — | `state.Items`, `state.Vouchers`, `state.UpdatedAt` |
|
||||||
|
| `mutation_set_user_id.go` | Ownership | — | `state.UserID`, `state.UpdatedAt` |
|
||||||
|
| `mutation_set_cart_type.go` (+ test) | Cart type | — | `state.Type`, `state.UpdatedAt` |
|
||||||
|
| `mutation_add_voucher.go` | Discount surface | `state.Vouchers` | `state.Vouchers`, `state.LineTotals` (re-eval), `state.UpdatedAt` |
|
||||||
|
| `mutation_set_custom_fields.go` (+ tests) | Custom K/V | `state.CustomFields` | `state.CustomFields`, `state.UpdatedAt` |
|
||||||
|
| `mutation_set_recovery_contact.go` (+ tests) | Recovery | — | `state.Recovery`, `state.UpdatedAt` |
|
||||||
|
| `mutation_subscription_added.go` | Subscription | `state.Items` | `state.Subscriptions`, `state.UpdatedAt` |
|
||||||
|
| `mutation_upsert_subscriptiondetails.go` | Subscription details | `state.Subscriptions` | `state.Subscriptions`, `state.UpdatedAt` |
|
||||||
|
| `mutation_markings.go` (`mutation_line_item_marking.go` and `mutation_remove_line_item_marking.go` merged) | Markings apply/remove | `state.Items[idx].Markings` | `state.Items[idx].Markings`, `state.UpdatedAt` |
|
||||||
|
|
||||||
|
## Delete-test quick check
|
||||||
|
|
||||||
|
For any proposed grouping, ask: does deleting the merged file concentrate
|
||||||
|
complexity, or just move lines? For markings — yes, the shared logic
|
||||||
|
(cascade to pricing) makes the merge worthwhile, and the merge is now done
|
||||||
|
(see below). For `voucher` vs `custom_fields` — no, those read different
|
||||||
|
state and have different error contracts; keep separate.
|
||||||
|
|
||||||
|
### What is already merged
|
||||||
|
|
||||||
|
`pkg/cart/mutation_items.go` (added 2025-07) — AddItem and RemoveItem
|
||||||
|
both touch `state.Items` and share the `ErrPaymentInProgress`,
|
||||||
|
`decodeExtra`, and `getOrgPrice` helpers. Merge passed the delete-test
|
||||||
|
checks: the receiver `*CartMutationContext`, same grain (`*CartGrain`),
|
||||||
|
same mutation-registry dispatch. Both `mutation_*_test.go` files remain
|
||||||
|
because they assert specific method behaviour, not generic package
|
||||||
|
behaviour. Verified: `go test -count=1 ./pkg/cart/...` clean (voucher test
|
||||||
|
which references `ErrPaymentInProgress` still green).
|
||||||
|
|
||||||
|
#### Table corrections when merging
|
||||||
|
|
||||||
|
When the two source rows collapsed into one, two corrections landed in
|
||||||
|
the merge:
|
||||||
|
|
||||||
|
- AddItem's old READS column listed `state.Vouchers (for stacking)`.
|
||||||
|
That clause was **incorrect** — voucher stacking is `AddVoucher`'s
|
||||||
|
concern, not AddItem's. The merge drops the clause because reading
|
||||||
|
`mutation_add_item.go` confirms AddItem only reads `state.Items`.
|
||||||
|
This is a documentation correction, not a behaviour change.
|
||||||
|
- Both `State it READS / WRITES` columns originally had `(both)`
|
||||||
|
annotations to remind that two distinct mutations were sharing the
|
||||||
|
row. After collapse the row already says `Line item add/remove`, so
|
||||||
|
the `(both)` markers are noise — dropped.
|
||||||
|
|
||||||
|
If a future merge lands, follow the same pattern: read the actual source
|
||||||
|
to verify the row's claimed state surface, and prune label-noise before
|
||||||
|
compressing two rows into one.
|
||||||
|
|
||||||
|
`pkg/cart/mutation_markings.go` (added 2025-07) — LineItemMarking and
|
||||||
|
RemoveLineItemMarking both touch `state.Items[idx].Markings` and share
|
||||||
|
the same item-lookup loop (find by ID, mutate `Marking` field). Merge
|
||||||
|
passed the delete-test: the shared write surface and identical error
|
||||||
|
contract ("item with ID %d not found") make the grouping real. No test
|
||||||
|
files existed for either mutation, so the test surface is unchanged.
|
||||||
|
Verified: `go build ./pkg/cart/...` and `go test -count=1 ./pkg/cart/...`
|
||||||
|
clean.
|
||||||
|
|
||||||
|
### NOT to merge next
|
||||||
|
|
||||||
|
`mutation_change_quantity.go` looks like an obvious candidate for the next
|
||||||
|
merge (same receiver, same grain, same `state.Items` write surface). It
|
||||||
|
is **not** — quantity has a partial-line-drop arithmetic contract that
|
||||||
|
add/remove do not:
|
||||||
|
|
||||||
|
- `AddItem` always merges or appends; never sets quantity down.
|
||||||
|
- `RemoveItem` always removes whole lines; never decrements quantity.
|
||||||
|
- `ChangeQuantity` may drop a line entirely if quantity reaches 0.
|
||||||
|
|
||||||
|
If you merge `ChangeQuantity` first, doing the same delete-test check
|
||||||
|
yields: deleting the merged file leaves the partial-line arithmetic
|
||||||
|
logic stranded in `Apply`, which IS a real concentration (good). But
|
||||||
|
the inverse: if `AddItem` later grows a "decrement" edge case, the
|
||||||
|
merged file will balloon and a re-split becomes expensive. Either
|
||||||
|
keep `ChangeQuantity` separate, OR merge all three with the merge
|
||||||
|
scoped to a "line item lifecycle" package and the partial-line
|
||||||
|
arithmetic under a clearly-named helper.
|
||||||
|
|
||||||
|
## How this maps onto a refactor
|
||||||
|
|
||||||
|
1. Pick a row with shared logic (markings, subscription details).
|
||||||
|
2. Move related mutations + their tests into one file under
|
||||||
|
`pkg/cart/<concern>.go`.
|
||||||
|
3. Re-run `go test ./pkg/cart/...` — same assertions, smaller surface.
|
||||||
|
4. Update this table to reflect the new file layout.
|
||||||
+47
-1
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +86,25 @@ type Notice struct {
|
|||||||
Code *string `json:"code,omitempty"`
|
Code *string `json:"code,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PushToken is the Go-level mirror of cart_messages.PushToken: a generic,
|
||||||
|
// provider-agnostic push-delivery target. Stored on the grain so the
|
||||||
|
// abandoned-cart recovery scanner can hand it to a notifier without
|
||||||
|
// re-decoding proto messages; the wire shape lives in proto/cart.proto.
|
||||||
|
//
|
||||||
|
// SECURITY: the raw Token persists verbatim to the per-pod event log
|
||||||
|
// (CartGrain JSON-marshals PushTokens on every mutation). Anyone with read
|
||||||
|
// access to the cart service's data dir — PVC snapshots, debug dumps, log
|
||||||
|
// archival — can extract device handles. A future hardening pass should
|
||||||
|
// either hash-on-write (hash.compareAtLaunch) for stateless matching or
|
||||||
|
// encrypt-at-rest with a key the notifier owns. v0 keeps the seam: a real
|
||||||
|
// notifier should treat the stored token as opaque, fetch any canonical
|
||||||
|
// canonicalised handle from the input side (frontend/web SDK), and use
|
||||||
|
// what's here only for routing.
|
||||||
|
type PushToken struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
type CartPaymentStatus string
|
type CartPaymentStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -119,6 +139,7 @@ type AppliedPromotion struct {
|
|||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
Discount *Price `json:"discount,omitempty"`
|
Discount *Price `json:"discount,omitempty"`
|
||||||
|
ItemIds []uint32 `json:"itemIds,omitempty"`
|
||||||
|
|
||||||
Pending bool `json:"pending,omitempty"`
|
Pending bool `json:"pending,omitempty"`
|
||||||
Progress map[string]interface{} `json:"progress,omitempty"`
|
Progress map[string]interface{} `json:"progress,omitempty"`
|
||||||
@@ -130,15 +151,28 @@ type CartGrain struct {
|
|||||||
lastVoucherId uint32
|
lastVoucherId uint32
|
||||||
lastAccess time.Time
|
lastAccess time.Time
|
||||||
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
||||||
userId string
|
UserId string `json:"userId,omitempty"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
Version uint `json:"version"`
|
Version uint `json:"version"`
|
||||||
InventoryReserved bool `json:"inventoryReserved"`
|
InventoryReserved bool `json:"inventoryReserved"`
|
||||||
|
Type cart_messages.CartType `json:"type,omitempty"`
|
||||||
Id CartId `json:"id"`
|
Id CartId `json:"id"`
|
||||||
Items []*CartItem `json:"items"`
|
Items []*CartItem `json:"items"`
|
||||||
TotalPrice *Price `json:"totalPrice"`
|
TotalPrice *Price `json:"totalPrice"`
|
||||||
TotalDiscount *Price `json:"totalDiscount"`
|
TotalDiscount *Price `json:"totalDiscount"`
|
||||||
|
// EvaluatedItems is the per-line promotion breakdown — see
|
||||||
|
// EvaluatedItem for shape and semantics. Populated by the canonical
|
||||||
|
// promotion pipeline (pkg/promotions.PromotionService.EvaluateAndApply)
|
||||||
|
// after every successful mutation, alongside TotalDiscount and
|
||||||
|
// AppliedPromotions, so any consumer of the grain (UCP cart response,
|
||||||
|
// legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) sees the
|
||||||
|
// per-line discount/effective totals without re-running the engine at
|
||||||
|
// response time. Mirrors the same shape /promotions/evaluate-with-cart
|
||||||
|
// has returned since the breakdown feature was introduced; deliberately
|
||||||
|
// persisted via the event-log JSON block so we don't have to re-derive
|
||||||
|
// it on every read path (same precedent as TotalPrice/TotalDiscount).
|
||||||
|
EvaluatedItems []EvaluatedItem `json:"evaluatedItems,omitempty"`
|
||||||
Processing bool `json:"processing"`
|
Processing bool `json:"processing"`
|
||||||
//PaymentInProgress uint16 `json:"paymentInProgress"`
|
//PaymentInProgress uint16 `json:"paymentInProgress"`
|
||||||
OrderReference string `json:"orderReference,omitempty"`
|
OrderReference string `json:"orderReference,omitempty"`
|
||||||
@@ -148,6 +182,14 @@ type CartGrain struct {
|
|||||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||||
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
||||||
|
|
||||||
|
// Email is the cart's recovery contact address. Set explicitly via the
|
||||||
|
// SetRecoveryContact mutation (independent of the linked profile's email),
|
||||||
|
// so abandoned-cart recovery can fire before login. Empty string = no email.
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
// PushTokens holds every push delivery target the shopper attached to this
|
||||||
|
// cart. Empty list = no push. PUT-style replaced by SetRecoveryContact.
|
||||||
|
PushTokens []PushToken `json:"pushTokens,omitempty"`
|
||||||
|
|
||||||
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
|
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
|
||||||
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
|
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
|
||||||
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
|
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
|
||||||
@@ -297,6 +339,10 @@ func (c *CartGrain) UpdateTotals() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Type == cart_messages.CartType_WISHLIST || c.Type == cart_messages.CartType_OFFER {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for _, voucher := range c.Vouchers {
|
for _, voucher := range c.Vouchers {
|
||||||
_, ok := voucher.AppliesTo(c)
|
_, ok := voucher.AppliesTo(c)
|
||||||
voucher.Applied = false
|
voucher.Applied = false
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ package cart
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"git.k6n.net/mats/platform/inventory"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CartMutationContext struct {
|
type CartMutationContext struct {
|
||||||
reservationService inventory.CartReservationService
|
reservationService inventory.ReservationPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCartMutationContext(reservationService inventory.CartReservationService) *CartMutationContext {
|
func NewCartMutationContext(reservationService inventory.ReservationPolicy) *CartMutationContext {
|
||||||
return &CartMutationContext{
|
return &CartMutationContext{
|
||||||
reservationService: reservationService,
|
reservationService: reservationService,
|
||||||
}
|
}
|
||||||
@@ -30,7 +32,7 @@ func (c *CartMutationContext) ReserveItem(ctx context.Context, cartId CartId, sk
|
|||||||
}
|
}
|
||||||
ttl := time.Minute * 15
|
ttl := time.Minute * 15
|
||||||
endTime := time.Now().Add(ttl)
|
endTime := time.Now().Add(ttl)
|
||||||
err := c.reservationService.ReserveForCart(ctx, inventory.CartReserveRequest{
|
err := c.reservationService.Reserve(ctx, inventory.CartReserveRequest{
|
||||||
CartID: inventory.CartID(cartId.String()),
|
CartID: inventory.CartID(cartId.String()),
|
||||||
InventoryReference: &inventory.InventoryReference{
|
InventoryReference: &inventory.InventoryReference{
|
||||||
SKU: inventory.SKU(sku),
|
SKU: inventory.SKU(sku),
|
||||||
@@ -57,7 +59,7 @@ func (c *CartMutationContext) UseReservations(item *CartItem) bool {
|
|||||||
if item.InventoryTracked {
|
if item.InventoryTracked {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return item.Cgm == "55010"
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
|
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
|
||||||
@@ -68,7 +70,101 @@ func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sk
|
|||||||
if locationId != nil {
|
if locationId != nil {
|
||||||
l = inventory.LocationID(*locationId)
|
l = inventory.LocationID(*locationId)
|
||||||
}
|
}
|
||||||
return c.reservationService.ReleaseForCart(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
return c.reservationService.Release(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// equalOptional is the canonical nullable-equality primitive used by the
|
||||||
|
// cart mutations when deciding whether two identifier fields (parent/child
|
||||||
|
// links, store ids, anything else nullable) refer to the same thing.
|
||||||
|
//
|
||||||
|
// Returns true when both pointers are nil, when both alias the same
|
||||||
|
// address, or when both are non-nil and dereference to equal values. The
|
||||||
|
// same-address short-circuit is a no-op for correctness but lets a caller
|
||||||
|
// who passes the same variable get back true without traversing the
|
||||||
|
// generic's comparison path.
|
||||||
|
//
|
||||||
|
// One tested primitive covers every *T the cart currently compares —
|
||||||
|
// `*uint32` for ParentId, `*string` for StoreId — and any future
|
||||||
|
// comparable pointer type. Hand-rolled "both nil OR both non-nil and
|
||||||
|
// equal" expressions were duplicated at AddItem's merge site and were a
|
||||||
|
// footgun whenever a new field was added (which axis of the predicate
|
||||||
|
// was the relevant one?). Keeping a single helper makes future merges
|
||||||
|
// safer to copy.
|
||||||
|
func equalOptional[T comparable](a, b *T) bool {
|
||||||
|
if a == b { // both nil OR same address
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return *a == *b
|
||||||
|
}
|
||||||
|
|
||||||
|
// CascadeChildQuantities scales every direct child's quantity
|
||||||
|
// proportionally when a parent's quantity is bumped (ChangeQuantity or
|
||||||
|
// the AddItem-merge path).
|
||||||
|
//
|
||||||
|
// - Ratio: newChildQty = oldChildQty × newParentQty / oldParentQty.
|
||||||
|
// - Floor: scale-down via integer division can yield 0 (e.g. parent
|
||||||
|
// 2 → 1, child qty 1 → (1×1)/2 = 0). We clamp to 1 so a scale-down
|
||||||
|
// never silently deletes an accessory. The mismatch is acceptable —
|
||||||
|
// shipping one extra child is better than dropping one.
|
||||||
|
// - Reservations: each child's reservation is released then re-acquired
|
||||||
|
// at the new quantity if UseReservations is true for that child AND a
|
||||||
|
// prior reservation existed. A failing reservation is logged, not
|
||||||
|
// raised, so a single bad child doesn't abort the whole cascade
|
||||||
|
// (matches the existing removal pattern in mutation_items.go).
|
||||||
|
// - Recursion: descends into grandchildren using the child's own
|
||||||
|
// before/after quantities as the next ratio's input, so deeply
|
||||||
|
// nested accessory trees (parent → child → grandchild) stay in
|
||||||
|
// ratio with the top-level change.
|
||||||
|
//
|
||||||
|
// Caller is responsible for locking any grain mutex and for re-running
|
||||||
|
// UpdateTotals after the cascade has settled.
|
||||||
|
func (c *CartMutationContext) CascadeChildQuantities(
|
||||||
|
ctx context.Context,
|
||||||
|
g *CartGrain,
|
||||||
|
parentLineId uint32,
|
||||||
|
oldParentQty uint16,
|
||||||
|
newParentQty uint16,
|
||||||
|
) {
|
||||||
|
if oldParentQty == 0 || oldParentQty == newParentQty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.ParentId == nil || *it.ParentId != parentLineId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldChildQty := it.Quantity
|
||||||
|
ratio := uint32(newParentQty) * uint32(oldChildQty) / uint32(oldParentQty)
|
||||||
|
newChildQty := uint16(ratio)
|
||||||
|
if newChildQty == 0 {
|
||||||
|
newChildQty = 1
|
||||||
|
}
|
||||||
|
if newChildQty == oldChildQty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(it) && it.ReservationEndTime != nil {
|
||||||
|
if err := c.ReleaseItem(ctx, g.Id, it.Sku, it.StoreId); err != nil {
|
||||||
|
log.Printf("CascadeChildQuantities: failed to release %s: %v", it.Sku, err)
|
||||||
|
}
|
||||||
|
endTime, err := c.ReserveItem(ctx, g.Id, it.Sku, it.StoreId, newChildQty)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("CascadeChildQuantities: failed to reserve %s at qty %d: %v", it.Sku, newChildQty, err)
|
||||||
|
} else if endTime != nil {
|
||||||
|
it.ReservationEndTime = endTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it.Quantity = newChildQty
|
||||||
|
// Descend so grandchildren track this child's ratio.
|
||||||
|
c.CascadeChildQuantities(ctx, g, it.Id, oldChildQty, newChildQty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
|
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
|
||||||
@@ -87,6 +183,8 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
|
|||||||
actor.NewMutation(RemoveLineItemMarking),
|
actor.NewMutation(RemoveLineItemMarking),
|
||||||
actor.NewMutation(SetLineItemCustomFields),
|
actor.NewMutation(SetLineItemCustomFields),
|
||||||
actor.NewMutation(SubscriptionAdded),
|
actor.NewMutation(SubscriptionAdded),
|
||||||
|
actor.NewMutation(context.SetCartType),
|
||||||
|
actor.NewMutation(SetRecoveryContact),
|
||||||
// actor.NewMutation(SubscriptionRemoved),
|
// actor.NewMutation(SubscriptionRemoved),
|
||||||
)
|
)
|
||||||
return reg
|
return reg
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// equalOptional is the nullable-equality primitive shared by the AddItem
|
||||||
|
// merge site and any future field-comparison. Two call sites use it today
|
||||||
|
// with two distinct types — *uint32 (ParentId) and *string (StoreId) — so
|
||||||
|
// the test pins both via generic instantiation.
|
||||||
|
func TestEqualOptional(t *testing.T) {
|
||||||
|
t.Run("both nil is equal", func(t *testing.T) {
|
||||||
|
if !equalOptional[string](nil, nil) {
|
||||||
|
t.Error("both nil should be equal")
|
||||||
|
}
|
||||||
|
if !equalOptional[uint32](nil, nil) {
|
||||||
|
t.Error("both nil should be equal (uint32)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("same address alias is equal", func(t *testing.T) {
|
||||||
|
s := "x"
|
||||||
|
if !equalOptional(&s, &s) {
|
||||||
|
t.Error("same-address *string should be equal")
|
||||||
|
}
|
||||||
|
v := uint32(5)
|
||||||
|
if !equalOptional(&v, &v) {
|
||||||
|
t.Error("same-address *uint32 should be equal")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("distinct addresses with equal values are equal", func(t *testing.T) {
|
||||||
|
a, b := "x", "x"
|
||||||
|
if !equalOptional(&a, &b) {
|
||||||
|
t.Error("equal strings at distinct addresses should be equal")
|
||||||
|
}
|
||||||
|
u, w := uint32(5), uint32(5)
|
||||||
|
if !equalOptional(&u, &w) {
|
||||||
|
t.Error("equal uint32 at distinct addresses should be equal")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("one nil, one set is not equal", func(t *testing.T) {
|
||||||
|
a := "x"
|
||||||
|
if equalOptional(&a, nil) {
|
||||||
|
t.Error("non-nil vs nil should differ (string)")
|
||||||
|
}
|
||||||
|
if equalOptional[string](nil, &a) {
|
||||||
|
t.Error("nil vs non-nil should differ (string)")
|
||||||
|
}
|
||||||
|
u := uint32(5)
|
||||||
|
if equalOptional(&u, nil) {
|
||||||
|
t.Error("non-nil vs nil should differ (uint32)")
|
||||||
|
}
|
||||||
|
if equalOptional[uint32](nil, &u) {
|
||||||
|
t.Error("nil vs non-nil should differ (uint32)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("differing values are not equal", func(t *testing.T) {
|
||||||
|
a, b := "x", "y"
|
||||||
|
if equalOptional(&a, &b) {
|
||||||
|
t.Error("differing strings should differ")
|
||||||
|
}
|
||||||
|
u, w := uint32(5), uint32(6)
|
||||||
|
if equalOptional(&u, &w) {
|
||||||
|
t.Error("differing uint32 should differ")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
// EvaluatedItem is one line in the per-line promotion breakdown exposed
|
||||||
|
// alongside a cart. It mirrors the line that was evaluated (sku,
|
||||||
|
// quantity, unit list price, optional OrgPrice for the strikethrough) and
|
||||||
|
// adds the per-line discount the engine applied (orgPrice-based +
|
||||||
|
// promotion-based, additive) plus the resulting effective per-unit and
|
||||||
|
// per-line totals.
|
||||||
|
//
|
||||||
|
// Lives in pkg/cart rather than pkg/promotions so CartGrain can carry the
|
||||||
|
// breakdown as a derived field (populated by the canonical promotion
|
||||||
|
// pipeline that already manages TotalDiscount/AppliedPromotions), and so
|
||||||
|
// every consumer of the grain — UCP cart response, legacy /cart HTTP
|
||||||
|
// handler, cart-mcp, AMQP mutation feed — naturally sees the same shape
|
||||||
|
// without ad-hoc wrappers at each call site. JSON tags are the canonical
|
||||||
|
// EvaluatedItem shape that /promotions/evaluate-with-cart has returned
|
||||||
|
// since the breakdown feature was introduced.
|
||||||
|
//
|
||||||
|
// Distributing the total cart discount down to the line level lets the
|
||||||
|
// storefront render "Item X: 100 kr → 80 kr" without re-doing the math
|
||||||
|
// on the client and lets verifiers confirm which promotion hit which
|
||||||
|
// line (per-line DiscountIncVat is the engine's authoritative answer).
|
||||||
|
type EvaluatedItem struct {
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Quantity uint16 `json:"qty"`
|
||||||
|
PriceIncVat int64 `json:"priceIncVat"` // per-unit list price
|
||||||
|
OrgPriceIncVat int64 `json:"orgPriceIncVat,omitempty"` // per-unit pre-discount list price (for strikethrough)
|
||||||
|
DiscountIncVat int64 `json:"discountIncVat"` // per-line TOTAL discount (orgPrice + promotion), incVat in öre
|
||||||
|
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` // per-unit, after discount, incVat in öre (rounded)
|
||||||
|
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` // per-line, after discount, incVat in öre (exact)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapEvaluatedItems walks the grain's items and produces the per-line
|
||||||
|
// EvaluatedItem list. The grain's item.Discount carries the TOTAL per-line
|
||||||
|
// discount (orgPrice + promotion — additive, set by UpdateTotals and the
|
||||||
|
// effects' per-line distribution). The effective totals are derived as
|
||||||
|
// (price * qty - discount), clamped to 0 (a promotion that over-discounted
|
||||||
|
// a line shows as free, not negative). The effective per-unit price is the
|
||||||
|
// per-line total divided by quantity, rounded to the nearest öre so the
|
||||||
|
// per-unit display matches the per-line total when multiplied back.
|
||||||
|
//
|
||||||
|
// Returns nil for a nil grain (callers that always pass a non-nil grain
|
||||||
|
// — the typical case — get a non-nil empty slice for an empty cart, which
|
||||||
|
// is the desired behavior for JSON omitempty at the consumer).
|
||||||
|
//
|
||||||
|
// Exported so the canonical promotion pipeline (pkg/promotions.EvaluateAndApply)
|
||||||
|
// and the manual /promotions/evaluate-with-cart preview handler can produce
|
||||||
|
// the same per-line shape.
|
||||||
|
func MapEvaluatedItems(g *CartGrain) []EvaluatedItem {
|
||||||
|
if g == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]EvaluatedItem, 0, len(g.Items))
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
if it.Meta != nil {
|
||||||
|
name = it.Meta.Name
|
||||||
|
}
|
||||||
|
var orgPrice int64
|
||||||
|
if it.OrgPrice != nil {
|
||||||
|
orgPrice = it.OrgPrice.IncVat.Int64()
|
||||||
|
}
|
||||||
|
var discount int64
|
||||||
|
if it.Discount != nil {
|
||||||
|
discount = it.Discount.IncVat.Int64()
|
||||||
|
}
|
||||||
|
priceRow := it.Price.IncVat.Int64() * int64(it.Quantity)
|
||||||
|
effTotal := priceRow - discount
|
||||||
|
if effTotal < 0 {
|
||||||
|
effTotal = 0
|
||||||
|
}
|
||||||
|
var effUnit int64
|
||||||
|
if it.Quantity > 0 {
|
||||||
|
// Nearest-öre rounding so per-unit * qty matches the
|
||||||
|
// per-line total (within 1 öre either way). Half-up:
|
||||||
|
// (effTotal + qty/2) / qty.
|
||||||
|
effUnit = (effTotal + int64(it.Quantity)/2) / int64(it.Quantity)
|
||||||
|
}
|
||||||
|
out = append(out, EvaluatedItem{
|
||||||
|
Sku: it.Sku,
|
||||||
|
Name: name,
|
||||||
|
Quantity: it.Quantity,
|
||||||
|
PriceIncVat: it.Price.IncVat.Int64(),
|
||||||
|
OrgPriceIncVat: orgPrice,
|
||||||
|
DiscountIncVat: discount,
|
||||||
|
EffectivePriceIncVat: effUnit,
|
||||||
|
EffectiveTotalIncVat: effTotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid
|
|
||||||
// payloads are logged and dropped rather than failing the mutation.
|
|
||||||
func decodeExtra(b []byte) map[string]json.RawMessage {
|
|
||||||
if len(b) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
m := map[string]json.RawMessage{}
|
|
||||||
if err := json.Unmarshal(b, &m); err != nil {
|
|
||||||
log.Printf("AddItem: invalid extra_json: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// mutation_add_item.go
|
|
||||||
//
|
|
||||||
// Registers the AddItem cart mutation in the generic mutation registry.
|
|
||||||
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
|
||||||
//
|
|
||||||
// Behavior:
|
|
||||||
// - Validates quantity > 0
|
|
||||||
// - If an item with the same item id (ItemId) exists -> increases quantity
|
|
||||||
// - Else creates a new CartItem with computed tax amounts
|
|
||||||
// - Totals recalculated automatically via WithTotals()
|
|
||||||
//
|
|
||||||
// Item identity is the catalog item id (ItemId), not the SKU: the product
|
|
||||||
// service is looked up by id and the returned SKU is reference-only.
|
|
||||||
//
|
|
||||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
|
||||||
// must keep this handler in sync.
|
|
||||||
var ErrPaymentInProgress = errors.New("payment in progress")
|
|
||||||
|
|
||||||
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("AddItem: nil payload")
|
|
||||||
}
|
|
||||||
if m.Quantity < 1 {
|
|
||||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge with any existing item having the same item id and matching StoreId
|
|
||||||
// (including both nil). Identity is the id; SKU is reference-only.
|
|
||||||
for _, existing := range g.Items {
|
|
||||||
if existing.ItemId != m.ItemId {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
|
|
||||||
(existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId)
|
|
||||||
if !sameStore {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c.UseReservations(existing) {
|
|
||||||
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
|
|
||||||
log.Printf("failed to release item %d: %v", existing.Id, err)
|
|
||||||
}
|
|
||||||
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
existing.ReservationEndTime = endTime
|
|
||||||
}
|
|
||||||
existing.Quantity += uint16(m.Quantity)
|
|
||||||
existing.Stock = uint16(m.Stock)
|
|
||||||
existing.InventoryTracked = m.InventoryTracked
|
|
||||||
existing.DropShip = m.DropShip
|
|
||||||
// If existing had nil store but new has one, adopt it.
|
|
||||||
if existing.StoreId == nil && m.StoreId != nil {
|
|
||||||
existing.StoreId = m.StoreId
|
|
||||||
}
|
|
||||||
// Refresh dynamic product data with the latest payload.
|
|
||||||
if extra := decodeExtra(m.ExtraJson); extra != nil {
|
|
||||||
existing.Extra = extra
|
|
||||||
}
|
|
||||||
// Replace custom fields when provided on the re-add.
|
|
||||||
if len(m.CustomFields) > 0 {
|
|
||||||
existing.CustomFields = m.CustomFields
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
|
|
||||||
g.lastItemId++
|
|
||||||
// m.Tax is the rate in basis points (2500 = 25%, 1250 = 12.5%) — the single
|
|
||||||
// platform scale; flows straight through, no conversion.
|
|
||||||
rateBp := 2500
|
|
||||||
if m.Tax > 0 {
|
|
||||||
rateBp = int(m.Tax)
|
|
||||||
}
|
|
||||||
|
|
||||||
pricePerItem := NewPriceFromIncVat(m.Price, rateBp)
|
|
||||||
|
|
||||||
needsReservation := true
|
|
||||||
if m.ReservationEndTime != nil {
|
|
||||||
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
cartItem := &CartItem{
|
|
||||||
Id: g.lastItemId,
|
|
||||||
ItemId: uint32(m.ItemId),
|
|
||||||
Quantity: uint16(m.Quantity),
|
|
||||||
Sku: m.Sku,
|
|
||||||
Tax: rateBp,
|
|
||||||
Meta: &ItemMeta{
|
|
||||||
Name: m.Name,
|
|
||||||
Image: m.Image,
|
|
||||||
Brand: m.Brand,
|
|
||||||
Category: m.Category,
|
|
||||||
Category2: m.Category2,
|
|
||||||
Category3: m.Category3,
|
|
||||||
Category4: m.Category4,
|
|
||||||
Category5: m.Category5,
|
|
||||||
Outlet: m.Outlet,
|
|
||||||
SellerName: m.SellerName,
|
|
||||||
},
|
|
||||||
SellerId: m.SellerId,
|
|
||||||
Cgm: m.Cgm,
|
|
||||||
SaleStatus: m.SaleStatus,
|
|
||||||
ParentId: m.ParentId,
|
|
||||||
|
|
||||||
Price: *pricePerItem,
|
|
||||||
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
|
||||||
|
|
||||||
Stock: uint16(m.Stock),
|
|
||||||
Disclaimer: m.Disclaimer,
|
|
||||||
|
|
||||||
OrgPrice: getOrgPrice(m.OrgPrice, rateBp),
|
|
||||||
ArticleType: m.ArticleType,
|
|
||||||
|
|
||||||
StoreId: m.StoreId,
|
|
||||||
|
|
||||||
Extra: decodeExtra(m.ExtraJson),
|
|
||||||
CustomFields: m.CustomFields,
|
|
||||||
InventoryTracked: m.InventoryTracked,
|
|
||||||
DropShip: m.DropShip,
|
|
||||||
}
|
|
||||||
|
|
||||||
if needsReservation && c.UseReservations(cartItem) {
|
|
||||||
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if endTime != nil {
|
|
||||||
m.ReservationEndTime = timestamppb.New(*endTime)
|
|
||||||
t := m.ReservationEndTime.AsTime()
|
|
||||||
cartItem.ReservationEndTime = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g.Items = append(g.Items, cartItem)
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOrgPrice(orgPrice int64, rateBp int) *Price {
|
|
||||||
if orgPrice <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return NewPriceFromIncVat(orgPrice, rateBp)
|
|
||||||
}
|
|
||||||
@@ -38,3 +38,155 @@ func TestAddItem_MergesByItemIdNotSku(t *testing.T) {
|
|||||||
t.Fatalf("items = %d, want 2", len(g.Items))
|
t.Fatalf("items = %d, want 2", len(g.Items))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-adding the same parent (same ItemId + StoreId, no ParentId on the
|
||||||
|
// re-add itself) merges the quantity, and the proportional cascade resizes
|
||||||
|
// every direct child to match the new parent qty so a "+1 drill" leaves
|
||||||
|
// the bundle internally consistent.
|
||||||
|
func TestAddItem_MergeCascadesToChildren(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||||
|
t.Fatalf("add parent: %v", err)
|
||||||
|
}
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
|
||||||
|
t.Fatalf("add child: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-add the same parent — same ItemId, no ParentId on the re-add itself.
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||||
|
t.Fatalf("re-add parent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent qty 1 -> 2; child qty 1 -> 2 via cascade.
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Quantity != 2 {
|
||||||
|
t.Errorf("line %s qty = %d, want 2 (merge cascade)", it.Sku, it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item identity for merge purposes includes ParentId: a standalone root
|
||||||
|
// line (ParentId == nil) and a child line (ParentId == &someParentLine) that
|
||||||
|
// happen to share an ItemId are different roles and MUST stay as distinct
|
||||||
|
// lines. Merging would silently turn an accessory into a +N on the root
|
||||||
|
// (or vice versa), corrupting the parent-child link.
|
||||||
|
func TestAddItem_DoesNotMergeWhenParentIdDiffers(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Standalone root (no ParentId).
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||||
|
t.Fatalf("add standalone: %v", err)
|
||||||
|
}
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
|
||||||
|
// Same ItemId+StoreId but with ParentId set — must create a NEW line,
|
||||||
|
// not bump the standalone's qty.
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000, ParentId: &parentLine}); err != nil {
|
||||||
|
t.Fatalf("add child-form: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Items) != 2 {
|
||||||
|
t.Fatalf("items = %d, want 2 (different ParentId => distinct lines)", len(g.Items))
|
||||||
|
}
|
||||||
|
// Standalone untouched at qty 1; child-form is its own line at qty 1.
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Quantity != 1 {
|
||||||
|
t.Errorf("line %d (parentId=%v) qty = %d, want 1 (no merge)", it.Id, it.ParentId, it.Quantity)
|
||||||
|
}
|
||||||
|
// One of them has nil ParentId and the other has &parentLine — guard
|
||||||
|
// against an accidental merge ever flipping both to non-nil.
|
||||||
|
}
|
||||||
|
var seenNil, seenSet bool
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.ParentId == nil {
|
||||||
|
seenNil = true
|
||||||
|
} else if *it.ParentId == parentLine {
|
||||||
|
seenSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seenNil || !seenSet {
|
||||||
|
t.Errorf("expected one line with nil ParentId and one with &parentLine; got nil=%v set=%v", seenNil, seenSet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positive control: when BOTH sides have ParentId set to the SAME parent
|
||||||
|
// line, the merge proceeds (this guards the new sameParent guard against
|
||||||
|
// accidentally rejecting a legitimate "re-add same child of same parent"
|
||||||
|
// case — e.g. an idempotent retry from a flaky request).
|
||||||
|
func TestAddItem_MergesWhenBothParentIdMatch(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||||
|
t.Fatalf("add parent: %v", err)
|
||||||
|
}
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
|
||||||
|
// First child: ItemId 200 under parent.
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
|
||||||
|
t.Fatalf("add child: %v", err)
|
||||||
|
}
|
||||||
|
// Re-add the SAME child (same ItemId+StoreId+ParentId) — must merge into
|
||||||
|
// qty 2, not create a second accessory line.
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
|
||||||
|
t.Fatalf("re-add child: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Items) != 2 {
|
||||||
|
t.Fatalf("items = %d, want 2 (parent + merged child)", len(g.Items))
|
||||||
|
}
|
||||||
|
for _, it := range g.Items {
|
||||||
|
switch it.Id {
|
||||||
|
case parentLine:
|
||||||
|
if it.Quantity != 1 {
|
||||||
|
t.Errorf("parent qty = %d, want 1 (untouched)", it.Quantity)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if it.Quantity != 2 {
|
||||||
|
t.Errorf("child qty = %d, want 2 (merged on same-PARENT re-add)", it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same ItemId+StoreId+both with ParentId set to *different* parent lines
|
||||||
|
// must also stay distinct — children belong to their own parent.
|
||||||
|
func TestAddItem_DoesNotMergeAcrossDifferentParentLines(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P1", Quantity: 1, Price: 500}); err != nil {
|
||||||
|
t.Fatalf("add parent A: %v", err)
|
||||||
|
}
|
||||||
|
parentA := g.Items[0].Id
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "P2", Quantity: 1, Price: 500}); err != nil {
|
||||||
|
t.Fatalf("add parent B: %v", err)
|
||||||
|
}
|
||||||
|
parentB := g.Items[1].Id
|
||||||
|
|
||||||
|
// Two children of different parents sharing an ItemId — distinct lines.
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 999, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentA}); err != nil {
|
||||||
|
t.Fatalf("add child of A: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 999, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentB}); err != nil {
|
||||||
|
t.Fatalf("add child of B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 parent lines + 2 child lines = 4 total; no merges across.
|
||||||
|
if len(g.Items) != 4 {
|
||||||
|
t.Errorf("items = %d, want 4 (parents A,B + their distinct children)", len(g.Items))
|
||||||
|
}
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Quantity != 1 {
|
||||||
|
t.Errorf("line %d qty = %d, want 1 (no accidental merge)", it.Id, it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"git.k6n.net/mats/platform/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockReservationPolicy struct {
|
||||||
|
reserveCount int
|
||||||
|
releaseCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockReservationPolicy) Reserve(ctx context.Context, req inventory.CartReserveRequest) error {
|
||||||
|
m.reserveCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockReservationPolicy) Release(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID, cartID inventory.CartID) error {
|
||||||
|
m.releaseCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockReservationPolicy) Available(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID) (int64, error) {
|
||||||
|
return 100, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartType_TransitionsAndReservations(t *testing.T) {
|
||||||
|
mockPolicy := &mockReservationPolicy{}
|
||||||
|
mutationCtx := NewCartMutationContext(mockPolicy)
|
||||||
|
reg := NewCartMultationRegistry(mutationCtx)
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. Initially REGULAR, adding item should trigger reservation
|
||||||
|
g.Type = cart_messages.CartType_REGULAR
|
||||||
|
_, err := reg.Apply(ctx, g, &cart_messages.AddItem{
|
||||||
|
ItemId: 101,
|
||||||
|
Sku: "SKU-1",
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 1000,
|
||||||
|
InventoryTracked: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddItem (REGULAR) failed: %v", err)
|
||||||
|
}
|
||||||
|
if mockPolicy.reserveCount != 1 {
|
||||||
|
t.Errorf("Expected 1 reserve call, got %d", mockPolicy.reserveCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Set type to WISHLIST. This should release active reservations.
|
||||||
|
_, err = reg.Apply(ctx, g, &cart_messages.SetCartType{
|
||||||
|
Type: cart_messages.CartType_WISHLIST,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetCartType (WISHLIST) failed: %v", err)
|
||||||
|
}
|
||||||
|
if g.Type != cart_messages.CartType_WISHLIST {
|
||||||
|
t.Errorf("Expected type to be WISHLIST, got %v", g.Type)
|
||||||
|
}
|
||||||
|
if mockPolicy.releaseCount != 1 {
|
||||||
|
t.Errorf("Expected 1 release call during transition, got %d", mockPolicy.releaseCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. AddItem in WISHLIST should NOT trigger reservation
|
||||||
|
_, err = reg.Apply(ctx, g, &cart_messages.AddItem{
|
||||||
|
ItemId: 102,
|
||||||
|
Sku: "SKU-2",
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 2000,
|
||||||
|
InventoryTracked: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddItem (WISHLIST) failed: %v", err)
|
||||||
|
}
|
||||||
|
if mockPolicy.reserveCount != 1 { // Should still be 1 from step 1
|
||||||
|
t.Errorf("Expected reserve count to remain 1, got %d", mockPolicy.reserveCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verify simple price sum is updated, but vouchers are not applied
|
||||||
|
g.Vouchers = append(g.Vouchers, &Voucher{
|
||||||
|
Code: "DISCOUNT",
|
||||||
|
Value: 500,
|
||||||
|
Applied: false,
|
||||||
|
})
|
||||||
|
g.UpdateTotals()
|
||||||
|
// Total price should be sum of 1000 + 2000 = 3000
|
||||||
|
if g.TotalPrice.IncVat != 3000 {
|
||||||
|
t.Errorf("Expected total price 3000, got %d", g.TotalPrice.IncVat)
|
||||||
|
}
|
||||||
|
if g.Vouchers[0].Applied {
|
||||||
|
t.Errorf("Expected voucher to NOT be applied in WISHLIST")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mutation_change_quantity.go
|
// mutation_change_quantity.go
|
||||||
@@ -15,8 +14,11 @@ import (
|
|||||||
//
|
//
|
||||||
// Behavior:
|
// Behavior:
|
||||||
// - Locates an item by its cart-local line item Id (not source item_id).
|
// - Locates an item by its cart-local line item Id (not source item_id).
|
||||||
// - If requested quantity <= 0 the line is removed.
|
// - If requested quantity <= 0 the line AND any descendants are
|
||||||
// - Otherwise the line's Quantity field is updated.
|
// removed via the shared RemoveItem cascade (transitive parent→child).
|
||||||
|
// - Otherwise the line's Quantity is updated and direct children are
|
||||||
|
// rescaled proportionally so a "2 of this + 2 of each accessory"
|
||||||
|
// bundle stays internally consistent when the parent becomes 3.
|
||||||
// - Totals are recalculated (WithTotals).
|
// - Totals are recalculated (WithTotals).
|
||||||
//
|
//
|
||||||
// Error handling:
|
// Error handling:
|
||||||
@@ -29,7 +31,7 @@ import (
|
|||||||
// (If strict locking is required around every mutation, wrap logic in
|
// (If strict locking is required around every mutation, wrap logic in
|
||||||
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||||
|
|
||||||
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
|
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *cart_messages.ChangeQuantity) error {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||||
}
|
}
|
||||||
@@ -48,23 +50,18 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.Quantity <= 0 {
|
if m.Quantity <= 0 {
|
||||||
// Remove the item
|
// Setting a parent line to 0 means "remove it AND its accessories".
|
||||||
itemToRemove := g.Items[foundIndex]
|
// Delegate to RemoveItem so the cascade logic in one place stays
|
||||||
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.After(time.Now()) {
|
// the source of truth for descendant cleanup + reservation
|
||||||
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
|
// release — otherwise we'd duplicate the transitive cascade here.
|
||||||
if err != nil {
|
return c.RemoveItem(g, &cart_messages.RemoveItem{Id: m.Id})
|
||||||
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
item := g.Items[foundIndex]
|
item := g.Items[foundIndex]
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||||
}
|
}
|
||||||
if c.UseReservations(item) {
|
oldQty := item.Quantity
|
||||||
|
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(item) {
|
||||||
if item.ReservationEndTime != nil {
|
if item.ReservationEndTime != nil {
|
||||||
err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
|
err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,6 +76,14 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
|
|||||||
item.ReservationEndTime = endTime
|
item.ReservationEndTime = endTime
|
||||||
}
|
}
|
||||||
item.Quantity = uint16(m.Quantity)
|
item.Quantity = uint16(m.Quantity)
|
||||||
|
// Rescale any direct child lines proportionally so accessories stay
|
||||||
|
// in sync with their parent. Descends recursively into grandchildren
|
||||||
|
// via CascadeChildQuantities itself. Skipped when the target itself
|
||||||
|
// is a child line — direct qty edits on a child are treated as
|
||||||
|
// explicit user intent and don't propagate further.
|
||||||
|
if item.ParentId == nil {
|
||||||
|
c.CascadeChildQuantities(ctx, g, item.Id, oldQty, item.Quantity)
|
||||||
|
}
|
||||||
g.UpdateTotals()
|
g.UpdateTotals()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parent qty 1 → 2 resizes every direct child proportionally. Children all
|
||||||
|
// start at qty 1 so the proportional factor of 2 doubles them.
|
||||||
|
func TestChangeQuantity_ParentCascadesDoublingChildren(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine})
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 2}); err != nil {
|
||||||
|
t.Fatalf("change qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Items[0].Quantity != 2 {
|
||||||
|
t.Errorf("parent qty = %d, want 2", g.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Id == parentLine {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.Quantity != 2 {
|
||||||
|
t.Errorf("child %s qty = %d, want 2 (cascaded from parent)", it.Sku, it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent qty 2 → 3 with children at qty 2 → 3 (factor 3/2 = 1.5, integer math).
|
||||||
|
func TestChangeQuantity_ParentCascadesNonIntegerRatio(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 2, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 2, Price: 100, ParentId: &parentLine})
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 3}); err != nil {
|
||||||
|
t.Fatalf("change qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parent: 2 -> 3 (direct set), child: 2 * 3 / 2 = 3
|
||||||
|
if g.Items[0].Quantity != 3 {
|
||||||
|
t.Errorf("parent qty = %d, want 3", g.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
if g.Items[1].Quantity != 3 {
|
||||||
|
t.Errorf("child qty = %d, want 3 (proportional scale)", g.Items[1].Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale-down that would round to 0 (parent 2 → 1, child qty 1 → (1*1)/2 = 0)
|
||||||
|
// must clamp to 1 so the child is never silently dropped by integer math.
|
||||||
|
func TestChangeQuantity_ParentScaleDownFloorsAtOne(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 2, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 1}); err != nil {
|
||||||
|
t.Fatalf("change qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Items[0].Quantity != 1 {
|
||||||
|
t.Errorf("parent qty = %d, want 1", g.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
if g.Items[1].Quantity != 1 {
|
||||||
|
t.Errorf("child qty = %d, want 1 (floor; integer math dropped to 0 without clamp)", g.Items[1].Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting parent qty to 0 delegates to RemoveItem so the existing parent→
|
||||||
|
// child cascade takes over. Unrelated lines are left untouched.
|
||||||
|
func TestChangeQuantity_ZeroCascadesRemoval(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 200, Sku: "OTHER", Quantity: 1, Price: 500})
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 0}); err != nil {
|
||||||
|
t.Fatalf("change qty 0: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Items) != 1 {
|
||||||
|
t.Fatalf("items = %d, want 1 (only OTHER survives)", len(g.Items))
|
||||||
|
}
|
||||||
|
if g.Items[0].Sku != "OTHER" {
|
||||||
|
t.Errorf("remaining sku = %q, want OTHER", g.Items[0].Sku)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing a child's qty directly updates only that child — siblings and
|
||||||
|
// the parent are untouched. (The UI no longer exposes this control, but
|
||||||
|
// the HTTP surface still allows it for tools & direct API calls.)
|
||||||
|
func TestChangeQuantity_ChildDoesNotCascade(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine})
|
||||||
|
targetChild := g.Items[1].Id
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: targetChild, Quantity: 3}); err != nil {
|
||||||
|
t.Fatalf("change child qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, it := range g.Items {
|
||||||
|
switch it.Id {
|
||||||
|
case parentLine:
|
||||||
|
if it.Quantity != 1 {
|
||||||
|
t.Errorf("parent qty = %d, want 1 (untouched on child edit)", it.Quantity)
|
||||||
|
}
|
||||||
|
case targetChild:
|
||||||
|
if it.Quantity != 3 {
|
||||||
|
t.Errorf("target child qty = %d, want 3", it.Quantity)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Sibling
|
||||||
|
if it.Quantity != 1 {
|
||||||
|
t.Errorf("sibling %s qty = %d, want 1 (untouched on child edit)", it.Sku, it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade descends into grandchildren so deeply nested accessory trees
|
||||||
|
// remain internally consistent with the top-level parent change.
|
||||||
|
func TestChangeQuantity_NestedChildren(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||||
|
childLine := g.Items[1].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "G1", Quantity: 1, Price: 50, ParentId: &childLine})
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 2}); err != nil {
|
||||||
|
t.Fatalf("change qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parent 1 -> 2; child 1 -> 2 (parent -> child cascade); grandchild 1 -> 2 (child -> grandchild cascade)
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Quantity != 2 {
|
||||||
|
t.Errorf("line %s qty = %d, want 2 (nested cascade)", it.Sku, it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
// mutation_items.go
|
||||||
|
//
|
||||||
|
// Single concern file for the cart line-item lifecycle: AddItem +
|
||||||
|
// RemoveItem (and the helpers they share). Pulled out of the per-mutation
|
||||||
|
// file layout as a proof-of-grouping for the pattern in
|
||||||
|
// pkg/cart/MUTATIONS.md. AddItem and RemoveItem are dispatched through the
|
||||||
|
// same MutationRegistry (cart-mutation-helper.go) and operate on the same
|
||||||
|
// *CartGrain, so keeping them adjacent lets future reviewers compare
|
||||||
|
// "what does add do when an item already exists" against "what does
|
||||||
|
// remove do when a parent is removed" without bouncing between files.
|
||||||
|
//
|
||||||
|
// What lives here:
|
||||||
|
//
|
||||||
|
// - AddItem: validation, merge-into-existing by ItemId, reservation
|
||||||
|
// handling, totals.
|
||||||
|
// - RemoveItem: by-line-id removal with transitive parent→child
|
||||||
|
// cascade, reservation release, totals.
|
||||||
|
// - Shared package helpers: decodeExtra (dynamic product extra JSON),
|
||||||
|
// getOrgPrice (origin price helper), ErrPaymentInProgress (used by
|
||||||
|
// mutation_add_voucher.go to refuse stacking during checkout).
|
||||||
|
//
|
||||||
|
// Out of scope here: change_quantity, set_custom_fields, line markings,
|
||||||
|
// subscription details, etc. Those are separate concerns and stay in
|
||||||
|
// their own files until they grow enough behaviour to invite merging.
|
||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrPaymentInProgress is returned by mutations that must not proceed
|
||||||
|
// while the cart is mid-payment (currently referenced by
|
||||||
|
// mutation_add_voucher.go to short-circuit adding a voucher mid-checkout).
|
||||||
|
var ErrPaymentInProgress = errors.New("payment in progress")
|
||||||
|
|
||||||
|
// decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid
|
||||||
|
// payloads are logged and dropped rather than failing the mutation.
|
||||||
|
func decodeExtra(b []byte) map[string]json.RawMessage {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := map[string]json.RawMessage{}
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
log.Printf("AddItem: invalid extra_json: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrgPrice(orgPrice int64, rateBp int) *Price {
|
||||||
|
if orgPrice <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NewPriceFromIncVat(orgPrice, rateBp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem (formerly mutation_add_item.go).
|
||||||
|
//
|
||||||
|
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||||
|
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||||
|
//
|
||||||
|
// Behaviour:
|
||||||
|
// - Validates quantity > 0
|
||||||
|
// - If an item with the same item id (ItemId) exists -> increases quantity
|
||||||
|
// - Else creates a new CartItem with computed tax amounts
|
||||||
|
// - Totals recalculated automatically via WithTotals()
|
||||||
|
//
|
||||||
|
// Item identity is the catalog item id (ItemId), not the SKU: the product
|
||||||
|
// service is looked up by id and the returned SKU is reference-only.
|
||||||
|
//
|
||||||
|
// NOTE: Any future field additions in messages.AddItem that affect pricing /
|
||||||
|
// tax must keep this handler in sync.
|
||||||
|
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("AddItem: nil payload")
|
||||||
|
}
|
||||||
|
if m.Quantity < 1 {
|
||||||
|
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with any existing item having the same item id, matching
|
||||||
|
// StoreId, and matching ParentId (all three including both nil).
|
||||||
|
// Identity is ItemId; SKU is reference-only.
|
||||||
|
//
|
||||||
|
// ParentId MUST match: a standalone root line (ParentId == nil) and
|
||||||
|
// a child line (ParentId == &someParentLine) that happen to share an
|
||||||
|
// ItemId are different roles in the cart, even though the catalog
|
||||||
|
// item is the same. Merging them would silently convert an accessory
|
||||||
|
// into a +N on the root (or vice versa), corrupting the parent-child
|
||||||
|
// link. Both checks are delegated to equalOptional so the nullable-
|
||||||
|
// equality idiom stays in one place (cart-mutation-helper.go).
|
||||||
|
for _, existing := range g.Items {
|
||||||
|
if existing.ItemId != m.ItemId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !equalOptional(existing.StoreId, m.StoreId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !equalOptional(existing.ParentId, m.ParentId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(existing) {
|
||||||
|
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
|
||||||
|
log.Printf("failed to release item %d: %v", existing.Id, err)
|
||||||
|
}
|
||||||
|
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing.ReservationEndTime = endTime
|
||||||
|
}
|
||||||
|
oldQty := existing.Quantity
|
||||||
|
existing.Quantity += uint16(m.Quantity)
|
||||||
|
existing.Stock = uint16(m.Stock)
|
||||||
|
existing.InventoryTracked = m.InventoryTracked
|
||||||
|
existing.DropShip = m.DropShip
|
||||||
|
// If existing had nil store but new has one, adopt it.
|
||||||
|
if existing.StoreId == nil && m.StoreId != nil {
|
||||||
|
existing.StoreId = m.StoreId
|
||||||
|
}
|
||||||
|
// Refresh dynamic product data with the latest payload.
|
||||||
|
if extra := decodeExtra(m.ExtraJson); extra != nil {
|
||||||
|
existing.Extra = extra
|
||||||
|
}
|
||||||
|
// Replace custom fields when provided on the re-add.
|
||||||
|
if len(m.CustomFields) > 0 {
|
||||||
|
existing.CustomFields = m.CustomFields
|
||||||
|
}
|
||||||
|
// Re-add grew the parent's qty — rescale direct child quantities
|
||||||
|
// proportionally so a "+1 drill" keeps its accessories consistent.
|
||||||
|
// Skip when the merged line IS itself a child (ParentId != nil):
|
||||||
|
// cascading up the tree isn't a thing — the parent would not know
|
||||||
|
// a child grew without re-running the promotion engine.
|
||||||
|
if existing.ParentId == nil {
|
||||||
|
c.CascadeChildQuantities(ctx, g, existing.Id, oldQty, existing.Quantity)
|
||||||
|
}
|
||||||
|
// Recompute totals so TotalPrice/TotalDiscount/Discount annotations
|
||||||
|
// reflect the merged parent + cascaded children. The non-merge
|
||||||
|
// (new-line) path below also calls UpdateTotals; the merge path
|
||||||
|
// would otherwise drift after a "+1" re-add, especially now that
|
||||||
|
// cascade may have rewritten N child quantities.
|
||||||
|
g.UpdateTotals()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
g.lastItemId++
|
||||||
|
// m.Tax is the rate in basis points (2500 = 25%, 1250 = 12.5%) — the single
|
||||||
|
// platform scale; flows straight through, no conversion.
|
||||||
|
rateBp := 2500
|
||||||
|
if m.Tax > 0 {
|
||||||
|
rateBp = int(m.Tax)
|
||||||
|
}
|
||||||
|
|
||||||
|
pricePerItem := NewPriceFromIncVat(m.Price, rateBp)
|
||||||
|
|
||||||
|
needsReservation := true
|
||||||
|
if m.ReservationEndTime != nil {
|
||||||
|
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
cartItem := &CartItem{
|
||||||
|
Id: g.lastItemId,
|
||||||
|
ItemId: uint32(m.ItemId),
|
||||||
|
Quantity: uint16(m.Quantity),
|
||||||
|
Sku: m.Sku,
|
||||||
|
Tax: rateBp,
|
||||||
|
Meta: &ItemMeta{
|
||||||
|
Name: m.Name,
|
||||||
|
Image: m.Image,
|
||||||
|
Brand: m.Brand,
|
||||||
|
Category: m.Category,
|
||||||
|
Category2: m.Category2,
|
||||||
|
Category3: m.Category3,
|
||||||
|
Category4: m.Category4,
|
||||||
|
Category5: m.Category5,
|
||||||
|
Outlet: m.Outlet,
|
||||||
|
SellerName: m.SellerName,
|
||||||
|
},
|
||||||
|
SellerId: m.SellerId,
|
||||||
|
Cgm: m.Cgm,
|
||||||
|
SaleStatus: m.SaleStatus,
|
||||||
|
ParentId: m.ParentId,
|
||||||
|
|
||||||
|
Price: *pricePerItem,
|
||||||
|
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
||||||
|
|
||||||
|
Stock: uint16(m.Stock),
|
||||||
|
Disclaimer: m.Disclaimer,
|
||||||
|
|
||||||
|
OrgPrice: getOrgPrice(m.OrgPrice, rateBp),
|
||||||
|
ArticleType: m.ArticleType,
|
||||||
|
|
||||||
|
StoreId: m.StoreId,
|
||||||
|
|
||||||
|
Extra: decodeExtra(m.ExtraJson),
|
||||||
|
CustomFields: m.CustomFields,
|
||||||
|
InventoryTracked: m.InventoryTracked,
|
||||||
|
DropShip: m.DropShip,
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Type == cart_messages.CartType_REGULAR && needsReservation && c.UseReservations(cartItem) {
|
||||||
|
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if endTime != nil {
|
||||||
|
m.ReservationEndTime = timestamppb.New(*endTime)
|
||||||
|
t := m.ReservationEndTime.AsTime()
|
||||||
|
cartItem.ReservationEndTime = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Items = append(g.Items, cartItem)
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItem (formerly mutation_remove_item.go).
|
||||||
|
//
|
||||||
|
// Registers the RemoveItem mutation.
|
||||||
|
//
|
||||||
|
// Behaviour:
|
||||||
|
// - Removes the cart line whose local cart line Id == payload.Id
|
||||||
|
// - Cascades: also removes any line whose ParentId points at a removed line
|
||||||
|
// (transitively), so removing a parent removes its child sub-articles
|
||||||
|
// - If no such line exists returns an error
|
||||||
|
// - Releases reservations for every removed line and recalculates totals
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - This removes only the line items; any deliveries referencing a removed
|
||||||
|
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||||
|
// semantics require pruning delivery.item_ids, extend this handler.
|
||||||
|
// - If multiple lines somehow shared the same Id (should not happen), all
|
||||||
|
// matches are removed — data integrity relies on unique line Ids.
|
||||||
|
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *cart_messages.RemoveItem) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("RemoveItem: nil payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID := uint32(m.Id)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Id == targetID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the target and, transitively, any children pointing at a removed
|
||||||
|
// line. Loops until no further descendants are found (handles nesting).
|
||||||
|
remove := map[uint32]bool{targetID: true}
|
||||||
|
for {
|
||||||
|
grew := false
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.ParentId != nil && remove[*it.ParentId] && !remove[it.Id] {
|
||||||
|
remove[it.Id] = true
|
||||||
|
grew = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !grew {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kept := g.Items[:0]
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if remove[it.Id] {
|
||||||
|
if it.ReservationEndTime != nil && it.ReservationEndTime.After(time.Now()) {
|
||||||
|
if err := c.ReleaseItem(context.Background(), g.Id, it.Sku, it.StoreId); err != nil {
|
||||||
|
log.Printf("unable to release reservation for item %d: %v", it.Id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, it)
|
||||||
|
}
|
||||||
|
g.Items = kept
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Items[i].Marking = &Marking{
|
|
||||||
Type: req.Type,
|
|
||||||
Text: req.Marking,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LineItemMarking sets a marking on the line item with the given ID.
|
||||||
|
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
|
||||||
|
for i, item := range grain.Items {
|
||||||
|
if item.Id == req.Id {
|
||||||
|
grain.Items[i].Marking = &Marking{
|
||||||
|
Type: req.Type,
|
||||||
|
Text: req.Marking,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("item with ID %d not found", req.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveLineItemMarking clears the marking on the line item with the given ID.
|
||||||
|
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
|
||||||
|
for i, item := range grain.Items {
|
||||||
|
if item.Id == req.Id {
|
||||||
|
grain.Items[i].Marking = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("item with ID %d not found", req.Id)
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mutation_remove_item.go
|
|
||||||
//
|
|
||||||
// Registers the RemoveItem mutation.
|
|
||||||
//
|
|
||||||
// Behavior:
|
|
||||||
// - Removes the cart line whose local cart line Id == payload.Id
|
|
||||||
// - Cascades: also removes any line whose ParentId points at a removed line
|
|
||||||
// (transitively), so removing a parent removes its child sub-articles
|
|
||||||
// - If no such line exists returns an error
|
|
||||||
// - Releases reservations for every removed line and recalculates totals
|
|
||||||
//
|
|
||||||
// Notes:
|
|
||||||
// - This removes only the line items; any deliveries referencing a removed
|
|
||||||
// item are NOT automatically adjusted (mirrors prior logic). If future
|
|
||||||
// semantics require pruning delivery.item_ids you can extend this handler.
|
|
||||||
// - If multiple lines somehow shared the same Id (should not happen), all
|
|
||||||
// matches are removed—data integrity relies on unique line Ids.
|
|
||||||
|
|
||||||
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("RemoveItem: nil payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
targetID := uint32(m.Id)
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, it := range g.Items {
|
|
||||||
if it.Id == targetID {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect the target and, transitively, any children pointing at a removed
|
|
||||||
// line. Loops until no further descendants are found (handles nesting).
|
|
||||||
remove := map[uint32]bool{targetID: true}
|
|
||||||
for {
|
|
||||||
grew := false
|
|
||||||
for _, it := range g.Items {
|
|
||||||
if it.ParentId != nil && remove[*it.ParentId] && !remove[it.Id] {
|
|
||||||
remove[it.Id] = true
|
|
||||||
grew = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !grew {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kept := g.Items[:0]
|
|
||||||
for _, it := range g.Items {
|
|
||||||
if remove[it.Id] {
|
|
||||||
if it.ReservationEndTime != nil && it.ReservationEndTime.After(time.Now()) {
|
|
||||||
if err := c.ReleaseItem(context.Background(), g.Id, it.Sku, it.StoreId); err != nil {
|
|
||||||
log.Printf("unable to release reservation for item %d: %v", it.Id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, it)
|
|
||||||
}
|
|
||||||
g.Items = kept
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
|
|
||||||
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Items[i].Marking = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *CartMutationContext) SetCartType(g *CartGrain, m *messages.SetCartType) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("SetCartType: nil payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldType := g.Type
|
||||||
|
newType := m.Type
|
||||||
|
|
||||||
|
if oldType == newType {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
g.Type = newType
|
||||||
|
|
||||||
|
// If transitioning from regular to wishlist or offer, release any active reservations
|
||||||
|
if oldType == messages.CartType_REGULAR && (newType == messages.CartType_WISHLIST || newType == messages.CartType_OFFER) {
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, item := range g.Items {
|
||||||
|
if item.ReservationEndTime != nil {
|
||||||
|
_ = c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
|
||||||
|
item.ReservationEndTime = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetRecoveryContact replaces the entire recovery-contact bundle on a cart in
|
||||||
|
// a single event. PUT-style semantics — empty email and an empty token list
|
||||||
|
// both persist as "no contact attached"; callers wanting to clear should send
|
||||||
|
// an empty bundle.
|
||||||
|
//
|
||||||
|
// Trivial whitespace-only normalization is intentionally NOT done here: the
|
||||||
|
// notifier layer owns validation (and the LoggingNotifier just logs). A future
|
||||||
|
// real notifier does its own normalization before sending, so we don't want
|
||||||
|
// to lose the original input.
|
||||||
|
func SetRecoveryContact(grain *CartGrain, req *messages.SetRecoveryContact) error {
|
||||||
|
if req == nil {
|
||||||
|
return errors.New("SetRecoveryContact: nil payload")
|
||||||
|
}
|
||||||
|
grain.Email = req.Email
|
||||||
|
if len(req.PushTokens) == 0 {
|
||||||
|
grain.PushTokens = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tokens := make([]PushToken, 0, len(req.PushTokens))
|
||||||
|
dropped := 0
|
||||||
|
for _, t := range req.PushTokens {
|
||||||
|
if t == nil {
|
||||||
|
// Defensive: a malformed wire request left a nil slot in the
|
||||||
|
// repeated field. Skip rather than crash and surface at debug
|
||||||
|
// level so a misbehaving client is visible without spamming.
|
||||||
|
dropped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tokens = append(tokens, PushToken{
|
||||||
|
Platform: t.Platform,
|
||||||
|
Token: t.Token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if dropped > 0 {
|
||||||
|
log.Printf("SetRecoveryContact: dropped %d nil push-token entries (malformed wire)", dropped)
|
||||||
|
}
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
grain.PushTokens = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
grain.PushTokens = tokens
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSetRecoveryContact_ReplacesEntireBundle verifies PUT semantics: a
|
||||||
|
// second call fully overwrites the first; calling with empty clears it.
|
||||||
|
func TestSetRecoveryContact_ReplacesEntireBundle(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Initial set.
|
||||||
|
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
|
||||||
|
Email: "first@example.com",
|
||||||
|
PushTokens: []*messages.PushToken{
|
||||||
|
{Platform: "fcm", Token: "AAA"},
|
||||||
|
{Platform: "apns", Token: "BBB"},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("first set: %v", err)
|
||||||
|
}
|
||||||
|
if g.Email != "first@example.com" {
|
||||||
|
t.Errorf("email = %q, want first@example.com", g.Email)
|
||||||
|
}
|
||||||
|
if len(g.PushTokens) != 2 {
|
||||||
|
t.Fatalf("push tokens = %d, want 2", len(g.PushTokens))
|
||||||
|
}
|
||||||
|
if g.PushTokens[0].Platform != "fcm" || g.PushTokens[1].Platform != "apns" {
|
||||||
|
t.Errorf("platforms = %+v, want [fcm apns]", g.PushTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace with one push token.
|
||||||
|
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
|
||||||
|
Email: "second@example.com",
|
||||||
|
PushTokens: []*messages.PushToken{{Platform: "webpush", Token: "CCC"}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("replace: %v", err)
|
||||||
|
}
|
||||||
|
if g.Email != "second@example.com" {
|
||||||
|
t.Errorf("after replace email = %q, want second@example.com", g.Email)
|
||||||
|
}
|
||||||
|
if len(g.PushTokens) != 1 {
|
||||||
|
t.Fatalf("after replace push tokens = %d, want 1", len(g.PushTokens))
|
||||||
|
}
|
||||||
|
if g.PushTokens[0].Platform != "webpush" {
|
||||||
|
t.Errorf("after replace platform = %q, want webpush", g.PushTokens[0].Platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear.
|
||||||
|
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{}); err != nil {
|
||||||
|
t.Fatalf("clear: %v", err)
|
||||||
|
}
|
||||||
|
if g.Email != "" || len(g.PushTokens) != 0 {
|
||||||
|
t.Errorf("after clear: email=%q tokens=%d, want empty", g.Email, len(g.PushTokens))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetRecoveryContact_NilPayload verifies defensive nil-input behavior.
|
||||||
|
// We document that nil returns an error (no silent swallow of bad requests).
|
||||||
|
func TestSetRecoveryContact_NilPayload(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
res, err := reg.Apply(ctx, g, (*messages.SetRecoveryContact)(nil))
|
||||||
|
// registry.Apply treats typed-nil proto messages as a no-op (see
|
||||||
|
// mutation_registry.go's "Typed nil" handling). The behavior we want is
|
||||||
|
// "no mutation applied to grain state" — either via no-op via the
|
||||||
|
// typed-nil path or via an error in the result list. We assert that the
|
||||||
|
// grain state was not modified.
|
||||||
|
if g.Email != "" || len(g.PushTokens) != 0 {
|
||||||
|
t.Errorf("nil-set should not modify grain: email=%q tokens=%d", g.Email, len(g.PushTokens))
|
||||||
|
}
|
||||||
|
_ = res
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetRecoveryContact_SkipsNilTokens verifies nil sub-message tokens are
|
||||||
|
// dropped rather than crashing.
|
||||||
|
func TestSetRecoveryContact_SkipsNilTokens(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(2, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
|
||||||
|
Email: "ok@example.com",
|
||||||
|
PushTokens: []*messages.PushToken{
|
||||||
|
nil,
|
||||||
|
{Platform: "fcm", Token: "REAL"},
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("set with nil tokens: %v", err)
|
||||||
|
}
|
||||||
|
if len(g.PushTokens) != 1 {
|
||||||
|
t.Fatalf("push tokens = %d, want 1 (nil entries dropped)", len(g.PushTokens))
|
||||||
|
}
|
||||||
|
if g.PushTokens[0].Platform != "fcm" {
|
||||||
|
t.Errorf("kept platform = %q, want fcm", g.PushTokens[0].Platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,6 @@ func SetUserId(grain *CartGrain, req *messages.SetUserId) error {
|
|||||||
if req.UserId == "" {
|
if req.UserId == "" {
|
||||||
return errors.New("user ID cannot be empty")
|
return errors.New("user ID cannot be empty")
|
||||||
}
|
}
|
||||||
grain.userId = req.UserId
|
grain.UserId = req.UserId
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoggingNotifier is the v0 default: it writes one INFO line per candidate
|
||||||
|
// and returns nil. It deliberately does NOT pretend to send anything.
|
||||||
|
//
|
||||||
|
// Future implementations (MailerSend, FCM, APNs) implement the same Notifier
|
||||||
|
// interface; the scanner doesn't change. LoggingNotifier also serves as the
|
||||||
|
// "dry-run" mode in production by leaving the seam unconfigured.
|
||||||
|
type LoggingNotifier struct{}
|
||||||
|
|
||||||
|
// NewLoggingNotifier returns a Notifier that logs every candidate. The struct
|
||||||
|
// is empty today; the constructor is kept for parity with future notifiers
|
||||||
|
// that need credentials / config.
|
||||||
|
func NewLoggingNotifier() *LoggingNotifier { return &LoggingNotifier{} }
|
||||||
|
|
||||||
|
// NotifyAbandoned logs the cart id, contact present, and a tiny summary.
|
||||||
|
// Push tokens are summarized by platform only — never log the raw token, so
|
||||||
|
// a misconfigured log aggregator can't leak device handles.
|
||||||
|
func (LoggingNotifier) NotifyAbandoned(_ context.Context, c RecoveryCandidate) error {
|
||||||
|
hasEmail := c.Email != ""
|
||||||
|
hasPush := len(c.PushTokens) > 0
|
||||||
|
channel := "none"
|
||||||
|
switch {
|
||||||
|
case hasEmail && hasPush:
|
||||||
|
channel = "email+push"
|
||||||
|
case hasEmail:
|
||||||
|
channel = "email"
|
||||||
|
case hasPush:
|
||||||
|
channel = "push"
|
||||||
|
}
|
||||||
|
log.Printf(
|
||||||
|
"recovery [dry-run]: cart=%d user=%q channel=%s items=%d total=%d currency=%s email=%q pushPlatforms=%v lastChangeUnix=%d",
|
||||||
|
c.CartID,
|
||||||
|
c.UserID,
|
||||||
|
channel,
|
||||||
|
c.ItemCount,
|
||||||
|
c.TotalIncVat,
|
||||||
|
c.Currency,
|
||||||
|
c.Email,
|
||||||
|
platformsOf(c.PushTokens),
|
||||||
|
c.LastChangeUnix,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func platformsOf(tokens []cart.PushToken) []string {
|
||||||
|
out := make([]string, 0, len(tokens))
|
||||||
|
for _, t := range tokens {
|
||||||
|
out = append(out, t.Platform)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureLog writes log output to an in-memory buffer for inspection.
|
||||||
|
func captureLog(t *testing.T) *bytes.Buffer {
|
||||||
|
t.Helper()
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
orig := log.Writer()
|
||||||
|
flags := log.Flags()
|
||||||
|
prefix := log.Prefix()
|
||||||
|
log.SetOutput(buf)
|
||||||
|
log.SetFlags(0)
|
||||||
|
log.SetPrefix("")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
log.SetOutput(orig)
|
||||||
|
log.SetFlags(flags)
|
||||||
|
log.SetPrefix(prefix)
|
||||||
|
})
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggingNotifier_LogsPlatformsNotTokens(t *testing.T) {
|
||||||
|
buf := captureLog(t)
|
||||||
|
n := LoggingNotifier{}
|
||||||
|
err := n.NotifyAbandoned(context.Background(), RecoveryCandidate{
|
||||||
|
CartID: 1,
|
||||||
|
Email: "doc@example.com",
|
||||||
|
PushTokens: []cart.PushToken{{Platform: "fcm", Token: "SECRET-TOKEN-MUST-NOT-LEAK"}, {Platform: "apns", Token: "ALSO-SECRET-2"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NotifyAbandoned: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "doc@example.com") {
|
||||||
|
t.Errorf("expected email in log: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "[fcm apns]") {
|
||||||
|
t.Errorf("expected bracketed platform list in log: %s", out)
|
||||||
|
}
|
||||||
|
for _, leak := range []string{"SECRET-TOKEN-MUST-NOT-LEAK", "ALSO-SECRET-2"} {
|
||||||
|
if strings.Contains(out, leak) {
|
||||||
|
t.Errorf("token leaked into log: %s line=%s", leak, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggingNotifier_ClassifiesChannel(t *testing.T) {
|
||||||
|
buf := captureLog(t)
|
||||||
|
n := LoggingNotifier{}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cand RecoveryCandidate
|
||||||
|
wantSub string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "email+push",
|
||||||
|
cand: RecoveryCandidate{Email: "a@b.c", PushTokens: []cart.PushToken{{Platform: "fcm"}}},
|
||||||
|
wantSub: "channel=email+push",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "email only",
|
||||||
|
cand: RecoveryCandidate{Email: "a@b.c"},
|
||||||
|
wantSub: "channel=email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "push only",
|
||||||
|
cand: RecoveryCandidate{PushTokens: []cart.PushToken{{Platform: "fcm"}}},
|
||||||
|
wantSub: "channel=push",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no contact",
|
||||||
|
cand: RecoveryCandidate{},
|
||||||
|
wantSub: "channel=none",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
buf.Reset()
|
||||||
|
if err := n.NotifyAbandoned(context.Background(), tc.cand); err != nil {
|
||||||
|
t.Fatalf("%s: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), tc.wantSub) {
|
||||||
|
t.Errorf("%s: log missing %q; got: %s", tc.name, tc.wantSub, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Package recovery is the v0 abandoned-cart-recovery seam.
|
||||||
|
//
|
||||||
|
// The contract is intentionally narrow: a Scanner finds candidates whose last
|
||||||
|
// mutation is within an "abandoned" window and that have BOTH an attachable
|
||||||
|
// contact (email and/or push tokens) and items in the cart, then hands them
|
||||||
|
// to a Notifier. The Notifier decides what to do — for now a logging shim.
|
||||||
|
//
|
||||||
|
// The seam intentionally avoids:
|
||||||
|
//
|
||||||
|
// - Email/push-token delivery (lives in the notifier impl).
|
||||||
|
// - Idempotency tracking ("did we already notify?"). v0: scanner windows
|
||||||
|
// are sized so a cart falls in once per detection threshold. Real
|
||||||
|
// idempotency is the next pass — see suggest_followups / plan-commerce-
|
||||||
|
// maturity C3.
|
||||||
|
// - Cross-pod coordination. Each cart pod owns its own shard of event logs
|
||||||
|
// (replicated read cache, no leader), so per-pod scanning is correct.
|
||||||
|
// A future AMQP fan-out of `cart.recovery.candidate` would let a single
|
||||||
|
// dedicated notifier consume instead — out of scope here.
|
||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecoveryCandidate is the projectable "we'd remind this shopper" shape.
|
||||||
|
// Fields surface just enough for a future email template / push payload; the
|
||||||
|
// underlying grain carries everything else.
|
||||||
|
type RecoveryCandidate struct {
|
||||||
|
CartID cart.CartId
|
||||||
|
UserID string // empty if the cart hasn't been linked to a profile
|
||||||
|
Email string
|
||||||
|
PushTokens []cart.PushToken
|
||||||
|
ItemCount int
|
||||||
|
TotalIncVat int64 // minor units (money.Cents == int64)
|
||||||
|
Currency string
|
||||||
|
// LastChange is best-effort: the scanner reads the event-log file's
|
||||||
|
// modification time. A pending notifier shouldn't use it as the canonical
|
||||||
|
// "when was this cart touched?" wall clock — see cart.LastChange.
|
||||||
|
LastChangeUnix int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier is the seam a real email/push implementation plugs into. The
|
||||||
|
// LoggingNotifier is the v0 default. The cart service ships the seam even
|
||||||
|
// before a real send exists so call-sites and tracking can be wired once.
|
||||||
|
type Notifier interface {
|
||||||
|
NotifyAbandoned(ctx context.Context, c RecoveryCandidate) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeFixtureLog writes a synthetic `<id>.events.log` to dir with the given
|
||||||
|
// mutations, then pins the file's mtime AFTER Close so the canonical
|
||||||
|
// DiskStorage.SaveLoop flush can't overwrite it on test machines that run
|
||||||
|
// the inner save() during teardown.
|
||||||
|
func writeFixtureLog(t *testing.T, dir string, id uint64, mtime time.Time, muts []proto.Message) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, strconv.FormatUint(id, 10)+".events.log")
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
if err := storage.AppendMutations(id, muts...); err != nil {
|
||||||
|
t.Fatalf("AppendMutations for fixture %d: %v", id, err)
|
||||||
|
}
|
||||||
|
// DiskStorage.Close runs save() which opens+appends the file; that
|
||||||
|
// updates mtime. Pin AFTER Close so the test mtime wins.
|
||||||
|
storage.Close()
|
||||||
|
if err := os.Chtimes(path, mtime, mtime); err != nil {
|
||||||
|
t.Fatalf("Chtimes %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// silenceLogs redirects the standard logger while the scanner runs so test
|
||||||
|
// output stays readable.
|
||||||
|
func silenceLogs(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
orig := log.Writer()
|
||||||
|
prevFlags := log.Flags()
|
||||||
|
log.SetOutput(devNull{})
|
||||||
|
log.SetFlags(0)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
log.SetOutput(orig)
|
||||||
|
log.SetFlags(prevFlags)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type devNull struct{}
|
||||||
|
|
||||||
|
func (devNull) Write(p []byte) (int, error) { return len(p), nil }
|
||||||
|
|
||||||
|
func TestScanner_FindsCandidateInWindow(t *testing.T) {
|
||||||
|
silenceLogs(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
|
||||||
|
// Cart that became eligible exactly in the middle of the window:
|
||||||
|
// delay 1h, window 1h → eligible mtime ∈ [now-2h, now-1h].
|
||||||
|
mtime := now.Add(-90 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 42, mtime, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000, Sku: "A"},
|
||||||
|
&messages.SetRecoveryContact{Email: "abandoned@example.com"},
|
||||||
|
})
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(dir, storage)
|
||||||
|
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1; full result=%+v", len(got), got)
|
||||||
|
}
|
||||||
|
c := got[0]
|
||||||
|
if c.CartID != cart.CartId(42) {
|
||||||
|
t.Errorf("CartID = %d, want 42", c.CartID)
|
||||||
|
}
|
||||||
|
if c.Email != "abandoned@example.com" {
|
||||||
|
t.Errorf("Email = %q, want abandoned@example.com", c.Email)
|
||||||
|
}
|
||||||
|
if c.ItemCount != 1 {
|
||||||
|
t.Errorf("ItemCount = %d, want 1", c.ItemCount)
|
||||||
|
}
|
||||||
|
if c.TotalIncVat != 1000 {
|
||||||
|
t.Errorf("TotalIncVat = %d, want 1000 (AddItem price 1000)", c.TotalIncVat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanner_SkipsCartsOutsideWindow(t *testing.T) {
|
||||||
|
silenceLogs(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
|
||||||
|
// Too fresh (mtime = now-30m, inside the 1h "still active" cutoff).
|
||||||
|
fresh := now.Add(-30 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 7, fresh, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
|
||||||
|
&messages.SetRecoveryContact{Email: "fresh@example.com"},
|
||||||
|
})
|
||||||
|
// Too old (mtime = now-25h, past the 2h window upper bound).
|
||||||
|
old := now.Add(-25 * time.Hour)
|
||||||
|
writeFixtureLog(t, dir, 8, old, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
|
||||||
|
&messages.SetRecoveryContact{Email: "old@example.com"},
|
||||||
|
})
|
||||||
|
// Inside window with NO contact (eligibility gate).
|
||||||
|
mid := now.Add(-90 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 9, mid, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
|
||||||
|
// no SetRecoveryContact
|
||||||
|
})
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(dir, storage)
|
||||||
|
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("candidates = %d, want 0 (all filtered out); result=%+v", len(got), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanner_FindsPushOnlyCandidate(t *testing.T) {
|
||||||
|
silenceLogs(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
|
||||||
|
mtime := now.Add(-75 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 11, mtime, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
|
||||||
|
&messages.SetRecoveryContact{
|
||||||
|
PushTokens: []*messages.PushToken{
|
||||||
|
{Platform: "fcm", Token: "tok-1"},
|
||||||
|
{Platform: "apns", Token: "tok-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(dir, storage)
|
||||||
|
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Email != "" {
|
||||||
|
t.Errorf("Email = %q, want empty (push-only)", got[0].Email)
|
||||||
|
}
|
||||||
|
if len(got[0].PushTokens) != 2 {
|
||||||
|
t.Fatalf("PushTokens = %d, want 2", len(got[0].PushTokens))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanner_IgnoresWishlistCarts(t *testing.T) {
|
||||||
|
silenceLogs(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
|
||||||
|
mtime := now.Add(-90 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 13, mtime, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
|
||||||
|
&messages.SetRecoveryContact{Email: "wishlist@example.com"},
|
||||||
|
&messages.SetCartType{Type: messages.CartType_WISHLIST},
|
||||||
|
})
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(dir, storage)
|
||||||
|
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("wishlist candidate leaked: got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanner_TotalPostReplay verifies the bug fix: replay does not run the
|
||||||
|
// post-mutation processor, so the scanner MUST call UpdateTotals to surface
|
||||||
|
// an accurate TotalIncVat.
|
||||||
|
func TestScanner_TotalPostReplay(t *testing.T) {
|
||||||
|
silenceLogs(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
|
||||||
|
mtime := now.Add(-75 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 21, mtime, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 2, Price: 500, Sku: "A"},
|
||||||
|
&messages.AddItem{ItemId: 2, Quantity: 1, Price: 1500, Sku: "B"},
|
||||||
|
&messages.SetRecoveryContact{Email: "totals@example.com"},
|
||||||
|
})
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(dir, storage)
|
||||||
|
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
// 2*500 + 1*1500 = 2500 inc-vat
|
||||||
|
if got[0].TotalIncVat != 2500 {
|
||||||
|
t.Errorf("TotalIncVat = %d, want 2500 (1000+1500 across two lines)", got[0].TotalIncVat)
|
||||||
|
}
|
||||||
|
if got[0].ItemCount != 2 {
|
||||||
|
t.Errorf("ItemCount = %d, want 2", got[0].ItemCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsValidEmail sanity for the optional helper exported for notifiers.
|
||||||
|
func TestIsValidEmail(t *testing.T) {
|
||||||
|
if !IsValidEmail("user@example.com") {
|
||||||
|
t.Errorf("user@example.com should be valid")
|
||||||
|
}
|
||||||
|
if IsValidEmail("not-an-email") {
|
||||||
|
t.Errorf("not-an-email should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileIDFor verifies the helper that parses <id>.events.log filenames.
|
||||||
|
func TestFileIDFor(t *testing.T) {
|
||||||
|
if id, ok := fileIDFor("12345.events.log"); !ok || id != 12345 {
|
||||||
|
t.Errorf("12345.events.log -> %d, %v; want 12345, true", id, ok)
|
||||||
|
}
|
||||||
|
if _, ok := fileIDFor("not-a-number.events.log"); ok {
|
||||||
|
t.Errorf("non-numeric should fail")
|
||||||
|
}
|
||||||
|
if _, ok := fileIDFor("0.events.log"); ok {
|
||||||
|
t.Errorf("id=0 should fail (no implicit zero-cart grain)")
|
||||||
|
}
|
||||||
|
if _, ok := fileIDFor("garbage.txt"); ok {
|
||||||
|
t.Errorf("non-event-log suffix should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scanner finds RecoveryCandidates by scanning the per-pod event-log directory.
|
||||||
|
//
|
||||||
|
// The model:
|
||||||
|
// - Each cart pod owns a shard of `<id>.events.log` files under `dir`.
|
||||||
|
// - File mtime approximates "last successful mutation" cheaply — replay only
|
||||||
|
// when something lands in the window.
|
||||||
|
// - For each candidate file, replay the grain (a few kb, ms-scale) and build
|
||||||
|
// a RecoveryCandidate iff it has items + a contact.
|
||||||
|
//
|
||||||
|
// Two important implementation details:
|
||||||
|
//
|
||||||
|
// 1. After LoadEvents we MUST call UpdateTotals(). Replaying event handlers
|
||||||
|
// runs the per-mutation registered handlers (SetUserId, SetRecoveryContact,
|
||||||
|
// AddItem, ...) but NOT the post-mutation processors — those run only on
|
||||||
|
// the live mutation path (see cmd/cart/main.go::RegisterProcessor for the
|
||||||
|
// "Totals and promotions" pipeline). Without this call, g.TotalPrice is
|
||||||
|
// nil and TotalIncVat in the candidate is always 0.
|
||||||
|
//
|
||||||
|
// 2. Read-vs-write races: DiskStorage may be in the middle of appending while
|
||||||
|
// Scan reads. The bufio scanner will fail JSON-unmarshal on a torn line and
|
||||||
|
// we silently continue — LogEvents treats that as "skip", here we do too.
|
||||||
|
// A persistent unreadable file is a separate problem runPurge handles.
|
||||||
|
//
|
||||||
|
// v0 deliberately does NOT track "already notified" within this scanner —
|
||||||
|
// the window sizing plus caller conventions give roughly-once delivery.
|
||||||
|
// Idempotency is the next pass.
|
||||||
|
type Scanner struct {
|
||||||
|
dir string
|
||||||
|
storage *actor.DiskStorage[cart.CartGrain]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScanner returns a Scanner over dir (the same CART_DIR used by the cart
|
||||||
|
// service). storage must share the same dir so LoadEvents reading paths
|
||||||
|
// match the scanning paths.
|
||||||
|
func NewScanner(dir string, storage *actor.DiskStorage[cart.CartGrain]) *Scanner {
|
||||||
|
return &Scanner{dir: dir, storage: storage}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan returns candidates whose event-log mtime falls in
|
||||||
|
// [now-delay-window, now-delay] AND whose grain has items AND (email OR
|
||||||
|
// push tokens). The half-open window semantics match the caller's intent:
|
||||||
|
//
|
||||||
|
// "carts whose mtime is in this slice of the recent past"
|
||||||
|
//
|
||||||
|
// — younger than now-delay (still active) and older than now-delay-window
|
||||||
|
// (never recovery-eligible yet).
|
||||||
|
//
|
||||||
|
// Empty slice + nil error means "no candidates this pass"; an error means the
|
||||||
|
// pass itself failed and the caller should log + retry.
|
||||||
|
func (s *Scanner) Scan(ctx context.Context, delay, window time.Duration, now time.Time) ([]RecoveryCandidate, error) {
|
||||||
|
if s == nil || s.storage == nil || s.dir == "" {
|
||||||
|
return nil, fmt.Errorf("recovery: scanner not configured")
|
||||||
|
}
|
||||||
|
if delay <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if window <= 0 {
|
||||||
|
window = delay
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := now.Add(-(delay + window))
|
||||||
|
upper := now.Add(-delay)
|
||||||
|
entries, err := os.ReadDir(s.dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("recovery: read dir %s: %w", s.dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]RecoveryCandidate, 0)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasSuffix(name, ".events.log") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, ok := fileIDFor(name)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, infoErr := e.Info()
|
||||||
|
if infoErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mtime := info.ModTime()
|
||||||
|
// Closed window:
|
||||||
|
// mtime BEFORE lower → never reached the abandonment threshold yet.
|
||||||
|
// mtime AFTER upper → still too fresh (caller is actively using it).
|
||||||
|
if mtime.Before(lower) || mtime.After(upper) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
grain := cart.NewCartGrain(id, mtime)
|
||||||
|
if loadErr := s.storage.LoadEvents(ctx, id, grain); loadErr != nil {
|
||||||
|
// Skip unreadable grains — they're purged separately by runPurge.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Replay does NOT run the Totals-and-promotions processor (see the
|
||||||
|
// package doc); recompute TotalPrice from the item list so the
|
||||||
|
// candidate TotalIncVat matches what the live API would return.
|
||||||
|
grain.UpdateTotals()
|
||||||
|
if !eligible(grain) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, buildCandidate(id, grain, mtime))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eligible mirrors "would we ever want to send a reminder?" — items present
|
||||||
|
// and a contact available. The grain's own UpdateTotals has already been
|
||||||
|
// run by every mutation's processor, so TotalPrice is reliable here.
|
||||||
|
//
|
||||||
|
// Wishlists / offers are excluded — a wishlist cart isn't forgotten, and an
|
||||||
|
// offer cart has its own delivery semantics.
|
||||||
|
func eligible(g *cart.CartGrain) bool {
|
||||||
|
if g == nil || len(g.Items) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch g.Type {
|
||||||
|
case cart_messages.CartType_WISHLIST, cart_messages.CartType_OFFER:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasContact := strings.TrimSpace(g.Email) != "" || len(g.PushTokens) > 0
|
||||||
|
return hasContact
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCandidate projects the replayed grain into a RecoveryCandidate. Email
|
||||||
|
// validation is intentionally lenient — we hand the value to the notifier
|
||||||
|
// and the notifier decides what to send; rejecting "looks-invalid" addresses
|
||||||
|
// here would just hide useful debugging signal from the dry-run logs.
|
||||||
|
func buildCandidate(id uint64, g *cart.CartGrain, mtime time.Time) RecoveryCandidate {
|
||||||
|
var total int64
|
||||||
|
if g.TotalPrice != nil {
|
||||||
|
// tax.Price.IncVat is int64-backed via money.Cents. Reading the field
|
||||||
|
// directly keeps the recovery layer decoupled from platform/money
|
||||||
|
// internals.
|
||||||
|
total = int64(g.TotalPrice.IncVat)
|
||||||
|
}
|
||||||
|
tokens := make([]cart.PushToken, len(g.PushTokens))
|
||||||
|
copy(tokens, g.PushTokens)
|
||||||
|
return RecoveryCandidate{
|
||||||
|
CartID: cart.CartId(id),
|
||||||
|
UserID: g.UserId,
|
||||||
|
Email: g.Email,
|
||||||
|
PushTokens: tokens,
|
||||||
|
ItemCount: len(g.Items),
|
||||||
|
TotalIncVat: total,
|
||||||
|
Currency: g.Currency,
|
||||||
|
LastChangeUnix: mtime.Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidEmail is exported as a convenience the notifier may use to decide
|
||||||
|
// whether to actually send (MailerSend will reject invalid); the scanner
|
||||||
|
// itself is permissive.
|
||||||
|
func IsValidEmail(s string) bool {
|
||||||
|
_, err := mail.ParseAddress(s)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileIDFor is a small helper that extracts the grain id from an .events.log
|
||||||
|
// filename. Returns 0/false if the name doesn't match the expected shape.
|
||||||
|
func fileIDFor(name string) (uint64, bool) {
|
||||||
|
base := strings.TrimSuffix(name, ".events.log")
|
||||||
|
id, err := strconv.ParseUint(base, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package discovery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/watch"
|
"k8s.io/apimachinery/pkg/watch"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/cache"
|
"k8s.io/client-go/tools/cache"
|
||||||
toolsWatch "k8s.io/client-go/tools/watch"
|
toolsWatch "k8s.io/client-go/tools/watch"
|
||||||
)
|
)
|
||||||
@@ -118,3 +120,51 @@ func NewK8sDiscoveryInNamespace(client *kubernetes.Clientset, namespace string,
|
|||||||
namespace: namespace,
|
namespace: namespace,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartK8sDiscovery initializes kubernetes discovery for target.
|
||||||
|
// If podIp is empty, it returns early.
|
||||||
|
func StartK8sDiscovery(podIp string, labelSelector string, timeoutSeconds int64, target DiscoveryTarget) {
|
||||||
|
if podIp == "" {
|
||||||
|
log.Print("No discovery service available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, kerr := rest.InClusterConfig()
|
||||||
|
if kerr != nil {
|
||||||
|
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||||
|
}
|
||||||
|
client, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating client: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hw := NewK8sDiscoveryInNamespace(client, InClusterNamespace(), metav1.ListOptions{
|
||||||
|
LabelSelector: labelSelector,
|
||||||
|
TimeoutSeconds: &timeoutSeconds,
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ch, err := hw.Watch()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Discovery error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for evt := range ch {
|
||||||
|
if evt.Host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch evt.IsReady {
|
||||||
|
case false:
|
||||||
|
if target.IsKnown(evt.Host) {
|
||||||
|
log.Printf("Host %s is not ready, removing", evt.Host)
|
||||||
|
target.RemoveHost(evt.Host)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if !target.IsKnown(evt.Host) {
|
||||||
|
log.Printf("Discovered host %s", evt.Host)
|
||||||
|
target.AddRemoteHost(evt.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,42 +54,78 @@ type HookInfo struct {
|
|||||||
// — so observability/notification hooks can't break the business transaction.
|
// — so observability/notification hooks can't break the business transaction.
|
||||||
type Hook func(ctx context.Context, st *State, info HookInfo, params json.RawMessage) error
|
type Hook func(ctx context.Context, st *State, info HookInfo, params json.RawMessage) error
|
||||||
|
|
||||||
|
// CapabilityMeta is the editor-facing documentation attached at registration
|
||||||
|
// time. The server generates `/sagas/capabilities` from these registrations, so
|
||||||
|
// UIs should consume this payload instead of hardcoding per-tool help.
|
||||||
|
//
|
||||||
|
// Register every action/predicate/hook with the matching *WithMeta helper when
|
||||||
|
// it should be discoverable in editors. ExampleParams is arbitrary JSON shown as
|
||||||
|
// a starter payload for params-capable tools.
|
||||||
|
type CapabilityMeta struct {
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
ExampleParams json.RawMessage `json:"exampleParams,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Registry holds the named actions, predicates and hooks a flow can reference.
|
// Registry holds the named actions, predicates and hooks a flow can reference.
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
actions map[string]Action
|
actions map[string]Action
|
||||||
|
actionMeta map[string]CapabilityMeta
|
||||||
predicates map[string]Predicate
|
predicates map[string]Predicate
|
||||||
|
predicateMeta map[string]CapabilityMeta
|
||||||
hooks map[string]Hook
|
hooks map[string]Hook
|
||||||
|
hookMeta map[string]CapabilityMeta
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry returns an empty registry.
|
// NewRegistry returns an empty registry.
|
||||||
func NewRegistry() *Registry {
|
func NewRegistry() *Registry {
|
||||||
return &Registry{
|
return &Registry{
|
||||||
actions: map[string]Action{},
|
actions: map[string]Action{},
|
||||||
|
actionMeta: map[string]CapabilityMeta{},
|
||||||
predicates: map[string]Predicate{},
|
predicates: map[string]Predicate{},
|
||||||
|
predicateMeta: map[string]CapabilityMeta{},
|
||||||
hooks: map[string]Hook{},
|
hooks: map[string]Hook{},
|
||||||
|
hookMeta: map[string]CapabilityMeta{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action registers an action under name (overwrites an existing one).
|
// Action registers an action under name (overwrites an existing one).
|
||||||
func (r *Registry) Action(name string, fn Action) {
|
func (r *Registry) Action(name string, fn Action) {
|
||||||
|
r.ActionWithMeta(name, fn, CapabilityMeta{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionWithMeta registers an action plus the metadata editors should display.
|
||||||
|
func (r *Registry) ActionWithMeta(name string, fn Action, meta CapabilityMeta) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
r.actions[name] = fn
|
r.actions[name] = fn
|
||||||
|
r.actionMeta[name] = meta
|
||||||
}
|
}
|
||||||
|
|
||||||
// Predicate registers a predicate under name.
|
// Predicate registers a predicate under name.
|
||||||
func (r *Registry) Predicate(name string, fn Predicate) {
|
func (r *Registry) Predicate(name string, fn Predicate) {
|
||||||
|
r.PredicateWithMeta(name, fn, CapabilityMeta{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PredicateWithMeta registers a predicate plus the metadata editors should display.
|
||||||
|
func (r *Registry) PredicateWithMeta(name string, fn Predicate, meta CapabilityMeta) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
r.predicates[name] = fn
|
r.predicates[name] = fn
|
||||||
|
r.predicateMeta[name] = meta
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook registers a hook under name.
|
// Hook registers a hook under name.
|
||||||
func (r *Registry) Hook(name string, fn Hook) {
|
func (r *Registry) Hook(name string, fn Hook) {
|
||||||
|
r.HookWithMeta(name, fn, CapabilityMeta{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookWithMeta registers a hook plus the metadata editors should display.
|
||||||
|
func (r *Registry) HookWithMeta(name string, fn Hook, meta CapabilityMeta) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
r.hooks[name] = fn
|
r.hooks[name] = fn
|
||||||
|
r.hookMeta[name] = meta
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) action(name string) (Action, bool) {
|
func (r *Registry) action(name string) (Action, bool) {
|
||||||
@@ -117,8 +153,11 @@ func (r *Registry) hook(name string) (Hook, bool) {
|
|||||||
// offer exactly the actions/predicates/hooks a flow may reference.
|
// offer exactly the actions/predicates/hooks a flow may reference.
|
||||||
type Capabilities struct {
|
type Capabilities struct {
|
||||||
Actions []string `json:"actions"`
|
Actions []string `json:"actions"`
|
||||||
|
ActionMeta map[string]CapabilityMeta `json:"actionMeta,omitempty"`
|
||||||
Predicates []string `json:"predicates"`
|
Predicates []string `json:"predicates"`
|
||||||
|
PredicateMeta map[string]CapabilityMeta `json:"predicateMeta,omitempty"`
|
||||||
Hooks []string `json:"hooks"`
|
Hooks []string `json:"hooks"`
|
||||||
|
HookMeta map[string]CapabilityMeta `json:"hookMeta,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capabilities returns the sorted registered names.
|
// Capabilities returns the sorted registered names.
|
||||||
@@ -127,11 +166,22 @@ func (r *Registry) Capabilities() Capabilities {
|
|||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
return Capabilities{
|
return Capabilities{
|
||||||
Actions: sortedKeys(r.actions),
|
Actions: sortedKeys(r.actions),
|
||||||
|
ActionMeta: cloneMeta(r.actionMeta),
|
||||||
Predicates: sortedKeys(r.predicates),
|
Predicates: sortedKeys(r.predicates),
|
||||||
|
PredicateMeta: cloneMeta(r.predicateMeta),
|
||||||
Hooks: sortedKeys(r.hooks),
|
Hooks: sortedKeys(r.hooks),
|
||||||
|
HookMeta: cloneMeta(r.hookMeta),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneMeta(in map[string]CapabilityMeta) map[string]CapabilityMeta {
|
||||||
|
out := make(map[string]CapabilityMeta, len(in))
|
||||||
|
for k, v := range in {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func sortedKeys[V any](m map[string]V) []string {
|
func sortedKeys[V any](m map[string]V) []string {
|
||||||
out := make([]string, 0, len(m))
|
out := make([]string, 0, len(m))
|
||||||
for k := range m {
|
for k := range m {
|
||||||
@@ -192,6 +242,11 @@ func (e *Engine) Validate(def *Definition) error {
|
|||||||
if _, ok := e.reg.hook(h.Type); !ok {
|
if _, ok := e.reg.hook(h.Type); !ok {
|
||||||
return fmt.Errorf("flow %q step %q: unknown hook %q", def.Name, s.Name, h.Type)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,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.
|
// 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) {
|
func (e *Engine) fireHooks(ctx context.Context, st *State, refs []HookRef, info HookInfo) {
|
||||||
for _, ref := range refs {
|
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)
|
h, ok := e.reg.hook(ref.Type)
|
||||||
if !ok {
|
if !ok {
|
||||||
st.Logger.Error("flow hook skipped: unknown type", "hook", ref.Type, "step", info.Step)
|
st.Logger.Error("flow hook skipped: unknown type", "hook", ref.Type, "step", info.Step)
|
||||||
|
|||||||
+3
-1
@@ -51,9 +51,11 @@ type Hooks struct {
|
|||||||
OnError []HookRef `json:"onError,omitempty"`
|
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 HookRef struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
When string `json:"when,omitempty"`
|
||||||
Params json.RawMessage `json:"params,omitempty"`
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -13,9 +13,17 @@ import (
|
|||||||
// (structured log line) and "webhook" (HTTP POST of the event). Domain packages
|
// (structured log line) and "webhook" (HTTP POST of the event). Domain packages
|
||||||
// register their own hooks (e.g. "amqp_emit") on the same registry.
|
// register their own hooks (e.g. "amqp_emit") on the same registry.
|
||||||
func RegisterBuiltinHooks(reg *Registry) {
|
func RegisterBuiltinHooks(reg *Registry) {
|
||||||
reg.Hook("log", logHook)
|
reg.HookWithMeta("log", logHook, CapabilityMeta{
|
||||||
reg.Hook("noop", func(context.Context, *State, HookInfo, json.RawMessage) error { return nil })
|
Description: "Write a structured log line for the current step and phase.",
|
||||||
reg.Hook("webhook", webhookHook(&http.Client{Timeout: 10 * time.Second}))
|
ExampleParams: json.RawMessage(`{"message":"payment captured"}`),
|
||||||
|
})
|
||||||
|
reg.HookWithMeta("noop", func(context.Context, *State, HookInfo, json.RawMessage) error { return nil }, CapabilityMeta{
|
||||||
|
Description: "Do nothing. Useful as a placeholder while shaping a flow.",
|
||||||
|
})
|
||||||
|
reg.HookWithMeta("webhook", webhookHook(&http.Client{Timeout: 10 * time.Second}), CapabilityMeta{
|
||||||
|
Description: "POST the flow event payload to an external HTTP endpoint.",
|
||||||
|
ExampleParams: json.RawMessage(`{"url":"https://example.test/order-events","headers":{"X-Flow":"place-and-pay"}}`),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// logHook emits a structured log line for the step/phase. Params (optional):
|
// logHook emits a structured log line for the step/phase. Params (optional):
|
||||||
|
|||||||
+114
-23
@@ -63,8 +63,8 @@ const PlaceOrderVar = "placeOrder"
|
|||||||
// RegisterFlowActions registers the order lifecycle actions on reg, driving the
|
// RegisterFlowActions registers the order lifecycle actions on reg, driving the
|
||||||
// grain through app and taking payment via provider. Compose with
|
// grain through app and taking payment via provider. Compose with
|
||||||
// flow.RegisterBuiltinHooks(reg) to get the "log"/"webhook" hooks too.
|
// 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.Action("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
reg.ActionWithMeta("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||||
po, ok := st.Vars[PlaceOrderVar].(*messages.PlaceOrder)
|
po, ok := st.Vars[PlaceOrderVar].(*messages.PlaceOrder)
|
||||||
if !ok || po == nil {
|
if !ok || po == nil {
|
||||||
return fmt.Errorf("place_order: flow var %q is not a *PlaceOrder", PlaceOrderVar)
|
return fmt.Errorf("place_order: flow var %q is not a *PlaceOrder", PlaceOrderVar)
|
||||||
@@ -78,9 +78,9 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
st.Vars["currency"] = po.GetCurrency()
|
st.Vars["currency"] = po.GetCurrency()
|
||||||
st.Vars["orderReference"] = po.GetOrderReference()
|
st.Vars["orderReference"] = po.GetOrderReference()
|
||||||
return nil
|
return nil
|
||||||
})
|
}, flow.CapabilityMeta{Description: "Create the order from the flow state variable `placeOrder`."})
|
||||||
|
|
||||||
reg.Action("authorize_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
reg.ActionWithMeta("authorize_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||||
o, err := app.Get(ctx, st.ID)
|
o, err := app.Get(ctx, st.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -101,9 +101,9 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
st.Vars["authProvider"] = auth.Provider
|
st.Vars["authProvider"] = auth.Provider
|
||||||
st.Vars["authAmount"] = auth.Amount
|
st.Vars["authAmount"] = auth.Amount
|
||||||
return nil
|
return nil
|
||||||
})
|
}, flow.CapabilityMeta{Description: "Authorize payment with the configured provider."})
|
||||||
|
|
||||||
reg.Action("capture_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
reg.ActionWithMeta("capture_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||||
authRef, _ := st.Vars["authRef"].(string)
|
authRef, _ := st.Vars["authRef"].(string)
|
||||||
amount, _ := st.Vars["authAmount"].(int64)
|
amount, _ := st.Vars["authAmount"].(int64)
|
||||||
if amount == 0 {
|
if amount == 0 {
|
||||||
@@ -121,11 +121,11 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
Reference: capture.Reference,
|
Reference: capture.Reference,
|
||||||
AtMs: nowMs(),
|
AtMs: nowMs(),
|
||||||
})
|
})
|
||||||
})
|
}, flow.CapabilityMeta{Description: "Capture the authorized payment."})
|
||||||
|
|
||||||
// void_payment is the compensation for authorize_payment: void with the
|
// void_payment is the compensation for authorize_payment: void with the
|
||||||
// provider and cancel the order if it is still in a cancellable state.
|
// provider and cancel the order if it is still in a cancellable state.
|
||||||
reg.Action("void_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
reg.ActionWithMeta("void_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||||
if authRef, ok := st.Vars["authRef"].(string); ok && authRef != "" {
|
if authRef, ok := st.Vars["authRef"].(string); ok && authRef != "" {
|
||||||
if err := provider.Void(ctx, authRef); err != nil {
|
if err := provider.Void(ctx, authRef); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -134,9 +134,9 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
// Best-effort cancel; ignore if no longer cancellable.
|
// Best-effort cancel; ignore if no longer cancellable.
|
||||||
_, _ = app.Apply(ctx, st.ID, &messages.CancelOrder{Reason: "compensation: payment voided", AtMs: nowMs()})
|
_, _ = app.Apply(ctx, st.ID, &messages.CancelOrder{Reason: "compensation: payment voided", AtMs: nowMs()})
|
||||||
return nil
|
return nil
|
||||||
})
|
}, flow.CapabilityMeta{Description: "Void the authorization and cancel the order as compensation."})
|
||||||
|
|
||||||
reg.Action("cancel_order", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
reg.ActionWithMeta("cancel_order", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
||||||
reason := "cancelled"
|
reason := "cancelled"
|
||||||
if len(params) > 0 {
|
if len(params) > 0 {
|
||||||
var p struct {
|
var p struct {
|
||||||
@@ -147,9 +147,12 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return applyOne(ctx, app, st.ID, &messages.CancelOrder{Reason: reason, AtMs: nowMs()})
|
return applyOne(ctx, app, st.ID, &messages.CancelOrder{Reason: reason, AtMs: nowMs()})
|
||||||
|
}, flow.CapabilityMeta{
|
||||||
|
Description: "Cancel the order with an optional reason.",
|
||||||
|
ExampleParams: json.RawMessage(`{"reason":"customer requested cancellation"}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
reg.Action("issue_refund", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
reg.ActionWithMeta("issue_refund", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
||||||
o, err := app.Get(ctx, st.ID)
|
o, err := app.Get(ctx, st.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -180,9 +183,71 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
Reference: ref.Reference,
|
Reference: ref.Reference,
|
||||||
AtMs: nowMs(),
|
AtMs: nowMs(),
|
||||||
})
|
})
|
||||||
|
}, flow.CapabilityMeta{
|
||||||
|
Description: "Refund the remaining or specified captured amount.",
|
||||||
|
ExampleParams: json.RawMessage(`{"amount":2500}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
registerPredicates(reg, app)
|
reg.ActionWithMeta("create_fulfillment", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
||||||
|
var p struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Carrier string `json:"carrier,omitempty"`
|
||||||
|
TrackingNumber string `json:"trackingNumber,omitempty"`
|
||||||
|
TrackingURI string `json:"trackingUri,omitempty"`
|
||||||
|
AtMs int64 `json:"atMs,omitempty"`
|
||||||
|
Lines []struct {
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
Quantity int32 `json:"quantity"`
|
||||||
|
} `json:"lines"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(params, &p); err != nil {
|
||||||
|
return fmt.Errorf("create_fulfillment: bad params: %w", err)
|
||||||
|
}
|
||||||
|
if len(p.Lines) == 0 {
|
||||||
|
return fmt.Errorf("create_fulfillment: missing lines")
|
||||||
|
}
|
||||||
|
id := p.ID
|
||||||
|
if id == "" {
|
||||||
|
fid, err := NewOrderId()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id = "f_" + fid.String()
|
||||||
|
}
|
||||||
|
atMs := p.AtMs
|
||||||
|
if atMs == 0 {
|
||||||
|
atMs = nowMs()
|
||||||
|
}
|
||||||
|
msg := &messages.CreateFulfillment{
|
||||||
|
Id: id,
|
||||||
|
Carrier: p.Carrier,
|
||||||
|
TrackingNumber: p.TrackingNumber,
|
||||||
|
TrackingUri: p.TrackingURI,
|
||||||
|
AtMs: atMs,
|
||||||
|
}
|
||||||
|
for _, line := range p.Lines {
|
||||||
|
if line.Reference == "" {
|
||||||
|
return fmt.Errorf("create_fulfillment: line missing reference")
|
||||||
|
}
|
||||||
|
if line.Quantity <= 0 {
|
||||||
|
return fmt.Errorf("create_fulfillment: line %q has invalid quantity %d", line.Reference, line.Quantity)
|
||||||
|
}
|
||||||
|
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{
|
||||||
|
Reference: line.Reference,
|
||||||
|
Quantity: line.Quantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return applyOne(ctx, app, st.ID, msg)
|
||||||
|
}, flow.CapabilityMeta{
|
||||||
|
Description: "Create a shipment / fulfillment and advance the order fulfillment status.",
|
||||||
|
ExampleParams: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"TRACK-123","trackingUri":"https://tracking.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
// registerPredicates adds the order gating predicates a step can reference via
|
||||||
@@ -190,42 +255,68 @@ 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
|
// 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
|
// order is captured). Predicates take no params (the engine's When is a bare
|
||||||
// name), so each is a fixed, composable boolean.
|
// 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) }
|
get := func(ctx context.Context, st *flow.State) (*OrderGrain, error) { return app.Get(ctx, st.ID) }
|
||||||
|
|
||||||
reg.Predicate("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
|
reg.PredicateWithMeta("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||||
o, err := get(ctx, st)
|
o, err := get(ctx, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return o.CustomerEmail != "", nil
|
return o.CustomerEmail != "", nil
|
||||||
})
|
}, flow.CapabilityMeta{Description: "Run only when the order has a customer email address."})
|
||||||
reg.Predicate("has_items", func(ctx context.Context, st *flow.State) (bool, error) {
|
reg.PredicateWithMeta("has_items", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||||
o, err := get(ctx, st)
|
o, err := get(ctx, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return len(o.Lines) > 0, nil
|
return len(o.Lines) > 0, nil
|
||||||
})
|
}, flow.CapabilityMeta{Description: "Run only when the order contains at least one line item."})
|
||||||
reg.Predicate("is_captured", func(ctx context.Context, st *flow.State) (bool, error) {
|
reg.PredicateWithMeta("is_captured", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||||
o, err := get(ctx, st)
|
o, err := get(ctx, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return o.Status == StatusCaptured, nil
|
return o.Status == StatusCaptured, nil
|
||||||
})
|
}, flow.CapabilityMeta{Description: "Run only after payment has been captured."})
|
||||||
reg.Predicate("not_captured", func(ctx context.Context, st *flow.State) (bool, error) {
|
reg.PredicateWithMeta("not_captured", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||||
o, err := get(ctx, st)
|
o, err := get(ctx, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return o.Status != StatusCaptured, nil
|
return o.Status != StatusCaptured, nil
|
||||||
})
|
}, flow.CapabilityMeta{Description: "Run only before payment is captured."})
|
||||||
reg.Predicate("has_open_balance", func(ctx context.Context, st *flow.State) (bool, error) {
|
reg.PredicateWithMeta("has_open_balance", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||||
o, err := get(ctx, st)
|
o, err := get(ctx, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return o.CapturedAmount-o.RefundedAmount > 0, nil
|
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."})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,28 @@ package order
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/mail"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||||
)
|
)
|
||||||
|
|
||||||
func fullRegistry(t *testing.T, pub Publisher) *flow.Registry {
|
func fullRegistry(t *testing.T, pub Publisher) *flow.Registry {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
pool := newPool(t)
|
||||||
reg := flow.NewRegistry()
|
reg := flow.NewRegistry()
|
||||||
flow.RegisterBuiltinHooks(reg)
|
flow.RegisterBuiltinHooks(reg)
|
||||||
RegisterFlowActions(reg, newPool(t), NewMockProvider())
|
RegisterFlowActions(reg, pool, NewMockProvider())
|
||||||
|
RegisterInventoryReservationActions(reg, pool, nil)
|
||||||
RegisterEmitHook(reg, pub)
|
RegisterEmitHook(reg, pub)
|
||||||
|
RegisterEmailHook(reg, pool, nil, nil)
|
||||||
|
RegisterFulfillmentWebhookHook(reg, pool, nil)
|
||||||
return reg
|
return reg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +39,92 @@ func TestCapabilitiesIncludesPredicatesAndEmit(t *testing.T) {
|
|||||||
if !slices.Contains(caps.Hooks, "amqp_emit") {
|
if !slices.Contains(caps.Hooks, "amqp_emit") {
|
||||||
t.Errorf("hooks missing amqp_emit (got %v)", caps.Hooks)
|
t.Errorf("hooks missing amqp_emit (got %v)", caps.Hooks)
|
||||||
}
|
}
|
||||||
|
if !slices.Contains(caps.Hooks, "send_email") {
|
||||||
|
t.Errorf("hooks missing send_email (got %v)", caps.Hooks)
|
||||||
|
}
|
||||||
|
if !slices.Contains(caps.Hooks, "fulfillment_webhook") {
|
||||||
|
t.Errorf("hooks missing fulfillment_webhook (got %v)", caps.Hooks)
|
||||||
|
}
|
||||||
|
if !slices.Contains(caps.Actions, "create_fulfillment") {
|
||||||
|
t.Errorf("actions missing create_fulfillment (got %v)", caps.Actions)
|
||||||
|
}
|
||||||
|
if !slices.Contains(caps.Actions, "reserve_inventory") {
|
||||||
|
t.Errorf("actions missing reserve_inventory (got %v)", caps.Actions)
|
||||||
|
}
|
||||||
|
if !slices.Contains(caps.Actions, "release_inventory") {
|
||||||
|
t.Errorf("actions missing release_inventory (got %v)", caps.Actions)
|
||||||
|
}
|
||||||
|
if caps.ActionMeta["create_fulfillment"].Description == "" {
|
||||||
|
t.Fatalf("action meta missing for create_fulfillment: %#v", caps.ActionMeta["create_fulfillment"])
|
||||||
|
}
|
||||||
|
if len(caps.HookMeta["fulfillment_webhook"].ExampleParams) == 0 {
|
||||||
|
t.Fatalf("hook meta missing example params for fulfillment_webhook: %#v", caps.HookMeta["fulfillment_webhook"])
|
||||||
|
}
|
||||||
|
if caps.PredicateMeta["has_customer_email"].Description == "" {
|
||||||
|
t.Fatalf("predicate meta missing for has_customer_email: %#v", caps.PredicateMeta["has_customer_email"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeInventoryReservationService struct {
|
||||||
|
reserved []InventoryReservationRequest
|
||||||
|
released []InventoryReservationRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeInventoryReservationService) Reserve(_ context.Context, req InventoryReservationRequest) error {
|
||||||
|
f.reserved = append(f.reserved, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeInventoryReservationService) Release(_ context.Context, req InventoryReservationRequest) error {
|
||||||
|
f.released = append(f.released, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveInventoryActionBuildsReservationRequest(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
reg := flow.NewRegistry()
|
||||||
|
RegisterFlowActions(reg, pool, NewMockProvider())
|
||||||
|
fake := &fakeInventoryReservationService{}
|
||||||
|
RegisterInventoryReservationActions(reg, pool, fake)
|
||||||
|
eng := flow.NewEngine(reg, nil)
|
||||||
|
|
||||||
|
const id = 703
|
||||||
|
st := flow.NewState(id, nil)
|
||||||
|
po := placeMsg()
|
||||||
|
st.Vars[PlaceOrderVar] = po
|
||||||
|
def, err := EmbeddedFlow("place-and-pay")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := eng.Run(context.Background(), def, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reserve := &flow.Definition{Name: "reserve", Steps: []flow.Step{{
|
||||||
|
Name: "reserve",
|
||||||
|
Action: "reserve_inventory",
|
||||||
|
Params: json.RawMessage(`{"ttlSeconds":600,"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`),
|
||||||
|
Compensate: &flow.ActionRef{
|
||||||
|
Action: "release_inventory",
|
||||||
|
Params: json.RawMessage(`{"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`),
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
if _, err := eng.Run(context.Background(), reserve, flow.NewState(id, nil)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fake.reserved) != 1 {
|
||||||
|
t.Fatalf("reserved = %d, want 1", len(fake.reserved))
|
||||||
|
}
|
||||||
|
got := fake.reserved[0]
|
||||||
|
if got.HolderID != "order:"+OrderId(id).String() {
|
||||||
|
t.Fatalf("holderId = %q", got.HolderID)
|
||||||
|
}
|
||||||
|
if got.TTL != 10*time.Minute {
|
||||||
|
t.Fatalf("ttl = %s", got.TTL)
|
||||||
|
}
|
||||||
|
if len(got.Lines) != 1 || got.Lines[0].SKU != "ABC" || got.Lines[0].Quantity != 1 {
|
||||||
|
t.Fatalf("lines = %+v", got.Lines)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsCapturedPredicateGatesStep(t *testing.T) {
|
func TestIsCapturedPredicateGatesStep(t *testing.T) {
|
||||||
@@ -94,3 +189,148 @@ func TestAmqpEmitHookPublishes(t *testing.T) {
|
|||||||
t.Fatalf("expected one message routed to k, got %v", fp.msgs)
|
t.Fatalf("expected one message routed to k, got %v", fp.msgs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeEmailSender struct {
|
||||||
|
defaultFrom mail.Address
|
||||||
|
msgs []EmailMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEmailSender) DefaultFrom() mail.Address { return f.defaultFrom }
|
||||||
|
|
||||||
|
func (f *fakeEmailSender) Send(_ context.Context, msg EmailMessage) error {
|
||||||
|
f.msgs = append(f.msgs, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendEmailHookRendersFulfillmentTemplate(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
reg := flow.NewRegistry()
|
||||||
|
RegisterFlowActions(reg, pool, NewMockProvider())
|
||||||
|
|
||||||
|
sender := &fakeEmailSender{defaultFrom: mail.Address{Name: "Ops", Address: "ops@example.com"}}
|
||||||
|
templates := newEmailTemplateCatalog(map[string]EmailTemplate{
|
||||||
|
"ship": {
|
||||||
|
Subject: "Order {{ .Order.OrderReference }} shipped",
|
||||||
|
Text: "Tracking {{ .Fulfillment.TrackingNumber }} for {{ .Order.CustomerEmail }}",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
RegisterEmailHook(reg, pool, sender, templates)
|
||||||
|
eng := flow.NewEngine(reg, nil)
|
||||||
|
|
||||||
|
const id = 701
|
||||||
|
st := flow.NewState(id, nil)
|
||||||
|
po := placeMsg()
|
||||||
|
po.CustomerEmail = "alice@example.com"
|
||||||
|
po.CustomerName = "Alice"
|
||||||
|
st.Vars[PlaceOrderVar] = po
|
||||||
|
|
||||||
|
def, err := EmbeddedFlow("place-and-pay")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := eng.Run(context.Background(), def, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{
|
||||||
|
Name: "fulfill",
|
||||||
|
Action: "create_fulfillment",
|
||||||
|
Params: json.RawMessage(`{"carrier":"postnord","trackingNumber":"TRACK-123","trackingUri":"https://track.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`),
|
||||||
|
Hooks: flow.Hooks{After: []flow.HookRef{{Type: "send_email", Params: json.RawMessage(`{"template":"ship"}`)}}},
|
||||||
|
}}}
|
||||||
|
if _, err := eng.Run(context.Background(), ship, flow.NewState(id, nil)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sender.msgs) != 1 {
|
||||||
|
t.Fatalf("sent %d emails, want 1", len(sender.msgs))
|
||||||
|
}
|
||||||
|
got := sender.msgs[0]
|
||||||
|
if got.From.Address != "ops@example.com" {
|
||||||
|
t.Fatalf("from = %q, want ops@example.com", got.From.Address)
|
||||||
|
}
|
||||||
|
if len(got.To) != 1 || got.To[0].Address != "alice@example.com" {
|
||||||
|
t.Fatalf("to = %+v, want alice@example.com", got.To)
|
||||||
|
}
|
||||||
|
if got.Subject != "Order ref-1 shipped" {
|
||||||
|
t.Fatalf("subject = %q", got.Subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.TextBody, "TRACK-123") {
|
||||||
|
t.Fatalf("text body = %q, want tracking number", got.TextBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFulfillmentWebhookPostsOrderPayload(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
reg := flow.NewRegistry()
|
||||||
|
RegisterFlowActions(reg, pool, NewMockProvider())
|
||||||
|
|
||||||
|
var gotMethod, gotPath, gotHeader string
|
||||||
|
var gotBody map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotMethod = r.Method
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotHeader = r.Header.Get("X-Integration")
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||||
|
t.Fatalf("decode webhook body: %v", err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
RegisterFulfillmentWebhookHook(reg, pool, srv.Client())
|
||||||
|
eng := flow.NewEngine(reg, nil)
|
||||||
|
|
||||||
|
const id = 702
|
||||||
|
st := flow.NewState(id, nil)
|
||||||
|
po := placeMsg()
|
||||||
|
po.CustomerEmail = "dropship@example.com"
|
||||||
|
st.Vars[PlaceOrderVar] = po
|
||||||
|
|
||||||
|
def, err := EmbeddedFlow("place-and-pay")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := eng.Run(context.Background(), def, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{
|
||||||
|
Name: "fulfill",
|
||||||
|
Action: "create_fulfillment",
|
||||||
|
Params: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"DS-123","lines":[{"reference":"l1","quantity":1}]}`),
|
||||||
|
Hooks: flow.Hooks{After: []flow.HookRef{{
|
||||||
|
Type: "fulfillment_webhook",
|
||||||
|
Params: json.RawMessage(fmt.Sprintf(`{
|
||||||
|
"url": %q,
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"X-Integration": "dropship {{ .Order.OrderReference }}"
|
||||||
|
}
|
||||||
|
}`, srv.URL+`/hooks/{{ .OrderID }}`)),
|
||||||
|
}}},
|
||||||
|
}}}
|
||||||
|
if _, err := eng.Run(context.Background(), ship, flow.NewState(id, nil)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotMethod != http.MethodPost {
|
||||||
|
t.Fatalf("method = %q, want POST", gotMethod)
|
||||||
|
}
|
||||||
|
if gotPath != "/hooks/"+OrderId(id).String() {
|
||||||
|
t.Fatalf("path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if gotHeader != "dropship ref-1" {
|
||||||
|
t.Fatalf("header = %q", gotHeader)
|
||||||
|
}
|
||||||
|
if gotBody["orderId"] != OrderId(id).String() {
|
||||||
|
t.Fatalf("body orderId = %v", gotBody["orderId"])
|
||||||
|
}
|
||||||
|
fulfillment, ok := gotBody["fulfillment"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("body fulfillment missing: %#v", gotBody["fulfillment"])
|
||||||
|
}
|
||||||
|
if fulfillment["trackingNumber"] != "DS-123" {
|
||||||
|
t.Fatalf("trackingNumber = %v", fulfillment["trackingNumber"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,592 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
htmltmpl "html/template"
|
||||||
|
"io/fs"
|
||||||
|
"mime"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
texttmpl "text/template"
|
||||||
|
"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.
|
||||||
|
type EmailMessage struct {
|
||||||
|
From mail.Address
|
||||||
|
To []mail.Address
|
||||||
|
Subject string
|
||||||
|
TextBody string
|
||||||
|
HTMLBody string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailSender delivers an already-rendered mail and exposes its default sender.
|
||||||
|
type EmailSender interface {
|
||||||
|
Send(ctx context.Context, msg EmailMessage) error
|
||||||
|
DefaultFrom() mail.Address
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTPEmailSenderConfig configures the SMTP mail transport.
|
||||||
|
type SMTPEmailSenderConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
DefaultFrom mail.Address
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type smtpEmailSender struct {
|
||||||
|
host string
|
||||||
|
addr string
|
||||||
|
auth smtp.Auth
|
||||||
|
defaultFrom mail.Address
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSMTPEmailSender returns an SMTP-backed sender. It supports plain SMTP with
|
||||||
|
// opportunistic STARTTLS, which is the only configured transport for now.
|
||||||
|
func NewSMTPEmailSender(cfg SMTPEmailSenderConfig) (EmailSender, error) {
|
||||||
|
if strings.TrimSpace(cfg.Host) == "" {
|
||||||
|
return nil, fmt.Errorf("smtp sender: missing host")
|
||||||
|
}
|
||||||
|
if cfg.Port <= 0 {
|
||||||
|
return nil, fmt.Errorf("smtp sender: invalid port %d", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
cfg.Timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.DefaultFrom.Address) == "" {
|
||||||
|
return nil, fmt.Errorf("smtp sender: missing default from address")
|
||||||
|
}
|
||||||
|
s := &smtpEmailSender{
|
||||||
|
host: cfg.Host,
|
||||||
|
addr: net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)),
|
||||||
|
defaultFrom: cfg.DefaultFrom,
|
||||||
|
timeout: cfg.Timeout,
|
||||||
|
}
|
||||||
|
if cfg.Username != "" {
|
||||||
|
s.auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpEmailSender) DefaultFrom() mail.Address { return s.defaultFrom }
|
||||||
|
|
||||||
|
func (s *smtpEmailSender) Send(ctx context.Context, msg EmailMessage) error {
|
||||||
|
if strings.TrimSpace(msg.From.Address) == "" {
|
||||||
|
return fmt.Errorf("smtp sender: missing from address")
|
||||||
|
}
|
||||||
|
if len(msg.To) == 0 {
|
||||||
|
return fmt.Errorf("smtp sender: missing recipients")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(msg.Subject) == "" {
|
||||||
|
return fmt.Errorf("smtp sender: missing subject")
|
||||||
|
}
|
||||||
|
body, err := buildSMTPMessage(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := (&net.Dialer{Timeout: s.timeout}).DialContext(ctx, "tcp", s.addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp sender: dial %s: %w", s.addr, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(s.timeout))
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, s.host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp sender: create client: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||||
|
if err := client.StartTLS(&tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||||
|
return fmt.Errorf("smtp sender: starttls: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.auth != nil {
|
||||||
|
if ok, _ := client.Extension("AUTH"); !ok {
|
||||||
|
return fmt.Errorf("smtp sender: server does not support auth")
|
||||||
|
}
|
||||||
|
if err := client.Auth(s.auth); err != nil {
|
||||||
|
return fmt.Errorf("smtp sender: auth: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.Mail(msg.From.Address); err != nil {
|
||||||
|
return fmt.Errorf("smtp sender: mail from %s: %w", msg.From.Address, err)
|
||||||
|
}
|
||||||
|
for _, rcpt := range msg.To {
|
||||||
|
if err := client.Rcpt(rcpt.Address); err != nil {
|
||||||
|
return fmt.Errorf("smtp sender: rcpt to %s: %w", rcpt.Address, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp sender: data: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(body); err != nil {
|
||||||
|
_ = w.Close()
|
||||||
|
return fmt.Errorf("smtp sender: write body: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("smtp sender: close body: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.Quit(); err != nil {
|
||||||
|
return fmt.Errorf("smtp sender: quit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSMTPMessage(msg EmailMessage) ([]byte, error) {
|
||||||
|
if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" {
|
||||||
|
return nil, fmt.Errorf("email: missing body")
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writeHeader := func(name, value string) {
|
||||||
|
buf.WriteString(name)
|
||||||
|
buf.WriteString(": ")
|
||||||
|
buf.WriteString(value)
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
writeHeader("From", msg.From.String())
|
||||||
|
toList := make([]string, 0, len(msg.To))
|
||||||
|
for _, rcpt := range msg.To {
|
||||||
|
toList = append(toList, rcpt.String())
|
||||||
|
}
|
||||||
|
writeHeader("To", strings.Join(toList, ", "))
|
||||||
|
writeHeader("Subject", mime.QEncoding.Encode("utf-8", msg.Subject))
|
||||||
|
writeHeader("Date", time.Now().Format(time.RFC1123Z))
|
||||||
|
writeHeader("MIME-Version", "1.0")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case msg.TextBody != "" && msg.HTMLBody != "":
|
||||||
|
boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano())
|
||||||
|
writeHeader("Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, boundary))
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
writeMIMEPart(&buf, boundary, "text/plain; charset=utf-8", msg.TextBody)
|
||||||
|
writeMIMEPart(&buf, boundary, "text/html; charset=utf-8", msg.HTMLBody)
|
||||||
|
buf.WriteString("--")
|
||||||
|
buf.WriteString(boundary)
|
||||||
|
buf.WriteString("--\r\n")
|
||||||
|
case msg.HTMLBody != "":
|
||||||
|
writeHeader("Content-Type", `text/html; charset=utf-8`)
|
||||||
|
writeHeader("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
if err := writeQuotedPrintable(&buf, msg.HTMLBody); err != nil {
|
||||||
|
return nil, fmt.Errorf("email: encode html body: %w", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
writeHeader("Content-Type", `text/plain; charset=utf-8`)
|
||||||
|
writeHeader("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
if err := writeQuotedPrintable(&buf, msg.TextBody); err != nil {
|
||||||
|
return nil, fmt.Errorf("email: encode text body: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMIMEPart(buf *bytes.Buffer, boundary, contentType, body string) {
|
||||||
|
buf.WriteString("--")
|
||||||
|
buf.WriteString(boundary)
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
buf.WriteString("Content-Type: ")
|
||||||
|
buf.WriteString(contentType)
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||||
|
_ = writeQuotedPrintable(buf, body)
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeQuotedPrintable(buf *bytes.Buffer, body string) error {
|
||||||
|
w := quotedprintable.NewWriter(buf)
|
||||||
|
if _, err := w.Write([]byte(strings.ReplaceAll(body, "\n", "\r\n"))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailTemplate is a named mail template, typically selected by a flow hook.
|
||||||
|
type EmailTemplate struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
HTML string `json:"html,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.Text) == "" && strings.TrimSpace(t.HTML) == "" {
|
||||||
|
return fmt.Errorf("email template %q: missing text/html body", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailTemplateStore resolves a template by name.
|
||||||
|
type EmailTemplateStore interface {
|
||||||
|
Get(name string) (EmailTemplate, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type emailTemplateCatalog struct {
|
||||||
|
templates map[string]EmailTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEmailTemplateCatalog(seed map[string]EmailTemplate) *emailTemplateCatalog {
|
||||||
|
out := &emailTemplateCatalog{templates: make(map[string]EmailTemplate, len(seed))}
|
||||||
|
for name, tmpl := range seed {
|
||||||
|
out.templates[name] = tmpl
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *emailTemplateCatalog) Get(name string) (EmailTemplate, error) {
|
||||||
|
tmpl, ok := c.templates[name]
|
||||||
|
if !ok {
|
||||||
|
return EmailTemplate{}, fmt.Errorf("unknown template %q", name)
|
||||||
|
}
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed email_templates/*.json
|
||||||
|
var embeddedEmailTemplateFS embed.FS
|
||||||
|
|
||||||
|
// LoadEmailTemplates returns the embedded templates overlaid by any JSON files in
|
||||||
|
// dir (same precedence model as flow definitions: on-disk wins).
|
||||||
|
func LoadEmailTemplates(dir string) (EmailTemplateStore, error) {
|
||||||
|
templates := map[string]EmailTemplate{}
|
||||||
|
|
||||||
|
matches, err := fs.Glob(embeddedEmailTemplateFS, "email_templates/*.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list embedded email templates: %w", err)
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
data, err := embeddedEmailTemplateFS.ReadFile(match)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read embedded email template %s: %w", match, err)
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(filepath.Base(match), ".json")
|
||||||
|
tmpl, err := parseEmailTemplate(name, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templates[name] = tmpl
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir != "" {
|
||||||
|
if _, err := os.Stat(dir); err == nil {
|
||||||
|
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list email templates in %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
data, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read email template %s: %w", file, err)
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(filepath.Base(file), ".json")
|
||||||
|
tmpl, err := parseEmailTemplate(name, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templates[name] = tmpl
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("stat email template dir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEmailTemplateCatalog(templates), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEmailTemplate(name string, data []byte) (EmailTemplate, error) {
|
||||||
|
var tmpl EmailTemplate
|
||||||
|
if err := json.Unmarshal(data, &tmpl); err != nil {
|
||||||
|
return EmailTemplate{}, fmt.Errorf("parse email template %q: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := tmpl.validate(name); err != nil {
|
||||||
|
return EmailTemplate{}, err
|
||||||
|
}
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type emailHookParams struct {
|
||||||
|
Template string `json:"template"`
|
||||||
|
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 {
|
||||||
|
Order *OrderGrain
|
||||||
|
OrderID string
|
||||||
|
Step string
|
||||||
|
Phase flow.Phase
|
||||||
|
Error string
|
||||||
|
Vars map[string]any
|
||||||
|
Fulfillment *Fulfillment
|
||||||
|
ShippingAddress any
|
||||||
|
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. 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>",
|
||||||
|
// "category": "order"
|
||||||
|
// }
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if templates == nil {
|
||||||
|
return fmt.Errorf("send_email: no template store configured")
|
||||||
|
}
|
||||||
|
var p emailHookParams
|
||||||
|
if len(params) > 0 {
|
||||||
|
if err := json.Unmarshal(params, &p); err != nil {
|
||||||
|
return fmt.Errorf("send_email: bad params: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Template == "" {
|
||||||
|
return fmt.Errorf("send_email: missing template")
|
||||||
|
}
|
||||||
|
o, err := app.Get(ctx, st.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpl, err := templates.Get(p.Template)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send_email: %w", err)
|
||||||
|
}
|
||||||
|
data := buildFlowTemplateData(o, st, info)
|
||||||
|
|
||||||
|
subjectSource := tmpl.Subject
|
||||||
|
if p.Subject != "" {
|
||||||
|
subjectSource = p.Subject
|
||||||
|
}
|
||||||
|
subject, err := renderTextTemplate("email-subject", subjectSource, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send_email: render subject: %w", err)
|
||||||
|
}
|
||||||
|
textBody, err := renderOptionalTextTemplate("email-text", tmpl.Text, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send_email: render text body: %w", err)
|
||||||
|
}
|
||||||
|
htmlBody, err := renderOptionalHTMLTemplate("email-html", tmpl.HTML, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send_email: render html body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
toSource := p.To
|
||||||
|
if strings.TrimSpace(toSource) == "" {
|
||||||
|
toSource = o.CustomerEmail
|
||||||
|
}
|
||||||
|
toRendered, err := renderTextTemplate("email-to", toSource, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send_email: render recipient: %w", err)
|
||||||
|
}
|
||||||
|
recipients, err := mail.ParseAddressList(toRendered)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send_email: parse recipient %q: %w", toRendered, err)
|
||||||
|
}
|
||||||
|
to := make([]mail.Address, 0, len(recipients))
|
||||||
|
for _, rcpt := range recipients {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send_email: render sender: %w", err)
|
||||||
|
}
|
||||||
|
parsed, err := mail.ParseAddress(strings.TrimSpace(fromRendered))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send_email: parse sender %q: %w", fromRendered, err)
|
||||||
|
}
|
||||||
|
from = *parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
return sender.Send(ctx, EmailMessage{
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
Subject: subject,
|
||||||
|
TextBody: textBody,
|
||||||
|
HTMLBody: htmlBody,
|
||||||
|
})
|
||||||
|
}, flow.CapabilityMeta{
|
||||||
|
Description: "Render a named email template and send it through the configured sender.",
|
||||||
|
ExampleParams: json.RawMessage(`{"template":"fulfillment-shipped","to":"{{ .Order.CustomerEmail }}","from":"Store <no-reply@example.com>"}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFlowTemplateData(o *OrderGrain, st *flow.State, info flow.HookInfo) flowTemplateData {
|
||||||
|
errStr := ""
|
||||||
|
if info.Err != nil {
|
||||||
|
errStr = info.Err.Error()
|
||||||
|
}
|
||||||
|
var lastFulfillment *Fulfillment
|
||||||
|
if n := len(o.Fulfillments); n > 0 {
|
||||||
|
lastFulfillment = &o.Fulfillments[n-1]
|
||||||
|
}
|
||||||
|
return flowTemplateData{
|
||||||
|
Order: o,
|
||||||
|
OrderID: OrderId(st.ID).String(),
|
||||||
|
Step: info.Step,
|
||||||
|
Phase: info.Phase,
|
||||||
|
Error: errStr,
|
||||||
|
Vars: st.Vars,
|
||||||
|
Fulfillment: lastFulfillment,
|
||||||
|
ShippingAddress: decodeEmailTemplateJSON(o.ShippingAddress),
|
||||||
|
BillingAddress: decodeEmailTemplateJSON(o.BillingAddress),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeEmailTemplateJSON(raw json.RawMessage) any {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(buf.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderOptionalTextTemplate(name, src string, data any) (string, error) {
|
||||||
|
if strings.TrimSpace(src) == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
tmpl, err := htmltmpl.New(name).Option("missingkey=error").Parse(src)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(buf.String()), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"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, url.PathEscape(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": "Your order {{ .Order.OrderReference }} has shipped",
|
||||||
|
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour order {{ .Order.OrderReference }} has shipped.\n{{ with .Fulfillment }}Carrier: {{ .Carrier }}\nTracking number: {{ .TrackingNumber }}\n{{ if .TrackingURI }}Track your shipment: {{ .TrackingURI }}\n{{ end }}{{ end }}\nOrder id: {{ .OrderID }}\n",
|
||||||
|
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your order <strong>{{ .Order.OrderReference }}</strong> has shipped.</p>{{ with .Fulfillment }}<ul><li><strong>Carrier:</strong> {{ .Carrier }}</li><li><strong>Tracking number:</strong> {{ .TrackingNumber }}</li>{{ if .TrackingURI }}<li><a href=\"{{ .TrackingURI }}\">Track your shipment</a></li>{{ end }}</ul>{{ end }}<p>Order id: <code>{{ .OrderID }}</code></p>"
|
||||||
|
}
|
||||||
@@ -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>"
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user