Files
go-cart-actor/internal/ucp/cart.go
T
mats 26fd6dc970
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
more cart
2026-07-01 22:11:37 +02:00

340 lines
9.2 KiB
Go

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
}