Abandoned Cart Recovery

Hourly cron, recovery email, open tracking, and admin triage dashboard (ZLY-929)

Overview

Abandoned Cart Recovery (ZLY-929) finds shoppers who filled a cart but never paid, nudges them back with a one-click email link, and gives admins a place to triage carts that need a human follow-up. It is built on the global cart model (ZLY-1226) and the shared-draft + per-user-copy fork model (ZLY-1303), so it is fork-aware end to end.

Deliverables:

  • An hourly cron that marks stale unpaid carts, emails recoverable ones, and never double-emails the same shopper for one logical cart.
  • A SendGrid recovery email with campaign branding, i18n, and a deep link to the global /cart page.
  • Email open tracking via SendGrid Event Webhook → journey_event (MERCH_EMAIL_OPENED).
  • An Abandoned Carts dashboard in zooly-stats for admin triage (status, comment, private-email flag).

Where to Access

SurfaceURL / pathWho
Abandoned Carts dashboardzooly-statsMerchAbandoned Carts (/merch/abandoned-carts)Admin only
Cron endpointGET /api/merch/cron/abandoned-carts on zooly-appBearer CRON_SECRET (Vercel cron)
Review write APIPATCH /api/merch/admin/abandoned-carts/{cartId}/review on zooly-appMerch admin (called cross-origin from stats)
SendGrid webhookPOST /api/merch/email/sendgrid-webhook on zooly-appSendGrid signed events

Local dev defaults:

AppPort
zooly-stats3010
zooly-app3000
Merch SPA3008

End-to-End Flow

flowchart TD Cron[Hourly cron] --> Stale{Active cart idle > 3h<br/>and not yet notified?} Stale -->|No| Skip[Skip] Stale -->|Yes| Resolve[Resolve targets fork-aware<br/>dedup by recipient email] Resolve --> Empty{Cart has items?} Empty -->|No| Skip Empty -->|Yes| Mark[Set abandoned_at<br/>keep status = active] Mark --> HasEmail{Resolvable email?} HasEmail -->|Yes| Email[Send recovery email<br/>link to /cart?s=session] Email --> Notified[Stamp abandoned_notified_at<br/>on entire draft→fork group] HasEmail -->|No| Manual[Mark only — no email] Email --> Dash[(Abandoned Carts dashboard)] Manual --> Dash Email --> Click[Shopper clicks link] Click --> CartPage[Lands on /cart with same items] CartPage --> Buy[Completes purchase] Buy --> Recovered[Dashboard auto-shows Recovered] Email -.open event.-> Webhook[SendGrid webhook] Webhook --> Journey[MERCH_EMAIL_OPENED journey_event] Journey --> Dash

What “abandoned” means

The cron does not flip merch_cart.status to abandoned. It only sets abandoned_at while leaving status = active. That keeps the recovery link valid: the shopper can reopen the exact same cart (including cross-device via ?s=<sessionId> on /cart).

A cart becomes a cron candidate when:

  • status = active
  • last_touched_at is older than the stale window (default 3 hours, overridable via MERCH_ABANDON_WINDOW_HOURS)
  • abandoned_notified_at IS NULL (never auto-emailed before)
  • Has at least one in-cart item (merch_item row with order_id IS NULL)

Fork / Draft Model

A logged-out shopper builds one cookie-anchored draft cart. When they capture an email, the system creates per-user fork copies (source_cart_id points at the draft). One logical cart can therefore be several merch_cart rows.

flowchart LR Draft[Cookie draft<br/>owner_cookie_id set<br/>source_cart_id null] Fork1[User fork A<br/>owner_user_id set<br/>source_cart_id = draft] Fork2[User fork B<br/>different email<br/>source_cart_id = draft] Draft -.shadow.-> Fork1 Draft -.shadow.-> Fork2

Dedup rules (resolveAbandonedCartTargets in @zooly/merch-srv):

  1. Skip a draft if any candidate fork points at it — the fork carries the real recipient.
  2. One email per resolved recipient within a cron run — two forks for the same person collapse to a single send. Distinct emails (gift scenario) each get their own.
  3. No resolvable email → mark abandoned_at only; cart still appears in the dashboard for manual follow-up (no auto-email).

After a successful send, markCartAbandonedNotifiedGroup stamps abandoned_notified_at on the root draft and every fork in the group so later cron runs never re-email the same shopper.


Architecture

apps/zooly-app/
  app/api/merch/cron/abandoned-carts/route.ts     Hourly cron (thin router)
  app/api/merch/email/sendgrid-webhook/route.ts   Open → journey_event
  app/api/merch/admin/abandoned-carts/[cartId]/review/route.ts  Writable triage API
  vercel.json                                     schedule: 0 * * * *

packages/merch/srv/ (@zooly/merch-srv)
  abandoned-cart.ts                               resolveAbandonedCartTargets()

packages/merch-fulfillment/srv/
  send-email.ts                                   sendMerchAbandonedCartEmail()

packages/db/ (@zooly/db)
  access/merch/merch-cart.ts                      listAbandonedCartCandidates, markCartAbandoned, markCartAbandonedNotifiedGroup
  access/merch/merch-cart-admin-review.ts         upsertCartReview, getCartReview
  schema/merchTables.ts                           merch_cart_admin_review table

apps/zooly-stats/
  app/merch/abandoned-carts/                      Admin dashboard (read-only DB)
  app/api/admin/merch/abandoned-carts/route.ts    List API

Read vs write split

