Files
go-cart-actor/pkg/backofficeadmin/customer.go
T
mats c0c5a8bc0f
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 2s
update customer
2026-06-25 21:09:58 +02:00

201 lines
6.0 KiB
Go

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 parses a profile ID string that is either a bare decimal
// number (legacy backoffice IDs) or a base62-encoded identifier (generated by
// the UCP profile service via profile.NewProfileId). It uses profile.ParseProfileId
// for the base62 case so both formats are handled consistently.
func isValidProfileId(id string) (uint64, bool) {
// Bare decimal (legacy backoffice listing returns numeric IDs).
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
return nr, true
}
// Base62 — the format used by the UCP profile service (cmd/profile).
if pid, ok := profile.ParseProfileId(id); ok {
return uint64(pid), 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
}