// 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) } }) }