@blamejs/blamejs-shop 0.4.20 → 0.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.22 (2026-06-06) — **Privacy export covers every table, erasure revokes the login, one-click unsubscribe actually works, and bounces feed the suppression list.** A privacy and email-integrity release. The data-subject export now includes reviews, consent history, wishlist, surveys, and recently-viewed data — with a manifest that makes any absent section visible. Erasing a customer also deletes their passkeys and social-login links and revokes live sessions, so a deleted account can no longer sign in. The one-click unsubscribe that mail clients invoke from their native button now works (it pointed at a route that didn't exist), a new intake endpoint feeds provider bounce and complaint webhooks into the marketing suppression list, support-ticket edge cases are fixed, and the CSV exports share one complete injection-neutralizer. **Added:** *Bounce and complaint webhook intake* — A new `POST /api/webhooks/mail-bounce` endpoint accepts bounce and complaint webhooks from Postmark, SES, or Resend (selectable per request). Spam complaints and permanently-dead addresses land on the marketing suppression list — transactional mail still flows — and campaign metrics gain the bounce events they were built to read. The endpoint is armed only when `MAIL_BOUNCE_SECRET` is set and the provider presents it in the `x-mail-bounce-secret` header; unconfigured, it answers 503 and accepts nothing. **Fixed:** *The data-subject export includes every table that holds the customer* — The export bundle silently omitted reviews, consent history, wishlist items, survey responses, and recently-viewed data. All five are now included, and the bundle carries a completeness manifest listing every section as exported, empty, or absent — an omission is visible rather than silent. · *Erasure revokes the customer's ability to sign in* — Deleting a customer scrubbed their profile but left passkeys, Google/Apple login links, and live sessions intact — the "deleted" account could sign right back in. Erasure now deletes the passkeys and social-login links, revokes live portal sessions, and tombstones the email lookup hash irreversibly, while the anonymized record survives for order-history integrity. · *One-click unsubscribe works from the mail client's native button* — The unsubscribe headers stamped on every campaign pointed at a route that didn't exist, and the unsubscribe endpoint read its token from the form body — which a mail client's native one-click POST never carries. The header now points at the real endpoint and the token rides the URL, so the RFC 8058 one-click flow a mail client fires actually unsubscribes. The browser confirmation page is unchanged. · *Support tickets: reopen on customer reply, atomic tags, honest first-response time* — A customer reply to a resolved ticket silently disappeared from every operator queue — it now reopens the ticket with a fresh activity timestamp. Concurrent tag edits no longer overwrite each other (tag changes are single-statement updates). And an internal-only operator note no longer counts as the first response — only a reply the customer can actually see stamps the first-response time. · *CSV exports share one complete injection neutralizer* — The order-export and segment-members CSV exports each carried their own formula-injection defense, and both missed the tab, carriage-return, newline, and pipe vectors. Both now compose the framework's CSV cell guard, which covers the full vector set — including signed numerics, which the previous neutralizers deliberately exempted.
12
+
13
+ - v0.4.21 (2026-06-06) — **Subscription changes reach the payment processor, smart collections match real products, refunds claw back earned points, and the remaining balance races are closed.** A correctness release across the commerce surfaces. Changing a subscription's quantity now updates the processor before the local record — what the customer sees is what they're billed — and a cadence change on a processor-backed plan is honestly refused rather than silently ignored. Smart collections, which matched zero products in production because their rules read fields the catalog rows never carried, now evaluate against the real product data. Loyalty points earned on a purchase are reversed when the order is refunded or cancelled. And the loyalty, store-credit, gift-registry, and gift-card-ledger write paths that could lose updates or oversell under concurrency now use atomic guards. **Fixed:** *Subscription quantity changes reach the payment processor* — Changing a subscription's quantity updated the local record and showed a success message while the processor kept billing the original amount. The change is now pushed to the processor first — a processor failure leaves the local record untouched and surfaces the error, so the customer-visible state and the billed state can no longer diverge. Changing delivery frequency on a processor-backed subscription is refused with honest guidance (the billing cadence is bound to the plan's price and cannot be re-cadenced in place); self-managed local subscriptions keep their frequency controls. Self-manage controls also now respect the processor's status — a subscription the processor reports as cancelled or expired shows its state instead of live controls. · *Smart collections match real catalog products* — Smart-collection rules read fields like tags, price, and stock from each product row — fields the real catalog listing never carried, so every smart collection matched zero products in production even though admin previews built on richer mock rows looked right. Rule evaluation now joins the real tag, category, vendor, price, and inventory data onto each product page, and the admin preview routes through the same path the storefront uses. Smart-collection pages also stop re-walking the entire catalog on every paginated request — the matched set is briefly cached and a rule edit invalidates it. · *Refunds and cancellations reverse the loyalty points the order earned* — Points awarded when an order was paid survived a refund or cancellation — buying, earning, and refunding farmed points indefinitely. The earn record is now claimed atomically on the order's refund or cancel transition and the awarded points are clawed back from the balance, floored at zero when some were already spent. The reversal is idempotent (a re-delivered payment webhook reverses exactly once) and lifetime points — which drive tier — are deliberately untouched. · *Balance and inventory-adjacent races closed across loyalty, store credit, gift registry, and gift-card ledger* — Loyalty earn and adjust used read-then-write absolute updates that could lose concurrent updates; the gift-registry purchase check could oversell a registry item under two simultaneous purchases; and the gift-card ledger's overdraft check could let two concurrent debits both pass. All of these now use single-statement conditional writes that refuse cleanly when the guard fails. Store-credit expiry sweeps also stop under-expiring when operator-initiated deductions exist — the sweep now keys on its own prior output rather than netting all expiry rows together. · *Search-ranking click-through metrics are bounded and attributed* — The ranking metrics screen could show click-through rates above 100%, and clicks were attributed from the query string alone — trivially spoofable. A click now only counts when the same session recorded a real impression for that query, and displayed rates are bounded at 100%.
14
+
11
15
  - v0.4.20 (2026-06-06) — **Payment integrity: refunds, gift-card balances, and checkout submission are race-proof, and abandoned checkouts return gift-card funds.** A money-path hardening release. Concurrent refund clicks on the same return can no longer reach the payment provider twice. A gift card that part-paid a checkout gets its balance back when the order is cancelled, fails payment, or is reaped as stale. Double-submitting checkout can no longer create two charges and two orders. Return requests are refused server-side on orders that aren't in a returnable state, the refund confirmation screen shows the currency the provider will actually refund, and a buy-online-pickup-in-store order now reaches its delivered state when picked up. **Fixed:** *A return can only be refunded once, even under concurrent requests* — Two simultaneous refund requests for the same approved return could both pass the status check and both reach the payment provider, each with a fresh idempotency key — a double refund. The refund now claims the return atomically before the provider is called (the second request answers 409), the provider call uses a key derived from the return itself so even a retry collapses into the same refund, and a provider failure releases the claim so the refund can be retried. · *Gift-card funds come back when a part-paid checkout dies* — When a gift card partially covered a checkout and the order was then cancelled, failed payment, or sat abandoned until reaped, the card's debit was never returned — the balance was permanently gone. Redemptions are now reversed on those order transitions: the card balance is restored (a fully drained card is reactivated), the reversal is idempotent, and it rides the same order lifecycle that releases inventory holds. · *Double-submitting checkout creates one charge and one order* — Two concurrent checkout submissions could both pass the cart-status read and each create a payment intent and an order. The cart is now claimed atomically as the single gate — the second submission is redirected back to the cart — and the payment-provider idempotency key is derived from the cart, so even a duplicate that slipped through would collapse into one charge on the provider's side. A failure before the order exists releases the claim so the customer can retry. · *Return requests are validated server-side* — The return-request form and its submission are now refused on orders that are not in a returnable state — a refunded, cancelled, or unpaid order answers with a clear message instead of opening a return that could feed a second refund downstream. Previously only the button's visibility enforced this. · *A failed gift-card settlement no longer strands a completed payment* — If recording a gift card's redemption failed after the payment had already succeeded and the order existed, the checkout aborted mid-way — leaving a paid order with a cart that never converted. The settlement step now completes the order regardless and surfaces the failure for follow-up instead of stranding the purchase. · *The refund confirmation shows the currency the provider refunds* — The confirmation screen displayed the return's approved currency, but the provider refunds in the original charge's currency. The screen now reads the same source the refund uses. · *Pickup orders reach their delivered state* — Marking a pickup order as picked up drove an order transition that was only legal for shipped orders, and the failure was silently swallowed — the pickup schedule said picked up while the order stayed in its paid state. The order lifecycle now has a pickup-completion transition, so a picked-up order genuinely lands in delivered and downstream reporting sees it.
