package ucp import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "time" "git.k6n.net/mats/go-cart-actor/pkg/profile" messages "git.k6n.net/mats/go-cart-actor/proto/profile" ) // CustomerServer is the UCP REST adapter over the profile grain pool. type CustomerServer struct { applier ProfileApplier } // NewCustomerServer builds a UCP REST adapter over a profile grain pool. func NewCustomerServer(applier ProfileApplier) *CustomerServer { return &CustomerServer{applier: applier} } // --------------------------------------------------------------------------- // UCP Customer endpoints // --------------------------------------------------------------------------- // handleCreateCustomer handles POST /customers — generates a new profile ID // server-side (base62-encoded random 64-bit), applies the profile mutation, // and returns 201 with the generated ID. Unlike PUT /customers/{id}, the // caller does not choose the identifier. func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Request) { var req CustomerUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } rawId, err := profile.NewProfileId() if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate id") return } id := uint64(rawId) msg := &messages.SetProfile{ Name: req.Name, Email: req.Email, Phone: req.Phone, Language: req.Language, Currency: req.Currency, AvatarUrl: req.AvatarUrl, } if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error()) return } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error()) return } writeJSON(w, http.StatusCreated, customerGrainToResponse(rawId.String(), g)) } // handleGetCustomer handles GET /customers/{id} with ETag-based conditional // GET support. When the client sends If-None-Match matching the current ETag, // a 304 Not Modified is returned with no body — the caller reuses their cached // response. The ETag is derived from the grain's lastChange timestamp and // customer data hash, so it changes on any mutation. func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) { id, ok := parseCustomerID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid customer id") return } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "customer not found") return } etag := computeCustomerETag(r.PathValue("id"), g) if match := r.Header.Get("If-None-Match"); match != "" { if match == etag || match == "*" { w.Header().Set("ETag", etag) w.Header().Set("Vary", "Accept-Encoding") w.WriteHeader(http.StatusNotModified) return } } w.Header().Set("ETag", etag) w.Header().Set("Vary", "Accept-Encoding") writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g)) } // handleUpdateCustomer handles PUT /customers/{id}. func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Request) { id, ok := parseCustomerID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid customer id") return } var req CustomerUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } msg := &messages.SetProfile{ Name: req.Name, Email: req.Email, Phone: req.Phone, Language: req.Language, Currency: req.Currency, AvatarUrl: req.AvatarUrl, } if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error()) return } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error()) return } writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g)) } // handleAddAddress handles POST /customers/{id}/addresses. func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) { id, ok := parseCustomerID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid customer id") return } var req AddAddressRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } if req.AddressLine1 == "" || req.City == "" || req.Zip == "" || req.Country == "" { writeError(w, http.StatusBadRequest, "addressLine1, city, zip, and country are required") return } addr := &messages.Address{ Label: req.Label, FullName: req.FullName, AddressLine1: req.AddressLine1, City: req.City, State: req.State, Zip: req.Zip, Country: req.Country, IsDefaultShipping: req.IsDefaultShipping, IsDefaultBilling: req.IsDefaultBilling, } if req.AddressLine2 != "" { addr.AddressLine2 = &req.AddressLine2 } if req.Phone != "" { addr.Phone = &req.Phone } msg := &messages.AddAddress{Address: addr} if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { writeError(w, http.StatusInternalServerError, "failed to add address: "+err.Error()) return } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error()) return } writeJSON(w, http.StatusCreated, customerGrainToResponse(r.PathValue("id"), g)) } // handleUpdateAddress handles PUT /customers/{id}/addresses/{addressId}. func (s *CustomerServer) handleUpdateAddress(w http.ResponseWriter, r *http.Request) { id, ok := parseCustomerID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid customer id") return } addressID, ok := parseAddressID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid address id") return } var req UpdateAddressRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } msg := &messages.UpdateAddress{ Id: addressID, Label: req.Label, FullName: req.FullName, AddressLine1: req.AddressLine1, AddressLine2: req.AddressLine2, City: req.City, State: req.State, Zip: req.Zip, Country: req.Country, Phone: req.Phone, IsDefaultShipping: req.IsDefaultShipping, IsDefaultBilling: req.IsDefaultBilling, } if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { writeError(w, http.StatusInternalServerError, "failed to update address: "+err.Error()) return } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error()) return } writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g)) } // handleRemoveAddress handles DELETE /customers/{id}/addresses/{addressId}. func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Request) { id, ok := parseCustomerID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid customer id") return } addressID, ok := parseAddressID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid address id") return } msg := &messages.RemoveAddress{Id: addressID} if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { // Check if the error is a "not found" error. errStr := err.Error() if containsNotFound(errStr) { writeError(w, http.StatusNotFound, fmt.Sprintf("address %d not found", addressID)) return } writeError(w, http.StatusInternalServerError, "failed to remove address: "+errStr) return } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error()) return } writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g)) } // --------------------------------------------------------------------------- // Identity linking helpers // --------------------------------------------------------------------------- // linkCartToProfile links a cart to the given profile by applying a LinkCart mutation. func linkCartToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, cartID uint64) error { if applier == nil { return nil } _, err := applier.Apply(ctx, profileID, &messages.LinkCart{ CartId: cartID, Label: "current cart", }) return err } // linkCheckoutToProfile links a checkout to the given profile by applying a LinkCheckout mutation. func linkCheckoutToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, checkoutID uint64, cartID uint64) error { if applier == nil { return nil } _, err := applier.Apply(ctx, profileID, &messages.LinkCheckout{ CheckoutId: checkoutID, CartId: cartID, }) return err } // linkOrderToProfile links an order to the given profile by applying a LinkOrder mutation. func linkOrderToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, orderReference string, cartID uint64, status string) error { if applier == nil { return nil } _, err := applier.Apply(ctx, profileID, &messages.LinkOrder{ OrderReference: orderReference, CartId: cartID, Status: status, }) return err } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // parseCustomerID parses the base62 customer id from the path. func parseCustomerID(r *http.Request) (uint64, bool) { raw := r.PathValue("id") if raw == "" { return 0, false } cid, ok := profile.ParseProfileId(raw) if !ok { return 0, false } return uint64(cid), true } // parseAddressID parses the numeric address id from the path. func parseAddressID(r *http.Request) (uint32, bool) { raw := r.PathValue("addressId") if raw == "" { return 0, false } // Address IDs are simple numeric strings. var id uint32 for _, c := range raw { if c < '0' || c > '9' { return 0, false } id = id*10 + uint32(c-'0') } return id, true } // containsNotFound reports whether errStr indicates a "not found" condition. func containsNotFound(errStr string) bool { // Check for common not-found patterns in the error message. for _, pattern := range []string{"not found", "not_found", "notfound"} { if len(errStr) >= len(pattern) { for i := 0; i <= len(errStr)-len(pattern); i++ { match := true for j := 0; j < len(pattern); j++ { c1 := errStr[i+j] c2 := pattern[j] if c1 >= 'A' && c1 <= 'Z' { c1 += 32 } if c1 != c2 { match = false break } } if match { return true } } } } return false } // computeCustomerETag returns a strong ETag for the customer's current state, // derived from the grain's lastChange timestamp and a hash of the ID + change // time. Any mutation (profile update, address change, linking) advances // lastChange, so the ETag changes when data changes. func computeCustomerETag(id string, g *profile.ProfileGrain) string { h := sha256.Sum256([]byte(id + "\x00" + g.GetLastChange().Format(time.RFC3339Nano))) return `"` + hex.EncodeToString(h[:16]) + `"` } // customerGrainToResponse converts a ProfileGrain to a CustomerResponse. func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerResponse { resp := CustomerResponse{ Id: id, Name: g.Name, Email: g.Email, Phone: g.Phone, Language: g.Language, Currency: g.Currency, AvatarUrl: g.AvatarUrl, Addresses: make([]AddressResponse, 0, len(g.Addresses)), } for _, a := range g.Addresses { resp.Addresses = append(resp.Addresses, AddressResponse{ Id: a.Id, Label: a.Label, FullName: a.FullName, AddressLine1: a.AddressLine1, AddressLine2: a.AddressLine2, City: a.City, State: a.State, Zip: a.Zip, Country: a.Country, Phone: a.Phone, IsDefaultShipping: a.IsDefaultShipping, IsDefaultBilling: a.IsDefaultBilling, }) } return resp }