add profile actors
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s

This commit is contained in:
2026-06-25 17:19:59 +02:00
parent faa5330b81
commit 86797e520e
18 changed files with 3436 additions and 10 deletions
+18
View File
@@ -21,6 +21,7 @@ import (
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-redis-inventory/pkg/inventory"
"git.k6n.net/mats/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go"
@@ -49,6 +50,8 @@ type Config struct {
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
// RedisAddress / RedisPassword reach the inventory store.
RedisAddress string
RedisPassword string
@@ -61,6 +64,7 @@ type App struct {
hub *Hub
rdb *redis.Client
inv *inventory.RedisInventoryService
cs *CustomerServer
}
// New constructs the commerce admin: it opens the Redis inventory service, the
@@ -93,11 +97,21 @@ func New(cfg Config) (*App, error) {
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(),
rdb: rdb,
inv: inventoryService,
cs: customerSrv,
}, nil
}
@@ -114,6 +128,10 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
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)
}
}
func (a *App) updateInventory(w http.ResponseWriter, r *http.Request) {
+195
View File
@@ -0,0 +1,195 @@
package backofficeadmin
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
)
// CustomerFileInfo is the metadata returned by the customer listing endpoint.
type CustomerFileInfo struct {
ID string `json:"id"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// CustomerServer serves backoffice admin endpoints for customer profiles.
type CustomerServer struct {
dataDir string
storage actor.LogStorage[profile.ProfileGrain]
}
// NewCustomerServer builds a CustomerServer that lists and reads profile event logs.
func NewCustomerServer(dataDir string, storage actor.LogStorage[profile.ProfileGrain]) *CustomerServer {
return &CustomerServer{
dataDir: dataDir,
storage: storage,
}
}
// RegisterRoutes mounts the customer admin endpoints on the given mux.
func (s *CustomerServer) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /customers", s.handleListCustomers)
mux.HandleFunc("GET /customer/{id}", s.handleGetCustomer)
}
// CustomerInfoResponse is the per-customer info for the listing endpoint.
type CustomerInfoResponse struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Size int64 `json:"size"`
Modified string `json:"modified"`
}
// CustomerDetailResponse is the full customer detail including linked carts/checkouts/orders/addresses.
type CustomerDetailResponse struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
Language string `json:"language,omitempty"`
Currency string `json:"currency,omitempty"`
AvatarUrl string `json:"avatarUrl,omitempty"`
Carts []profile.LinkedCart `json:"carts,omitempty"`
Checkouts []profile.LinkedCheckout `json:"checkouts,omitempty"`
Orders []profile.LinkedOrder `json:"orders,omitempty"`
Addresses []profile.StoredAddress `json:"addresses,omitempty"`
Size int64 `json:"size"`
Modified string `json:"modified"`
}
// handleListCustomers lists all customer profiles.
func (s *CustomerServer) handleListCustomers(w http.ResponseWriter, r *http.Request) {
list, err := s.listProfileFiles()
if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"count": len(list),
"customers": list,
})
}
// handleGetCustomer returns full detail for a single customer profile.
func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
if idStr == "" {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
return
}
id, ok := isValidProfileId(idStr)
if !ok {
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
return
}
// Reconstruct profile from event log.
grain := profile.NewProfileGrain(id, time.Now())
if err := s.storage.LoadEvents(r.Context(), id, grain); err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
path := filepath.Join(s.dataDir, fmt.Sprintf("%d.events.log", id))
info, err := os.Stat(path)
if err != nil && errors.Is(err, os.ErrNotExist) {
writeJSON(w, http.StatusNotFound, JsonError{Error: "customer not found"})
return
} else if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
}
resp := CustomerDetailResponse{
ID: idStr,
Name: grain.Name,
Email: grain.Email,
Phone: grain.Phone,
Language: grain.Language,
Currency: grain.Currency,
AvatarUrl: grain.AvatarUrl,
Carts: grain.Carts,
Checkouts: grain.Checkouts,
Orders: grain.Orders,
Addresses: grain.Addresses,
Size: info.Size(),
Modified: info.ModTime().Format(time.RFC3339),
}
writeJSON(w, http.StatusOK, map[string]any{
"customer": resp,
})
}
// isValidProfileId tries to parse a numeric or base62-encoded profile id.
func isValidProfileId(id string) (uint64, bool) {
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
return nr, true
}
if nr, err := strconv.ParseUint(id, 36, 64); err == nil {
return nr, true
}
return 0, false
}
// listProfileFiles lists all profile event log files in the data directory.
func (s *CustomerServer) listProfileFiles() ([]CustomerInfoResponse, error) {
entries, err := os.ReadDir(s.dataDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []CustomerInfoResponse{}, nil
}
return nil, err
}
out := make([]CustomerInfoResponse, 0)
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
parts := strings.Split(name, ".")
if len(parts) < 2 || parts[1] != "events" {
continue
}
id, err := strconv.ParseUint(parts[0], 10, 64)
if err != nil {
continue
}
info, err := e.Info()
if err != nil {
continue
}
// Quick peek at the profile for email/name by loading the grain
grain := profile.NewProfileGrain(id, info.ModTime())
_ = s.storage.LoadEvents(context.Background(), id, grain)
out = append(out, CustomerInfoResponse{
ID: fmt.Sprintf("%d", id),
Email: grain.Email,
Name: grain.Name,
Size: info.Size(),
Modified: info.ModTime().Format(time.RFC3339),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].Modified > out[j].Modified
})
return out, nil
}