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 }