more cart
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s

This commit is contained in:
2026-07-01 22:11:37 +02:00
parent 1c97a4bdb0
commit 26fd6dc970
23 changed files with 1717 additions and 250 deletions
+104
View File
@@ -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)
}
}
}
+48
View File
@@ -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)))
+51
View File
@@ -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{
+46
View File
@@ -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)
}
}
+46
View File
@@ -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)
}
}
+12
View File
@@ -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
+5 -4
View File
@@ -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)
+44 -2
View File
@@ -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
}
+5 -4
View File
@@ -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.
+66
View File
@@ -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
}
+84
View File
@@ -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)")
}
}
+65
View File
@@ -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)
+27
View File
@@ -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"`
+1
View File
@@ -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
+52
View File
@@ -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
}
@@ -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)
}
}
+59
View File
@@ -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
}
@@ -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())
}
}
}
+49
View File
@@ -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
}
+257
View File
@@ -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 `<id>.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 <id>.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")
}
}
+187
View File
@@ -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 `<id>.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
}
+20
View File
@@ -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;
}
}
+290 -240
View File
@@ -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,
},