339 lines
9.1 KiB
Go
339 lines
9.1 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",
|
|
}
|
|
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
|
|
}
|