add profile actors
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user