Files
go-cart-actor/pkg/backofficeadmin/app.go
T
mats 26fd6dc970
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
more cart
2026-07-01 22:11:37 +02:00

233 lines
8.2 KiB
Go

// Package backofficeadmin is the commerce control-plane admin surface for the
// cart-actor system: read-only cart/checkout history (replayed from event logs),
// inventory CRUD (Redis), promotions/vouchers JSON, and a WebSocket feed of cart
// mutations consumed from RabbitMQ.
//
// It is extracted from the former cmd/backoffice main package so the same admin
// surface can run standalone (cmd/backoffice) or be mounted into the all-in-one
// backoffice host. Because it imports the cart/checkout domain (and the private
// git.k6n.net module), the host only compiles it into its implementation-specific
// (commerce) build variant.
package backofficeadmin
import (
"context"
"log"
"net/http"
"os"
"strings"
"time"
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/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
"git.k6n.net/mats/platform/rabbit"
amqp "github.com/rabbitmq/amqp091-go"
)
// CartFileInfo is the metadata returned by the cart listing endpoint.
type CartFileInfo struct {
ID string `json:"id"`
CartId cart.CartId `json:"cartId"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// CheckoutFileInfo is the metadata returned by the checkout listing endpoint.
type CheckoutFileInfo struct {
ID string `json:"id"`
CheckoutId checkout.CheckoutId `json:"checkoutId"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// Config parameterizes the commerce admin App.
type Config struct {
// DataDir holds the cart event logs (default "data").
DataDir string
// CheckoutDataDir holds the checkout event logs (default "checkout-data").
CheckoutDataDir string
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
ProfileDataDir string
}
// App is the commerce admin control plane. Construct with New, register its
// HTTP surface with RegisterRoutes, run background work with Start.
type App struct {
fs *FileServer
hub *Hub
cs *CustomerServer
}
// New constructs the commerce admin: it opens the cart/checkout disk event-log
// stores, the file server, and the WebSocket hub. It does not register routes
// (RegisterRoutes), start background work (Start), 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) {
if cfg.DataDir == "" {
cfg.DataDir = "data"
}
if cfg.CheckoutDataDir == "" {
cfg.CheckoutDataDir = "checkout-data"
}
_ = os.MkdirAll(cfg.DataDir, 0755)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
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)
_ = os.MkdirAll(cfg.CheckoutDataDir, 0755)
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](cfg.CheckoutDataDir, regCheckout)
// Optional customer/profile storage.
var customerSrv *CustomerServer
if cfg.ProfileDataDir != "" {
_ = os.MkdirAll(cfg.ProfileDataDir, 0755)
regProfile := profile.NewProfileMutationRegistry()
profileStorage := actor.NewDiskStorage[profile.ProfileGrain](cfg.ProfileDataDir, regProfile)
customerSrv = NewCustomerServer(cfg.ProfileDataDir, profileStorage)
}
return &App{
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout),
hub: NewHub(),
cs: customerSrv,
}, nil
}
// RegisterRoutes registers the commerce admin HTTP surface on mux. It does not
// apply auth or CORS — the composing host (or standalone main) wraps the mux
// with those.
func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /carts", a.fs.CartsHandler)
mux.HandleFunc("GET /cart/{id}", a.fs.CartHandler)
mux.HandleFunc("GET /checkouts", a.fs.CheckoutsHandler)
mux.HandleFunc("GET /checkout/{id}", a.fs.CheckoutHandler)
mux.HandleFunc("/promotions", a.fs.PromotionsHandler)
mux.HandleFunc("/vouchers", a.fs.VoucherHandler)
mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler)
mux.HandleFunc("/ws", a.hub.ServeWS)
// Optional customer endpoints (only mounted when ProfileDataDir is configured).
if a.cs != nil {
a.cs.RegisterRoutes(mux)
}
}
// Start runs the WebSocket hub loop and, when conn is non-nil, the RabbitMQ
// mutation consumer that broadcasts cart mutations to connected clients. The
// consumer re-subscribes automatically on reconnect (conn is a managed
// reconnecting connection). Background goroutines stop when ctx is cancelled.
func (a *App) Start(ctx context.Context, conn *rabbit.Conn) error {
go a.hub.Run()
if conn != nil {
startMutationConsumer(ctx, conn, a.hub)
}
return nil
}
// Shutdown releases the App's resources. The background goroutines are stopped
// by cancelling the ctx passed to Start.
func (a *App) Shutdown(_ context.Context) error {
return nil
}
// startMutationConsumer subscribes to the cart "mutation" topic and forwards
// each message to the hub for broadcast over WebSocket. It (re)subscribes on the
// initial connect and on every reconnect — via conn.NotifyOnReconnect — so a
// broker blip doesn't silently kill the feed. Best-effort: a full hub queue
// drops the message rather than blocking.
func startMutationConsumer(ctx context.Context, conn *rabbit.Conn, hub *Hub) {
conn.NotifyOnReconnect(func() {
ch, err := conn.Channel()
if err != nil {
log.Printf("mutation consumer: open channel: %v", err)
return
}
// Subscribe to every grain's mutation stream (mutation.#) on the shared
// "mutations" exchange. Bodies are platform/event.Event envelopes
// (Source=grain, Subject=id, Payload=mutation summary) — forwarded raw to
// WebSocket clients, which is exactly what a debug feed wants.
if err := rabbit.ListenToPattern(ch, actor.MutationExchange, "mutation.#", func(d amqp.Delivery) error {
if hub != nil {
select {
case hub.broadcast <- d.Body:
default:
// hub queue full: drop to avoid blocking
}
}
return nil
}); err != nil {
log.Printf("mutation consumer: subscribe: %v", err)
}
})
}