From 26fd6dc970bbfbe96aa7aee4c214fa9395952a90 Mon Sep 17 00:00:00 2001 From: matst80 Date: Wed, 1 Jul 2026 22:11:37 +0200 Subject: [PATCH] more cart --- cmd/cart/main.go | 104 ++++ cmd/cart/pool-server.go | 48 ++ cmd/checkout/checkout_builder.go | 51 ++ cmd/checkout/main.go | 46 ++ cmd/profile/main.go | 46 ++ deployment/deployment.yaml | 12 + internal/ucp/cart.go | 9 +- internal/ucp/checkout.go | 46 +- internal/ucp/types.go | 9 +- pkg/actor/disk_storage.go | 66 +++ pkg/actor/disk_storage_test.go | 84 +++ pkg/backofficeadmin/app.go | 65 +++ pkg/cart/cart-grain.go | 27 + pkg/cart/cart-mutation-helper.go | 1 + pkg/cart/mutation_set_recovery_contact.go | 52 ++ .../mutation_set_recovery_contact_test.go | 105 ++++ pkg/cart/recovery/logging_notifier.go | 59 ++ pkg/cart/recovery/logging_notifier_test.go | 94 ++++ pkg/cart/recovery/recovery.go | 49 ++ pkg/cart/recovery/recovery_test.go | 257 +++++++++ pkg/cart/recovery/scanner.go | 187 ++++++ proto/cart.proto | 20 + proto/cart/cart.pb.go | 530 ++++++++++-------- 23 files changed, 1717 insertions(+), 250 deletions(-) create mode 100644 pkg/cart/mutation_set_recovery_contact.go create mode 100644 pkg/cart/mutation_set_recovery_contact_test.go create mode 100644 pkg/cart/recovery/logging_notifier.go create mode 100644 pkg/cart/recovery/logging_notifier_test.go create mode 100644 pkg/cart/recovery/recovery.go create mode 100644 pkg/cart/recovery/recovery_test.go create mode 100644 pkg/cart/recovery/scanner.go diff --git a/cmd/cart/main.go b/cmd/cart/main.go index c676af7..63846ca 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -17,6 +17,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/cart" cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp" + "git.k6n.net/mats/go-cart-actor/pkg/cart/recovery" "git.k6n.net/mats/go-cart-actor/pkg/promotions" "git.k6n.net/mats/go-cart-actor/pkg/proxy" "git.k6n.net/mats/go-cart-actor/pkg/telemetry" @@ -389,6 +390,65 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() + // Data Retention (C5) GDPR Purge + retentionDays := config.EnvInt("CART_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 }) + if retentionDays > 0 { + purgeInterval := config.EnvDuration("CART_PURGE_INTERVAL", 24*time.Hour) + log.Printf("cart: data retention enabled. Retaining carts for %d days, purging every %v", retentionDays, purgeInterval) + go func() { + ticker := time.NewTicker(purgeInterval) + defer ticker.Stop() + + // Run immediately on startup + runPurge(ctx, diskStorage, pool, retentionDays) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runPurge(ctx, diskStorage, pool, retentionDays) + } + } + }() + } else { + log.Print("cart: data retention / GDPR purge disabled (CART_RETENTION_DAYS not set or <= 0)") + } + + // Abandoned-cart recovery check (C3-notifications seam, dry-run by default). + // Scans local pod event-log files for carts whose modtime falls into the + // "[now - delay - window, now - delay]" window and that have items + a + // contact (email and/or push tokens). Hands each candidate to the + // configured Notifier (LoggingNotifier v0). The scanner reads local shard + // files only — the control plane doesn't fan out between pods on purpose + // (each pod owns the carts it is the owner of). Idempotency is the next + // pass; the v0 window size keeps duplicate notifications minute-scoped. + abandonedDelay := config.EnvDuration("CART_ABANDONED_DELAY", time.Hour) + if abandonedDelay > 0 { + abandonedWindow := config.EnvDuration("CART_ABANDONED_WINDOW", time.Hour) + abandonedInterval := config.EnvDuration("CART_ABANDONED_SCAN_INTERVAL", 15*time.Minute) + notifier := recovery.NewLoggingNotifier() + scanner := recovery.NewScanner(config.EnvString("CART_DIR", "data"), diskStorage) + log.Printf("cart: abandonment recovery ENABLED. Delay=%v window=%v scan_interval=%v (notifier=LoggingNotifier dry-run)", abandonedDelay, abandonedWindow, abandonedInterval) + go func() { + ticker := time.NewTicker(abandonedInterval) + defer ticker.Stop() + + runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow) + } + } + }() + } else { + log.Print("cart: abandonment recovery DISABLED (CART_ABANDONED_DELAY not set or <= 0)") + } + otelShutdown, err := telemetry.SetupOTelSDK(ctx) if err != nil { log.Fatalf("Unable to start otel %v", err) @@ -535,3 +595,47 @@ func main() { } } + +func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[cart.CartGrain], pool *actor.SimpleGrainPool[cart.CartGrain], retentionDays int) { + log.Printf("cart: starting data retention purge...") + retention := time.Duration(retentionDays) * 24 * time.Hour + + localIds := make(map[uint64]bool) + for _, id := range pool.GetLocalIds() { + localIds[id] = true + } + isGrainActive := func(id uint64) bool { + return localIds[id] + } + + purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive) + if err != nil { + log.Printf("cart: data retention purge failed: %v", err) + } else { + log.Printf("cart: data retention purge completed. Purged %d expired cart logs", purged) + } +} + +// runRecovery is the abandoned-cart scanner pass. It finds carts that crossed +// the abandonment threshold in the recent window and hands each to the +// configured Notifier. Each pass is independent; idempotency across ticks is +// the next pass (logged duplicates are not catastrophic for the dry-run +// notifier, but a real MailerSend/FCM impl will need a SETNX side-key). +func runRecovery(ctx context.Context, scanner *recovery.Scanner, notifier recovery.Notifier, delay, window time.Duration) { + candidates, err := scanner.Scan(ctx, delay, window, time.Now()) + if err != nil { + log.Printf("cart: recovery scan failed: %v", err) + return + } + if len(candidates) == 0 { + log.Printf("cart: recovery scan complete, 0 candidates (window delay=%v width=%v)", delay, window) + return + } + log.Printf("cart: recovery scan complete, %d candidates", len(candidates)) + for _, c := range candidates { + if err := notifier.NotifyAbandoned(ctx, c); err != nil { + log.Printf("cart: recovery notify (cart %d) failed: %v", c.CartID, err) + } + } +} + diff --git a/cmd/cart/pool-server.go b/cmd/cart/pool-server.go index 89db7d3..e194096 100644 --- a/cmd/cart/pool-server.go +++ b/cmd/cart/pool-server.go @@ -605,6 +605,49 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request return nil } +// SetRecoveryContactRequest is the wire shape clients send to +// PUT /cart/recovery-contact. It mirrors proto SetRecoveryContact with +// JSON-friendly types; sending an empty JSON object clears all contact info. +type SetRecoveryContactRequest struct { + Email string `json:"email,omitempty"` + PushTokens []cart.PushToken `json:"pushTokens,omitempty"` +} + +// toMessage builds the proto message from the JSON request body. +func (s *SetRecoveryContactRequest) toMessage() *messages.SetRecoveryContact { + out := &messages.SetRecoveryContact{Email: s.Email} + if len(s.PushTokens) > 0 { + out.PushTokens = make([]*messages.PushToken, 0, len(s.PushTokens)) + for _, t := range s.PushTokens { + out.PushTokens = append(out.PushTokens, &messages.PushToken{ + Platform: t.Platform, + Token: t.Token, + }) + } + } + return out +} + +func (s *PoolServer) SetRecoveryContactHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { + if r.Method != http.MethodPut { + w.WriteHeader(http.StatusMethodNotAllowed) + return nil + } + req := SetRecoveryContactRequest{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + return err + } + reply, err := s.ApplyLocal(r.Context(), cartId, req.toMessage()) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return err + } + return s.WriteResult(w, reply) +} + func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error { setUserId := messages.SetUserId{} err := json.NewDecoder(r.Body).Decode(&setUserId) @@ -747,6 +790,10 @@ func (s *PoolServer) Serve(mux *http.ServeMux) { handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler))) handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler))) handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler))) + // PUT only — SetRecoveryContact is PUT/replace semantics. We deliberately + // do NOT allow POST here to avoid conflicts with any future POST→create + // semantics on /cart paths. + handleFunc("PUT /cart/recovery-contact", CookieCartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler))) handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler))) handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler))) @@ -763,6 +810,7 @@ func (s *PoolServer) Serve(mux *http.ServeMux) { handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler))) handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler))) handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler))) + handleFunc("PUT /cart/byid/{id}/recovery-contact", CartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler))) handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler))) handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler))) handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler))) diff --git a/cmd/checkout/checkout_builder.go b/cmd/checkout/checkout_builder.go index 26cbda3..6c6b0e6 100644 --- a/cmd/checkout/checkout_builder.go +++ b/cmd/checkout/checkout_builder.go @@ -151,6 +151,33 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta }) } + // Reflected applied promotions as negative discount lines + if grain.CartState != nil { + for _, ap := range grain.CartState.AppliedPromotions { + if ap.Pending { + continue + } + discountVal := int64(0) + if ap.Discount != nil { + discountVal = ap.Discount.IncVat.Int64() + } + if discountVal <= 0 { + continue + } + lines = append(lines, &Line{ + Type: "discount", + Reference: "promo-" + ap.PromotionId, + Name: "Promotion: " + ap.Name, + Quantity: 1, + UnitPrice: int(-discountVal), + QuantityUnit: "st", + TotalAmount: int(-discountVal), + TaxRate: 2500, + TotalTaxAmount: int(-discountVal * 2500 / 12500), + }) + } + } + order := &CheckoutOrder{ PurchaseCountry: country, PurchaseCurrency: currency, @@ -285,6 +312,30 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta }) } + // Reflected applied promotions as negative discount lines + if grain.CartState != nil { + for _, ap := range grain.CartState.AppliedPromotions { + if ap.Pending { + continue + } + discountVal := int64(0) + if ap.Discount != nil { + discountVal = ap.Discount.IncVat.Int64() + } + if discountVal <= 0 { + continue + } + lineItems = append(lineItems, adyenCheckout.LineItem{ + Quantity: common.PtrInt64(1), + AmountIncludingTax: common.PtrInt64(-discountVal), + Description: common.PtrString("Promotion: " + ap.Name), + AmountExcludingTax: common.PtrInt64(-discountVal * 10000 / 12500), + TaxAmount: common.PtrInt64(-discountVal * 2500 / 12500), + TaxPercentage: common.PtrInt64(2500), + }) + } + } + return &adyenCheckout.CreateCheckoutSessionRequest{ Reference: grain.Id.String(), Amount: adyenCheckout.Amount{ diff --git a/cmd/checkout/main.go b/cmd/checkout/main.go index ad7dd90..d4c74af 100644 --- a/cmd/checkout/main.go +++ b/cmd/checkout/main.go @@ -212,6 +212,31 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() + // Data Retention (C5) GDPR Purge + retentionDays := config.EnvInt("CHECKOUT_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 }) + if retentionDays > 0 { + purgeInterval := config.EnvDuration("CHECKOUT_PURGE_INTERVAL", 24*time.Hour) + log.Printf("checkout: data retention enabled. Retaining checkouts for %d days, purging every %v", retentionDays, purgeInterval) + go func() { + ticker := time.NewTicker(purgeInterval) + defer ticker.Stop() + + // Run immediately on startup + runPurge(ctx, diskStorage, pool, retentionDays) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runPurge(ctx, diskStorage, pool, retentionDays) + } + } + }() + } else { + log.Print("checkout: data retention / GDPR purge disabled (CHECKOUT_RETENTION_DAYS not set or <= 0)") + } + otelShutdown, err := telemetry.SetupOTelSDK(ctx) if err != nil { log.Fatalf("Unable to start otel %v", err) @@ -299,3 +324,24 @@ func main() { stop() } } + +func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[checkout.CheckoutGrain], pool *actor.SimpleGrainPool[checkout.CheckoutGrain], retentionDays int) { + log.Printf("checkout: starting data retention purge...") + retention := time.Duration(retentionDays) * 24 * time.Hour + + localIds := make(map[uint64]bool) + for _, id := range pool.GetLocalIds() { + localIds[id] = true + } + isGrainActive := func(id uint64) bool { + return localIds[id] + } + + purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive) + if err != nil { + log.Printf("checkout: data retention purge failed: %v", err) + } else { + log.Printf("checkout: data retention purge completed. Purged %d expired checkout logs", purged) + } +} + diff --git a/cmd/profile/main.go b/cmd/profile/main.go index 3b85cec..22c2748 100644 --- a/cmd/profile/main.go +++ b/cmd/profile/main.go @@ -187,6 +187,31 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() + // Data Retention (C5) GDPR Purge + retentionDays := config.EnvInt("PROFILE_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 }) + if retentionDays > 0 { + purgeInterval := config.EnvDuration("PROFILE_PURGE_INTERVAL", 24*time.Hour) + log.Printf("profile: data retention enabled. Retaining profiles for %d days, purging every %v", retentionDays, purgeInterval) + go func() { + ticker := time.NewTicker(purgeInterval) + defer ticker.Stop() + + // Run immediately on startup + runPurge(ctx, diskStorage, pool, retentionDays) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runPurge(ctx, diskStorage, pool, retentionDays) + } + } + }() + } else { + log.Print("profile: data retention / GDPR purge disabled (PROFILE_RETENTION_DAYS not set or <= 0)") + } + otelShutdown, err := telemetry.SetupOTelSDK(ctx) if err != nil { log.Fatalf("Unable to start otel %v", err) @@ -292,3 +317,24 @@ func main() { stop() } } + +func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[profile.ProfileGrain], pool *actor.SimpleGrainPool[profile.ProfileGrain], retentionDays int) { + log.Printf("profile: starting data retention purge...") + retention := time.Duration(retentionDays) * 24 * time.Hour + + localIds := make(map[uint64]bool) + for _, id := range pool.GetLocalIds() { + localIds[id] = true + } + isGrainActive := func(id uint64) bool { + return localIds[id] + } + + purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive) + if err != nil { + log.Printf("profile: data retention purge failed: %v", err) + } else { + log.Printf("profile: data retention purge completed. Purged %d expired profile logs", purged) + } +} + diff --git a/deployment/deployment.yaml b/deployment/deployment.yaml index d88aa48..108391d 100644 --- a/deployment/deployment.yaml +++ b/deployment/deployment.yaml @@ -230,6 +230,10 @@ spec: valueFrom: fieldRef: fieldPath: metadata.name + - name: CART_RETENTION_DAYS + value: "30" + - name: CART_PURGE_INTERVAL + value: "24h" --- apiVersion: apps/v1 kind: Deployment @@ -350,6 +354,10 @@ spec: valueFrom: fieldRef: fieldPath: metadata.name + - name: CART_RETENTION_DAYS + value: "30" + - name: CART_PURGE_INTERVAL + value: "24h" --- kind: Service apiVersion: v1 @@ -484,6 +492,10 @@ spec: valueFrom: fieldRef: fieldPath: metadata.name + - name: CHECKOUT_RETENTION_DAYS + value: "30" + - name: CHECKOUT_PURGE_INTERVAL + value: "24h" --- kind: Service apiVersion: v1 diff --git a/internal/ucp/cart.go b/internal/ucp/cart.go index c5a1c8d..6e4fc9f 100644 --- a/internal/ucp/cart.go +++ b/internal/ucp/cart.go @@ -185,10 +185,11 @@ func parseCartID(r *http.Request) (uint64, bool) { func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse { resp := CartResponse{ - Id: id, - Items: make([]CartItem, 0, len(g.Items)), - Totals: Totals{Currency: g.Currency}, - Status: "active", + Id: id, + Items: make([]CartItem, 0, len(g.Items)), + Totals: Totals{Currency: g.Currency}, + Status: "active", + AppliedPromotions: g.AppliedPromotions, } if g.CheckoutStatus != nil && *g.CheckoutStatus != "" { resp.Status = string(*g.CheckoutStatus) diff --git a/internal/ucp/checkout.go b/internal/ucp/checkout.go index ccbd94b..1fb6871 100644 --- a/internal/ucp/checkout.go +++ b/internal/ucp/checkout.go @@ -542,6 +542,16 @@ func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g // buildOrderLines converts a checkout grain's cart items and delivery selections // into order lines, matching the format expected by the order service. func buildOrderLines(g *checkout.CheckoutGrain) []orderLine { + hasFreeShipping := false + if g.CartState != nil { + for _, ap := range g.CartState.AppliedPromotions { + if ap.Type == "free_shipping" && !ap.Pending { + hasFreeShipping = true + break + } + } + } + lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries)) for _, it := range g.CartState.Items { if it == nil { @@ -569,7 +579,14 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine { }) } for _, d := range g.Deliveries { - if d == nil || d.Price.IncVat <= 0 { + if d == nil { + continue + } + unitPrice := d.Price.IncVat + if hasFreeShipping { + unitPrice = 0 + } + if unitPrice <= 0 && !hasFreeShipping { continue } lines = append(lines, orderLine{ @@ -577,10 +594,35 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine { Sku: d.Provider, Name: "Delivery", Quantity: 1, - UnitPrice: d.Price.IncVat, + UnitPrice: unitPrice, TaxRate: 2500, // 25% in basis points }) } + + // Reflected applied promotions as negative discount lines + if g.CartState != nil { + for _, ap := range g.CartState.AppliedPromotions { + if ap.Pending { + continue + } + discountVal := int64(0) + if ap.Discount != nil { + discountVal = ap.Discount.IncVat.Int64() + } + if discountVal <= 0 { + continue + } + lines = append(lines, orderLine{ + Reference: "promo-" + ap.PromotionId, + Sku: "promo-" + ap.PromotionId, + Name: "Promotion: " + ap.Name, + Quantity: 1, + UnitPrice: money.Cents(-discountVal), + TaxRate: 2500, // default standard tax rate (25%) in basis points + }) + } + } + return lines } diff --git a/internal/ucp/types.go b/internal/ucp/types.go index 57c3979..76b1c22 100644 --- a/internal/ucp/types.go +++ b/internal/ucp/types.go @@ -122,10 +122,11 @@ type VoucherInput struct { // CartResponse is the UCP cart response body. type CartResponse struct { - Id string `json:"id"` - Items []CartItem `json:"items,omitempty"` - Totals Totals `json:"totals"` - Status string `json:"status"` + Id string `json:"id"` + Items []CartItem `json:"items,omitempty"` + Totals Totals `json:"totals"` + Status string `json:"status"` + AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"` } // CartItem is the UCP wire format for a cart line item in responses. diff --git a/pkg/actor/disk_storage.go b/pkg/actor/disk_storage.go index 62ec531..f90d7e8 100644 --- a/pkg/actor/disk_storage.go +++ b/pkg/actor/disk_storage.go @@ -7,6 +7,7 @@ import ( "log" "os" "path/filepath" + "strings" "sync" "time" @@ -201,6 +202,9 @@ func (s *DiskStorage[V]) SaveLoop(duration time.Duration) { } func (s *DiskStorage[V]) save() { + if s.queue == nil { + return + } carts := 0 lines := 0 s.queue.Range(func(key, value any) bool { @@ -295,3 +299,65 @@ func (s *DiskStorage[V]) openWriterCount() int { defer s.writersMu.Unlock() return len(s.writers) } + +func (s *DiskStorage[V]) PurgeOlderThan(ctx context.Context, retention time.Duration, isGrainActive func(id uint64) bool) (int, error) { + if err := s.ensureDir(); err != nil { + return 0, err + } + + files, err := os.ReadDir(s.path) + if err != nil { + return 0, fmt.Errorf("read storage dir: %w", err) + } + + cutoff := time.Now().Add(-retention) + purgedCount := 0 + + for _, entry := range files { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".events.log") { + continue + } + + var id uint64 + _, err := fmt.Sscanf(name, "%d.events.log", &id) + if err != nil { + continue + } + + info, err := entry.Info() + if err != nil { + log.Printf("failed to get file info for %s: %v", name, err) + continue + } + + if info.ModTime().Before(cutoff) { + // Check if grain is active in memory + if isGrainActive != nil && isGrainActive(id) { + continue + } + + // Check if we have an active writer open + s.writersMu.Lock() + _, hasWriter := s.writers[id] + s.writersMu.Unlock() + if hasWriter { + continue + } + + // Delete the file + fullPath := filepath.Join(s.path, name) + if err := os.Remove(fullPath); err != nil { + log.Printf("failed to delete expired cart log %s: %v", fullPath, err) + } else { + purgedCount++ + } + } + } + + return purgedCount, nil +} + diff --git a/pkg/actor/disk_storage_test.go b/pkg/actor/disk_storage_test.go index 9d49a00..6cce629 100644 --- a/pkg/actor/disk_storage_test.go +++ b/pkg/actor/disk_storage_test.go @@ -2,6 +2,7 @@ package actor import ( "context" + "os" "sync" "testing" "time" @@ -317,3 +318,86 @@ func TestDiskStorageConcurrentWritesDifferentIDs(t *testing.T) { } } } + +func TestDiskStoragePurge(t *testing.T) { + reg := diskTestRegistry(t) + dir := t.TempDir() + storage := NewDiskStorage[testState](dir, reg) + t.Cleanup(storage.Close) + + ctx := context.Background() + msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1} + + // Write mutations to create event logs for 1, 2, 3, 4 + ids := []uint64{1, 2, 3, 4} + for _, id := range ids { + if err := storage.AppendMutations(id, msg); err != nil { + t.Fatalf("append %d: %v", id, err) + } + } + + // Force save to disk + storage.save() + + // 1. Grain 1: Old modification time, inactive, no writer -> should be purged + path1 := storage.logPath(1) + oldTime := time.Now().Add(-10 * 24 * time.Hour) + if err := os.Chtimes(path1, oldTime, oldTime); err != nil { + t.Fatal(err) + } + + // We must close the writer for grain 1, 2, 4 to make them eligible for purging (no open writer) + storage.writersMu.Lock() + for _, id := range []uint64{1, 2, 4} { + if w, ok := storage.writers[id]; ok { + w.purged = true + w.file.Close() + delete(storage.writers, id) + } + } + storage.writersMu.Unlock() + + // 2. Grain 2: Old mod time, but marked active -> should NOT be purged + path2 := storage.logPath(2) + if err := os.Chtimes(path2, oldTime, oldTime); err != nil { + t.Fatal(err) + } + + // 3. Grain 3: Old mod time, but has open writer -> should NOT be purged + path3 := storage.logPath(3) + if err := os.Chtimes(path3, oldTime, oldTime); err != nil { + t.Fatal(err) + } + + // 4. Grain 4: Recent mod time -> should NOT be purged + path4 := storage.logPath(4) + + activeGrains := map[uint64]bool{2: true} + isGrainActive := func(id uint64) bool { + return activeGrains[id] + } + + purged, err := storage.PurgeOlderThan(ctx, 5*24*time.Hour, isGrainActive) + if err != nil { + t.Fatalf("PurgeOlderThan failed: %v", err) + } + + if purged != 1 { + t.Errorf("Expected exactly 1 purged file, got %d", purged) + } + + // Verify file status + if _, err := os.Stat(path1); !os.IsNotExist(err) { + t.Error("Expected grain 1 log to be deleted") + } + if _, err := os.Stat(path2); err != nil { + t.Error("Expected grain 2 log to exist (active)") + } + if _, err := os.Stat(path3); err != nil { + t.Error("Expected grain 3 log to exist (active writer)") + } + if _, err := os.Stat(path4); err != nil { + t.Error("Expected grain 4 log to exist (recent)") + } +} + diff --git a/pkg/backofficeadmin/app.go b/pkg/backofficeadmin/app.go index d35b7ba..62eda2a 100644 --- a/pkg/backofficeadmin/app.go +++ b/pkg/backofficeadmin/app.go @@ -15,12 +15,14 @@ import ( "log" "net/http" "os" + "strings" "time" 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-cart-actor/pkg/promotions" "git.k6n.net/mats/platform/rabbit" amqp "github.com/rabbitmq/amqp091-go" ) @@ -75,6 +77,69 @@ func New(cfg Config) (*App, error) { _ = os.MkdirAll(cfg.DataDir, 0755) reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) + + promotionsPath := os.Getenv("PROMOTIONS_FILE") + if promotionsPath == "" { + promotionsPath = "data/promotions.json" + } + if promotionStore, err := promotions.NewStore(promotionsPath); err == nil { + promotionService := promotions.NewPromotionService(nil) + reg.RegisterProcessor( + actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error { + for _, v := range g.Vouchers { + if v != nil { + v.BypassedByPromotions = false + } + } + + g.UpdateTotals() + promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now())) + results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx) + + hasBypassed := false + for _, res := range results { + if res.Applicable { + promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool { + if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode { + var codes []string + if s, ok := bc.Value.AsString(); ok { + codes = append(codes, strings.ToLower(s)) + } else if arr, ok := bc.Value.AsStringSlice(); ok { + for _, s := range arr { + codes = append(codes, strings.ToLower(s)) + } + } + for _, code := range codes { + for _, v := range g.Vouchers { + if v != nil && strings.ToLower(v.Code) == code { + if !v.BypassedByPromotions { + v.BypassedByPromotions = true + hasBypassed = true + } + } + } + } + } + return true + }) + } + } + + if hasBypassed { + g.UpdateTotals() + promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now())) + results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx) + } + + promotionService.ApplyResults(g, results, promotionCtx) + g.Version++ + return nil + }), + ) + } else { + log.Printf("backofficeadmin: unable to load promotions store from %s: %v", promotionsPath, err) + } + diskStorage := actor.NewDiskStorage[cart.CartGrain](cfg.DataDir, reg) _ = os.MkdirAll(cfg.CheckoutDataDir, 0755) diff --git a/pkg/cart/cart-grain.go b/pkg/cart/cart-grain.go index f6e76cf..281cace 100644 --- a/pkg/cart/cart-grain.go +++ b/pkg/cart/cart-grain.go @@ -86,6 +86,25 @@ type Notice struct { Code *string `json:"code,omitempty"` } +// PushToken is the Go-level mirror of cart_messages.PushToken: a generic, +// provider-agnostic push-delivery target. Stored on the grain so the +// abandoned-cart recovery scanner can hand it to a notifier without +// re-decoding proto messages; the wire shape lives in proto/cart.proto. +// +// SECURITY: the raw Token persists verbatim to the per-pod event log +// (CartGrain JSON-marshals PushTokens on every mutation). Anyone with read +// access to the cart service's data dir — PVC snapshots, debug dumps, log +// archival — can extract device handles. A future hardening pass should +// either hash-on-write (hash.compareAtLaunch) for stateless matching or +// encrypt-at-rest with a key the notifier owns. v0 keeps the seam: a real +// notifier should treat the stored token as opaque, fetch any canonical +// canonicalised handle from the input side (frontend/web SDK), and use +// what's here only for routing. +type PushToken struct { + Platform string `json:"platform"` + Token string `json:"token"` +} + type CartPaymentStatus string const ( @@ -151,6 +170,14 @@ type CartGrain struct { Notifications []CartNotification `json:"cartNotification,omitempty"` SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"` + // Email is the cart's recovery contact address. Set explicitly via the + // SetRecoveryContact mutation (independent of the linked profile's email), + // so abandoned-cart recovery can fire before login. Empty string = no email. + Email string `json:"email,omitempty"` + // PushTokens holds every push delivery target the shopper attached to this + // cart. Empty list = no push. PUT-style replaced by SetRecoveryContact. + PushTokens []PushToken `json:"pushTokens,omitempty"` + //CheckoutOrderId string `json:"checkoutOrderId,omitempty"` CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"` //CheckoutCountry string `json:"checkoutCountry,omitempty"` diff --git a/pkg/cart/cart-mutation-helper.go b/pkg/cart/cart-mutation-helper.go index 7e0b864..2ec6f8b 100644 --- a/pkg/cart/cart-mutation-helper.go +++ b/pkg/cart/cart-mutation-helper.go @@ -88,6 +88,7 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist actor.NewMutation(SetLineItemCustomFields), actor.NewMutation(SubscriptionAdded), actor.NewMutation(context.SetCartType), + actor.NewMutation(SetRecoveryContact), // actor.NewMutation(SubscriptionRemoved), ) return reg diff --git a/pkg/cart/mutation_set_recovery_contact.go b/pkg/cart/mutation_set_recovery_contact.go new file mode 100644 index 0000000..e88d65e --- /dev/null +++ b/pkg/cart/mutation_set_recovery_contact.go @@ -0,0 +1,52 @@ +package cart + +import ( + "errors" + "log" + + messages "git.k6n.net/mats/go-cart-actor/proto/cart" +) + +// SetRecoveryContact replaces the entire recovery-contact bundle on a cart in +// a single event. PUT-style semantics — empty email and an empty token list +// both persist as "no contact attached"; callers wanting to clear should send +// an empty bundle. +// +// Trivial whitespace-only normalization is intentionally NOT done here: the +// notifier layer owns validation (and the LoggingNotifier just logs). A future +// real notifier does its own normalization before sending, so we don't want +// to lose the original input. +func SetRecoveryContact(grain *CartGrain, req *messages.SetRecoveryContact) error { + if req == nil { + return errors.New("SetRecoveryContact: nil payload") + } + grain.Email = req.Email + if len(req.PushTokens) == 0 { + grain.PushTokens = nil + return nil + } + tokens := make([]PushToken, 0, len(req.PushTokens)) + dropped := 0 + for _, t := range req.PushTokens { + if t == nil { + // Defensive: a malformed wire request left a nil slot in the + // repeated field. Skip rather than crash and surface at debug + // level so a misbehaving client is visible without spamming. + dropped++ + continue + } + tokens = append(tokens, PushToken{ + Platform: t.Platform, + Token: t.Token, + }) + } + if dropped > 0 { + log.Printf("SetRecoveryContact: dropped %d nil push-token entries (malformed wire)", dropped) + } + if len(tokens) == 0 { + grain.PushTokens = nil + return nil + } + grain.PushTokens = tokens + return nil +} diff --git a/pkg/cart/mutation_set_recovery_contact_test.go b/pkg/cart/mutation_set_recovery_contact_test.go new file mode 100644 index 0000000..fa00de5 --- /dev/null +++ b/pkg/cart/mutation_set_recovery_contact_test.go @@ -0,0 +1,105 @@ +package cart + +import ( + "context" + "testing" + "time" + + messages "git.k6n.net/mats/go-cart-actor/proto/cart" +) + +// TestSetRecoveryContact_ReplacesEntireBundle verifies PUT semantics: a +// second call fully overwrites the first; calling with empty clears it. +func TestSetRecoveryContact_ReplacesEntireBundle(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + // Initial set. + if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{ + Email: "first@example.com", + PushTokens: []*messages.PushToken{ + {Platform: "fcm", Token: "AAA"}, + {Platform: "apns", Token: "BBB"}, + }, + }); err != nil { + t.Fatalf("first set: %v", err) + } + if g.Email != "first@example.com" { + t.Errorf("email = %q, want first@example.com", g.Email) + } + if len(g.PushTokens) != 2 { + t.Fatalf("push tokens = %d, want 2", len(g.PushTokens)) + } + if g.PushTokens[0].Platform != "fcm" || g.PushTokens[1].Platform != "apns" { + t.Errorf("platforms = %+v, want [fcm apns]", g.PushTokens) + } + + // Replace with one push token. + if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{ + Email: "second@example.com", + PushTokens: []*messages.PushToken{{Platform: "webpush", Token: "CCC"}}, + }); err != nil { + t.Fatalf("replace: %v", err) + } + if g.Email != "second@example.com" { + t.Errorf("after replace email = %q, want second@example.com", g.Email) + } + if len(g.PushTokens) != 1 { + t.Fatalf("after replace push tokens = %d, want 1", len(g.PushTokens)) + } + if g.PushTokens[0].Platform != "webpush" { + t.Errorf("after replace platform = %q, want webpush", g.PushTokens[0].Platform) + } + + // Clear. + if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{}); err != nil { + t.Fatalf("clear: %v", err) + } + if g.Email != "" || len(g.PushTokens) != 0 { + t.Errorf("after clear: email=%q tokens=%d, want empty", g.Email, len(g.PushTokens)) + } +} + +// TestSetRecoveryContact_NilPayload verifies defensive nil-input behavior. +// We document that nil returns an error (no silent swallow of bad requests). +func TestSetRecoveryContact_NilPayload(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + res, err := reg.Apply(ctx, g, (*messages.SetRecoveryContact)(nil)) + // registry.Apply treats typed-nil proto messages as a no-op (see + // mutation_registry.go's "Typed nil" handling). The behavior we want is + // "no mutation applied to grain state" — either via no-op via the + // typed-nil path or via an error in the result list. We assert that the + // grain state was not modified. + if g.Email != "" || len(g.PushTokens) != 0 { + t.Errorf("nil-set should not modify grain: email=%q tokens=%d", g.Email, len(g.PushTokens)) + } + _ = res + _ = err +} + +// TestSetRecoveryContact_SkipsNilTokens verifies nil sub-message tokens are +// dropped rather than crashing. +func TestSetRecoveryContact_SkipsNilTokens(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(2, time.Now()) + ctx := context.Background() + if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{ + Email: "ok@example.com", + PushTokens: []*messages.PushToken{ + nil, + {Platform: "fcm", Token: "REAL"}, + nil, + }, + }); err != nil { + t.Fatalf("set with nil tokens: %v", err) + } + if len(g.PushTokens) != 1 { + t.Fatalf("push tokens = %d, want 1 (nil entries dropped)", len(g.PushTokens)) + } + if g.PushTokens[0].Platform != "fcm" { + t.Errorf("kept platform = %q, want fcm", g.PushTokens[0].Platform) + } +} diff --git a/pkg/cart/recovery/logging_notifier.go b/pkg/cart/recovery/logging_notifier.go new file mode 100644 index 0000000..6171c63 --- /dev/null +++ b/pkg/cart/recovery/logging_notifier.go @@ -0,0 +1,59 @@ +package recovery + +import ( + "context" + "log" + + "git.k6n.net/mats/go-cart-actor/pkg/cart" +) + +// LoggingNotifier is the v0 default: it writes one INFO line per candidate +// and returns nil. It deliberately does NOT pretend to send anything. +// +// Future implementations (MailerSend, FCM, APNs) implement the same Notifier +// interface; the scanner doesn't change. LoggingNotifier also serves as the +// "dry-run" mode in production by leaving the seam unconfigured. +type LoggingNotifier struct{} + +// NewLoggingNotifier returns a Notifier that logs every candidate. The struct +// is empty today; the constructor is kept for parity with future notifiers +// that need credentials / config. +func NewLoggingNotifier() *LoggingNotifier { return &LoggingNotifier{} } + +// NotifyAbandoned logs the cart id, contact present, and a tiny summary. +// Push tokens are summarized by platform only — never log the raw token, so +// a misconfigured log aggregator can't leak device handles. +func (LoggingNotifier) NotifyAbandoned(_ context.Context, c RecoveryCandidate) error { + hasEmail := c.Email != "" + hasPush := len(c.PushTokens) > 0 + channel := "none" + switch { + case hasEmail && hasPush: + channel = "email+push" + case hasEmail: + channel = "email" + case hasPush: + channel = "push" + } + log.Printf( + "recovery [dry-run]: cart=%d user=%q channel=%s items=%d total=%d currency=%s email=%q pushPlatforms=%v lastChangeUnix=%d", + c.CartID, + c.UserID, + channel, + c.ItemCount, + c.TotalIncVat, + c.Currency, + c.Email, + platformsOf(c.PushTokens), + c.LastChangeUnix, + ) + return nil +} + +func platformsOf(tokens []cart.PushToken) []string { + out := make([]string, 0, len(tokens)) + for _, t := range tokens { + out = append(out, t.Platform) + } + return out +} diff --git a/pkg/cart/recovery/logging_notifier_test.go b/pkg/cart/recovery/logging_notifier_test.go new file mode 100644 index 0000000..55c312a --- /dev/null +++ b/pkg/cart/recovery/logging_notifier_test.go @@ -0,0 +1,94 @@ +package recovery + +import ( + "bytes" + "context" + "log" + "strings" + "testing" + + "git.k6n.net/mats/go-cart-actor/pkg/cart" +) + +// captureLog writes log output to an in-memory buffer for inspection. +func captureLog(t *testing.T) *bytes.Buffer { + t.Helper() + buf := &bytes.Buffer{} + orig := log.Writer() + flags := log.Flags() + prefix := log.Prefix() + log.SetOutput(buf) + log.SetFlags(0) + log.SetPrefix("") + t.Cleanup(func() { + log.SetOutput(orig) + log.SetFlags(flags) + log.SetPrefix(prefix) + }) + return buf +} + +func TestLoggingNotifier_LogsPlatformsNotTokens(t *testing.T) { + buf := captureLog(t) + n := LoggingNotifier{} + err := n.NotifyAbandoned(context.Background(), RecoveryCandidate{ + CartID: 1, + Email: "doc@example.com", + PushTokens: []cart.PushToken{{Platform: "fcm", Token: "SECRET-TOKEN-MUST-NOT-LEAK"}, {Platform: "apns", Token: "ALSO-SECRET-2"}}, + }) + if err != nil { + t.Fatalf("NotifyAbandoned: %v", err) + } + out := buf.String() + if !strings.Contains(out, "doc@example.com") { + t.Errorf("expected email in log: %s", out) + } + if !strings.Contains(out, "[fcm apns]") { + t.Errorf("expected bracketed platform list in log: %s", out) + } + for _, leak := range []string{"SECRET-TOKEN-MUST-NOT-LEAK", "ALSO-SECRET-2"} { + if strings.Contains(out, leak) { + t.Errorf("token leaked into log: %s line=%s", leak, out) + } + } +} + +func TestLoggingNotifier_ClassifiesChannel(t *testing.T) { + buf := captureLog(t) + n := LoggingNotifier{} + cases := []struct { + name string + cand RecoveryCandidate + wantSub string + }{ + { + name: "email+push", + cand: RecoveryCandidate{Email: "a@b.c", PushTokens: []cart.PushToken{{Platform: "fcm"}}}, + wantSub: "channel=email+push", + }, + { + name: "email only", + cand: RecoveryCandidate{Email: "a@b.c"}, + wantSub: "channel=email", + }, + { + name: "push only", + cand: RecoveryCandidate{PushTokens: []cart.PushToken{{Platform: "fcm"}}}, + wantSub: "channel=push", + }, + { + name: "no contact", + cand: RecoveryCandidate{}, + wantSub: "channel=none", + }, + } + for _, tc := range cases { + buf.Reset() + if err := n.NotifyAbandoned(context.Background(), tc.cand); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if !strings.Contains(buf.String(), tc.wantSub) { + t.Errorf("%s: log missing %q; got: %s", tc.name, tc.wantSub, buf.String()) + } + } +} diff --git a/pkg/cart/recovery/recovery.go b/pkg/cart/recovery/recovery.go new file mode 100644 index 0000000..81f6bf4 --- /dev/null +++ b/pkg/cart/recovery/recovery.go @@ -0,0 +1,49 @@ +// Package recovery is the v0 abandoned-cart-recovery seam. +// +// The contract is intentionally narrow: a Scanner finds candidates whose last +// mutation is within an "abandoned" window and that have BOTH an attachable +// contact (email and/or push tokens) and items in the cart, then hands them +// to a Notifier. The Notifier decides what to do — for now a logging shim. +// +// The seam intentionally avoids: +// +// - Email/push-token delivery (lives in the notifier impl). +// - Idempotency tracking ("did we already notify?"). v0: scanner windows +// are sized so a cart falls in once per detection threshold. Real +// idempotency is the next pass — see suggest_followups / plan-commerce- +// maturity C3. +// - Cross-pod coordination. Each cart pod owns its own shard of event logs +// (replicated read cache, no leader), so per-pod scanning is correct. +// A future AMQP fan-out of `cart.recovery.candidate` would let a single +// dedicated notifier consume instead — out of scope here. +package recovery + +import ( + "context" + + "git.k6n.net/mats/go-cart-actor/pkg/cart" +) + +// RecoveryCandidate is the projectable "we'd remind this shopper" shape. +// Fields surface just enough for a future email template / push payload; the +// underlying grain carries everything else. +type RecoveryCandidate struct { + CartID cart.CartId + UserID string // empty if the cart hasn't been linked to a profile + Email string + PushTokens []cart.PushToken + ItemCount int + TotalIncVat int64 // minor units (money.Cents == int64) + Currency string + // LastChange is best-effort: the scanner reads the event-log file's + // modification time. A pending notifier shouldn't use it as the canonical + // "when was this cart touched?" wall clock — see cart.LastChange. + LastChangeUnix int64 +} + +// Notifier is the seam a real email/push implementation plugs into. The +// LoggingNotifier is the v0 default. The cart service ships the seam even +// before a real send exists so call-sites and tracking can be wired once. +type Notifier interface { + NotifyAbandoned(ctx context.Context, c RecoveryCandidate) error +} diff --git a/pkg/cart/recovery/recovery_test.go b/pkg/cart/recovery/recovery_test.go new file mode 100644 index 0000000..86eff75 --- /dev/null +++ b/pkg/cart/recovery/recovery_test.go @@ -0,0 +1,257 @@ +package recovery + +import ( + "context" + "log" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/cart" + messages "git.k6n.net/mats/go-cart-actor/proto/cart" + "google.golang.org/protobuf/proto" +) + +// writeFixtureLog writes a synthetic `.events.log` to dir with the given +// mutations, then pins the file's mtime AFTER Close so the canonical +// DiskStorage.SaveLoop flush can't overwrite it on test machines that run +// the inner save() during teardown. +func writeFixtureLog(t *testing.T, dir string, id uint64, mtime time.Time, muts []proto.Message) { + t.Helper() + path := filepath.Join(dir, strconv.FormatUint(id, 10)+".events.log") + + reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) + storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) + if err := storage.AppendMutations(id, muts...); err != nil { + t.Fatalf("AppendMutations for fixture %d: %v", id, err) + } + // DiskStorage.Close runs save() which opens+appends the file; that + // updates mtime. Pin AFTER Close so the test mtime wins. + storage.Close() + if err := os.Chtimes(path, mtime, mtime); err != nil { + t.Fatalf("Chtimes %s: %v", path, err) + } +} + +// silenceLogs redirects the standard logger while the scanner runs so test +// output stays readable. +func silenceLogs(t *testing.T) { + t.Helper() + orig := log.Writer() + prevFlags := log.Flags() + log.SetOutput(devNull{}) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(orig) + log.SetFlags(prevFlags) + }) +} + +type devNull struct{} + +func (devNull) Write(p []byte) (int, error) { return len(p), nil } + +func TestScanner_FindsCandidateInWindow(t *testing.T) { + silenceLogs(t) + dir := t.TempDir() + now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + + reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) + storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) + + // Cart that became eligible exactly in the middle of the window: + // delay 1h, window 1h → eligible mtime ∈ [now-2h, now-1h]. + mtime := now.Add(-90 * time.Minute) + writeFixtureLog(t, dir, 42, mtime, []proto.Message{ + &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000, Sku: "A"}, + &messages.SetRecoveryContact{Email: "abandoned@example.com"}, + }) + defer storage.Close() + + scanner := NewScanner(dir, storage) + got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(got) != 1 { + t.Fatalf("candidates = %d, want 1; full result=%+v", len(got), got) + } + c := got[0] + if c.CartID != cart.CartId(42) { + t.Errorf("CartID = %d, want 42", c.CartID) + } + if c.Email != "abandoned@example.com" { + t.Errorf("Email = %q, want abandoned@example.com", c.Email) + } + if c.ItemCount != 1 { + t.Errorf("ItemCount = %d, want 1", c.ItemCount) + } + if c.TotalIncVat != 1000 { + t.Errorf("TotalIncVat = %d, want 1000 (AddItem price 1000)", c.TotalIncVat) + } +} + +func TestScanner_SkipsCartsOutsideWindow(t *testing.T) { + silenceLogs(t) + dir := t.TempDir() + now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + + reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) + storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) + + // Too fresh (mtime = now-30m, inside the 1h "still active" cutoff). + fresh := now.Add(-30 * time.Minute) + writeFixtureLog(t, dir, 7, fresh, []proto.Message{ + &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000}, + &messages.SetRecoveryContact{Email: "fresh@example.com"}, + }) + // Too old (mtime = now-25h, past the 2h window upper bound). + old := now.Add(-25 * time.Hour) + writeFixtureLog(t, dir, 8, old, []proto.Message{ + &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000}, + &messages.SetRecoveryContact{Email: "old@example.com"}, + }) + // Inside window with NO contact (eligibility gate). + mid := now.Add(-90 * time.Minute) + writeFixtureLog(t, dir, 9, mid, []proto.Message{ + &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000}, + // no SetRecoveryContact + }) + defer storage.Close() + + scanner := NewScanner(dir, storage) + got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(got) != 0 { + t.Fatalf("candidates = %d, want 0 (all filtered out); result=%+v", len(got), got) + } +} + +func TestScanner_FindsPushOnlyCandidate(t *testing.T) { + silenceLogs(t) + dir := t.TempDir() + now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + + reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) + storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) + + mtime := now.Add(-75 * time.Minute) + writeFixtureLog(t, dir, 11, mtime, []proto.Message{ + &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000}, + &messages.SetRecoveryContact{ + PushTokens: []*messages.PushToken{ + {Platform: "fcm", Token: "tok-1"}, + {Platform: "apns", Token: "tok-2"}, + }, + }, + }) + defer storage.Close() + + scanner := NewScanner(dir, storage) + got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(got) != 1 { + t.Fatalf("candidates = %d, want 1", len(got)) + } + if got[0].Email != "" { + t.Errorf("Email = %q, want empty (push-only)", got[0].Email) + } + if len(got[0].PushTokens) != 2 { + t.Fatalf("PushTokens = %d, want 2", len(got[0].PushTokens)) + } +} + +func TestScanner_IgnoresWishlistCarts(t *testing.T) { + silenceLogs(t) + dir := t.TempDir() + now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + + reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) + storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) + + mtime := now.Add(-90 * time.Minute) + writeFixtureLog(t, dir, 13, mtime, []proto.Message{ + &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000}, + &messages.SetRecoveryContact{Email: "wishlist@example.com"}, + &messages.SetCartType{Type: messages.CartType_WISHLIST}, + }) + defer storage.Close() + + scanner := NewScanner(dir, storage) + got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(got) != 0 { + t.Fatalf("wishlist candidate leaked: got %+v", got) + } +} + +// TestScanner_TotalPostReplay verifies the bug fix: replay does not run the +// post-mutation processor, so the scanner MUST call UpdateTotals to surface +// an accurate TotalIncVat. +func TestScanner_TotalPostReplay(t *testing.T) { + silenceLogs(t) + dir := t.TempDir() + now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + + reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) + storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) + + mtime := now.Add(-75 * time.Minute) + writeFixtureLog(t, dir, 21, mtime, []proto.Message{ + &messages.AddItem{ItemId: 1, Quantity: 2, Price: 500, Sku: "A"}, + &messages.AddItem{ItemId: 2, Quantity: 1, Price: 1500, Sku: "B"}, + &messages.SetRecoveryContact{Email: "totals@example.com"}, + }) + defer storage.Close() + + scanner := NewScanner(dir, storage) + got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(got) != 1 { + t.Fatalf("candidates = %d, want 1", len(got)) + } + // 2*500 + 1*1500 = 2500 inc-vat + if got[0].TotalIncVat != 2500 { + t.Errorf("TotalIncVat = %d, want 2500 (1000+1500 across two lines)", got[0].TotalIncVat) + } + if got[0].ItemCount != 2 { + t.Errorf("ItemCount = %d, want 2", got[0].ItemCount) + } +} + +// TestIsValidEmail sanity for the optional helper exported for notifiers. +func TestIsValidEmail(t *testing.T) { + if !IsValidEmail("user@example.com") { + t.Errorf("user@example.com should be valid") + } + if IsValidEmail("not-an-email") { + t.Errorf("not-an-email should be invalid") + } +} + +// TestFileIDFor verifies the helper that parses .events.log filenames. +func TestFileIDFor(t *testing.T) { + if id, ok := fileIDFor("12345.events.log"); !ok || id != 12345 { + t.Errorf("12345.events.log -> %d, %v; want 12345, true", id, ok) + } + if _, ok := fileIDFor("not-a-number.events.log"); ok { + t.Errorf("non-numeric should fail") + } + if _, ok := fileIDFor("0.events.log"); ok { + t.Errorf("id=0 should fail (no implicit zero-cart grain)") + } + if _, ok := fileIDFor("garbage.txt"); ok { + t.Errorf("non-event-log suffix should fail") + } +} diff --git a/pkg/cart/recovery/scanner.go b/pkg/cart/recovery/scanner.go new file mode 100644 index 0000000..51de180 --- /dev/null +++ b/pkg/cart/recovery/scanner.go @@ -0,0 +1,187 @@ +package recovery + +import ( + "context" + "fmt" + "net/mail" + "os" + "strconv" + "strings" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/cart" + cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart" +) + +// Scanner finds RecoveryCandidates by scanning the per-pod event-log directory. +// +// The model: +// - Each cart pod owns a shard of `.events.log` files under `dir`. +// - File mtime approximates "last successful mutation" cheaply — replay only +// when something lands in the window. +// - For each candidate file, replay the grain (a few kb, ms-scale) and build +// a RecoveryCandidate iff it has items + a contact. +// +// Two important implementation details: +// +// 1. After LoadEvents we MUST call UpdateTotals(). Replaying event handlers +// runs the per-mutation registered handlers (SetUserId, SetRecoveryContact, +// AddItem, ...) but NOT the post-mutation processors — those run only on +// the live mutation path (see cmd/cart/main.go::RegisterProcessor for the +// "Totals and promotions" pipeline). Without this call, g.TotalPrice is +// nil and TotalIncVat in the candidate is always 0. +// +// 2. Read-vs-write races: DiskStorage may be in the middle of appending while +// Scan reads. The bufio scanner will fail JSON-unmarshal on a torn line and +// we silently continue — LogEvents treats that as "skip", here we do too. +// A persistent unreadable file is a separate problem runPurge handles. +// +// v0 deliberately does NOT track "already notified" within this scanner — +// the window sizing plus caller conventions give roughly-once delivery. +// Idempotency is the next pass. +type Scanner struct { + dir string + storage *actor.DiskStorage[cart.CartGrain] +} + +// NewScanner returns a Scanner over dir (the same CART_DIR used by the cart +// service). storage must share the same dir so LoadEvents reading paths +// match the scanning paths. +func NewScanner(dir string, storage *actor.DiskStorage[cart.CartGrain]) *Scanner { + return &Scanner{dir: dir, storage: storage} +} + +// Scan returns candidates whose event-log mtime falls in +// [now-delay-window, now-delay] AND whose grain has items AND (email OR +// push tokens). The half-open window semantics match the caller's intent: +// +// "carts whose mtime is in this slice of the recent past" +// +// — younger than now-delay (still active) and older than now-delay-window +// (never recovery-eligible yet). +// +// Empty slice + nil error means "no candidates this pass"; an error means the +// pass itself failed and the caller should log + retry. +func (s *Scanner) Scan(ctx context.Context, delay, window time.Duration, now time.Time) ([]RecoveryCandidate, error) { + if s == nil || s.storage == nil || s.dir == "" { + return nil, fmt.Errorf("recovery: scanner not configured") + } + if delay <= 0 { + return nil, nil + } + if window <= 0 { + window = delay + } + + lower := now.Add(-(delay + window)) + upper := now.Add(-delay) + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil, fmt.Errorf("recovery: read dir %s: %w", s.dir, err) + } + + out := make([]RecoveryCandidate, 0) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".events.log") { + continue + } + id, ok := fileIDFor(name) + if !ok { + continue + } + info, infoErr := e.Info() + if infoErr != nil { + continue + } + mtime := info.ModTime() + // Closed window: + // mtime BEFORE lower → never reached the abandonment threshold yet. + // mtime AFTER upper → still too fresh (caller is actively using it). + if mtime.Before(lower) || mtime.After(upper) { + continue + } + + grain := cart.NewCartGrain(id, mtime) + if loadErr := s.storage.LoadEvents(ctx, id, grain); loadErr != nil { + // Skip unreadable grains — they're purged separately by runPurge. + continue + } + // Replay does NOT run the Totals-and-promotions processor (see the + // package doc); recompute TotalPrice from the item list so the + // candidate TotalIncVat matches what the live API would return. + grain.UpdateTotals() + if !eligible(grain) { + continue + } + out = append(out, buildCandidate(id, grain, mtime)) + } + return out, nil +} + +// eligible mirrors "would we ever want to send a reminder?" — items present +// and a contact available. The grain's own UpdateTotals has already been +// run by every mutation's processor, so TotalPrice is reliable here. +// +// Wishlists / offers are excluded — a wishlist cart isn't forgotten, and an +// offer cart has its own delivery semantics. +func eligible(g *cart.CartGrain) bool { + if g == nil || len(g.Items) == 0 { + return false + } + switch g.Type { + case cart_messages.CartType_WISHLIST, cart_messages.CartType_OFFER: + return false + } + hasContact := strings.TrimSpace(g.Email) != "" || len(g.PushTokens) > 0 + return hasContact +} + +// buildCandidate projects the replayed grain into a RecoveryCandidate. Email +// validation is intentionally lenient — we hand the value to the notifier +// and the notifier decides what to send; rejecting "looks-invalid" addresses +// here would just hide useful debugging signal from the dry-run logs. +func buildCandidate(id uint64, g *cart.CartGrain, mtime time.Time) RecoveryCandidate { + var total int64 + if g.TotalPrice != nil { + // tax.Price.IncVat is int64-backed via money.Cents. Reading the field + // directly keeps the recovery layer decoupled from platform/money + // internals. + total = int64(g.TotalPrice.IncVat) + } + tokens := make([]cart.PushToken, len(g.PushTokens)) + copy(tokens, g.PushTokens) + return RecoveryCandidate{ + CartID: cart.CartId(id), + UserID: g.UserId, + Email: g.Email, + PushTokens: tokens, + ItemCount: len(g.Items), + TotalIncVat: total, + Currency: g.Currency, + LastChangeUnix: mtime.Unix(), + } +} + +// IsValidEmail is exported as a convenience the notifier may use to decide +// whether to actually send (MailerSend will reject invalid); the scanner +// itself is permissive. +func IsValidEmail(s string) bool { + _, err := mail.ParseAddress(s) + return err == nil +} + +// fileIDFor is a small helper that extracts the grain id from an .events.log +// filename. Returns 0/false if the name doesn't match the expected shape. +func fileIDFor(name string) (uint64, bool) { + base := strings.TrimSuffix(name, ".events.log") + id, err := strconv.ParseUint(base, 10, 64) + if err != nil || id == 0 { + return 0, false + } + return id, true +} diff --git a/proto/cart.proto b/proto/cart.proto index 13af35d..c221b50 100644 --- a/proto/cart.proto +++ b/proto/cart.proto @@ -105,6 +105,25 @@ message SetCartType { CartType type = 1; } +// PushToken is a generic, provider-agnostic push-delivery target. The cart layer +// only stores what it was given — actual delivery (FCM, APNs, Web Push) is the +// notifier's job (see pkg/cart/recovery.LoggingNotifier for the v0 contract). +// +// Platform is free-form (e.g. "fcm", "apns", "webpush") so callers can record +// multiple devices per cart without forcing a schema decision here. +message PushToken { + string platform = 1; + string token = 2; +} + +// SetRecoveryContact attaches the contact bundle used by the abandoned-cart +// recovery flow. PUT-style: replaces the entire contact in one event — empty +// strings or empty token lists are persisted as "no contact". +message SetRecoveryContact { + string email = 1; + repeated PushToken push_tokens = 2; +} + message Mutation { oneof type { ClearCartRequest clear_cart = 1; @@ -120,6 +139,7 @@ message Mutation { RemoveVoucher remove_voucher = 21; UpsertSubscriptionDetails upsert_subscription_details = 22; SetCartType set_cart_type = 23; + SetRecoveryContact set_recovery_contact = 24; } } diff --git a/proto/cart/cart.pb.go b/proto/cart/cart.pb.go index 106fa51..64d8960 100644 --- a/proto/cart/cart.pb.go +++ b/proto/cart/cart.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 +// protoc-gen-go v1.36.11 // protoc v7.35.1 // source: cart.proto @@ -971,6 +971,119 @@ func (x *SetCartType) GetType() CartType { return CartType_REGULAR } +// PushToken is a generic, provider-agnostic push-delivery target. The cart layer +// only stores what it was given — actual delivery (FCM, APNs, Web Push) is the +// notifier's job (see pkg/cart/recovery.LoggingNotifier for the v0 contract). +// +// Platform is free-form (e.g. "fcm", "apns", "webpush") so callers can record +// multiple devices per cart without forcing a schema decision here. +type PushToken struct { + state protoimpl.MessageState `protogen:"open.v1"` + Platform string `protobuf:"bytes,1,opt,name=platform,proto3" json:"platform,omitempty"` + Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PushToken) Reset() { + *x = PushToken{} + mi := &file_cart_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PushToken) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PushToken) ProtoMessage() {} + +func (x *PushToken) ProtoReflect() protoreflect.Message { + mi := &file_cart_proto_msgTypes[13] + 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 PushToken.ProtoReflect.Descriptor instead. +func (*PushToken) Descriptor() ([]byte, []int) { + return file_cart_proto_rawDescGZIP(), []int{13} +} + +func (x *PushToken) GetPlatform() string { + if x != nil { + return x.Platform + } + return "" +} + +func (x *PushToken) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +// SetRecoveryContact attaches the contact bundle used by the abandoned-cart +// recovery flow. PUT-style: replaces the entire contact in one event — empty +// strings or empty token lists are persisted as "no contact". +type SetRecoveryContact struct { + state protoimpl.MessageState `protogen:"open.v1"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + PushTokens []*PushToken `protobuf:"bytes,2,rep,name=push_tokens,json=pushTokens,proto3" json:"push_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetRecoveryContact) Reset() { + *x = SetRecoveryContact{} + mi := &file_cart_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetRecoveryContact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetRecoveryContact) ProtoMessage() {} + +func (x *SetRecoveryContact) ProtoReflect() protoreflect.Message { + mi := &file_cart_proto_msgTypes[14] + 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 SetRecoveryContact.ProtoReflect.Descriptor instead. +func (*SetRecoveryContact) Descriptor() ([]byte, []int) { + return file_cart_proto_rawDescGZIP(), []int{14} +} + +func (x *SetRecoveryContact) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *SetRecoveryContact) GetPushTokens() []*PushToken { + if x != nil { + return x.PushTokens + } + return nil +} + type Mutation struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Type: @@ -988,6 +1101,7 @@ type Mutation struct { // *Mutation_RemoveVoucher // *Mutation_UpsertSubscriptionDetails // *Mutation_SetCartType + // *Mutation_SetRecoveryContact Type isMutation_Type `protobuf_oneof:"type"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -995,7 +1109,7 @@ type Mutation struct { func (x *Mutation) Reset() { *x = Mutation{} - mi := &file_cart_proto_msgTypes[13] + mi := &file_cart_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1007,7 +1121,7 @@ func (x *Mutation) String() string { func (*Mutation) ProtoMessage() {} func (x *Mutation) ProtoReflect() protoreflect.Message { - mi := &file_cart_proto_msgTypes[13] + mi := &file_cart_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1020,7 +1134,7 @@ func (x *Mutation) ProtoReflect() protoreflect.Message { // Deprecated: Use Mutation.ProtoReflect.Descriptor instead. func (*Mutation) Descriptor() ([]byte, []int) { - return file_cart_proto_rawDescGZIP(), []int{13} + return file_cart_proto_rawDescGZIP(), []int{15} } func (x *Mutation) GetType() isMutation_Type { @@ -1147,6 +1261,15 @@ func (x *Mutation) GetSetCartType() *SetCartType { return nil } +func (x *Mutation) GetSetRecoveryContact() *SetRecoveryContact { + if x != nil { + if x, ok := x.Type.(*Mutation_SetRecoveryContact); ok { + return x.SetRecoveryContact + } + } + return nil +} + type isMutation_Type interface { isMutation_Type() } @@ -1203,6 +1326,10 @@ type Mutation_SetCartType struct { SetCartType *SetCartType `protobuf:"bytes,23,opt,name=set_cart_type,json=setCartType,proto3,oneof"` } +type Mutation_SetRecoveryContact struct { + SetRecoveryContact *SetRecoveryContact `protobuf:"bytes,24,opt,name=set_recovery_contact,json=setRecoveryContact,proto3,oneof"` +} + func (*Mutation_ClearCart) isMutation_Type() {} func (*Mutation_AddItem) isMutation_Type() {} @@ -1229,214 +1356,132 @@ func (*Mutation_UpsertSubscriptionDetails) isMutation_Type() {} func (*Mutation_SetCartType) isMutation_Type() {} +func (*Mutation_SetRecoveryContact) isMutation_Type() {} + var File_cart_proto protoreflect.FileDescriptor -var file_cart_proto_rawDesc = string([]byte{ - 0x0a, 0x0a, 0x63, 0x61, 0x72, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x63, 0x61, - 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x19, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, - 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, 0x65, 0x61, 0x72, - 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xaa, 0x08, 0x0a, 0x07, - 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74, 0x65, 0x6d, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, - 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, - 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x70, 0x72, 0x69, - 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x18, 0x09, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, 0x10, - 0x0a, 0x03, 0x73, 0x6b, 0x75, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x6b, 0x75, - 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, - 0x6f, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x74, 0x6f, 0x63, 0x6b, - 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x74, - 0x61, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x74, 0x65, - 0x67, 0x6f, 0x72, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x61, 0x74, 0x65, - 0x67, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, - 0x32, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, - 0x79, 0x32, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x33, 0x18, - 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x33, - 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34, 0x18, 0x11, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34, 0x12, 0x1c, - 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x18, 0x12, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x12, 0x1e, 0x0a, 0x0a, - 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x12, 0x20, 0x0a, 0x0b, - 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1a, - 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x18, 0x13, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x65, - 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, - 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x61, 0x6c, 0x65, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x18, 0x18, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x61, 0x6c, 0x65, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x1b, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x88, 0x01, - 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x18, 0x16, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x88, 0x01, 0x01, - 0x12, 0x1f, 0x0a, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18, 0x17, 0x20, 0x01, - 0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, - 0x01, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x67, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x63, 0x67, 0x6d, 0x12, 0x4f, 0x0a, 0x12, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, - 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x03, 0x52, 0x12, 0x72, - 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, - 0x65, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x72, 0x61, 0x5f, 0x6a, 0x73, - 0x6f, 0x6e, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x65, 0x78, 0x74, 0x72, 0x61, 0x4a, - 0x73, 0x6f, 0x6e, 0x12, 0x4d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, - 0x65, 0x6c, 0x64, 0x73, 0x18, 0x1c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x61, 0x72, - 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74, - 0x65, 0x6d, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, - 0x64, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x69, 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x5f, - 0x74, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, - 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x12, - 0x1b, 0x0a, 0x09, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x73, 0x68, 0x69, 0x70, 0x18, 0x1e, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x08, 0x64, 0x72, 0x6f, 0x70, 0x53, 0x68, 0x69, 0x70, 0x1a, 0x3f, 0x0a, 0x11, - 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x09, 0x0a, - 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, - 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, - 0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f, - 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, - 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e, - 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, - 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x22, 0x27, 0x0a, 0x15, 0x52, 0x65, - 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, - 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, - 0x02, 0x69, 0x64, 0x22, 0xc9, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, - 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, - 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, - 0x5d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, - 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x2e, 0x43, - 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x3f, - 0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, - 0x71, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, - 0x64, 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, - 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72, - 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x22, 0x7c, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, - 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x76, 0x6f, - 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x20, - 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, - 0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, - 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, - 0x64, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, - 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, - 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x02, 0x69, - 0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e, 0x67, - 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x65, - 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e, - 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, - 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x12, 0x28, 0x0a, 0x04, 0x64, 0x61, - 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x04, - 0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x64, 0x22, 0x3a, 0x0a, 0x0b, 0x53, - 0x65, 0x74, 0x43, 0x61, 0x72, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x2b, 0x0a, 0x04, 0x74, 0x79, - 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x61, 0x72, 0x74, 0x54, 0x79, 0x70, - 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xea, 0x07, 0x0a, 0x08, 0x4d, 0x75, 0x74, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x40, 0x0a, 0x0a, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x5f, 0x63, 0x61, - 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, - 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x09, 0x63, 0x6c, 0x65, - 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x12, 0x33, 0x0a, 0x08, 0x61, 0x64, 0x64, 0x5f, 0x69, 0x74, - 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, - 0x48, 0x00, 0x52, 0x07, 0x61, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x3c, 0x0a, 0x0b, 0x72, - 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, - 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x0a, 0x72, - 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x48, 0x0a, 0x0f, 0x63, 0x68, 0x61, - 0x6e, 0x67, 0x65, 0x5f, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x73, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x48, 0x00, 0x52, 0x0e, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x12, 0x3a, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x64, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, - 0x4c, 0x0a, 0x11, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, - 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x63, 0x61, 0x72, - 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x65, 0x49, - 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x0f, 0x6c, 0x69, - 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x5f, 0x0a, - 0x18, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, - 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x24, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, - 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, - 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x15, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, - 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x51, - 0x0a, 0x12, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x61, - 0x64, 0x64, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x63, 0x61, 0x72, - 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, - 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64, 0x48, 0x00, 0x52, 0x11, - 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, - 0x64, 0x12, 0x66, 0x0a, 0x1b, 0x73, 0x65, 0x74, 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, - 0x65, 0x6d, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, - 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, - 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x48, 0x00, - 0x52, 0x17, 0x73, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, - 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x3c, 0x0a, 0x0b, 0x61, 0x64, 0x64, - 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, - 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, - 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, 0x64, - 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x45, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, - 0x65, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1c, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, - 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x48, 0x00, 0x52, - 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x6a, - 0x0a, 0x1b, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x5f, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x16, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, - 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x48, 0x00, 0x52, - 0x19, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x40, 0x0a, 0x0d, 0x73, 0x65, - 0x74, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x17, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x73, 0x2e, 0x53, 0x65, 0x74, 0x43, 0x61, 0x72, 0x74, 0x54, 0x79, 0x70, 0x65, 0x48, 0x00, 0x52, - 0x0b, 0x73, 0x65, 0x74, 0x43, 0x61, 0x72, 0x74, 0x54, 0x79, 0x70, 0x65, 0x42, 0x06, 0x0a, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x2a, 0x30, 0x0a, 0x08, 0x43, 0x61, 0x72, 0x74, 0x54, 0x79, 0x70, 0x65, - 0x12, 0x0b, 0x0a, 0x07, 0x52, 0x45, 0x47, 0x55, 0x4c, 0x41, 0x52, 0x10, 0x00, 0x12, 0x0c, 0x0a, - 0x08, 0x57, 0x49, 0x53, 0x48, 0x4c, 0x49, 0x53, 0x54, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x4f, - 0x46, 0x46, 0x45, 0x52, 0x10, 0x02, 0x42, 0x39, 0x5a, 0x37, 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, 0x63, - 0x61, 0x72, 0x74, 0x3b, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_cart_proto_rawDesc = "" + + "\n" + + "\n" + + "cart.proto\x12\rcart_messages\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x12\n" + + "\x10ClearCartRequest\"\xaa\b\n" + + "\aAddItem\x12\x17\n" + + "\aitem_id\x18\x01 \x01(\rR\x06itemId\x12\x1a\n" + + "\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" + + "\x05price\x18\x03 \x01(\x03R\x05price\x12\x1a\n" + + "\borgPrice\x18\t \x01(\x03R\borgPrice\x12\x10\n" + + "\x03sku\x18\x04 \x01(\tR\x03sku\x12\x12\n" + + "\x04name\x18\x05 \x01(\tR\x04name\x12\x14\n" + + "\x05image\x18\x06 \x01(\tR\x05image\x12\x14\n" + + "\x05stock\x18\a \x01(\x05R\x05stock\x12\x10\n" + + "\x03tax\x18\b \x01(\x05R\x03tax\x12\x14\n" + + "\x05brand\x18\r \x01(\tR\x05brand\x12\x1a\n" + + "\bcategory\x18\x0e \x01(\tR\bcategory\x12\x1c\n" + + "\tcategory2\x18\x0f \x01(\tR\tcategory2\x12\x1c\n" + + "\tcategory3\x18\x10 \x01(\tR\tcategory3\x12\x1c\n" + + "\tcategory4\x18\x11 \x01(\tR\tcategory4\x12\x1c\n" + + "\tcategory5\x18\x12 \x01(\tR\tcategory5\x12\x1e\n" + + "\n" + + "disclaimer\x18\n" + + " \x01(\tR\n" + + "disclaimer\x12 \n" + + "\varticleType\x18\v \x01(\tR\varticleType\x12\x1a\n" + + "\bsellerId\x18\x13 \x01(\tR\bsellerId\x12\x1e\n" + + "\n" + + "sellerName\x18\x14 \x01(\tR\n" + + "sellerName\x12\x18\n" + + "\acountry\x18\x15 \x01(\tR\acountry\x12\x1e\n" + + "\n" + + "saleStatus\x18\x18 \x01(\tR\n" + + "saleStatus\x12\x1b\n" + + "\x06outlet\x18\f \x01(\tH\x00R\x06outlet\x88\x01\x01\x12\x1d\n" + + "\astoreId\x18\x16 \x01(\tH\x01R\astoreId\x88\x01\x01\x12\x1f\n" + + "\bparentId\x18\x17 \x01(\rH\x02R\bparentId\x88\x01\x01\x12\x10\n" + + "\x03cgm\x18\x19 \x01(\tR\x03cgm\x12O\n" + + "\x12reservationEndTime\x18\x1a \x01(\v2\x1a.google.protobuf.TimestampH\x03R\x12reservationEndTime\x88\x01\x01\x12\x1d\n" + + "\n" + + "extra_json\x18\x1b \x01(\fR\textraJson\x12M\n" + + "\rcustom_fields\x18\x1c \x03(\v2(.cart_messages.AddItem.CustomFieldsEntryR\fcustomFields\x12+\n" + + "\x11inventory_tracked\x18\x1d \x01(\bR\x10inventoryTracked\x12\x1b\n" + + "\tdrop_ship\x18\x1e \x01(\bR\bdropShip\x1a?\n" + + "\x11CustomFieldsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\t\n" + + "\a_outletB\n" + + "\n" + + "\b_storeIdB\v\n" + + "\t_parentIdB\x15\n" + + "\x13_reservationEndTime\"\x1c\n" + + "\n" + + "RemoveItem\x12\x0e\n" + + "\x02Id\x18\x01 \x01(\rR\x02Id\"<\n" + + "\x0eChangeQuantity\x12\x0e\n" + + "\x02Id\x18\x01 \x01(\rR\x02Id\x12\x1a\n" + + "\bquantity\x18\x02 \x01(\x05R\bquantity\"#\n" + + "\tSetUserId\x12\x16\n" + + "\x06userId\x18\x01 \x01(\tR\x06userId\"O\n" + + "\x0fLineItemMarking\x12\x0e\n" + + "\x02id\x18\x01 \x01(\rR\x02id\x12\x12\n" + + "\x04type\x18\x02 \x01(\rR\x04type\x12\x18\n" + + "\amarking\x18\x03 \x01(\tR\amarking\"'\n" + + "\x15RemoveLineItemMarking\x12\x0e\n" + + "\x02id\x18\x01 \x01(\rR\x02id\"\xc9\x01\n" + + "\x17SetLineItemCustomFields\x12\x0e\n" + + "\x02id\x18\x01 \x01(\rR\x02id\x12]\n" + + "\rcustom_fields\x18\x02 \x03(\v28.cart_messages.SetLineItemCustomFields.CustomFieldsEntryR\fcustomFields\x1a?\n" + + "\x11CustomFieldsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"q\n" + + "\x11SubscriptionAdded\x12\x16\n" + + "\x06itemId\x18\x01 \x01(\rR\x06itemId\x12\x1c\n" + + "\tdetailsId\x18\x03 \x01(\tR\tdetailsId\x12&\n" + + "\x0eorderReference\x18\x04 \x01(\tR\x0eorderReference\"|\n" + + "\n" + + "AddVoucher\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value\x12\"\n" + + "\fvoucherRules\x18\x03 \x03(\tR\fvoucherRules\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\"\x1f\n" + + "\rRemoveVoucher\x12\x0e\n" + + "\x02id\x18\x01 \x01(\rR\x02id\"\xa7\x01\n" + + "\x19UpsertSubscriptionDetails\x12\x13\n" + + "\x02id\x18\x01 \x01(\tH\x00R\x02id\x88\x01\x01\x12\"\n" + + "\fofferingCode\x18\x02 \x01(\tR\fofferingCode\x12 \n" + + "\vsigningType\x18\x03 \x01(\tR\vsigningType\x12(\n" + + "\x04data\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\x04dataB\x05\n" + + "\x03_id\":\n" + + "\vSetCartType\x12+\n" + + "\x04type\x18\x01 \x01(\x0e2\x17.cart_messages.CartTypeR\x04type\"=\n" + + "\tPushToken\x12\x1a\n" + + "\bplatform\x18\x01 \x01(\tR\bplatform\x12\x14\n" + + "\x05token\x18\x02 \x01(\tR\x05token\"e\n" + + "\x12SetRecoveryContact\x12\x14\n" + + "\x05email\x18\x01 \x01(\tR\x05email\x129\n" + + "\vpush_tokens\x18\x02 \x03(\v2\x18.cart_messages.PushTokenR\n" + + "pushTokens\"\xc1\b\n" + + "\bMutation\x12@\n" + + "\n" + + "clear_cart\x18\x01 \x01(\v2\x1f.cart_messages.ClearCartRequestH\x00R\tclearCart\x123\n" + + "\badd_item\x18\x02 \x01(\v2\x16.cart_messages.AddItemH\x00R\aaddItem\x12<\n" + + "\vremove_item\x18\x03 \x01(\v2\x19.cart_messages.RemoveItemH\x00R\n" + + "removeItem\x12H\n" + + "\x0fchange_quantity\x18\x04 \x01(\v2\x1d.cart_messages.ChangeQuantityH\x00R\x0echangeQuantity\x12:\n" + + "\vset_user_id\x18\x05 \x01(\v2\x18.cart_messages.SetUserIdH\x00R\tsetUserId\x12L\n" + + "\x11line_item_marking\x18\x06 \x01(\v2\x1e.cart_messages.LineItemMarkingH\x00R\x0flineItemMarking\x12_\n" + + "\x18remove_line_item_marking\x18\a \x01(\v2$.cart_messages.RemoveLineItemMarkingH\x00R\x15removeLineItemMarking\x12Q\n" + + "\x12subscription_added\x18\b \x01(\v2 .cart_messages.SubscriptionAddedH\x00R\x11subscriptionAdded\x12f\n" + + "\x1bset_line_item_custom_fields\x18\t \x01(\v2&.cart_messages.SetLineItemCustomFieldsH\x00R\x17setLineItemCustomFields\x12<\n" + + "\vadd_voucher\x18\x14 \x01(\v2\x19.cart_messages.AddVoucherH\x00R\n" + + "addVoucher\x12E\n" + + "\x0eremove_voucher\x18\x15 \x01(\v2\x1c.cart_messages.RemoveVoucherH\x00R\rremoveVoucher\x12j\n" + + "\x1bupsert_subscription_details\x18\x16 \x01(\v2(.cart_messages.UpsertSubscriptionDetailsH\x00R\x19upsertSubscriptionDetails\x12@\n" + + "\rset_cart_type\x18\x17 \x01(\v2\x1a.cart_messages.SetCartTypeH\x00R\vsetCartType\x12U\n" + + "\x14set_recovery_contact\x18\x18 \x01(\v2!.cart_messages.SetRecoveryContactH\x00R\x12setRecoveryContactB\x06\n" + + "\x04type*0\n" + + "\bCartType\x12\v\n" + + "\aREGULAR\x10\x00\x12\f\n" + + "\bWISHLIST\x10\x01\x12\t\n" + + "\x05OFFER\x10\x02B9Z7git.k6n.net/mats/go-cart-actor/proto/cart;cart_messagesb\x06proto3" var ( file_cart_proto_rawDescOnce sync.Once @@ -1451,7 +1496,7 @@ func file_cart_proto_rawDescGZIP() []byte { } var file_cart_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_cart_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_cart_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_cart_proto_goTypes = []any{ (CartType)(0), // 0: cart_messages.CartType (*ClearCartRequest)(nil), // 1: cart_messages.ClearCartRequest @@ -1467,36 +1512,40 @@ var file_cart_proto_goTypes = []any{ (*RemoveVoucher)(nil), // 11: cart_messages.RemoveVoucher (*UpsertSubscriptionDetails)(nil), // 12: cart_messages.UpsertSubscriptionDetails (*SetCartType)(nil), // 13: cart_messages.SetCartType - (*Mutation)(nil), // 14: cart_messages.Mutation - nil, // 15: cart_messages.AddItem.CustomFieldsEntry - nil, // 16: cart_messages.SetLineItemCustomFields.CustomFieldsEntry - (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp - (*anypb.Any)(nil), // 18: google.protobuf.Any + (*PushToken)(nil), // 14: cart_messages.PushToken + (*SetRecoveryContact)(nil), // 15: cart_messages.SetRecoveryContact + (*Mutation)(nil), // 16: cart_messages.Mutation + nil, // 17: cart_messages.AddItem.CustomFieldsEntry + nil, // 18: cart_messages.SetLineItemCustomFields.CustomFieldsEntry + (*timestamppb.Timestamp)(nil), // 19: google.protobuf.Timestamp + (*anypb.Any)(nil), // 20: google.protobuf.Any } var file_cart_proto_depIdxs = []int32{ - 17, // 0: cart_messages.AddItem.reservationEndTime:type_name -> google.protobuf.Timestamp - 15, // 1: cart_messages.AddItem.custom_fields:type_name -> cart_messages.AddItem.CustomFieldsEntry - 16, // 2: cart_messages.SetLineItemCustomFields.custom_fields:type_name -> cart_messages.SetLineItemCustomFields.CustomFieldsEntry - 18, // 3: cart_messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any + 19, // 0: cart_messages.AddItem.reservationEndTime:type_name -> google.protobuf.Timestamp + 17, // 1: cart_messages.AddItem.custom_fields:type_name -> cart_messages.AddItem.CustomFieldsEntry + 18, // 2: cart_messages.SetLineItemCustomFields.custom_fields:type_name -> cart_messages.SetLineItemCustomFields.CustomFieldsEntry + 20, // 3: cart_messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any 0, // 4: cart_messages.SetCartType.type:type_name -> cart_messages.CartType - 1, // 5: cart_messages.Mutation.clear_cart:type_name -> cart_messages.ClearCartRequest - 2, // 6: cart_messages.Mutation.add_item:type_name -> cart_messages.AddItem - 3, // 7: cart_messages.Mutation.remove_item:type_name -> cart_messages.RemoveItem - 4, // 8: cart_messages.Mutation.change_quantity:type_name -> cart_messages.ChangeQuantity - 5, // 9: cart_messages.Mutation.set_user_id:type_name -> cart_messages.SetUserId - 6, // 10: cart_messages.Mutation.line_item_marking:type_name -> cart_messages.LineItemMarking - 7, // 11: cart_messages.Mutation.remove_line_item_marking:type_name -> cart_messages.RemoveLineItemMarking - 9, // 12: cart_messages.Mutation.subscription_added:type_name -> cart_messages.SubscriptionAdded - 8, // 13: cart_messages.Mutation.set_line_item_custom_fields:type_name -> cart_messages.SetLineItemCustomFields - 10, // 14: cart_messages.Mutation.add_voucher:type_name -> cart_messages.AddVoucher - 11, // 15: cart_messages.Mutation.remove_voucher:type_name -> cart_messages.RemoveVoucher - 12, // 16: cart_messages.Mutation.upsert_subscription_details:type_name -> cart_messages.UpsertSubscriptionDetails - 13, // 17: cart_messages.Mutation.set_cart_type:type_name -> cart_messages.SetCartType - 18, // [18:18] is the sub-list for method output_type - 18, // [18:18] is the sub-list for method input_type - 18, // [18:18] is the sub-list for extension type_name - 18, // [18:18] is the sub-list for extension extendee - 0, // [0:18] is the sub-list for field type_name + 14, // 5: cart_messages.SetRecoveryContact.push_tokens:type_name -> cart_messages.PushToken + 1, // 6: cart_messages.Mutation.clear_cart:type_name -> cart_messages.ClearCartRequest + 2, // 7: cart_messages.Mutation.add_item:type_name -> cart_messages.AddItem + 3, // 8: cart_messages.Mutation.remove_item:type_name -> cart_messages.RemoveItem + 4, // 9: cart_messages.Mutation.change_quantity:type_name -> cart_messages.ChangeQuantity + 5, // 10: cart_messages.Mutation.set_user_id:type_name -> cart_messages.SetUserId + 6, // 11: cart_messages.Mutation.line_item_marking:type_name -> cart_messages.LineItemMarking + 7, // 12: cart_messages.Mutation.remove_line_item_marking:type_name -> cart_messages.RemoveLineItemMarking + 9, // 13: cart_messages.Mutation.subscription_added:type_name -> cart_messages.SubscriptionAdded + 8, // 14: cart_messages.Mutation.set_line_item_custom_fields:type_name -> cart_messages.SetLineItemCustomFields + 10, // 15: cart_messages.Mutation.add_voucher:type_name -> cart_messages.AddVoucher + 11, // 16: cart_messages.Mutation.remove_voucher:type_name -> cart_messages.RemoveVoucher + 12, // 17: cart_messages.Mutation.upsert_subscription_details:type_name -> cart_messages.UpsertSubscriptionDetails + 13, // 18: cart_messages.Mutation.set_cart_type:type_name -> cart_messages.SetCartType + 15, // 19: cart_messages.Mutation.set_recovery_contact:type_name -> cart_messages.SetRecoveryContact + 20, // [20:20] is the sub-list for method output_type + 20, // [20:20] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_cart_proto_init() } @@ -1506,7 +1555,7 @@ func file_cart_proto_init() { } file_cart_proto_msgTypes[1].OneofWrappers = []any{} file_cart_proto_msgTypes[11].OneofWrappers = []any{} - file_cart_proto_msgTypes[13].OneofWrappers = []any{ + file_cart_proto_msgTypes[15].OneofWrappers = []any{ (*Mutation_ClearCart)(nil), (*Mutation_AddItem)(nil), (*Mutation_RemoveItem)(nil), @@ -1520,6 +1569,7 @@ func file_cart_proto_init() { (*Mutation_RemoveVoucher)(nil), (*Mutation_UpsertSubscriptionDetails)(nil), (*Mutation_SetCartType)(nil), + (*Mutation_SetRecoveryContact)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -1527,7 +1577,7 @@ func file_cart_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_cart_proto_rawDesc), len(file_cart_proto_rawDesc)), NumEnums: 1, - NumMessages: 16, + NumMessages: 18, NumExtensions: 0, NumServices: 0, },