From 86797e520e2bce91431b7bd232c87eae0c585f84 Mon Sep 17 00:00:00 2001 From: matst80 Date: Thu, 25 Jun 2026 17:19:59 +0200 Subject: [PATCH] add profile actors --- cmd/cart/main.go | 33 + internal/ucp/cart.go | 23 +- internal/ucp/checkout.go | 30 +- internal/ucp/customer.go | 351 ++++++++++ internal/ucp/customer_test.go | 498 +++++++++++++ internal/ucp/handler.go | 42 ++ internal/ucp/types.go | 91 ++- pkg/backofficeadmin/app.go | 18 + pkg/backofficeadmin/customer.go | 195 ++++++ pkg/profile/id.go | 70 ++ pkg/profile/mutation-context.go | 21 + pkg/profile/mutation_address.go | 130 ++++ pkg/profile/mutation_links.go | 73 ++ pkg/profile/mutation_set_profile.go | 33 + pkg/profile/profile-grain.go | 108 +++ pkg/profile/profile_test.go | 633 +++++++++++++++++ proto/profile.proto | 90 +++ proto/profile/profile.pb.go | 1007 +++++++++++++++++++++++++++ 18 files changed, 3436 insertions(+), 10 deletions(-) create mode 100644 internal/ucp/customer.go create mode 100644 internal/ucp/customer_test.go create mode 100644 pkg/backofficeadmin/customer.go create mode 100644 pkg/profile/id.go create mode 100644 pkg/profile/mutation-context.go create mode 100644 pkg/profile/mutation_address.go create mode 100644 pkg/profile/mutation_links.go create mode 100644 pkg/profile/mutation_set_profile.go create mode 100644 pkg/profile/profile-grain.go create mode 100644 pkg/profile/profile_test.go create mode 100644 proto/profile.proto create mode 100644 proto/profile/profile.pb.go diff --git a/cmd/cart/main.go b/cmd/cart/main.go index da3dc47..38cb182 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -18,6 +18,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/promotions" promotionmcp "git.k6n.net/mats/go-cart-actor/pkg/promotions/mcp" "git.k6n.net/mats/go-cart-actor/internal/ucp" + "git.k6n.net/mats/go-cart-actor/pkg/profile" "git.k6n.net/mats/go-cart-actor/pkg/proxy" "git.k6n.net/mats/go-cart-actor/pkg/voucher" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" @@ -296,6 +297,38 @@ func main() { syncedServer.Serve(mux) + // ── Profile / Customer UCP endpoint ─────────────────────────────────── + profileReg := profile.NewProfileMutationRegistry() + profileDir := getEnv("PROFILE_DIR", "data/profiles") + _ = os.MkdirAll(profileDir, 0755) + profileStorage := actor.NewDiskStorage[profile.ProfileGrain](profileDir, profileReg) + + profilePoolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{ + MutationRegistry: profileReg, + Storage: profileStorage, + Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) { + ret := profile.NewProfileGrain(id, time.Now()) + err := profileStorage.LoadEvents(ctx, id, ret) + return ret, err + }, + TTL: 10 * time.Minute, + PoolSize: 65535, + Hostname: podIp, + } + + profilePool, poolErr := actor.NewSimpleGrainPool(profilePoolConfig) + if poolErr != nil { + log.Fatalf("Error creating profile pool: %v\n", poolErr) + } + + // UCP Customer REST adapter + customerUCP := ucp.CustomerHandler(profilePool) + if signer := loadUCPCartSigner(); signer != nil { + customerUCP = ucp.WithSigning(customerUCP, signer) + log.Print("ucp customer signing enabled") + } + mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP)) + // Promotion MCP edge: list/get/upsert/status/delete promotions and preview // discounts. Mutations persist to data/promotions.json and are picked up by // the cart's totals processor on the next mutation (it reads a fresh snapshot). diff --git a/internal/ucp/cart.go b/internal/ucp/cart.go index 002db7a..c5a1c8d 100644 --- a/internal/ucp/cart.go +++ b/internal/ucp/cart.go @@ -3,10 +3,12 @@ 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" @@ -14,8 +16,9 @@ import ( // CartServer is the UCP REST adapter over the cart grain pool. type CartServer struct { - applier CartApplier - newCartId func() (cart.CartId, error) + 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. @@ -26,6 +29,11 @@ func NewCartServer(applier CartApplier) *CartServer { } } +// SetProfileApplier enables identity linking on this server. +func (s *CartServer) SetProfileApplier(pa ProfileApplier) { + s.profileApplier = pa +} + // --------------------------------------------------------------------------- // UCP Cart endpoints // --------------------------------------------------------------------------- @@ -113,12 +121,21 @@ func (s *CartServer) handleUpdateCart(w http.ResponseWriter, r *http.Request) { } } - // Set user ID. + // 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) diff --git a/internal/ucp/checkout.go b/internal/ucp/checkout.go index 2e2006b..1a673af 100644 --- a/internal/ucp/checkout.go +++ b/internal/ucp/checkout.go @@ -6,12 +6,14 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" "time" "git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/mats/go-cart-actor/pkg/cart" + profilePkg "git.k6n.net/mats/go-cart-actor/pkg/profile" checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/timestamppb" @@ -19,8 +21,9 @@ import ( // CheckoutServer is the UCP REST adapter over the checkout grain pool. type CheckoutServer struct { - applier CheckoutApplier - orderSvc OrderApplier // optional; when set creates real orders on complete + applier CheckoutApplier + profileApplier ProfileApplier // optional; when set links checkouts/orders to profiles + orderSvc OrderApplier // optional; when set creates real orders on complete } // NewCheckoutServer builds a UCP REST adapter over a checkout grain pool. @@ -32,6 +35,11 @@ func NewCheckoutServer(applier CheckoutApplier, orderSvc ...OrderApplier) *Check return s } +// SetProfileApplier enables identity linking on this server. +func (s *CheckoutServer) SetProfileApplier(pa ProfileApplier) { + s.profileApplier = pa +} + // --------------------------------------------------------------------------- // UCP Checkout endpoints // --------------------------------------------------------------------------- @@ -180,6 +188,15 @@ func (s *CheckoutServer) handleCreateCheckout(w http.ResponseWriter, r *http.Req return } + // Link checkout to customer profile if identity linking is enabled. + if s.profileApplier != nil && req.CustomerId != "" { + if pid, ok := profilePkg.ParseProfileId(req.CustomerId); ok { + if err := linkCheckoutToProfile(s.profileApplier, r.Context(), uint64(pid), uint64(checkoutId), uint64(cartId)); err != nil { + log.Printf("identity: failed to link checkout %d to profile %s: %v", uint64(checkoutId), req.CustomerId, err) + } + } + } + writeJSON(w, http.StatusCreated, checkoutGrainToResponse(checkoutId.String(), g)) } @@ -336,6 +353,15 @@ func (s *CheckoutServer) handleCompleteCheckout(w http.ResponseWriter, r *http.R return } + // Auto-link order to customer profile if identity linking is enabled. + if s.profileApplier != nil && orderID != "" && req.CustomerId != "" { + if pid, ok := profilePkg.ParseProfileId(req.CustomerId); ok { + if err := linkOrderToProfile(s.profileApplier, r.Context(), uint64(pid), orderID, uint64(g.CartId), "placed"); err != nil { + log.Printf("identity: failed to link order %s to profile %s: %v", orderID, req.CustomerId, err) + } + } + } + writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g)) } diff --git a/internal/ucp/customer.go b/internal/ucp/customer.go new file mode 100644 index 0000000..b82ab01 --- /dev/null +++ b/internal/ucp/customer.go @@ -0,0 +1,351 @@ +package ucp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "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 +// --------------------------------------------------------------------------- + +// handleGetCustomer handles GET /customers/{id}. +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 + } + + 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 +} + +// 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 +} diff --git a/internal/ucp/customer_test.go b/internal/ucp/customer_test.go new file mode 100644 index 0000000..4a19817 --- /dev/null +++ b/internal/ucp/customer_test.go @@ -0,0 +1,498 @@ +package ucp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/cart" + "git.k6n.net/mats/go-cart-actor/pkg/profile" + messages "git.k6n.net/mats/go-cart-actor/proto/profile" + "google.golang.org/protobuf/proto" +) + +// testProfileApplier implements ProfileApplier with in-memory grains. +type testProfileApplier struct { + grains map[uint64]*profile.ProfileGrain +} + +func newTestProfileApplier() *testProfileApplier { + return &testProfileApplier{ + grains: make(map[uint64]*profile.ProfileGrain), + } +} + +func (a *testProfileApplier) Get(_ context.Context, id uint64) (*profile.ProfileGrain, error) { + g, ok := a.grains[id] + if !ok { + g = profile.NewProfileGrain(id, time.UnixMilli(1000000)) + a.grains[id] = g + } + return g, nil +} + +func (a *testProfileApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) { + g, err := a.Get(ctx, id) + if err != nil { + return nil, err + } + for _, msg := range msgs { + switch m := msg.(type) { + case *messages.SetProfile: + if m.Name != nil { + g.Name = *m.Name + } + if m.Email != nil { + g.Email = *m.Email + } + if m.Phone != nil { + g.Phone = *m.Phone + } + if m.Language != nil { + g.Language = *m.Language + } + if m.Currency != nil { + g.Currency = *m.Currency + } + if m.AvatarUrl != nil { + g.AvatarUrl = *m.AvatarUrl + } + case *messages.AddAddress: + if m.Address == nil { + continue + } + g.Addresses = append(g.Addresses, profile.StoredAddress{ + Id: g.NextAddrId(), + Label: m.Address.Label, + FullName: m.Address.FullName, + AddressLine1: m.Address.AddressLine1, + AddressLine2: m.Address.GetAddressLine2(), + City: m.Address.City, + State: m.Address.State, + Zip: m.Address.Zip, + Country: m.Address.Country, + Phone: m.Address.GetPhone(), + IsDefaultShipping: m.Address.IsDefaultShipping, + IsDefaultBilling: m.Address.IsDefaultBilling, + }) + case *messages.UpdateAddress: + for i := range g.Addresses { + if g.Addresses[i].Id == m.Id { + if m.Label != nil { + g.Addresses[i].Label = *m.Label + } + if m.AddressLine1 != nil { + g.Addresses[i].AddressLine1 = *m.AddressLine1 + } + } + } + case *messages.RemoveAddress: + for i := range g.Addresses { + if g.Addresses[i].Id == m.Id { + g.Addresses = append(g.Addresses[:i], g.Addresses[i+1:]...) + break + } + } + case *messages.LinkCart: + g.Carts = append(g.Carts, profile.LinkedCart{CartId: m.CartId, Label: m.Label}) + case *messages.LinkCheckout: + g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId}) + case *messages.LinkOrder: + g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status}) + } + } + return &actor.MutationResult[profile.ProfileGrain]{ + Result: *g, + }, nil +} + +// testCartApplier is a minimal CartApplier mock for combined handler tests. +type testCartApplier struct{} + +func (a *testCartApplier) Get(ctx context.Context, id uint64) (*cart.CartGrain, error) { + return nil, fmt.Errorf("not implemented") +} + +func (a *testCartApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) { + return nil, fmt.Errorf("not implemented") +} + +// Helper to get a test profile ID as a base62 string. +func testProfileID(t *testing.T, applier *testProfileApplier) string { + t.Helper() + pid, err := profile.NewProfileId() + if err != nil { + t.Fatalf("failed to generate profile id: %v", err) + } + // Seed the grain. + applier.Get(context.Background(), uint64(pid)) + return pid.String() +} + +func TestGetCustomer(t *testing.T) { + applier := newTestProfileApplier() + id := testProfileID(t, applier) + handler := CustomerHandler(applier) + + // Set up some profile data + pid, _ := profile.ParseProfileId(id) + applier.Apply(context.Background(), uint64(pid), &messages.SetProfile{ + Name: proto.String("Alice"), + Email: proto.String("alice@example.com"), + }) + + // GET /customers/{id} + req := httptest.NewRequest("GET", fmt.Sprintf("/%s", id), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String()) + } + + var resp CustomerResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Id != id { + t.Fatalf("expected id %q, got %q", id, resp.Id) + } + if resp.Name != "Alice" { + t.Fatalf("expected Name 'Alice', got %q", resp.Name) + } + if resp.Email != "alice@example.com" { + t.Fatalf("expected Email 'alice@example.com', got %q", resp.Email) + } +} + +func TestGetCustomerNotFound(t *testing.T) { + applier := newTestProfileApplier() + handler := CustomerHandler(applier) + + // "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet. + req := httptest.NewRequest("GET", "/!invalid!", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 Bad Request for invalid id, got %d. Body: %s", rec.Code, rec.Body.String()) + } +} + +func TestUpdateCustomer(t *testing.T) { + applier := newTestProfileApplier() + id := testProfileID(t, applier) + handler := CustomerHandler(applier) + + body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}` + req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String()) + } + + var resp CustomerResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Name != "Bob" { + t.Fatalf("expected Name 'Bob', got %q", resp.Name) + } + if resp.Email != "bob@example.com" { + t.Fatalf("expected Email 'bob@example.com', got %q", resp.Email) + } + if resp.Phone != "+46701234567" { + t.Fatalf("expected Phone '+46701234567', got %q", resp.Phone) + } + if resp.Language != "sv" { + t.Fatalf("expected Language 'sv', got %q", resp.Language) + } + if resp.Currency != "SEK" { + t.Fatalf("expected Currency 'SEK', got %q", resp.Currency) + } +} + +func TestAddAddress(t *testing.T) { + applier := newTestProfileApplier() + id := testProfileID(t, applier) + handler := CustomerHandler(applier) + + body := `{ + "label": "Home", + "fullName": "Alice", + "addressLine1": "Storgatan 1", + "addressLine2": "Lgh 101", + "city": "Stockholm", + "zip": "111 22", + "country": "SE", + "phone": "+46701234567" + }` + req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String()) + } + + var resp CustomerResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(resp.Addresses) != 1 { + t.Fatalf("expected 1 address, got %d", len(resp.Addresses)) + } + if resp.Addresses[0].Label != "Home" { + t.Fatalf("expected address label 'Home', got %q", resp.Addresses[0].Label) + } + if resp.Addresses[0].AddressLine1 != "Storgatan 1" { + t.Fatalf("expected addressLine1 'Storgatan 1', got %q", resp.Addresses[0].AddressLine1) + } + if resp.Addresses[0].AddressLine2 != "Lgh 101" { + t.Fatalf("expected addressLine2 'Lgh 101', got %q", resp.Addresses[0].AddressLine2) + } +} + +func TestAddAddressMissingRequired(t *testing.T) { + applier := newTestProfileApplier() + id := testProfileID(t, applier) + handler := CustomerHandler(applier) + + // Missing addressLine1 + body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}` + req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 Bad Request for missing required fields, got %d. Body: %s", rec.Code, rec.Body.String()) + } +} + +func TestUpdateAddress(t *testing.T) { + applier := newTestProfileApplier() + id := testProfileID(t, applier) + handler := CustomerHandler(applier) + + // First add an address + pid, _ := profile.ParseProfileId(id) + applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{ + Address: &messages.Address{ + Label: "Old Home", + FullName: "Alice", + AddressLine1: "Old Street 1", + City: "Old City", + Zip: "000 00", + Country: "SE", + }, + }) + + // Get the address id from the grain + grain, _ := applier.Get(context.Background(), uint64(pid)) + if len(grain.Addresses) == 0 { + t.Fatal("expected at least one address") + } + addrID := grain.Addresses[0].Id + + body := fmt.Sprintf(`{"label": "New Home", "addressLine1": "New Street 2"}`) + req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/%d", id, addrID), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String()) + } + + var resp CustomerResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(resp.Addresses) != 1 { + t.Fatalf("expected 1 address, got %d", len(resp.Addresses)) + } + if resp.Addresses[0].Label != "New Home" { + t.Fatalf("expected label 'New Home', got %q", resp.Addresses[0].Label) + } + if resp.Addresses[0].AddressLine1 != "New Street 2" { + t.Fatalf("expected addressLine1 'New Street 2', got %q", resp.Addresses[0].AddressLine1) + } + // Unchanged fields should still be there + if resp.Addresses[0].City != "Old City" { + t.Fatalf("expected City 'Old City', got %q", resp.Addresses[0].City) + } +} + +func TestUpdateAddressInvalidID(t *testing.T) { + applier := newTestProfileApplier() + id := testProfileID(t, applier) + handler := CustomerHandler(applier) + + body := `{"label": "New Home"}` + req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 Bad Request for invalid address id, got %d", rec.Code) + } +} + +func TestRemoveAddress(t *testing.T) { + applier := newTestProfileApplier() + id := testProfileID(t, applier) + handler := CustomerHandler(applier) + + // First add an address + pid, _ := profile.ParseProfileId(id) + applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{ + Address: &messages.Address{ + Label: "Remove Me", + FullName: "Alice", + AddressLine1: "Storgatan 1", + City: "Stockholm", + Zip: "111 22", + Country: "SE", + }, + }) + + grain, _ := applier.Get(context.Background(), uint64(pid)) + if len(grain.Addresses) == 0 { + t.Fatal("expected at least one address") + } + addrID := grain.Addresses[0].Id + + // DELETE /customers/{id}/addresses/{addressId} + req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/%d", id, addrID), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String()) + } + + grain, _ = applier.Get(context.Background(), uint64(pid)) + if len(grain.Addresses) != 0 { + t.Fatalf("expected 0 addresses after removal, got %d", len(grain.Addresses)) + } +} + +func TestRemoveAddressNotFound(t *testing.T) { + applier := newTestProfileApplier() + pid, err := profile.NewProfileId() + if err != nil { + t.Fatalf("failed to generate profile id: %v", err) + } + applier.Get(context.Background(), uint64(pid)) + + id := pid.String() + handler := CustomerHandler(applier) + req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + // Test applier silently ignores not-found removals (returns no error). + // In production, HandleRemoveAddress returns an error which maps to 404. + // Accept 200 here since the test applier is a simplified mock. + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 (test applier limitation), got %d. Body: %s", rec.Code, rec.Body.String()) + } +} + +// TestRemoveAddressNotFoundRealistic tests the real mutation handler path via the registry. +// It uses the real HandleRemoveAddress mutation to verify 404 behavior. +func TestRemoveAddressNotFoundMutation(t *testing.T) { + // Test the real HandleRemoveAddress mutation directly. + g := profile.NewProfileGrain(1, time.Now()) + + // Add an address via the real handler + if err := profile.HandleAddAddress(g, &messages.AddAddress{ + Address: &messages.Address{ + Label: "Test", + FullName: "Alice", + AddressLine1: "Storgatan 1", + City: "Stockholm", + Zip: "111 22", + Country: "SE", + }, + }); err != nil { + t.Fatalf("HandleAddAddress failed: %v", err) + } + + // Try to remove address 999 (doesn't exist) + err := profile.HandleRemoveAddress(g, &messages.RemoveAddress{Id: 999}) + if err == nil { + t.Fatal("expected error for non-existent address, got nil") + } + expected := "RemoveAddress: address 999 not found" + if err.Error() != expected { + t.Fatalf("expected error %q, got %q", expected, err.Error()) + } +} + +func TestCustomerHandlerInvalidID(t *testing.T) { + applier := newTestProfileApplier() + handler := CustomerHandler(applier) + + // Path "/" doesn't match the /{id} pattern — falls through to 404. + // Paths with invalid base62 characters should get 400. + tests := []struct { + path string + expectedStatus int + }{ + {"/", http.StatusNotFound}, // doesn't match /{id} + {"/!invalid!", http.StatusBadRequest}, // has non-base62 chars + } + for _, tt := range tests { + req := httptest.NewRequest("GET", tt.path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != tt.expectedStatus { + t.Fatalf("for path %q: expected %d, got %d. Body: %s", tt.path, tt.expectedStatus, rec.Code, rec.Body.String()) + } + } +} + +func TestCustomerHandlerThroughCombinedMount(t *testing.T) { + // Test that the combined Handler correctly mounts customer endpoints. + // Use a mock CartApplier (bare minimum — never called in this test). + mockCart := &testCartApplier{} + profileApplier := newTestProfileApplier() + + pid, _ := profile.NewProfileId() + profileApplier.Get(context.Background(), uint64(pid)) + + handler := Handler(mockCart, Options{ + ProfileApplier: profileApplier, + }) + + // GET /customers/{id} should work + id := pid.String() + req := httptest.NewRequest("GET", fmt.Sprintf("/customers/%s", id), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String()) + } + + var resp CustomerResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Id != id { + t.Fatalf("expected id %q, got %q", id, resp.Id) + } +} diff --git a/internal/ucp/handler.go b/internal/ucp/handler.go index 75fc357..90a72ff 100644 --- a/internal/ucp/handler.go +++ b/internal/ucp/handler.go @@ -73,6 +73,30 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han return withUCPAgent(withJSONContentType(mux)) } +// --------------------------------------------------------------------------- +// Customer HTTP handler mounting +// --------------------------------------------------------------------------- + +// CustomerHandler returns an http.Handler that routes UCP customer/profile +// REST endpoints. Mount it under any prefix (e.g. /ucp/v1) in a ServeMux: +// +// mux.Handle("/ucp/v1/customers", ucp.CustomerHandler(pool)) +// mux.Handle("/ucp/v1/customers/", ucp.CustomerHandler(pool)) +// +// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning: +// +// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer)) +func CustomerHandler(applier ProfileApplier) http.Handler { + s := NewCustomerServer(applier) + mux := http.NewServeMux() + mux.HandleFunc("GET /{id}", s.handleGetCustomer) + mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer) + mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress) + mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress) + mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress) + return withUCPAgent(withJSONContentType(mux)) +} + // --------------------------------------------------------------------------- // Combined handler for mounting all UCP endpoints under one prefix // --------------------------------------------------------------------------- @@ -81,6 +105,7 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han type Options struct { CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions OrderApplier OrderApplier // optional order creation for complete endpoint + ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking } // Handler returns an http.Handler that serves all mounted UCP endpoints under @@ -88,12 +113,16 @@ type Options struct { // // mux.Handle("/ucp/v1/", ucp.Handler(pool, ucp.Options{ // CheckoutApplier: checkoutPool, +// ProfileApplier: profilePool, // })) func Handler(cartApplier CartApplier, opts Options) http.Handler { mux := http.NewServeMux() // Cart endpoints cartSrv := NewCartServer(cartApplier) + if opts.ProfileApplier != nil { + cartSrv.SetProfileApplier(opts.ProfileApplier) + } mux.HandleFunc("POST /carts", cartSrv.handleCreateCart) mux.HandleFunc("GET /carts/{id}", cartSrv.handleGetCart) mux.HandleFunc("PUT /carts/{id}", cartSrv.handleUpdateCart) @@ -106,6 +135,9 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler { orderOpts = []OrderApplier{opts.OrderApplier} } checkoutSrv := NewCheckoutServer(opts.CheckoutApplier, orderOpts...) + if opts.ProfileApplier != nil { + checkoutSrv.SetProfileApplier(opts.ProfileApplier) + } mux.HandleFunc("POST /checkout-sessions", checkoutSrv.handleCreateCheckout) mux.HandleFunc("GET /checkout-sessions/{id}", checkoutSrv.handleGetCheckout) mux.HandleFunc("PUT /checkout-sessions/{id}", checkoutSrv.handleUpdateCheckout) @@ -113,6 +145,16 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler { mux.HandleFunc("POST /checkout-sessions/{id}/cancel", checkoutSrv.handleCancelCheckout) } + // Customer endpoints (optional) + if opts.ProfileApplier != nil { + customerSrv := NewCustomerServer(opts.ProfileApplier) + mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer) + mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer) + mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress) + mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress) + mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress) + } + return withUCPAgent(withJSONContentType(mux)) } diff --git a/internal/ucp/types.go b/internal/ucp/types.go index ff2247d..33d1ae1 100644 --- a/internal/ucp/types.go +++ b/internal/ucp/types.go @@ -22,6 +22,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/mats/go-cart-actor/pkg/order" + "git.k6n.net/mats/go-cart-actor/pkg/profile" "git.k6n.net/mats/go-cart-actor/pkg/actor" "google.golang.org/protobuf/proto" ) @@ -49,6 +50,12 @@ type OrderReadApplier interface { Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) } +// ProfileApplier is the minimal grain-pool interface the UCP customer adapter needs. +type ProfileApplier interface { + Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error) + Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) +} + // OrderApplier is the interface the UCP checkout adapter uses to create a real // order from a completed checkout session. It abstracts over the transport // (direct grain pool, HTTP, etc.) so the adapter is portable. @@ -143,11 +150,12 @@ type Totals struct { // CreateCheckoutRequest is the body for POST /checkout-sessions. type CreateCheckoutRequest struct { - CartId string `json:"cartId"` - Items []CartItemInput `json:"items,omitempty"` - LineItems []CartItemInput `json:"line_items,omitempty"` - Country string `json:"country,omitempty"` - Currency string `json:"currency,omitempty"` + CartId string `json:"cartId"` + Items []CartItemInput `json:"items,omitempty"` + LineItems []CartItemInput `json:"line_items,omitempty"` + Country string `json:"country,omitempty"` + Currency string `json:"currency,omitempty"` + CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking } // UpdateCheckoutRequest is the body for PUT /checkout-sessions/{id}. @@ -187,6 +195,7 @@ type CompleteCheckoutRequest struct { Provider string `json:"provider,omitempty"` Currency string `json:"currency,omitempty"` Country string `json:"country,omitempty"` + CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking } // CheckoutResponse is the UCP checkout session response. @@ -316,6 +325,78 @@ type OrderLineEntry struct { Quantity int `json:"quantity"` } +// --------------------------------------------------------------------------- +// UCP Customer types +// --------------------------------------------------------------------------- + +// CustomerResponse is the UCP customer profile response body. +type CustomerResponse 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"` + Addresses []AddressResponse `json:"addresses,omitempty"` +} + +// AddressResponse is the UCP wire format for an address. +type AddressResponse struct { + Id uint32 `json:"id"` + Label string `json:"label,omitempty"` + FullName string `json:"fullName,omitempty"` + AddressLine1 string `json:"addressLine1"` + AddressLine2 string `json:"addressLine2,omitempty"` + City string `json:"city"` + State string `json:"state,omitempty"` + Zip string `json:"zip"` + Country string `json:"country"` + Phone string `json:"phone,omitempty"` + IsDefaultShipping bool `json:"isDefaultShipping"` + IsDefaultBilling bool `json:"isDefaultBilling"` +} + +// CustomerUpdateRequest is the body for PUT /customers/{id}. +type CustomerUpdateRequest struct { + 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"` +} + +// AddAddressRequest is the body for POST /customers/{id}/addresses. +type AddAddressRequest struct { + Label string `json:"label,omitempty"` + FullName string `json:"fullName,omitempty"` + AddressLine1 string `json:"addressLine1"` + AddressLine2 string `json:"addressLine2,omitempty"` + City string `json:"city"` + State string `json:"state,omitempty"` + Zip string `json:"zip"` + Country string `json:"country"` + Phone string `json:"phone,omitempty"` + IsDefaultShipping bool `json:"isDefaultShipping,omitempty"` + IsDefaultBilling bool `json:"isDefaultBilling,omitempty"` +} + +// UpdateAddressRequest is the body for PUT /customers/{id}/addresses/{addressId}. +type UpdateAddressRequest struct { + Label *string `json:"label,omitempty"` + FullName *string `json:"fullName,omitempty"` + AddressLine1 *string `json:"addressLine1,omitempty"` + AddressLine2 *string `json:"addressLine2,omitempty"` + City *string `json:"city,omitempty"` + State *string `json:"state,omitempty"` + Zip *string `json:"zip,omitempty"` + Country *string `json:"country,omitempty"` + Phone *string `json:"phone,omitempty"` + IsDefaultShipping *bool `json:"isDefaultShipping,omitempty"` + IsDefaultBilling *bool `json:"isDefaultBilling,omitempty"` +} + // --------------------------------------------------------------------------- // UCP Profile handler (/.well-known/ucp) // --------------------------------------------------------------------------- diff --git a/pkg/backofficeadmin/app.go b/pkg/backofficeadmin/app.go index 85f82d5..2dc3f47 100644 --- a/pkg/backofficeadmin/app.go +++ b/pkg/backofficeadmin/app.go @@ -21,6 +21,7 @@ import ( actor "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/checkout" + "git.k6n.net/mats/go-cart-actor/pkg/profile" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" "git.k6n.net/mats/slask-finder/pkg/messaging" amqp "github.com/rabbitmq/amqp091-go" @@ -49,6 +50,8 @@ type Config struct { DataDir string // CheckoutDataDir holds the checkout event logs (default "checkout-data"). CheckoutDataDir string + // ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled). + ProfileDataDir string // RedisAddress / RedisPassword reach the inventory store. RedisAddress string RedisPassword string @@ -61,6 +64,7 @@ type App struct { hub *Hub rdb *redis.Client inv *inventory.RedisInventoryService + cs *CustomerServer } // New constructs the commerce admin: it opens the Redis inventory service, the @@ -93,11 +97,21 @@ func New(cfg Config) (*App, error) { regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext()) diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](cfg.CheckoutDataDir, regCheckout) + // Optional customer/profile storage. + var customerSrv *CustomerServer + if cfg.ProfileDataDir != "" { + _ = os.MkdirAll(cfg.ProfileDataDir, 0755) + regProfile := profile.NewProfileMutationRegistry() + profileStorage := actor.NewDiskStorage[profile.ProfileGrain](cfg.ProfileDataDir, regProfile) + customerSrv = NewCustomerServer(cfg.ProfileDataDir, profileStorage) + } + return &App{ fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout), hub: NewHub(), rdb: rdb, inv: inventoryService, + cs: customerSrv, }, nil } @@ -114,6 +128,10 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/vouchers", a.fs.VoucherHandler) mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler) mux.HandleFunc("/ws", a.hub.ServeWS) + // Optional customer endpoints (only mounted when ProfileDataDir is configured). + if a.cs != nil { + a.cs.RegisterRoutes(mux) + } } func (a *App) updateInventory(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/backofficeadmin/customer.go b/pkg/backofficeadmin/customer.go new file mode 100644 index 0000000..8c71c07 --- /dev/null +++ b/pkg/backofficeadmin/customer.go @@ -0,0 +1,195 @@ +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 +} diff --git a/pkg/profile/id.go b/pkg/profile/id.go new file mode 100644 index 0000000..0fbdb4e --- /dev/null +++ b/pkg/profile/id.go @@ -0,0 +1,70 @@ +package profile + +import ( + "crypto/rand" + "fmt" +) + +// ProfileId is a 64-bit profile identifier with a compact base62 string form, +// the same scheme the cart and order use so ids are consistent across services. +type ProfileId uint64 + +const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +var base62Rev [256]byte + +func init() { + for i := range base62Rev { + base62Rev[i] = 0xFF + } + for i := 0; i < len(base62Alphabet); i++ { + base62Rev[base62Alphabet[i]] = byte(i) + } +} + +// String returns the canonical base62 encoding. +func (id ProfileId) String() string { return encodeBase62(uint64(id)) } + +// NewProfileId generates a cryptographically random non-zero id. +func NewProfileId() (ProfileId, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, fmt.Errorf("NewProfileId: %w", err) + } + u := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 | + uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7]) + if u == 0 { + return NewProfileId() + } + return ProfileId(u), nil +} + +// ParseProfileId parses a base62 string into a ProfileId. +func ParseProfileId(s string) (ProfileId, bool) { + if len(s) == 0 || len(s) > 11 { + return 0, false + } + var v uint64 + for i := 0; i < len(s); i++ { + d := base62Rev[s[i]] + if d == 0xFF { + return 0, false + } + v = v*62 + uint64(d) + } + return ProfileId(v), true +} + +func encodeBase62(u uint64) string { + if u == 0 { + return "0" + } + var buf [11]byte + i := len(buf) + for u > 0 { + i-- + buf[i] = base62Alphabet[u%62] + u /= 62 + } + return string(buf[i:]) +} diff --git a/pkg/profile/mutation-context.go b/pkg/profile/mutation-context.go new file mode 100644 index 0000000..3a89676 --- /dev/null +++ b/pkg/profile/mutation-context.go @@ -0,0 +1,21 @@ +package profile + +import ( + "git.k6n.net/mats/go-cart-actor/pkg/actor" +) + +// NewProfileMutationRegistry creates a mutation registry with all profile +// mutation handlers registered. +func NewProfileMutationRegistry() actor.MutationRegistry { + reg := actor.NewMutationRegistry() + reg.RegisterMutations( + actor.NewMutation(HandleSetProfile), + actor.NewMutation(HandleAddAddress), + actor.NewMutation(HandleUpdateAddress), + actor.NewMutation(HandleRemoveAddress), + actor.NewMutation(HandleLinkCart), + actor.NewMutation(HandleLinkCheckout), + actor.NewMutation(HandleLinkOrder), + ) + return reg +} diff --git a/pkg/profile/mutation_address.go b/pkg/profile/mutation_address.go new file mode 100644 index 0000000..894853d --- /dev/null +++ b/pkg/profile/mutation_address.go @@ -0,0 +1,130 @@ +package profile + +import ( + "fmt" + + messages "git.k6n.net/mats/go-cart-actor/proto/profile" +) + +// HandleAddAddress adds a new address to the user's address book. +func HandleAddAddress(p *ProfileGrain, m *messages.AddAddress) error { + if m == nil { + return fmt.Errorf("AddAddress: nil payload") + } + if m.Address == nil { + return fmt.Errorf("AddAddress: address is required") + } + + id := p.NextAddrId() + addr := protoToStored(id, m.Address) + if addr.IsDefaultShipping { + p.clearDefaultShipping() + } + if addr.IsDefaultBilling { + p.clearDefaultBilling() + } + p.Addresses = append(p.Addresses, *addr) + return nil +} + +// HandleUpdateAddress updates an existing address (matched by id). +func HandleUpdateAddress(p *ProfileGrain, m *messages.UpdateAddress) error { + if m == nil { + return fmt.Errorf("UpdateAddress: nil payload") + } + existing := p.findAddress(m.Id) + if existing == nil { + return fmt.Errorf("UpdateAddress: address %d not found", m.Id) + } + if m.Label != nil { + existing.Label = *m.Label + } + if m.FullName != nil { + existing.FullName = *m.FullName + } + if m.AddressLine1 != nil { + existing.AddressLine1 = *m.AddressLine1 + } + if m.AddressLine2 != nil { + existing.AddressLine2 = *m.AddressLine2 + } + if m.City != nil { + existing.City = *m.City + } + if m.State != nil { + existing.State = *m.State + } + if m.Zip != nil { + existing.Zip = *m.Zip + } + if m.Country != nil { + existing.Country = *m.Country + } + if m.Phone != nil { + existing.Phone = *m.Phone + } + if m.IsDefaultShipping != nil { + if *m.IsDefaultShipping { + p.clearDefaultShipping() + } + existing.IsDefaultShipping = *m.IsDefaultShipping + } + if m.IsDefaultBilling != nil { + if *m.IsDefaultBilling { + p.clearDefaultBilling() + } + existing.IsDefaultBilling = *m.IsDefaultBilling + } + return nil +} + +// HandleRemoveAddress removes an address from the address book. +func HandleRemoveAddress(p *ProfileGrain, m *messages.RemoveAddress) error { + if m == nil { + return fmt.Errorf("RemoveAddress: nil payload") + } + idx := -1 + for i, a := range p.Addresses { + if a.Id == m.Id { + idx = i + break + } + } + if idx == -1 { + return fmt.Errorf("RemoveAddress: address %d not found", m.Id) + } + p.Addresses = append(p.Addresses[:idx], p.Addresses[idx+1:]...) + return nil +} + +// clearDefaultShipping unsets IsDefaultShipping on all addresses. +func (p *ProfileGrain) clearDefaultShipping() { + for i := range p.Addresses { + p.Addresses[i].IsDefaultShipping = false + } +} + +// clearDefaultBilling unsets IsDefaultBilling on all addresses. +func (p *ProfileGrain) clearDefaultBilling() { + for i := range p.Addresses { + p.Addresses[i].IsDefaultBilling = false + } +} + +// protoToStored converts a proto Address to a stored StoredAddress. +func protoToStored(id uint32, m *messages.Address) *StoredAddress { + return &StoredAddress{ + Id: id, + Label: m.Label, + FullName: m.FullName, + AddressLine1: m.AddressLine1, + AddressLine2: m.GetAddressLine2(), + City: m.City, + State: m.State, + Zip: m.Zip, + Country: m.Country, + Phone: m.GetPhone(), + IsDefaultShipping: m.IsDefaultShipping, + IsDefaultBilling: m.IsDefaultBilling, + } +} diff --git a/pkg/profile/mutation_links.go b/pkg/profile/mutation_links.go new file mode 100644 index 0000000..a6c799e --- /dev/null +++ b/pkg/profile/mutation_links.go @@ -0,0 +1,73 @@ +package profile + +import ( + "fmt" + + messages "git.k6n.net/mats/go-cart-actor/proto/profile" +) + +// HandleLinkCart links a cart grain to this user profile. +func HandleLinkCart(p *ProfileGrain, m *messages.LinkCart) error { + if m == nil { + return fmt.Errorf("LinkCart: nil payload") + } + if m.CartId == 0 { + return fmt.Errorf("LinkCart: cartId is required") + } + // Check for duplicate + for _, c := range p.Carts { + if c.CartId == m.CartId { + return nil // already linked; idempotent + } + } + p.Carts = append(p.Carts, LinkedCart{ + CartId: m.CartId, + Label: m.Label, + }) + return nil +} + +// HandleLinkCheckout links a checkout grain to this user profile. +func HandleLinkCheckout(p *ProfileGrain, m *messages.LinkCheckout) error { + if m == nil { + return fmt.Errorf("LinkCheckout: nil payload") + } + if m.CheckoutId == 0 { + return fmt.Errorf("LinkCheckout: checkoutId is required") + } + // Check for duplicate + for _, ch := range p.Checkouts { + if ch.CheckoutId == m.CheckoutId { + return nil // already linked; idempotent + } + } + p.Checkouts = append(p.Checkouts, LinkedCheckout{ + CheckoutId: m.CheckoutId, + CartId: m.CartId, + }) + return nil +} + +// HandleLinkOrder links an order to this user profile. +func HandleLinkOrder(p *ProfileGrain, m *messages.LinkOrder) error { + if m == nil { + return fmt.Errorf("LinkOrder: nil payload") + } + if m.OrderReference == "" { + return fmt.Errorf("LinkOrder: orderReference is required") + } + // Check for duplicate (by index so we can mutate) + for i := range p.Orders { + if p.Orders[i].OrderReference == m.OrderReference { + // Update status snapshot + p.Orders[i].Status = m.Status + return nil + } + } + p.Orders = append(p.Orders, LinkedOrder{ + OrderReference: m.OrderReference, + CartId: m.CartId, + Status: m.Status, + }) + return nil +} diff --git a/pkg/profile/mutation_set_profile.go b/pkg/profile/mutation_set_profile.go new file mode 100644 index 0000000..1bbbcfd --- /dev/null +++ b/pkg/profile/mutation_set_profile.go @@ -0,0 +1,33 @@ +package profile + +import ( + "fmt" + + messages "git.k6n.net/mats/go-cart-actor/proto/profile" +) + +// HandleSetProfile updates the user's core profile information. +func HandleSetProfile(p *ProfileGrain, m *messages.SetProfile) error { + if m == nil { + return fmt.Errorf("SetProfile: nil payload") + } + if m.Name != nil { + p.Name = *m.Name + } + if m.Email != nil { + p.Email = *m.Email + } + if m.Phone != nil { + p.Phone = *m.Phone + } + if m.Language != nil { + p.Language = *m.Language + } + if m.Currency != nil { + p.Currency = *m.Currency + } + if m.AvatarUrl != nil { + p.AvatarUrl = *m.AvatarUrl + } + return nil +} diff --git a/pkg/profile/profile-grain.go b/pkg/profile/profile-grain.go new file mode 100644 index 0000000..788f4d0 --- /dev/null +++ b/pkg/profile/profile-grain.go @@ -0,0 +1,108 @@ +// Package profile implements a user-profile actor grain (same event-driven +// framework as the cart, checkout, and order grains). Mutations are the proto +// messages in proto/profile; the grain's event log is the source of truth and +// ProfileGrain is the projection rebuilt by replaying them. +package profile + +import ( + "encoding/json" + "sync" + "time" +) + +// LinkedCart records a cart id linked to this profile. +type LinkedCart struct { + CartId uint64 `json:"cartId"` + Label string `json:"label,omitempty"` +} + +// LinkedCheckout records a checkout id linked to this profile. +type LinkedCheckout struct { + CheckoutId uint64 `json:"checkoutId"` + CartId uint64 `json:"cartId"` +} + +// LinkedOrder records an order linked to this profile. +type LinkedOrder struct { + OrderReference string `json:"orderReference"` + CartId uint64 `json:"cartId"` + Status string `json:"status,omitempty"` +} + +// StoredAddress is a saved address in the user's address book. +type StoredAddress struct { + Id uint32 `json:"id"` + Label string `json:"label,omitempty"` + FullName string `json:"fullName,omitempty"` + AddressLine1 string `json:"addressLine1"` + AddressLine2 string `json:"addressLine2,omitempty"` + City string `json:"city"` + State string `json:"state,omitempty"` + Zip string `json:"zip"` + Country string `json:"country"` + Phone string `json:"phone,omitempty"` + IsDefaultShipping bool `json:"isDefaultShipping,omitempty"` + IsDefaultBilling bool `json:"isDefaultBilling,omitempty"` +} + +// ProfileGrain is the projected current state of a user profile. It implements +// actor.Grain[ProfileGrain]. +type ProfileGrain struct { + mu sync.RWMutex + lastAccess time.Time + lastChange time.Time + nextAddrId uint32 + + Id uint64 `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 []LinkedCart `json:"carts,omitempty"` + Checkouts []LinkedCheckout `json:"checkouts,omitempty"` + Orders []LinkedOrder `json:"orders,omitempty"` + Addresses []StoredAddress `json:"addresses,omitempty"` +} + +// NewProfileGrain returns an empty profile grain for id. +func NewProfileGrain(id uint64, ts time.Time) *ProfileGrain { + return &ProfileGrain{ + Id: id, + lastAccess: ts, + lastChange: ts, + } +} + +// --- actor.Grain[ProfileGrain] --- + +func (p *ProfileGrain) GetId() uint64 { return p.Id } + +func (p *ProfileGrain) GetLastAccess() time.Time { return p.lastAccess } + +func (p *ProfileGrain) GetLastChange() time.Time { return p.lastChange } + +func (p *ProfileGrain) GetCurrentState() (*ProfileGrain, error) { + p.lastAccess = time.Now() + return p, nil +} + +func (p *ProfileGrain) GetState() ([]byte, error) { return json.Marshal(p) } + +// NextAddrId returns the next address id and advances the counter. +func (p *ProfileGrain) NextAddrId() uint32 { + p.nextAddrId++ + return p.nextAddrId +} + +// findAddress returns a pointer to the address with the given id, or nil. +func (p *ProfileGrain) findAddress(id uint32) *StoredAddress { + for i := range p.Addresses { + if p.Addresses[i].Id == id { + return &p.Addresses[i] + } + } + return nil +} diff --git a/pkg/profile/profile_test.go b/pkg/profile/profile_test.go new file mode 100644 index 0000000..42e4f8d --- /dev/null +++ b/pkg/profile/profile_test.go @@ -0,0 +1,633 @@ +package profile + +import ( + "context" + "testing" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + messages "git.k6n.net/mats/go-cart-actor/proto/profile" + "google.golang.org/protobuf/proto" +) + +// --- helpers (mirroring order/order_test.go) --- + +func apply(t *testing.T, reg actor.MutationRegistry, g *ProfileGrain, msg proto.Message) error { + t.Helper() + results, err := reg.Apply(context.Background(), g, msg) + if err != nil { + return err + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + return results[0].Error +} + +func mustApply(t *testing.T, reg actor.MutationRegistry, g *ProfileGrain, msg proto.Message) { + t.Helper() + if err := apply(t, reg, g, msg); err != nil { + t.Fatalf("apply %T: %v", msg, err) + } +} + +func newRegistry() actor.MutationRegistry { + return NewProfileMutationRegistry() +} + +// strPtr is a helper for proto optional string fields. +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } + +// --- SetProfile --- + +func TestSetProfileAllFields(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(1, time.Now()) + + mustApply(t, reg, g, &messages.SetProfile{ + Name: strPtr("Jane Doe"), + Email: strPtr("jane@example.com"), + Phone: strPtr("+46 70 123 45 67"), + Language: strPtr("sv"), + Currency: strPtr("SEK"), + AvatarUrl: strPtr("https://cdn.example.com/avatar/jane.jpg"), + }) + + if g.Name != "Jane Doe" { + t.Fatalf("Name = %q, want %q", g.Name, "Jane Doe") + } + if g.Email != "jane@example.com" { + t.Fatalf("Email = %q, want %q", g.Email, "jane@example.com") + } + if g.Phone != "+46 70 123 45 67" { + t.Fatalf("Phone = %q, want %q", g.Phone, "+46 70 123 45 67") + } + if g.Language != "sv" { + t.Fatalf("Language = %q, want %q", g.Language, "sv") + } + if g.Currency != "SEK" { + t.Fatalf("Currency = %q, want %q", g.Currency, "SEK") + } + if g.AvatarUrl != "https://cdn.example.com/avatar/jane.jpg" { + t.Fatalf("AvatarUrl = %q, want %q", g.AvatarUrl, "https://cdn.example.com/avatar/jane.jpg") + } +} + +func TestSetProfilePartialUpdate(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(2, time.Now()) + g.Name = "Old Name" + g.Email = "old@example.com" + + mustApply(t, reg, g, &messages.SetProfile{ + Email: strPtr("updated@example.com"), + }) + + if g.Name != "Old Name" { + t.Fatalf("Name should be unchanged, got %q", g.Name) + } + if g.Email != "updated@example.com" { + t.Fatalf("Email = %q, want %q", g.Email, "updated@example.com") + } +} + +func TestSetProfileNilMessage(t *testing.T) { + g := NewProfileGrain(3, time.Now()) + if err := HandleSetProfile(g, nil); err == nil { + t.Fatal("expected error for nil SetProfile, got nil") + } +} + +// --- AddAddress --- + +func addrProto(id *uint32) *messages.Address { + return &messages.Address{ + Label: "Home", + FullName: "Jane Doe", + AddressLine1: "Storgatan 1", + City: "Stockholm", + State: "Stockholm", + Zip: "111 22", + Country: "SE", + Phone: strPtr("+46 70 123 45 67"), + } +} + +func TestAddAddress(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(10, time.Now()) + + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + + if len(g.Addresses) != 1 { + t.Fatalf("expected 1 address, got %d", len(g.Addresses)) + } + a := g.Addresses[0] + if a.Id != 1 { + t.Fatalf("expected address id 1, got %d", a.Id) + } + if a.Label != "Home" { + t.Fatalf("Label = %q, want %q", a.Label, "Home") + } + if a.City != "Stockholm" { + t.Fatalf("City = %q, want %q", a.City, "Stockholm") + } + if a.Phone != "+46 70 123 45 67" { + t.Fatalf("Phone = %q, want %q", a.Phone, "+46 70 123 45 67") + } +} + +func TestAddAddressIncrementsId(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(11, time.Now()) + + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + + if len(g.Addresses) != 3 { + t.Fatalf("expected 3 addresses, got %d", len(g.Addresses)) + } + for i, a := range g.Addresses { + if want := uint32(i + 1); a.Id != want { + t.Fatalf("address %d id = %d, want %d", i, a.Id, want) + } + } +} + +func TestAddAddressDefaultShippingClearsOthers(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(12, time.Now()) + + // Add two non-default addresses + addr1 := addrProto(nil) + addr1.IsDefaultShipping = false + addr1.IsDefaultBilling = false + mustApply(t, reg, g, &messages.AddAddress{Address: addr1}) + + addr2 := addrProto(nil) + addr2.IsDefaultShipping = false + addr2.IsDefaultBilling = false + mustApply(t, reg, g, &messages.AddAddress{Address: addr2}) + + // Add a default-shipping address — should clear the previous ones + addr3 := addrProto(nil) + addr3.IsDefaultShipping = true + mustApply(t, reg, g, &messages.AddAddress{Address: addr3}) + + if len(g.Addresses) != 3 { + t.Fatalf("expected 3 addresses, got %d", len(g.Addresses)) + } + if !g.Addresses[2].IsDefaultShipping { + t.Fatal("expected address 3 to be default shipping") + } + for i := 0; i < 2; i++ { + if g.Addresses[i].IsDefaultShipping { + t.Fatalf("address %d should not be default shipping", i) + } + } +} + +func TestAddAddressNil(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(13, time.Now()) + + if err := apply(t, reg, g, &messages.AddAddress{Address: nil}); err == nil { + t.Fatal("expected error for nil address, got nil") + } +} + +// --- UpdateAddress --- + +func TestUpdateAddressAllFields(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(20, time.Now()) + + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + + mustApply(t, reg, g, &messages.UpdateAddress{ + Id: 1, + Label: strPtr("Work"), + FullName: strPtr("Jane Doe Updated"), + AddressLine1: strPtr("Drottninggatan 10"), + City: strPtr("Göteborg"), + State: strPtr("Västra Götaland"), + Zip: strPtr("411 03"), + Country: strPtr("SE"), + Phone: strPtr("+46 70 987 65 43"), + }) + + a := g.Addresses[0] + if a.Label != "Work" { + t.Fatalf("Label = %q, want %q", a.Label, "Work") + } + if a.City != "Göteborg" { + t.Fatalf("City = %q, want %q", a.City, "Göteborg") + } + if a.Phone != "+46 70 987 65 43" { + t.Fatalf("Phone = %q, want %q", a.Phone, "+46 70 987 65 43") + } +} + +func TestUpdateAddressPartialFields(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(21, time.Now()) + + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + + // Update only the label + mustApply(t, reg, g, &messages.UpdateAddress{ + Id: 1, + Label: strPtr("Summer Home"), + }) + + a := g.Addresses[0] + if a.Label != "Summer Home" { + t.Fatalf("Label = %q, want %q", a.Label, "Summer Home") + } + if a.City != "Stockholm" { + t.Fatalf("City should be unchanged, got %q", a.City) + } +} + +func TestUpdateAddressNotFound(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(22, time.Now()) + + if err := apply(t, reg, g, &messages.UpdateAddress{Id: 999, Label: strPtr("Nowhere")}); err == nil { + t.Fatal("expected error for non-existent address, got nil") + } +} + +func TestUpdateAddressDefaultShippingClearsOthers(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(23, time.Now()) + + addr1 := addrProto(nil) + addr1.IsDefaultShipping = true + mustApply(t, reg, g, &messages.AddAddress{Address: addr1}) + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + + // Update address 2 to be default shipping — should clear address 1 + mustApply(t, reg, g, &messages.UpdateAddress{ + Id: 2, + IsDefaultShipping: boolPtr(true), + }) + + if !g.Addresses[1].IsDefaultShipping { + t.Fatal("expected address 2 to be default shipping") + } + if g.Addresses[0].IsDefaultShipping { + t.Fatal("address 1 should no longer be default shipping") + } +} + +// --- RemoveAddress --- + +func TestRemoveAddress(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(30, time.Now()) + + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + + mustApply(t, reg, g, &messages.RemoveAddress{Id: 2}) + + if len(g.Addresses) != 2 { + t.Fatalf("expected 2 addresses after removal, got %d", len(g.Addresses)) + } + if g.Addresses[0].Id != 1 { + t.Fatalf("expected address id 1 at index 0, got %d", g.Addresses[0].Id) + } + if g.Addresses[1].Id != 3 { + t.Fatalf("expected address id 3 at index 1, got %d", g.Addresses[1].Id) + } +} + +func TestRemoveAddressNotFound(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(31, time.Now()) + + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + + if err := apply(t, reg, g, &messages.RemoveAddress{Id: 99}); err == nil { + t.Fatal("expected error for removing non-existent address, got nil") + } +} + +func TestRemoveAddressLastElement(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(32, time.Now()) + + mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)}) + mustApply(t, reg, g, &messages.RemoveAddress{Id: 1}) + + if len(g.Addresses) != 0 { + t.Fatalf("expected 0 addresses, got %d", len(g.Addresses)) + } +} + +// --- LinkCart --- + +func TestLinkCart(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(40, time.Now()) + + mustApply(t, reg, g, &messages.LinkCart{ + CartId: 1001, + Label: "current cart", + }) + + if len(g.Carts) != 1 { + t.Fatalf("expected 1 linked cart, got %d", len(g.Carts)) + } + if g.Carts[0].CartId != 1001 { + t.Fatalf("CartId = %d, want %d", g.Carts[0].CartId, 1001) + } + if g.Carts[0].Label != "current cart" { + t.Fatalf("Label = %q, want %q", g.Carts[0].Label, "current cart") + } +} + +func TestLinkCartMultiple(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(41, time.Now()) + + mustApply(t, reg, g, &messages.LinkCart{CartId: 1, Label: "first"}) + mustApply(t, reg, g, &messages.LinkCart{CartId: 2, Label: "second"}) + + if len(g.Carts) != 2 { + t.Fatalf("expected 2 linked carts, got %d", len(g.Carts)) + } +} + +func TestLinkCartDuplicateIdempotent(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(42, time.Now()) + + mustApply(t, reg, g, &messages.LinkCart{CartId: 1001}) + mustApply(t, reg, g, &messages.LinkCart{CartId: 1001}) // same cart again + + if len(g.Carts) != 1 { + t.Fatalf("expected 1 linked cart (idempotent), got %d", len(g.Carts)) + } +} + +func TestLinkCartMissingId(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(43, time.Now()) + + if err := apply(t, reg, g, &messages.LinkCart{CartId: 0}); err == nil { + t.Fatal("expected error for zero cartId, got nil") + } +} + +// --- LinkCheckout --- + +func TestLinkCheckout(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(50, time.Now()) + + mustApply(t, reg, g, &messages.LinkCheckout{ + CheckoutId: 2001, + CartId: 1001, + }) + + if len(g.Checkouts) != 1 { + t.Fatalf("expected 1 linked checkout, got %d", len(g.Checkouts)) + } + if g.Checkouts[0].CheckoutId != 2001 { + t.Fatalf("CheckoutId = %d, want %d", g.Checkouts[0].CheckoutId, 2001) + } + if g.Checkouts[0].CartId != 1001 { + t.Fatalf("CartId = %d, want %d", g.Checkouts[0].CartId, 1001) + } +} + +func TestLinkCheckoutMultiple(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(51, time.Now()) + + mustApply(t, reg, g, &messages.LinkCheckout{CheckoutId: 1, CartId: 10}) + mustApply(t, reg, g, &messages.LinkCheckout{CheckoutId: 2, CartId: 20}) + + if len(g.Checkouts) != 2 { + t.Fatalf("expected 2 linked checkouts, got %d", len(g.Checkouts)) + } +} + +func TestLinkCheckoutDuplicateIdempotent(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(52, time.Now()) + + mustApply(t, reg, g, &messages.LinkCheckout{CheckoutId: 2001, CartId: 1001}) + mustApply(t, reg, g, &messages.LinkCheckout{CheckoutId: 2001, CartId: 1001}) + + if len(g.Checkouts) != 1 { + t.Fatalf("expected 1 linked checkout (idempotent), got %d", len(g.Checkouts)) + } +} + +func TestLinkCheckoutMissingId(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(53, time.Now()) + + if err := apply(t, reg, g, &messages.LinkCheckout{CheckoutId: 0}); err == nil { + t.Fatal("expected error for zero checkoutId, got nil") + } +} + +// --- LinkOrder --- + +func TestLinkOrder(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(60, time.Now()) + + mustApply(t, reg, g, &messages.LinkOrder{ + OrderReference: "ORD-001", + CartId: 1001, + Status: "pending", + }) + + if len(g.Orders) != 1 { + t.Fatalf("expected 1 linked order, got %d", len(g.Orders)) + } + if g.Orders[0].OrderReference != "ORD-001" { + t.Fatalf("OrderReference = %q, want %q", g.Orders[0].OrderReference, "ORD-001") + } + if g.Orders[0].CartId != 1001 { + t.Fatalf("CartId = %d, want %d", g.Orders[0].CartId, 1001) + } + if g.Orders[0].Status != "pending" { + t.Fatalf("Status = %q, want %q", g.Orders[0].Status, "pending") + } +} + +func TestLinkOrderUpdatesStatusOnDuplicate(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(61, time.Now()) + + mustApply(t, reg, g, &messages.LinkOrder{ + OrderReference: "ORD-001", + CartId: 1001, + Status: "pending", + }) + mustApply(t, reg, g, &messages.LinkOrder{ + OrderReference: "ORD-001", + CartId: 1001, + Status: "captured", + }) + + if len(g.Orders) != 1 { + t.Fatalf("expected 1 linked order (idempotent), got %d", len(g.Orders)) + } + if g.Orders[0].Status != "captured" { + t.Fatalf("Status = %q, want %q", g.Orders[0].Status, "captured") + } +} + +func TestLinkOrderMissingReference(t *testing.T) { + reg := newRegistry() + g := NewProfileGrain(62, time.Now()) + + if err := apply(t, reg, g, &messages.LinkOrder{OrderReference: "", CartId: 1001}); err == nil { + t.Fatal("expected error for empty orderReference, got nil") + } +} + +// --- Nil message edge cases (call handlers directly with typed nil pointers; +// the registry silently skips typed-nil messages, so Apply does not reach +// the handler and we must test the handler in isolation) --- + +func TestNilMessages(t *testing.T) { + tests := []struct { + name string + err error + }{ + {"nil SetProfile", HandleSetProfile(NewProfileGrain(70, time.Now()), (*messages.SetProfile)(nil))}, + {"nil AddAddress", HandleAddAddress(NewProfileGrain(70, time.Now()), (*messages.AddAddress)(nil))}, + {"nil UpdateAddress", HandleUpdateAddress(NewProfileGrain(70, time.Now()), (*messages.UpdateAddress)(nil))}, + {"nil RemoveAddress", HandleRemoveAddress(NewProfileGrain(70, time.Now()), (*messages.RemoveAddress)(nil))}, + {"nil LinkCart", HandleLinkCart(NewProfileGrain(70, time.Now()), (*messages.LinkCart)(nil))}, + {"nil LinkCheckout", HandleLinkCheckout(NewProfileGrain(70, time.Now()), (*messages.LinkCheckout)(nil))}, + {"nil LinkOrder", HandleLinkOrder(NewProfileGrain(70, time.Now()), (*messages.LinkOrder)(nil))}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.err == nil { + t.Fatal("expected error for nil message, got nil") + } + }) + } +} + +// --- Event-sourced replay test (mirroring order/order_test.go) --- + +func TestEventSourcedReplay(t *testing.T) { + reg := newRegistry() + storage := actor.NewDiskStorage[ProfileGrain](t.TempDir(), reg) + + const id uint64 = 100 + g := NewProfileGrain(id, time.Now()) + + events := []proto.Message{ + &messages.SetProfile{ + Name: strPtr("Jane Doe"), + Email: strPtr("jane@example.com"), + }, + &messages.AddAddress{Address: addrProto(nil)}, + &messages.LinkCart{CartId: 1001, Label: "current"}, + &messages.LinkCheckout{CheckoutId: 2001, CartId: 1001}, + &messages.LinkOrder{OrderReference: "ORD-001", CartId: 1001, Status: "pending"}, + &messages.AddAddress{ + Address: &messages.Address{ + Label: "Work", + FullName: "Jane Doe", + AddressLine1: "Kungsgatan 5", + City: "Malmö", + State: "Skåne", + Zip: "211 11", + Country: "SE", + }, + }, + &messages.UpdateAddress{ + Id: 1, + Label: strPtr("Home (Updated)"), + }, + &messages.LinkOrder{OrderReference: "ORD-002", CartId: 1002, Status: "captured"}, + } + + for _, e := range events { + mustApply(t, reg, g, e) + if err := storage.AppendMutations(id, e); err != nil { + t.Fatalf("append: %v", err) + } + } + + // Verify initial grain state before replay. + if g.Name != "Jane Doe" { + t.Fatalf("Name = %q, want %q", g.Name, "Jane Doe") + } + if len(g.Addresses) != 2 { + t.Fatalf("expected 2 addresses, got %d", len(g.Addresses)) + } + if g.Addresses[0].Label != "Home (Updated)" { + t.Fatalf("first address label = %q, want %q", g.Addresses[0].Label, "Home (Updated)") + } + if len(g.Carts) != 1 { + t.Fatalf("expected 1 cart, got %d", len(g.Carts)) + } + if len(g.Checkouts) != 1 { + t.Fatalf("expected 1 checkout, got %d", len(g.Checkouts)) + } + if len(g.Orders) != 2 { + t.Fatalf("expected 2 orders, got %d", len(g.Orders)) + } + if g.Orders[1].Status != "captured" { + t.Fatalf("second order status = %q, want %q", g.Orders[1].Status, "captured") + } + + // Replay into a brand-new grain and compare the projection. + replayed := NewProfileGrain(id, time.Now()) + if err := storage.LoadEvents(context.Background(), id, replayed); err != nil { + t.Fatalf("replay: %v", err) + } + + if replayed.Name != g.Name { + t.Fatalf("replayed Name = %q, want %q", replayed.Name, g.Name) + } + if replayed.Email != g.Email { + t.Fatalf("replayed Email = %q, want %q", replayed.Email, g.Email) + } + if len(replayed.Addresses) != len(g.Addresses) { + t.Fatalf("replayed addresses = %d, want %d", len(replayed.Addresses), len(g.Addresses)) + } + if replayed.Addresses[0].Label != g.Addresses[0].Label { + t.Fatalf("replayed first address label = %q, want %q", replayed.Addresses[0].Label, g.Addresses[0].Label) + } + if replayed.Addresses[0].Id != g.Addresses[0].Id { + t.Fatalf("replayed first address id = %d, want %d", replayed.Addresses[0].Id, g.Addresses[0].Id) + } + // Address book: second address should have been added AFTER the update to + // address 1, so its id is 2 and it should match the original. + if replayed.Addresses[1].City != g.Addresses[1].City { + t.Fatalf("replayed second address city = %q, want %q", replayed.Addresses[1].City, g.Addresses[1].City) + } + if len(replayed.Carts) != len(g.Carts) { + t.Fatalf("replayed carts = %d, want %d", len(replayed.Carts), len(g.Carts)) + } + if len(replayed.Checkouts) != len(g.Checkouts) { + t.Fatalf("replayed checkouts = %d, want %d", len(replayed.Checkouts), len(g.Checkouts)) + } + if len(replayed.Orders) != len(g.Orders) { + t.Fatalf("replayed orders = %d, want %d", len(replayed.Orders), len(g.Orders)) + } + if replayed.Orders[0].Status != g.Orders[0].Status { + t.Fatalf("replayed first order status = %q, want %q", replayed.Orders[0].Status, g.Orders[0].Status) + } + if replayed.Orders[1].Status != g.Orders[1].Status { + t.Fatalf("replayed second order status = %q, want %q", replayed.Orders[1].Status, g.Orders[1].Status) + } +} diff --git a/proto/profile.proto b/proto/profile.proto new file mode 100644 index 0000000..cd9122d --- /dev/null +++ b/proto/profile.proto @@ -0,0 +1,90 @@ +syntax = "proto3"; +package profile_messages; +option go_package = "git.k6n.net/mats/go-cart-actor/proto/profile;profile_messages"; + +// Profile mutations — each message is an immutable event in a user's profile +// event log (actor.DiskStorage). The current ProfileGrain is a projection +// rebuilt by replaying them in order. + +// Address represents a stored address in the user's profile. +message Address { + uint32 id = 1; + string label = 2; // e.g. "Home", "Work" + string fullName = 3; + string addressLine1 = 4; + optional string addressLine2 = 5; + string city = 6; + string state = 7; + string zip = 8; + string country = 9; + optional string phone = 10; + bool isDefaultShipping = 11; + bool isDefaultBilling = 12; +} + +// SetProfile sets the user's core profile information. +message SetProfile { + optional string name = 1; + optional string email = 2; + optional string phone = 3; + optional string language = 4; + optional string currency = 5; + optional string avatarUrl = 6; +} + +// AddAddress adds a new address to the user's address book. +message AddAddress { + Address address = 1; +} + +// UpdateAddress updates an existing address (matched by id). +message UpdateAddress { + uint32 id = 1; + optional string label = 2; + optional string fullName = 3; + optional string addressLine1 = 4; + optional string addressLine2 = 5; + optional string city = 6; + optional string state = 7; + optional string zip = 8; + optional string country = 9; + optional string phone = 10; + optional bool isDefaultShipping = 11; + optional bool isDefaultBilling = 12; +} + +// RemoveAddress removes an address from the address book. +message RemoveAddress { + uint32 id = 1; +} + +// LinkCart links a cart grain to this user profile. +message LinkCart { + uint64 cartId = 1; + string label = 2; // optional label, e.g. "current cart" +} + +// LinkCheckout links a checkout grain to this user profile. +message LinkCheckout { + uint64 checkoutId = 1; + uint64 cartId = 2; // the cart this checkout belongs to +} + +// LinkOrder links an order to this user profile. +message LinkOrder { + string orderReference = 1; // order reference / id + uint64 cartId = 2; // originating cart + string status = 3; // order status snapshot +} + +message Mutation { + oneof type { + SetProfile set_profile = 1; + AddAddress add_address = 2; + UpdateAddress update_address = 3; + RemoveAddress remove_address = 4; + LinkCart link_cart = 5; + LinkCheckout link_checkout = 6; + LinkOrder link_order = 7; + } +} diff --git a/proto/profile/profile.pb.go b/proto/profile/profile.pb.go new file mode 100644 index 0000000..1f93e67 --- /dev/null +++ b/proto/profile/profile.pb.go @@ -0,0 +1,1007 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc v7.35.0 +// source: proto/profile.proto + +package profile_messages + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Address represents a stored address in the user's profile. +type Address struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"` // e.g. "Home", "Work" + FullName string `protobuf:"bytes,3,opt,name=fullName,proto3" json:"fullName,omitempty"` + AddressLine1 string `protobuf:"bytes,4,opt,name=addressLine1,proto3" json:"addressLine1,omitempty"` + AddressLine2 *string `protobuf:"bytes,5,opt,name=addressLine2,proto3,oneof" json:"addressLine2,omitempty"` + City string `protobuf:"bytes,6,opt,name=city,proto3" json:"city,omitempty"` + State string `protobuf:"bytes,7,opt,name=state,proto3" json:"state,omitempty"` + Zip string `protobuf:"bytes,8,opt,name=zip,proto3" json:"zip,omitempty"` + Country string `protobuf:"bytes,9,opt,name=country,proto3" json:"country,omitempty"` + Phone *string `protobuf:"bytes,10,opt,name=phone,proto3,oneof" json:"phone,omitempty"` + IsDefaultShipping bool `protobuf:"varint,11,opt,name=isDefaultShipping,proto3" json:"isDefaultShipping,omitempty"` + IsDefaultBilling bool `protobuf:"varint,12,opt,name=isDefaultBilling,proto3" json:"isDefaultBilling,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Address) Reset() { + *x = Address{} + mi := &file_proto_profile_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Address) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Address) ProtoMessage() {} + +func (x *Address) ProtoReflect() protoreflect.Message { + mi := &file_proto_profile_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Address.ProtoReflect.Descriptor instead. +func (*Address) Descriptor() ([]byte, []int) { + return file_proto_profile_proto_rawDescGZIP(), []int{0} +} + +func (x *Address) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Address) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +func (x *Address) GetFullName() string { + if x != nil { + return x.FullName + } + return "" +} + +func (x *Address) GetAddressLine1() string { + if x != nil { + return x.AddressLine1 + } + return "" +} + +func (x *Address) GetAddressLine2() string { + if x != nil && x.AddressLine2 != nil { + return *x.AddressLine2 + } + return "" +} + +func (x *Address) GetCity() string { + if x != nil { + return x.City + } + return "" +} + +func (x *Address) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Address) GetZip() string { + if x != nil { + return x.Zip + } + return "" +} + +func (x *Address) GetCountry() string { + if x != nil { + return x.Country + } + return "" +} + +func (x *Address) GetPhone() string { + if x != nil && x.Phone != nil { + return *x.Phone + } + return "" +} + +func (x *Address) GetIsDefaultShipping() bool { + if x != nil { + return x.IsDefaultShipping + } + return false +} + +func (x *Address) GetIsDefaultBilling() bool { + if x != nil { + return x.IsDefaultBilling + } + return false +} + +// SetProfile sets the user's core profile information. +type SetProfile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name *string `protobuf:"bytes,1,opt,name=name,proto3,oneof" json:"name,omitempty"` + Email *string `protobuf:"bytes,2,opt,name=email,proto3,oneof" json:"email,omitempty"` + Phone *string `protobuf:"bytes,3,opt,name=phone,proto3,oneof" json:"phone,omitempty"` + Language *string `protobuf:"bytes,4,opt,name=language,proto3,oneof" json:"language,omitempty"` + Currency *string `protobuf:"bytes,5,opt,name=currency,proto3,oneof" json:"currency,omitempty"` + AvatarUrl *string `protobuf:"bytes,6,opt,name=avatarUrl,proto3,oneof" json:"avatarUrl,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetProfile) Reset() { + *x = SetProfile{} + mi := &file_proto_profile_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetProfile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetProfile) ProtoMessage() {} + +func (x *SetProfile) ProtoReflect() protoreflect.Message { + mi := &file_proto_profile_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetProfile.ProtoReflect.Descriptor instead. +func (*SetProfile) Descriptor() ([]byte, []int) { + return file_proto_profile_proto_rawDescGZIP(), []int{1} +} + +func (x *SetProfile) GetName() string { + if x != nil && x.Name != nil { + return *x.Name + } + return "" +} + +func (x *SetProfile) GetEmail() string { + if x != nil && x.Email != nil { + return *x.Email + } + return "" +} + +func (x *SetProfile) GetPhone() string { + if x != nil && x.Phone != nil { + return *x.Phone + } + return "" +} + +func (x *SetProfile) GetLanguage() string { + if x != nil && x.Language != nil { + return *x.Language + } + return "" +} + +func (x *SetProfile) GetCurrency() string { + if x != nil && x.Currency != nil { + return *x.Currency + } + return "" +} + +func (x *SetProfile) GetAvatarUrl() string { + if x != nil && x.AvatarUrl != nil { + return *x.AvatarUrl + } + return "" +} + +// AddAddress adds a new address to the user's address book. +type AddAddress struct { + state protoimpl.MessageState `protogen:"open.v1"` + Address *Address `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddAddress) Reset() { + *x = AddAddress{} + mi := &file_proto_profile_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddAddress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddAddress) ProtoMessage() {} + +func (x *AddAddress) ProtoReflect() protoreflect.Message { + mi := &file_proto_profile_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddAddress.ProtoReflect.Descriptor instead. +func (*AddAddress) Descriptor() ([]byte, []int) { + return file_proto_profile_proto_rawDescGZIP(), []int{2} +} + +func (x *AddAddress) GetAddress() *Address { + if x != nil { + return x.Address + } + return nil +} + +// UpdateAddress updates an existing address (matched by id). +type UpdateAddress struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Label *string `protobuf:"bytes,2,opt,name=label,proto3,oneof" json:"label,omitempty"` + FullName *string `protobuf:"bytes,3,opt,name=fullName,proto3,oneof" json:"fullName,omitempty"` + AddressLine1 *string `protobuf:"bytes,4,opt,name=addressLine1,proto3,oneof" json:"addressLine1,omitempty"` + AddressLine2 *string `protobuf:"bytes,5,opt,name=addressLine2,proto3,oneof" json:"addressLine2,omitempty"` + City *string `protobuf:"bytes,6,opt,name=city,proto3,oneof" json:"city,omitempty"` + State *string `protobuf:"bytes,7,opt,name=state,proto3,oneof" json:"state,omitempty"` + Zip *string `protobuf:"bytes,8,opt,name=zip,proto3,oneof" json:"zip,omitempty"` + Country *string `protobuf:"bytes,9,opt,name=country,proto3,oneof" json:"country,omitempty"` + Phone *string `protobuf:"bytes,10,opt,name=phone,proto3,oneof" json:"phone,omitempty"` + IsDefaultShipping *bool `protobuf:"varint,11,opt,name=isDefaultShipping,proto3,oneof" json:"isDefaultShipping,omitempty"` + IsDefaultBilling *bool `protobuf:"varint,12,opt,name=isDefaultBilling,proto3,oneof" json:"isDefaultBilling,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateAddress) Reset() { + *x = UpdateAddress{} + mi := &file_proto_profile_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateAddress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateAddress) ProtoMessage() {} + +func (x *UpdateAddress) ProtoReflect() protoreflect.Message { + mi := &file_proto_profile_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateAddress.ProtoReflect.Descriptor instead. +func (*UpdateAddress) Descriptor() ([]byte, []int) { + return file_proto_profile_proto_rawDescGZIP(), []int{3} +} + +func (x *UpdateAddress) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *UpdateAddress) GetLabel() string { + if x != nil && x.Label != nil { + return *x.Label + } + return "" +} + +func (x *UpdateAddress) GetFullName() string { + if x != nil && x.FullName != nil { + return *x.FullName + } + return "" +} + +func (x *UpdateAddress) GetAddressLine1() string { + if x != nil && x.AddressLine1 != nil { + return *x.AddressLine1 + } + return "" +} + +func (x *UpdateAddress) GetAddressLine2() string { + if x != nil && x.AddressLine2 != nil { + return *x.AddressLine2 + } + return "" +} + +func (x *UpdateAddress) GetCity() string { + if x != nil && x.City != nil { + return *x.City + } + return "" +} + +func (x *UpdateAddress) GetState() string { + if x != nil && x.State != nil { + return *x.State + } + return "" +} + +func (x *UpdateAddress) GetZip() string { + if x != nil && x.Zip != nil { + return *x.Zip + } + return "" +} + +func (x *UpdateAddress) GetCountry() string { + if x != nil && x.Country != nil { + return *x.Country + } + return "" +} + +func (x *UpdateAddress) GetPhone() string { + if x != nil && x.Phone != nil { + return *x.Phone + } + return "" +} + +func (x *UpdateAddress) GetIsDefaultShipping() bool { + if x != nil && x.IsDefaultShipping != nil { + return *x.IsDefaultShipping + } + return false +} + +func (x *UpdateAddress) GetIsDefaultBilling() bool { + if x != nil && x.IsDefaultBilling != nil { + return *x.IsDefaultBilling + } + return false +} + +// RemoveAddress removes an address from the address book. +type RemoveAddress struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveAddress) Reset() { + *x = RemoveAddress{} + mi := &file_proto_profile_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveAddress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveAddress) ProtoMessage() {} + +func (x *RemoveAddress) ProtoReflect() protoreflect.Message { + mi := &file_proto_profile_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveAddress.ProtoReflect.Descriptor instead. +func (*RemoveAddress) Descriptor() ([]byte, []int) { + return file_proto_profile_proto_rawDescGZIP(), []int{4} +} + +func (x *RemoveAddress) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +// LinkCart links a cart grain to this user profile. +type LinkCart struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId uint64 `protobuf:"varint,1,opt,name=cartId,proto3" json:"cartId,omitempty"` + Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"` // optional label, e.g. "current cart" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LinkCart) Reset() { + *x = LinkCart{} + mi := &file_proto_profile_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LinkCart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LinkCart) ProtoMessage() {} + +func (x *LinkCart) ProtoReflect() protoreflect.Message { + mi := &file_proto_profile_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LinkCart.ProtoReflect.Descriptor instead. +func (*LinkCart) Descriptor() ([]byte, []int) { + return file_proto_profile_proto_rawDescGZIP(), []int{5} +} + +func (x *LinkCart) GetCartId() uint64 { + if x != nil { + return x.CartId + } + return 0 +} + +func (x *LinkCart) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +// LinkCheckout links a checkout grain to this user profile. +type LinkCheckout struct { + state protoimpl.MessageState `protogen:"open.v1"` + CheckoutId uint64 `protobuf:"varint,1,opt,name=checkoutId,proto3" json:"checkoutId,omitempty"` + CartId uint64 `protobuf:"varint,2,opt,name=cartId,proto3" json:"cartId,omitempty"` // the cart this checkout belongs to + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LinkCheckout) Reset() { + *x = LinkCheckout{} + mi := &file_proto_profile_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LinkCheckout) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LinkCheckout) ProtoMessage() {} + +func (x *LinkCheckout) ProtoReflect() protoreflect.Message { + mi := &file_proto_profile_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LinkCheckout.ProtoReflect.Descriptor instead. +func (*LinkCheckout) Descriptor() ([]byte, []int) { + return file_proto_profile_proto_rawDescGZIP(), []int{6} +} + +func (x *LinkCheckout) GetCheckoutId() uint64 { + if x != nil { + return x.CheckoutId + } + return 0 +} + +func (x *LinkCheckout) GetCartId() uint64 { + if x != nil { + return x.CartId + } + return 0 +} + +// LinkOrder links an order to this user profile. +type LinkOrder struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrderReference string `protobuf:"bytes,1,opt,name=orderReference,proto3" json:"orderReference,omitempty"` // order reference / id + CartId uint64 `protobuf:"varint,2,opt,name=cartId,proto3" json:"cartId,omitempty"` // originating cart + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` // order status snapshot + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LinkOrder) Reset() { + *x = LinkOrder{} + mi := &file_proto_profile_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LinkOrder) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LinkOrder) ProtoMessage() {} + +func (x *LinkOrder) ProtoReflect() protoreflect.Message { + mi := &file_proto_profile_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LinkOrder.ProtoReflect.Descriptor instead. +func (*LinkOrder) Descriptor() ([]byte, []int) { + return file_proto_profile_proto_rawDescGZIP(), []int{7} +} + +func (x *LinkOrder) GetOrderReference() string { + if x != nil { + return x.OrderReference + } + return "" +} + +func (x *LinkOrder) GetCartId() uint64 { + if x != nil { + return x.CartId + } + return 0 +} + +func (x *LinkOrder) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +type Mutation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Type: + // + // *Mutation_SetProfile + // *Mutation_AddAddress + // *Mutation_UpdateAddress + // *Mutation_RemoveAddress + // *Mutation_LinkCart + // *Mutation_LinkCheckout + // *Mutation_LinkOrder + Type isMutation_Type `protobuf_oneof:"type"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Mutation) Reset() { + *x = Mutation{} + mi := &file_proto_profile_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Mutation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Mutation) ProtoMessage() {} + +func (x *Mutation) ProtoReflect() protoreflect.Message { + mi := &file_proto_profile_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Mutation.ProtoReflect.Descriptor instead. +func (*Mutation) Descriptor() ([]byte, []int) { + return file_proto_profile_proto_rawDescGZIP(), []int{8} +} + +func (x *Mutation) GetType() isMutation_Type { + if x != nil { + return x.Type + } + return nil +} + +func (x *Mutation) GetSetProfile() *SetProfile { + if x != nil { + if x, ok := x.Type.(*Mutation_SetProfile); ok { + return x.SetProfile + } + } + return nil +} + +func (x *Mutation) GetAddAddress() *AddAddress { + if x != nil { + if x, ok := x.Type.(*Mutation_AddAddress); ok { + return x.AddAddress + } + } + return nil +} + +func (x *Mutation) GetUpdateAddress() *UpdateAddress { + if x != nil { + if x, ok := x.Type.(*Mutation_UpdateAddress); ok { + return x.UpdateAddress + } + } + return nil +} + +func (x *Mutation) GetRemoveAddress() *RemoveAddress { + if x != nil { + if x, ok := x.Type.(*Mutation_RemoveAddress); ok { + return x.RemoveAddress + } + } + return nil +} + +func (x *Mutation) GetLinkCart() *LinkCart { + if x != nil { + if x, ok := x.Type.(*Mutation_LinkCart); ok { + return x.LinkCart + } + } + return nil +} + +func (x *Mutation) GetLinkCheckout() *LinkCheckout { + if x != nil { + if x, ok := x.Type.(*Mutation_LinkCheckout); ok { + return x.LinkCheckout + } + } + return nil +} + +func (x *Mutation) GetLinkOrder() *LinkOrder { + if x != nil { + if x, ok := x.Type.(*Mutation_LinkOrder); ok { + return x.LinkOrder + } + } + return nil +} + +type isMutation_Type interface { + isMutation_Type() +} + +type Mutation_SetProfile struct { + SetProfile *SetProfile `protobuf:"bytes,1,opt,name=set_profile,json=setProfile,proto3,oneof"` +} + +type Mutation_AddAddress struct { + AddAddress *AddAddress `protobuf:"bytes,2,opt,name=add_address,json=addAddress,proto3,oneof"` +} + +type Mutation_UpdateAddress struct { + UpdateAddress *UpdateAddress `protobuf:"bytes,3,opt,name=update_address,json=updateAddress,proto3,oneof"` +} + +type Mutation_RemoveAddress struct { + RemoveAddress *RemoveAddress `protobuf:"bytes,4,opt,name=remove_address,json=removeAddress,proto3,oneof"` +} + +type Mutation_LinkCart struct { + LinkCart *LinkCart `protobuf:"bytes,5,opt,name=link_cart,json=linkCart,proto3,oneof"` +} + +type Mutation_LinkCheckout struct { + LinkCheckout *LinkCheckout `protobuf:"bytes,6,opt,name=link_checkout,json=linkCheckout,proto3,oneof"` +} + +type Mutation_LinkOrder struct { + LinkOrder *LinkOrder `protobuf:"bytes,7,opt,name=link_order,json=linkOrder,proto3,oneof"` +} + +func (*Mutation_SetProfile) isMutation_Type() {} + +func (*Mutation_AddAddress) isMutation_Type() {} + +func (*Mutation_UpdateAddress) isMutation_Type() {} + +func (*Mutation_RemoveAddress) isMutation_Type() {} + +func (*Mutation_LinkCart) isMutation_Type() {} + +func (*Mutation_LinkCheckout) isMutation_Type() {} + +func (*Mutation_LinkOrder) isMutation_Type() {} + +var File_proto_profile_proto protoreflect.FileDescriptor + +var file_proto_profile_proto_rawDesc = string([]byte{ + 0x0a, 0x13, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x10, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xfe, 0x02, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x75, 0x6c, + 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x75, 0x6c, + 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x00, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, + 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, + 0x7a, 0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x12, 0x18, + 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, + 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, + 0x88, 0x01, 0x01, 0x12, 0x2c, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, + 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, + 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x12, 0x2a, 0x0a, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, + 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44, + 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x42, 0x0f, 0x0a, + 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x08, + 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x22, 0x85, 0x02, 0x0a, 0x0a, 0x53, 0x65, 0x74, + 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, + 0x12, 0x19, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x01, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70, + 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x05, 0x70, 0x68, + 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, + 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, + 0x75, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, + 0x6e, 0x63, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x08, 0x63, 0x75, 0x72, + 0x72, 0x65, 0x6e, 0x63, 0x79, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x61, 0x76, 0x61, 0x74, + 0x61, 0x72, 0x55, 0x72, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x09, 0x61, + 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, + 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x08, + 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x6e, + 0x67, 0x75, 0x61, 0x67, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, + 0x63, 0x79, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, + 0x22, 0x41, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x33, + 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, + 0x65, 0x73, 0x73, 0x22, 0xab, 0x04, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x88, 0x01, 0x01, + 0x12, 0x1f, 0x0a, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x48, 0x01, 0x52, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, + 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, + 0x31, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x48, 0x03, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, + 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x48, 0x04, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, + 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x05, 0x73, + 0x74, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d, + 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x07, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, + 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x08, 0x52, 0x05, + 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65, + 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x08, 0x48, 0x09, 0x52, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, + 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x69, + 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, + 0x0c, 0x20, 0x01, 0x28, 0x08, 0x48, 0x0a, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, + 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, + 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x4e, + 0x61, 0x6d, 0x65, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, + 0x69, 0x6e, 0x65, 0x31, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x08, + 0x0a, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, + 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x08, 0x0a, 0x06, + 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, + 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x42, 0x13, 0x0a, 0x11, + 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, + 0x67, 0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, + 0x69, 0x64, 0x22, 0x38, 0x0a, 0x08, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x16, + 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, + 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x46, 0x0a, 0x0c, + 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x1e, 0x0a, 0x0a, + 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x0a, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, + 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, + 0x72, 0x74, 0x49, 0x64, 0x22, 0x63, 0x0a, 0x09, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, + 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, + 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, + 0x74, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, + 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xe8, 0x03, 0x0a, 0x08, 0x4d, 0x75, + 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3f, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x72, + 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, + 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, + 0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x73, 0x65, 0x74, + 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x3f, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x61, + 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, + 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, + 0x41, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, + 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x75, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x48, 0x00, 0x52, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, + 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, + 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72, + 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09, + 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c, + 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x45, 0x0a, 0x0d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, + 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, + 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x48, 0x00, + 0x52, 0x0c, 0x6c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x3c, + 0x0a, 0x0a, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x48, + 0x00, 0x52, 0x09, 0x6c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x06, 0x0a, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, + 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, + 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, + 0x66, 0x69, 0x6c, 0x65, 0x3b, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_proto_profile_proto_rawDescOnce sync.Once + file_proto_profile_proto_rawDescData []byte +) + +func file_proto_profile_proto_rawDescGZIP() []byte { + file_proto_profile_proto_rawDescOnce.Do(func() { + file_proto_profile_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_profile_proto_rawDesc), len(file_proto_profile_proto_rawDesc))) + }) + return file_proto_profile_proto_rawDescData +} + +var file_proto_profile_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_proto_profile_proto_goTypes = []any{ + (*Address)(nil), // 0: profile_messages.Address + (*SetProfile)(nil), // 1: profile_messages.SetProfile + (*AddAddress)(nil), // 2: profile_messages.AddAddress + (*UpdateAddress)(nil), // 3: profile_messages.UpdateAddress + (*RemoveAddress)(nil), // 4: profile_messages.RemoveAddress + (*LinkCart)(nil), // 5: profile_messages.LinkCart + (*LinkCheckout)(nil), // 6: profile_messages.LinkCheckout + (*LinkOrder)(nil), // 7: profile_messages.LinkOrder + (*Mutation)(nil), // 8: profile_messages.Mutation +} +var file_proto_profile_proto_depIdxs = []int32{ + 0, // 0: profile_messages.AddAddress.address:type_name -> profile_messages.Address + 1, // 1: profile_messages.Mutation.set_profile:type_name -> profile_messages.SetProfile + 2, // 2: profile_messages.Mutation.add_address:type_name -> profile_messages.AddAddress + 3, // 3: profile_messages.Mutation.update_address:type_name -> profile_messages.UpdateAddress + 4, // 4: profile_messages.Mutation.remove_address:type_name -> profile_messages.RemoveAddress + 5, // 5: profile_messages.Mutation.link_cart:type_name -> profile_messages.LinkCart + 6, // 6: profile_messages.Mutation.link_checkout:type_name -> profile_messages.LinkCheckout + 7, // 7: profile_messages.Mutation.link_order:type_name -> profile_messages.LinkOrder + 8, // [8:8] is the sub-list for method output_type + 8, // [8:8] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name +} + +func init() { file_proto_profile_proto_init() } +func file_proto_profile_proto_init() { + if File_proto_profile_proto != nil { + return + } + file_proto_profile_proto_msgTypes[0].OneofWrappers = []any{} + file_proto_profile_proto_msgTypes[1].OneofWrappers = []any{} + file_proto_profile_proto_msgTypes[3].OneofWrappers = []any{} + file_proto_profile_proto_msgTypes[8].OneofWrappers = []any{ + (*Mutation_SetProfile)(nil), + (*Mutation_AddAddress)(nil), + (*Mutation_UpdateAddress)(nil), + (*Mutation_RemoveAddress)(nil), + (*Mutation_LinkCart)(nil), + (*Mutation_LinkCheckout)(nil), + (*Mutation_LinkOrder)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_profile_proto_rawDesc), len(file_proto_profile_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_proto_profile_proto_goTypes, + DependencyIndexes: file_proto_profile_proto_depIdxs, + MessageInfos: file_proto_profile_proto_msgTypes, + }.Build() + File_proto_profile_proto = out.File + file_proto_profile_proto_goTypes = nil + file_proto_profile_proto_depIdxs = nil +}