more cart
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user