Hourly cron, recovery email, open tracking, and admin triage dashboard (ZLY-929)
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:
/cart page.journey_event (MERCH_EMAIL_OPENED).zooly-stats for admin triage (status, comment, private-email flag).| Surface | URL / path | Who |
|---|---|---|
| Abandoned Carts dashboard | zooly-stats → Merch → Abandoned Carts (/merch/abandoned-carts) | Admin only |
| Cron endpoint | GET /api/merch/cron/abandoned-carts on zooly-app | Bearer CRON_SECRET (Vercel cron) |
| Review write API | PATCH /api/merch/admin/abandoned-carts/{cartId}/review on zooly-app | Merch admin (called cross-origin from stats) |
| SendGrid webhook | POST /api/merch/email/sendgrid-webhook on zooly-app | SendGrid signed events |
Local dev defaults:
| App | Port |
|---|---|
zooly-stats | 3010 |
zooly-app | 3000 |
| Merch SPA | 3008 |
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 = activelast_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)merch_item row with order_id IS NULL)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.
Dedup rules (resolveAbandonedCartTargets in @zooly/merch-srv):
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.
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
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.
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:
| Variable | Default | Purpose |
|---|---|---|
CRON_SECRET | — | Bearer token for cron auth |
MERCH_ABANDON_WINDOW_HOURS | 3 | Idle time before a cart is stale |
NEXT_PUBLIC_MERCH_URL | — | Base URL for recovery link ({host}/cart?s={sessionId}) |
Response counters: candidates, targets, marked, emailed, skippedEmpty, errors[].
Per-target loop:
markCartAbandoned(cartId) — sets abandoned_at, idempotent on active + abandoned_at IS NULL.sendMerchAbandonedCartEmail with SendGrid customArgs (merchSessionId, emailType: abandoned_cart).markCartAbandonedNotifiedGroup(cartId).Local test:
curl -H "Authorization: Bearer $CRON_SECRET" \
http://localhost:3000/api/merch/cron/abandoned-carts
sendMerchAbandonedCartEmail mirrors the existing merch email shell (SendGrid, campaign i18n via createTranslator).
/cart (not the store landing), with ?s=<sessionId> when a session exists so cross-device recovery works.default-merch-strings.ts: email_abandoned_subject, email_abandoned_header, email_abandoned_body, email_abandoned_cta.customArgs.merchSessionId + customArgs.emailType = abandoned_cart for the webhook.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.
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).
| Column | Source |
|---|---|
| Created / last touched | merch_cart timestamps |
| Items / value | merch_item aggregate |
| Campaign / store | latest in-cart item’s campaign |
session delayed_email or shipping_info.email | |
| Automated email sent | abandoned_notified_at |
| Opened | journey_event where event_type = MERCH_EMAIL_OPENED |
| Status | review workflow (see below) |
| Comment | merch_cart_admin_review.admin_comment |
| Private email sent | merch_cart_admin_review.private_email_sent_at |
Manual triage statuses (stored in merch_cart_admin_review.review_status):
| DB value | Dashboard label |
|---|---|
new | Automated email sent |
contacted | Privately contacted |
recovered | Recovered |
lost | Lost |
Recovered is derived automatically from cart state — no manual click required. A cart shows as Recovered when:
converted or discarded, orManual status edits apply only while the cart is not auto-recovered.
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.
merch_cart (cron-relevant columns)| Column | Role |
|---|---|
status | Stays active through abandonment; becomes converted / discarded on purchase lifecycle |
last_touched_at | Idle timer for cron candidacy |
abandoned_at | Set by cron when cart is stale and has items |
abandoned_notified_at | Set after auto-email (whole group); prevents re-email |
source_cart_id | Fork → draft link (ZLY-1303) |
owner_cookie_id / owner_user_id | Draft vs fork vs standalone user cart |
merch_cart_admin_review (new, ZLY-929)| Column | Type | Purpose |
|---|---|---|
cart_id | text PK | 1:1 with merch_cart.id |
review_status | enum | new, contacted, recovered, lost |
admin_comment | text | Free-text triage note |
private_email_sent_at | timestamptz | When Jeana sent a manual follow-up |
updated_by_email | varchar | Last editor |
On This Page
OverviewWhere to AccessEnd-to-End FlowWhat “abandoned” meansFork / Draft ModelArchitectureRead vs write splitCron DetailsRecovery EmailEmail Open TrackingAdmin DashboardColumnsReview status workflowWritable APIDatabase[object Object], (cron-relevant columns)[object Object], (new, ZLY-929)Out of Scope (for now)