12
16
 
13
17
  - v0.4.19 (2026-06-06) — **Wishlist alerts fire once per event instead of repeating daily, stale digests catch up with a single email, and webhook delivery stops throttling itself.** A set of notification and delivery fixes. Back-in-stock and price-drop wishlist alerts now fire only when something actually changes — a steadily in-stock item no longer re-alerts every day. A wishlist digest whose schedule fell behind sends one catch-up email instead of one per minute until current. Outbound webhook delivery no longer counts its own rate-limit refusals against the limit (which kept the throttle tripped once hit), retrying an exhausted delivery no longer duplicates its dead-letter record, and the blog RSS feed renders correctly when a post title or body contains dollar-sign sequences. **Fixed:** *Back-in-stock and price-drop alerts fire on the change, not the state* — The wishlist alert sweep evaluated the current state of each item, so anything in stock re-alerted every wishlister on every sweep — the daily dedupe window just made the flood daily. Alerts now track the last-observed state per customer and item: back-in-stock fires only on an out-of-stock-to-in-stock transition, price-drop only when the price falls below the level already alerted, and a recovered price re-arms the alert. A steadily in-stock item never re-fires. The weekly cap and dedupe are also enforced atomically, so two overlapping sweeps can't double-send past the cap. · *A stale digest enrollment catches up with one email* — A wishlist digest whose next-send time had fallen far behind advanced one period per scheduler tick and sent an email each time — a subscriber could receive dozens of digests in an hour while the schedule caught up. Catching up now snaps the schedule to the present and sends at most one digest. · *Webhook delivery rate limiting no longer feeds on its own refusals* — The outbound webhook rate-limit gate counted its own refusal records toward the limit, so once an endpoint tripped the throttle it could stay tripped indefinitely, and every suppressed attempt persisted another row. Refusals are no longer counted by the gate and collapse to a single record per endpoint per window. · *Retrying an exhausted webhook delivery is idempotent* — Manually retrying a delivery that had already exhausted its attempts inserted a fresh dead-letter record on every click. Retry of an exhausted or already-delivered delivery now answers as a clean no-op, and the dead-letter table enforces one record per delivery. · *The RSS feed renders posts containing dollar-sign sequences* — The feed assembled items with a plain string replacement, which gives `$` sequences special meaning — a post title or body containing a dollar sign followed by a backtick or ampersand could corrupt the entire feed XML. The feed now uses the same literal splice the page renderers use. The feed's author field also no longer exposes an internal identifier; it carries the shop name, matching the blog pages.
