package ucp import ( "context" "encoding/json" "log" "net/http" "sync" "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/profile" "git.k6n.net/mats/go-cart-actor/pkg/actor" messages "git.k6n.net/mats/go-cart-actor/proto/cart" "google.golang.org/protobuf/proto" ) // CartServer is the UCP REST adapter over the cart grain pool. type CartServer struct { applier CartApplier profileApplier ProfileApplier // optional; when set, links carts to customer profiles newCartId func() (cart.CartId, error) } // NewCartServer builds a UCP REST adapter over a cart grain pool. func NewCartServer(applier CartApplier) *CartServer { return &CartServer{ applier: applier, newCartId: cart.NewCartId, } } // SetProfileApplier enables identity linking on this server. func (s *CartServer) SetProfileApplier(pa ProfileApplier) { s.profileApplier = pa } // --------------------------------------------------------------------------- // UCP Cart endpoints // --------------------------------------------------------------------------- // handleCreateCart handles POST /carts. func (s *CartServer) handleCreateCart(w http.ResponseWriter, r *http.Request) { cid, err := s.newCartId() if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate cart id") return } id := uint64(cid) // Seed the cart grain by calling Get once (spawns if new). if _, err := s.applier.Get(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, "failed to create cart: "+err.Error()) return } // If items were supplied, add them. var req CreateCartRequest hasBody := false if r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&req); err == nil { hasBody = true } } if hasBody && len(req.Items) > 0 { groups := buildItemGroups(r.Context(), req.Items, "") if _, err := applyItemGroups(r.Context(), s.applier, id, groups); err != nil { writeError(w, http.StatusInternalServerError, "failed to add items: "+err.Error()) return } } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error()) return } writeJSON(w, http.StatusCreated, cartGrainToResponse(cid.String(), g)) } // handleGetCart handles GET /carts/{id}. func (s *CartServer) handleGetCart(w http.ResponseWriter, r *http.Request) { id, ok := parseCartID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid cart id") return } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "cart not found") return } writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g)) } // handleUpdateCart handles PUT /carts/{id}. func (s *CartServer) handleUpdateCart(w http.ResponseWriter, r *http.Request) { id, ok := parseCartID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid cart id") return } var req UpdateCartRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } // Clear first, then apply. if _, err := s.applier.Apply(r.Context(), id, &messages.ClearCartRequest{}); err != nil { writeError(w, http.StatusInternalServerError, "failed to clear cart: "+err.Error()) return } // Add items. if len(req.Items) > 0 { groups := buildItemGroups(r.Context(), req.Items, req.Country) if _, err := applyItemGroups(r.Context(), s.applier, id, groups); err != nil { writeError(w, http.StatusInternalServerError, "failed to add items: "+err.Error()) return } } // Set user ID and auto-link cart to customer profile. if req.UserId != nil && *req.UserId != "" { if _, err := s.applier.Apply(r.Context(), id, &messages.SetUserId{UserId: *req.UserId}); err != nil { writeError(w, http.StatusInternalServerError, "failed to set user: "+err.Error()) return } // Auto-link this cart to the customer's profile if identity linking is enabled. if s.profileApplier != nil { if pid, ok := profile.ParseProfileId(*req.UserId); ok { if err := linkCartToProfile(s.profileApplier, r.Context(), uint64(pid), id); err != nil { log.Printf("identity: failed to link cart %d to profile %d: %v", id, uint64(pid), err) } } } } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error()) return } writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g)) } // handleCancelCart handles POST /carts/{id}/cancel. func (s *CartServer) handleCancelCart(w http.ResponseWriter, r *http.Request) { id, ok := parseCartID(r) if !ok { writeError(w, http.StatusBadRequest, "invalid cart id") return } if _, err := s.applier.Apply(r.Context(), id, &messages.ClearCartRequest{}); err != nil { writeError(w, http.StatusInternalServerError, "failed to clear cart: "+err.Error()) return } g, err := s.applier.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error()) return } writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g)) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- func parseCartID(r *http.Request) (uint64, bool) { raw := r.PathValue("id") if raw == "" { return 0, false } cid, ok := cart.ParseCartId(raw) if !ok { return 0, false } return uint64(cid), true } func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse { resp := CartResponse{ Id: id, Items: make([]CartItem, 0, len(g.Items)), Totals: Totals{Currency: g.Currency}, Status: "active", AppliedPromotions: g.AppliedPromotions, } if g.CheckoutStatus != nil && *g.CheckoutStatus != "" { resp.Status = string(*g.CheckoutStatus) } for _, it := range g.Items { if it == nil { continue } item := CartItem{ Sku: it.Sku, Quantity: int(it.Quantity), UnitPrice: it.Price.IncVat, TotalPrice: it.TotalPrice.IncVat, TaxRate: it.Tax, ItemId: it.Id, Fields: it.CustomFields, } if it.Meta != nil { item.Name = it.Meta.Name item.Image = it.Meta.Image } resp.Items = append(resp.Items, item) } if g.TotalPrice != nil { resp.Totals.TotalIncVat = g.TotalPrice.IncVat resp.Totals.TotalExVat = g.TotalPrice.ValueExVat() resp.Totals.TotalVat = g.TotalPrice.TotalVat() } if g.TotalDiscount != nil { resp.Totals.Discount = g.TotalDiscount.IncVat } return resp } // --------------------------------------------------------------------------- // Item group building (adapted from cmd/cart/pool-server.go) // --------------------------------------------------------------------------- type itemGroup struct { parent *messages.AddItem children []*messages.AddItem } func buildItemGroups(_ context.Context, items []CartItemInput, _ string) []itemGroup { groups := make([]itemGroup, len(items)) var mu sync.Mutex var wg sync.WaitGroup for i, itm := range items { wg.Add(1) go func(i int, itm CartItemInput) { defer wg.Done() parentMsg := buildAddItem(itm, 0) mu.Lock() groups[i].parent = parentMsg mu.Unlock() if len(itm.Children) > 0 { children := make([]*messages.AddItem, len(itm.Children)) for j, child := range itm.Children { children[j] = buildAddItem(child, parentMsg.ItemId) } mu.Lock() groups[i].children = children mu.Unlock() } }(i, itm) } wg.Wait() return groups } func buildAddItem(input CartItemInput, parentItemId uint32) *messages.AddItem { qty := int32(input.Quantity) if qty <= 0 { qty = 1 } msg := &messages.AddItem{ Sku: input.Sku, Quantity: qty, } if input.StoreId != nil { msg.StoreId = input.StoreId } if input.Sku != "" { // Pass along the item id so findParentLineId can match. msg.ItemId = parentItemId } if len(input.Fields) > 0 { msg.CustomFields = input.Fields } if parentItemId > 0 { msg.ParentId = &parentItemId } return msg } func applyItemGroups(ctx context.Context, applier CartApplier, id uint64, groups []itemGroup) (*actor.MutationResult[cart.CartGrain], error) { var last *actor.MutationResult[cart.CartGrain] for _, g := range groups { if g.parent == nil { continue } res, err := applier.Apply(ctx, id, g.parent) if err != nil { return nil, err } last = res if len(g.children) == 0 { continue } parentLineId, ok := findParentLineId(&res.Result, g.parent) if !ok { continue } childMsgs := make([]proto.Message, len(g.children)) for i, c := range g.children { c.ParentId = &parentLineId childMsgs[i] = c } res, err = applier.Apply(ctx, id, childMsgs...) if err != nil { return nil, err } last = res } return last, nil } func findParentLineId(grain *cart.CartGrain, parent *messages.AddItem) (uint32, bool) { for _, it := range grain.Items { if it == nil || it.ParentId != nil { continue } if it.ItemId != parent.ItemId { continue } sameStore := (it.StoreId == nil && parent.StoreId == nil) || (it.StoreId != nil && parent.StoreId != nil && *it.StoreId == *parent.StoreId) if sameStore { return it.Id, true } } return 0, false }