201 lines
6.0 KiB
Go
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
|
|
}
|