zooly-stats connects to production via a read-only DB user. The dashboard reads abandoned carts from stats APIs and writes triage updates (status, comment, private-email flag) through the writable zooly-app admin API with credentials + CORS.

sequenceDiagram participant Admin participant Stats as zooly-stats participant App as zooly-app participant DB as PostgreSQL Admin->>Stats: Load /merch/abandoned-carts Stats->>DB: Read-only SQL (merch_cart + review + journey_event) DB-->>Stats: Abandoned cart rows Stats-->>Admin: Render table Admin->>Stats: Edit status / comment / mark private email sent Stats->>App: PATCH .../abandoned-carts/{cartId}/review App->>DB: upsertCartReview() DB-->>App: OK App-->>Stats: Updated review row

Cron Details

Route: GET /api/merch/cron/abandoned-carts

Auth: Authorization: Bearer $CRON_SECRET (same pattern as offers crons).

Schedule: hourly (0 * * * * in apps/zooly-app/vercel.json).

Env:

VariableDefaultPurpose
CRON_SECRETBearer token for cron auth
MERCH_ABANDON_WINDOW_HOURS3Idle time before a cart is stale
NEXT_PUBLIC_MERCH_URLBase URL for recovery link ({host}/cart?s={sessionId})

Response counters: candidates, targets, marked, emailed, skippedEmpty, errors[].

Per-target loop:

  1. Skip empty carts.
  2. markCartAbandoned(cartId) — sets abandoned_at, idempotent on active + abandoned_at IS NULL.
  3. If email + campaign + merch host resolve → sendMerchAbandonedCartEmail with SendGrid customArgs (merchSessionId, emailType: abandoned_cart).
  4. On send success → markCartAbandonedNotifiedGroup(cartId).

Local test:

curl -H "Authorization: Bearer $CRON_SECRET" \
  http://localhost:3000/api/merch/cron/abandoned-carts

Recovery Email

sendMerchAbandonedCartEmail mirrors the existing merch email shell (SendGrid, campaign i18n via createTranslator).

  • Link target: global /cart (not the store landing), with ?s=<sessionId> when a session exists so cross-device recovery works.
  • Branding: campaign of the most-recent in-cart item.
  • i18n keys in default-merch-strings.ts: email_abandoned_subject, email_abandoned_header, email_abandoned_body, email_abandoned_cta.
  • Open tracking: customArgs.merchSessionId + customArgs.emailType = abandoned_cart for the webhook.

Email Open Tracking

SendGrid Open Tracking + Signed Event Webhook point at POST /api/merch/email/sendgrid-webhook.

On event === "open" with emailType === "abandoned_cart":

createJourneyEvent({
  eventType: "MERCH_EMAIL_OPENED",
  merchSessionId: /* from custom_args */,
  eventData: { email, emailType, sgEventId },
});

Set SENDGRID_WEBHOOK_PUBLIC_KEY in production to verify ECDSA signatures. When unset, events are processed with a dev warning (no verification).

The stats sessions and abandoned-cart list APIs already read MERCH_EMAIL_OPENED to populate an Opened column.


Admin Dashboard

Path: /merch/abandoned-carts in zooly-stats (admin role required; non-admins → /denied).

One row per logical cart (draft + forks collapsed by group in the list query).

Columns

ColumnSource
Created / last touchedmerch_cart timestamps
Items / valuemerch_item aggregate
Campaign / storelatest in-cart item’s campaign
Emailsession delayed_email or shipping_info.email
Automated email sentabandoned_notified_at
Openedjourney_event where event_type = MERCH_EMAIL_OPENED
Statusreview workflow (see below)
Commentmerch_cart_admin_review.admin_comment
Private email sentmerch_cart_admin_review.private_email_sent_at

Review status workflow

Manual triage statuses (stored in merch_cart_admin_review.review_status):

DB valueDashboard label
newAutomated email sent
contactedPrivately contacted
recoveredRecovered
lostLost

Recovered is derived automatically from cart state — no manual click required. A cart shows as Recovered when:

  • The cart itself is converted or discarded, or
  • Any cart in its draft→fork group converted (shopper came back and bought).

Manual status edits apply only while the cart is not auto-recovered.

Writable API

PATCH /api/merch/admin/abandoned-carts/{cartId}/review

Body (all optional):

{
  "reviewStatus": "contacted",
  "adminComment": "Called shopper, waiting on reply",
  "privateEmailSent": true
}

Guarded by requireAdmin() from @zooly/merch-admin-srv. Sets updated_by_email from the admin identity.


Database

merch_cart (cron-relevant columns)

ColumnRole
statusStays active through abandonment; becomes converted / discarded on purchase lifecycle
last_touched_atIdle timer for cron candidacy
abandoned_atSet by cron when cart is stale and has items
abandoned_notified_atSet after auto-email (whole group); prevents re-email
source_cart_idFork → draft link (ZLY-1303)
owner_cookie_id / owner_user_idDraft vs fork vs standalone user cart

merch_cart_admin_review (new, ZLY-929)

ColumnTypePurpose
cart_idtext PK1:1 with merch_cart.id
review_statusenumnew, contacted, recovered, lost
admin_commenttextFree-text triage note
private_email_sent_attimestamptzWhen Jeana sent a manual follow-up
updated_by_emailvarcharLast editor

Out of Scope (for now)

  • Multi-step email drip (one recovery email only).
  • Discount / coupon in the recovery email.
  • Click tracking beyond opens (can extend the same webhook later).
  • Generation-level dropoff re-engagement (covered by the stats Drop-offs tab; cart is the unit here).