package/README.md CHANGED
@@ -204,6 +204,7 @@ variables. A signed-in operator can see the live on/off status of each at
204
204
  | **PayPal checkout** | A native PayPal button on `/checkout` (PayPal Orders v2 — create / approve / capture), distinct from PayPal-through-Stripe. | `PAYPAL_CLIENT_ID`, `PAYPAL_SECRET` (a PayPal REST app), `PAYPAL_WEBHOOK_ID`, `PAYPAL_ENV` (`sandbox`\|`live`); Stripe checkout must also be live | The shop exchanges the OAuth2 token and creates / captures orders server-side; the button drives `/checkout/paypal/create` + `/checkout/paypal/capture`. Point a PayPal webhook at `/api/webhooks/paypal` (verified through PayPal's API). Allow `www.paypal.com` in your CSP `script-src` / `frame-src` (as you would `js.stripe.com`). |
205
205
  | **Transactional email (SMTP)** | Order/ship/refund mail, abandoned-cart recovery, back-in-stock alerts, **wishlist sale + restock alerts and the periodic wishlist digest** (opt-in per customer on `/account/wishlist`), and **email magic-link sign-in** (a *Email me a sign-in link* option on `/account/login` for shoppers without a passkey or social login). | `SMTP_HOST`, `MAIL_FROM` (plus optional `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS`) | Without a mailer these surfaces stay inert — the wishlist crons scan nothing, the magic-link page reports email sign-in unavailable, and passkey / social login are unchanged. **Wishlist alerts + digests are additionally gated on an email-address resolver:** the customer store keeps only a salted email *hash* (never the plaintext), so out of the box there is no deliverable address and the crons send nothing even with SMTP set. They begin sending once you supply a resolver that maps a customer id to a deliverable address from your own plaintext-address store — the same hook abandoned-cart recovery uses. |
206
206
 
207
+ | **Email bounce / complaint intake** | Hard bounces and spam complaints from your email provider land on the marketing suppression list automatically (and backfill campaign metrics), so broadcasts stop mailing dead or complaining addresses. | `MAIL_BOUNCE_SECRET` (the value your provider sends in an `x-mail-bounce-secret` header), optional `MAIL_BOUNCE_VENDOR` (`postmark`\|`ses`\|`resend`, default `postmark`); point the provider's bounce/complaint webhook at `POST /api/webhooks/mail-bounce` (a `?vendor=` query overrides per request) | Without the secret the endpoint answers 503 — it never accepts an unauthenticated bounce. Complaints and hard bounces suppress at marketing scope (transactional mail still flows); soft bounces only backfill metrics. |
207
208
  **Planned / not available:**
208
209
 
209
210
  - **Shop Pay / "Sign in with Shop"** — **not available** to a self-hosted,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.20",
2
+ "version": "0.4.22",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
@@ -248,21 +248,45 @@ function _now() { return Date.now(); }
248
248
  // in isolation against a synthetic product.
249
249
  function _matchRule(rule, product) {
250
250
  var fieldVal = product == null ? undefined : product[rule.field];
251
+ // A catalog field can be MULTI-VALUED — a product carries an array
252
+ // of `tags` and an array of `category` memberships. For the scalar
253
+ // ops (`eq` / `neq` / `in` / `not_in`) an array field matches with
254
+ // membership semantics ("any of the product's categories equals X").
255
+ // A scalar field value keeps strict-equality semantics, so the
256
+ // existing single-valued fields (`vendor`, numeric fields) are
257
+ // unaffected. This is what lets a smart rule like
258
+ // `{ field: "category", op: "eq", value: "apparel" }` match a
259
+ // product that lives in apparel + clearance + outdoor.
260
+ var isArrayVal = Array.isArray(fieldVal);
251
261
  switch (rule.op) {
252
- case "eq": return fieldVal === rule.value;
253
- case "neq": return fieldVal !== rule.value;
262
+ case "eq": return isArrayVal ? fieldVal.indexOf(rule.value) >= 0 : fieldVal === rule.value;
263
+ case "neq": return isArrayVal ? fieldVal.indexOf(rule.value) < 0 : fieldVal !== rule.value;
254
264
  case "contains": {
255
265
  // Validator guaranteed an array field; if the catalog row
256
266
  // hasn't populated it, treat as empty (not a match).
257
- if (!Array.isArray(fieldVal)) return false;
267
+ if (!isArrayVal) return false;
258
268
  return fieldVal.indexOf(rule.value) >= 0;
259
269
  }
260
270
  case "gt": return typeof fieldVal === "number" && fieldVal > rule.value;
261
271
  case "gte": return typeof fieldVal === "number" && fieldVal >= rule.value;
262
272
  case "lt": return typeof fieldVal === "number" && fieldVal < rule.value;
263
273
  case "lte": return typeof fieldVal === "number" && fieldVal <= rule.value;
264
- case "in": return rule.value.indexOf(fieldVal) >= 0;
265
- case "not_in": return rule.value.indexOf(fieldVal) < 0;
274
+ case "in":
275
+ if (isArrayVal) {
276
+ for (var ai = 0; ai < fieldVal.length; ai += 1) {
277
+ if (rule.value.indexOf(fieldVal[ai]) >= 0) return true;
278
+ }
279
+ return false;
280
+ }
281
+ return rule.value.indexOf(fieldVal) >= 0;
282
+ case "not_in":
283
+ if (isArrayVal) {
284
+ for (var ni = 0; ni < fieldVal.length; ni += 1) {
285
+ if (rule.value.indexOf(fieldVal[ni]) >= 0) return false;
286
+ }
287
+ return true;
288
+ }
289
+ return rule.value.indexOf(fieldVal) < 0;
266
290
  case "between": {
267
291
  if (typeof fieldVal !== "number") return false;
268
292
  return fieldVal >= rule.value[0] && fieldVal <= rule.value[1];
@@ -299,6 +323,20 @@ function _evaluateRules(input) {
299
323
  return _matchRuleset(rules, product);
300
324
  }
301
325
 
326
+ // Collect the distinct catalog fields a (validated) rule set reads, so
327
+ // the smart-eval path can enrich ONLY the fields a collection actually
328
+ // references rather than every supported field on every product. `rules`
329
+ // is the `{ all, any }` shape `_validateRules` returns.
330
+ function _fieldsReferenced(rules) {
331
+ var seen = Object.create(null);
332
+ function _walk(list) {
333
+ for (var i = 0; i < list.length; i += 1) seen[list[i].field] = true;
334
+ }
335
+ _walk(rules.all);
336
+ _walk(rules.any);
337
+ return Object.keys(seen);
338
+ }
339
+
302
340
  // ---- sort comparators for smart collections ------------------------------
303
341
 
304
342
  // Best-selling needs a sales-rank on the product object; the catalog
@@ -369,6 +407,63 @@ function create(opts) {
369
407
  }
370
408
  var cursorSecret = opts.cursorSecret;
371
409
 
410
+ // Smart-collection matched-roster cache. A smart collection has no
411
+ // materialised membership — `productsIn` walks the WHOLE catalog,
412
+ // evaluates rules, sorts, then slices the requested page. Paginating
413
+ // a smart collection therefore re-walked the entire catalog on every
414
+ // page request (page 2 redid the work page 1 already did). This brief
415
+ // in-process cache holds the sorted matched roster keyed by the
416
+ // collection's identity + rule fingerprint, so successive page
417
+ // requests within the TTL slice the cached roster instead of
418
+ // re-walking. The TTL is short (a smart collection that just lost a
419
+ // product to an archive shows the stale member for at most one TTL)
420
+ // and configurable; pass `smartCacheTtlMs: 0` to disable. The cache
421
+ // is per-factory-instance and bounded by an LRU-ish cap so it can't
422
+ // grow unbounded across many distinct collections.
423
+ var SMART_CACHE_TTL_MS = opts.smartCacheTtlMs != null
424
+ ? opts.smartCacheTtlMs
425
+ : b.constants.TIME.seconds(30);
426
+ if (typeof SMART_CACHE_TTL_MS !== "number" || !isFinite(SMART_CACHE_TTL_MS) || SMART_CACHE_TTL_MS < 0) {
427
+ throw new TypeError("collections.create: smartCacheTtlMs must be a non-negative number of ms");
428
+ }
429
+ var SMART_CACHE_MAX_ENTRIES = 64;
430
+ var _smartCache = new Map(); // key -> { roster, expires_at }
431
+
432
+ function _smartCacheKey(slug, row) {
433
+ // The rule fingerprint + sort_strategy + updated_at are part of the
434
+ // key, so re-defining a smart collection's rules (which bumps
435
+ // updated_at and rewrites rules_json) misses the prior entry and
436
+ // re-walks — no stale roster after an operator edit. NUL joins the
437
+ // parts (it can't appear in a slug, timestamp, sort strategy, or
438
+ // JSON, so the parts can't collide), written as an escape so the
439
+ // source stays plain text.
440
+ return slug + "\u0000" + (row.updated_at == null ? "" : row.updated_at) +
441
+ "\u0000" + (row.sort_strategy || "") + "\u0000" + (row.rules_json || "");
442
+ }
443
+
444
+ function _smartCacheGet(key) {
445
+ if (SMART_CACHE_TTL_MS === 0) return null;
446
+ var hit = _smartCache.get(key);
447
+ if (!hit) return null;
448
+ if (hit.expires_at <= _now()) { _smartCache.delete(key); return null; }
449
+ // Refresh LRU recency.
450
+ _smartCache.delete(key);
451
+ _smartCache.set(key, hit);
452
+ return hit.roster;
453
+ }
454
+
455
+ function _smartCacheSet(key, roster) {
456
+ if (SMART_CACHE_TTL_MS === 0) return;
457
+ if (_smartCache.size >= SMART_CACHE_MAX_ENTRIES) {
458
+ // Evict the least-recently-used entry (first key in insertion
459
+ // order — `get` re-inserts on hit so live entries drift to the
460
+ // tail).
461
+ var oldest = _smartCache.keys().next().value;
462
+ if (oldest !== undefined) _smartCache.delete(oldest);
463
+ }
464
+ _smartCache.set(key, { roster: roster, expires_at: _now() + SMART_CACHE_TTL_MS });
465
+ }
466
+
372
467
  // ---- internal helpers --------------------------------------------------
373
468
 
374
469
  async function _row(slug) {
@@ -406,8 +501,12 @@ function create(opts) {
406
501
  // caller-supplied visitor. The catalog is expected to expose
407
502
  // `products.list({ limit, cursor?, status? })`; the smart-eval
408
503
  // path filters to `status = 'active'` so archived rows never leak
409
- // into a smart collection.
410
- async function _walkCatalogActive(visit) {
504
+ // into a smart collection. `enrichFields` (optional) is the list of
505
+ // smart-rule fields to join onto each page's rows before the visitor
506
+ // sees them, so the rule evaluator reads real catalog data rather
507
+ // than `undefined` — enrichment is batched per page (one query per
508
+ // referenced supporting table per page, not per product).
509
+ async function _walkCatalogActive(visit, enrichFields) {
411
510
  var cursor = null;
412
511
  var pages = 0;
413
512
  var pageLimit = 200;
@@ -419,6 +518,7 @@ function create(opts) {
419
518
  while (pages < MAX_PAGES) {
420
519
  var page = await catalog.products.list({ status: "active", limit: pageLimit, cursor: cursor });
421
520
  var rows = (page && page.rows) || [];
521
+ if (enrichFields && enrichFields.length) await _enrichRows(rows, enrichFields);
422
522
  for (var i = 0; i < rows.length; i += 1) await visit(rows[i]);
423
523
  cursor = (page && page.next_cursor) || null;
424
524
  pages += 1;
@@ -427,6 +527,149 @@ function create(opts) {
427
527
  throw new Error("collections: catalog walk exceeded " + MAX_PAGES + " pages — pre-materialise smart membership");
428
528
  }
429
529
 
530
+ // ---- smart-rule field enrichment --------------------------------------
531
+
532
+ // The catalog's `products.list` returns the bare product row — id,
533
+ // slug, title, description, status, created_at, updated_at. A smart
534
+ // collection's rules read `tags`, `category`, `vendor`, `price_minor`,
535
+ // and `inventory_count`, none of which live on that row; they're
536
+ // joined from supporting tables (`product_tags`, `product_categories`,
537
+ // `vendor_skus` via `variants.sku`, `prices`, `inventory`). Without
538
+ // this enrichment every rule on those fields evaluated against
539
+ // `undefined` and a smart collection matched ZERO products in
540
+ // production while the unit-test mock (which pre-stuffs the fields)
541
+ // stayed green — a dark feature.
542
+ //
543
+ // Enrichment is batched per catalog page and scoped to ONLY the fields
544
+ // the active rule set references (computed once by the caller), so a
545
+ // collection that filters on price alone never pays for the tag /
546
+ // category / vendor joins. A row that ALREADY carries a field (e.g. a
547
+ // caller that pre-decorated, or the test mock) is left untouched — the
548
+ // enrichment only fills gaps. Each supporting-table read is wrapped so
549
+ // an unmigrated table degrades the field to its empty form (empty
550
+ // array / null / 0) rather than throwing out of the catalog walk.
551
+ async function _enrichRows(rows, fields) {
552
+ if (!rows.length || !fields.length) return;
553
+ var need = {};
554
+ for (var f = 0; f < fields.length; f += 1) need[fields[f]] = true;
555
+
556
+ // Only enrich product rows that are MISSING the requested field —
557
+ // gather their ids per field so a pre-decorated row is never
558
+ // re-queried.
559
+ var ids = [];
560
+ var idSeen = Object.create(null);
561
+ for (var i = 0; i < rows.length; i += 1) {
562
+ var id = rows[i] && rows[i].id;
563
+ if (id == null) continue;
564
+ if (!idSeen[id]) { idSeen[id] = true; ids.push(id); }
565
+ }
566
+ if (!ids.length) return;
567
+ var ph = ids.map(function (_v, n) { return "?" + (n + 1); }).join(",");
568
+
569
+ async function _safeQuery(sql, params) {
570
+ try { return (await query(sql, params)).rows; }
571
+ catch (_e) { return []; } // unmigrated supporting table → empty
572
+ }
573
+
574
+ var tagsByProduct = null;
575
+ if (need.tags) {
576
+ tagsByProduct = Object.create(null);
577
+ var tagRows = await _safeQuery(
578
+ "SELECT product_id, tag FROM product_tags WHERE product_id IN (" + ph + ")",
579
+ ids,
580
+ );
581
+ for (var t = 0; t < tagRows.length; t += 1) {
582
+ var tp = tagRows[t].product_id;
583
+ (tagsByProduct[tp] || (tagsByProduct[tp] = [])).push(tagRows[t].tag);
584
+ }
585
+ }
586
+
587
+ var catByProduct = null;
588
+ if (need.category) {
589
+ catByProduct = Object.create(null);
590
+ var catRows = await _safeQuery(
591
+ "SELECT product_id, category FROM product_categories WHERE product_id IN (" + ph + ")",
592
+ ids,
593
+ );
594
+ for (var c = 0; c < catRows.length; c += 1) {
595
+ var cp = catRows[c].product_id;
596
+ (catByProduct[cp] || (catByProduct[cp] = [])).push(catRows[c].category);
597
+ }
598
+ }
599
+
600
+ var vendorByProduct = null;
601
+ if (need.vendor) {
602
+ vendorByProduct = Object.create(null);
603
+ // Vendor binds to a SKU (vendor_skus.sku), and a SKU belongs to a
604
+ // variant of a product. A product with several variants could in
605
+ // principle map to more than one vendor; the smart-rule field is
606
+ // scalar, so we take the lexicographically-smallest assigned
607
+ // vendor slug for determinism. Products with no assigned vendor
608
+ // get null (no row), and the rule on `vendor` simply won't match.
609
+ var venRows = await _safeQuery(
610
+ "SELECT v.product_id AS product_id, MIN(vs.vendor_slug) AS vendor " +
611
+ "FROM variants v JOIN vendor_skus vs ON vs.sku = v.sku " +
612
+ "WHERE v.product_id IN (" + ph + ") GROUP BY v.product_id",
613
+ ids,
614
+ );
615
+ for (var vi = 0; vi < venRows.length; vi += 1) {
616
+ vendorByProduct[venRows[vi].product_id] = venRows[vi].vendor;
617
+ }
618
+ }
619
+
620
+ var priceByProduct = null;
621
+ if (need.price_minor) {
622
+ priceByProduct = Object.create(null);
623
+ // Lowest CURRENT price across the product's variants — the
624
+ // `effective_until IS NULL` row is the live price the catalog's
625
+ // own decorator reads. "Starting at" is the natural price a
626
+ // price-bounded smart rule (`price_minor < 5000`) should test.
627
+ var priceRows = await _safeQuery(
628
+ "SELECT v.product_id AS product_id, MIN(pr.amount_minor) AS price_minor " +
629
+ "FROM variants v JOIN prices pr ON pr.variant_id = v.id " +
630
+ "WHERE v.product_id IN (" + ph + ") AND pr.effective_until IS NULL " +
631
+ "GROUP BY v.product_id",
632
+ ids,
633
+ );
634
+ for (var pi = 0; pi < priceRows.length; pi += 1) {
635
+ priceByProduct[priceRows[pi].product_id] = priceRows[pi].price_minor;
636
+ }
637
+ }
638
+
639
+ var invByProduct = null;
640
+ if (need.inventory_count) {
641
+ invByProduct = Object.create(null);
642
+ // Total on-hand units across the product's variant SKUs. A rule
643
+ // like `inventory_count > 0` ("only in-stock") reads this.
644
+ var invRows = await _safeQuery(
645
+ "SELECT v.product_id AS product_id, COALESCE(SUM(inv.stock_on_hand), 0) AS inventory_count " +
646
+ "FROM variants v JOIN inventory inv ON inv.sku = v.sku " +
647
+ "WHERE v.product_id IN (" + ph + ") GROUP BY v.product_id",
648
+ ids,
649
+ );
650
+ for (var ii = 0; ii < invRows.length; ii += 1) {
651
+ invByProduct[invRows[ii].product_id] = invRows[ii].inventory_count;
652
+ }
653
+ }
654
+
655
+ for (var r = 0; r < rows.length; r += 1) {
656
+ var row = rows[r];
657
+ var rid = row && row.id;
658
+ if (rid == null) continue;
659
+ if (need.tags && !("tags" in row)) row.tags = tagsByProduct[rid] || [];
660
+ if (need.category && !("category" in row)) row.category = catByProduct[rid] || [];
661
+ if (need.vendor && !("vendor" in row)) {
662
+ row.vendor = Object.prototype.hasOwnProperty.call(vendorByProduct, rid) ? vendorByProduct[rid] : null;
663
+ }
664
+ if (need.price_minor && !("price_minor" in row)) {
665
+ row.price_minor = Object.prototype.hasOwnProperty.call(priceByProduct, rid) ? priceByProduct[rid] : null;
666
+ }
667
+ if (need.inventory_count && !("inventory_count" in row)) {
668
+ row.inventory_count = Object.prototype.hasOwnProperty.call(invByProduct, rid) ? invByProduct[rid] : 0;
669
+ }
670
+ }
671
+ }
672
+
430
673
  // ---- defineManual ------------------------------------------------------
431
674
 
432
675
  async function defineManual(input) {
@@ -824,12 +1067,22 @@ function create(opts) {
824
1067
  }
825
1068
  }
826
1069
 
827
- var matched = [];
828
- await _walkCatalogActive(function (product) {
829
- if (_matchRuleset(rules, product)) matched.push(product);
830
- });
831
- var sortStrategy = row.sort_strategy === "manual" ? "newest" : row.sort_strategy;
832
- matched.sort(_smartCompare(sortStrategy));
1070
+ // Reuse the cached sorted roster when a recent page request already
1071
+ // walked + sorted this exact (slug, rules, sort, updated_at)
1072
+ // combination; otherwise walk the catalog once, sort, and cache.
1073
+ // Paginating no longer re-walks the whole catalog per page.
1074
+ var cacheKey = _smartCacheKey(input.slug, row);
1075
+ var matched = _smartCacheGet(cacheKey);
1076
+ if (matched == null) {
1077
+ var enrichFields = _fieldsReferenced(rules);
1078
+ matched = [];
1079
+ await _walkCatalogActive(function (product) {
1080
+ if (_matchRuleset(rules, product)) matched.push(product);
1081
+ }, enrichFields);
1082
+ var sortStrategy = row.sort_strategy === "manual" ? "newest" : row.sort_strategy;
1083
+ matched.sort(_smartCompare(sortStrategy));
1084
+ _smartCacheSet(cacheKey, matched);
1085
+ }
833
1086
  var slice = matched.slice(startIdx, startIdx + limit);
834
1087
  var nextS = null;
835
1088
  if (startIdx + slice.length < matched.length) {
@@ -879,12 +1132,27 @@ function create(opts) {
879
1132
  "SELECT * FROM collections WHERE type = 'smart' AND archived_at IS NULL",
880
1133
  [],
881
1134
  );
1135
+ // Parse every active smart rule set once, collecting the UNION
1136
+ // of catalog fields they read so the single product can be
1137
+ // enriched with real tag / category / vendor / price /
1138
+ // inventory data in one batch before any rule evaluates —
1139
+ // mirroring the productsIn smart path. Without this the reverse
1140
+ // lookup matched zero smart collections for the same dark-feature
1141
+ // reason the forward path did.
1142
+ var parsed = [];
1143
+ var fieldSeen = Object.create(null);
882
1144
  for (var s = 0; s < smartRows.rows.length; s += 1) {
883
1145
  if (seen[smartRows.rows[s].slug]) continue;
884
1146
  var rules;
885
1147
  try { rules = JSON.parse(smartRows.rows[s].rules_json); }
886
1148
  catch (_e) { continue; }
887
- if (_matchRuleset(rules, product)) out.push(_decode(smartRows.rows[s]));
1149
+ parsed.push({ row: smartRows.rows[s], rules: rules });
1150
+ var refs = _fieldsReferenced(rules);
1151
+ for (var rf = 0; rf < refs.length; rf += 1) fieldSeen[refs[rf]] = true;
1152
+ }
1153
+ await _enrichRows([product], Object.keys(fieldSeen));
1154
+ for (var pj = 0; pj < parsed.length; pj += 1) {
1155
+ if (_matchRuleset(parsed[pj].rules, product)) out.push(_decode(parsed[pj].row));
888
1156
  }
889
1157
  }
890
1158
  }
@@ -10,8 +10,11 @@
10
10
  * me" (export) or "erase everything you hold on me" (deletion).
11
11
  * The primitive owns the request lifecycle + composes per-domain
12
12
  * readers (customers, order, order-notes, subscriptions,
13
- * addresses, payment-methods, support-tickets, loyalty) to
14
- * assemble the bundle. Delivery (email / signed URL / secure
13
+ * addresses, payment-methods, support-tickets, loyalty, reviews,
14
+ * consent ledger, wishlist, surveys, recently-viewed) to assemble
15
+ * the bundle — every table that keys a row by the customer, so the
16
+ * export holds the whole record, not just the order/identity core.
17
+ * Delivery (email / signed URL / secure
15
18
  * download portal) is the operator worker's concern — this
16
19
  * primitive returns the bundle as structured JSON and stamps
17
20
  * the lifecycle row when the worker confirms dispatch.
@@ -76,7 +79,11 @@
76
79
  *
77
80
  * Scope semantics on export:
78
81
  *
79
- * - `full` — every injected reader contributes.
82
+ * - `full` — every injected reader contributes (identity,
83
+ * orders, subscriptions, addresses, payment
84
+ * methods, support tickets, loyalty, reviews,
85
+ * consent ledger, wishlist, surveys,
86
+ * recently-viewed).
80
87
  * - `orders_only` — only `order` + `orderNotes` contribute;
81
88
  * identity / loyalty / subscriptions /
82
89
  * addresses / payment methods / support
@@ -92,7 +99,9 @@
92
99
  *
93
100
  * @primitive complianceExport
94
101
  * @related customers, order, orderNotes, subscriptions, addresses,
95
- * paymentMethods, supportTickets, loyalty, orderExport
102
+ * paymentMethods, supportTickets, loyalty, reviews,
103
+ * consentLedger, wishlist, customerSurveys, recentlyViewed,
104
+ * orderExport
96
105
  */
97
106
 
98
107
  var b = require("./vendor/blamejs");
@@ -124,10 +133,19 @@ var ZERO_WIDTH_RE = new RegExp(
124
133
 
125
134
  // Scope -> which injected readers contribute on export. Keeps the
126
135
  // fulfillRequest logic a single lookup instead of nested if-else.
136
+ //
137
+ // `full` enumerates every domain that keys a row by the customer
138
+ // (directly via customer_id, or via a per-customer hash) so a
139
+ // subject-access export holds everything the controller stores about
140
+ // the person — not just the order / identity core. A domain that
141
+ // isn't wired (or whose reader is absent) is reported in
142
+ // `sections_absent`, so an unexported table is always visible in the
143
+ // bundle manifest rather than silently dropped.
127
144
  var SCOPE_SECTIONS = Object.freeze({
128
145
  full: Object.freeze([
129
146
  "customers", "addresses", "order", "orderNotes",
130
147
  "subscriptions", "paymentMethods", "supportTickets", "loyalty",
148
+ "reviews", "consentLedger", "wishlist", "surveys", "recentlyViewed",
131
149
  ]),
132
150
  orders_only: Object.freeze(["order", "orderNotes"]),
133
151
  identity_only: Object.freeze(["customers", "addresses"]),
@@ -220,6 +238,28 @@ function _limit(n) {
220
238
 
221
239
  function _now() { return Date.now(); }
222
240
 
241
+ // Classify a reader's returned section for the completeness manifest.
242
+ // A section is "empty" when the reader is wired but holds nothing for
243
+ // this customer: null, an empty array, or an object whose own values
244
+ // are all themselves empty (e.g. `{ balance: 0, history: [] }`). Any
245
+ // other shape (a non-empty array, an object carrying a row, a scalar)
246
+ // counts as "exported". The distinction lets the auditor tell "we hold
247
+ // nothing here" apart from "this is real PII we exported".
248
+ function _isEmptySection(section) {
249
+ if (section == null) return true;
250
+ if (Array.isArray(section)) return section.length === 0;
251
+ if (typeof section === "object") {
252
+ var keys = Object.keys(section);
253
+ if (keys.length === 0) return true;
254
+ for (var i = 0; i < keys.length; i += 1) {
255
+ if (!_isEmptySection(section[keys[i]])) return false;
256
+ }
257
+ return true;
258
+ }
259
+ // A scalar (string / number / boolean) is real exported data.
260
+ return false;
261
+ }
262
+
223
263
  // ---- row hydration ------------------------------------------------------
224
264
 
225
265
  function _hydrate(r) {
@@ -267,6 +307,11 @@ function create(opts) {
267
307
  paymentMethods: opts.paymentMethods || null,
268
308
  supportTickets: opts.supportTickets || null,
269
309
  loyalty: opts.loyalty || null,
310
+ reviews: opts.reviews || null,
311
+ consentLedger: opts.consentLedger || null,
312
+ wishlist: opts.wishlist || null,
313
+ surveys: opts.surveys || null,
314
+ recentlyViewed: opts.recentlyViewed || null,
270
315
  };
271
316
 
272
317
  // ---- requestExport -------------------------------------------------
@@ -391,12 +436,21 @@ function create(opts) {
391
436
  var bundle = {};
392
437
  var sectionsPresent = [];
393
438
  var sectionsAbsent = [];
439
+ // Per-section completeness manifest. Every scope section lands here
440
+ // with an explicit status — "exported" (reader wired + returned
441
+ // data), "empty" (reader wired but the customer has no rows here),
442
+ // or "absent" (no reader wired at fulfillment time). An auditor (or
443
+ // the data subject) reads this to confirm the bundle isn't
444
+ // surreptitiously incomplete: an unexported table is visible as
445
+ // `absent`, never silently dropped.
446
+ var manifest = [];
394
447
 
395
448
  for (var s = 0; s < sections.length; s += 1) {
396
449
  var sectionName = sections[s];
397
450
  var reader = injectedReaders[sectionName];
398
451
  if (!reader || typeof reader.forCustomerExport !== "function") {
399
452
  sectionsAbsent.push(sectionName);
453
+ manifest.push({ section: sectionName, status: "absent" });
400
454
  continue;
401
455
  }
402
456
  // The reader returns whatever shape it owns (array of rows,
@@ -406,6 +460,7 @@ function create(opts) {
406
460
  var section = await reader.forCustomerExport(row.customer_id);
407
461
  bundle[sectionName] = section == null ? null : section;
408
462
  sectionsPresent.push(sectionName);
463
+ manifest.push({ section: sectionName, status: _isEmptySection(section) ? "empty" : "exported" });
409
464
  }
410
465
 
411
466
  var fulfilledAt = _now();
@@ -422,6 +477,7 @@ function create(opts) {
422
477
  fulfilled_at: fulfilledAt,
423
478
  sections_present: sectionsPresent,
424
479
  sections_absent: sectionsAbsent,
480
+ manifest: manifest,
425
481
  data: bundle,
426
482
  };
427
483
  }
@@ -504,6 +560,7 @@ function create(opts) {
504
560
  }
505
561
 
506
562
  var domainOrder = [
563
+ "recentlyViewed", "wishlist", "surveys", "reviews", "consentLedger",
507
564
  "supportTickets", "orderNotes", "order", "subscriptions",
508
565
  "paymentMethods", "loyalty", "addresses", "customers",
509
566
  ];
@@ -287,6 +287,29 @@ function create(opts) {
287
287
  return { revoked: Number(r.rowCount || 0) > 0 };
288
288
  },
289
289
 
290
+ // Bulk eject every LIVE (issued) session for one customer in a
291
+ // single statement. The right-to-erasure / password-reset hammer:
292
+ // where `revokeSession` kills one id, this kills the whole live set
293
+ // so a deleted (or compromised) customer's outstanding portal links
294
+ // all stop working at once. Idempotent — a customer with no issued
295
+ // session flips zero rows. Only `issued` rows move (consumed /
296
+ // expired / already-revoked stay terminal). Returns the count.
297
+ revokeAllForCustomer: async function (customerId, reason) {
298
+ var cid = _uuid(customerId, "customer_id");
299
+ var clean = _optShortString(reason, "reason", MAX_REASON_LEN);
300
+ if (clean == null) {
301
+ throw new TypeError("customer-portal.revokeAllForCustomer: reason required (non-empty string ≤ " + MAX_REASON_LEN + " chars)");
302
+ }
303
+ var now = _now();
304
+ var r = await query(
305
+ "UPDATE customer_portal_sessions " +
306
+ "SET status = 'revoked', revoked_at = ?1, revoke_reason = ?2 " +
307
+ "WHERE customer_id = ?3 AND status = 'issued'",
308
+ [now, clean, cid],
309
+ );
310
+ return { revoked: Number(r.rowCount || 0) };
311
+ },
312
+
290
313
  // Audit / debugging entry point. Returns every session ever
291
314
  // minted for a customer, newest-first by created_at (id as
292
315
  // tiebreak — both are uuid.v7-derived so the ordering is
@@ -332,10 +332,13 @@ function _now() { return Date.now(); }
332
332
  // ---- CSV helpers (members export) ---------------------------------------
333
333
  //
334
334
  // Same RFC-4180 quoting + spreadsheet-formula-injection neutralization
335
- // the order-export primitive uses: every cell is quoted, embedded `"`
336
- // doubled, and any cell beginning with `=` / `+` / `-` / `@` is prefixed
337
- // with `'` so a spreadsheet treats it as text (a customer-controlled
338
- // display_name is the injection vector). Signed numerics pass through.
335
+ // the order-export primitive uses, composing the SAME shared vendored
336
+ // neutralizer (b.guardCsv.escapeCell) so the two CSV surfaces can't drift:
337
+ // every cell is quoted, embedded `"` doubled, and a cell beginning with any
338
+ // formula-trigger char — `= + - @` plus the tab / CR / LF / pipe / full-
339
+ // width variants an `= + - @`-only check misses — gets a leading TAB so a
340
+ // spreadsheet treats it as text (a customer-controlled display_name is the
341
+ // injection vector here).
339
342
 
340
343
  function _coerceCell(value) {
341
344
  if (value == null) return "";
@@ -344,14 +347,8 @@ function _coerceCell(value) {
344
347
  return JSON.stringify(value);
345
348
  }
346
349
 
347
- var _NUMERIC_SIGN_RE = /^[+-](?:\d+(?:\.\d+)?|\.\d+)$/;
348
-
349
350
  function _neutralizeInjection(s) {
350
- if (s.length === 0) return s;
351
- var first = s.charAt(0);
352
- if (first !== "=" && first !== "+" && first !== "-" && first !== "@") return s;
353
- if ((first === "+" || first === "-") && _NUMERIC_SIGN_RE.test(s)) return s;
354
- return "'" + s;
351
+ return b.guardCsv.escapeCell(s);
355
352
  }
356
353
 
357
354
  function _csvCell(value) {