@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 +4 -0
- package/README.md +1 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/collections.js +282 -14
- package/lib/compliance-export.js +61 -4
- package/lib/customer-portal.js +23 -0
- package/lib/customer-segments.js +8 -11
- package/lib/customers.js +72 -0
- package/lib/email-campaigns.js +8 -1
- package/lib/gift-card-ledger.js +37 -8
- package/lib/gift-registry.js +43 -5
- package/lib/loyalty-earn-rules.js +106 -0
- package/lib/loyalty.js +63 -30
- package/lib/order-export.js +14 -17
- package/lib/order.js +24 -0
- package/lib/search-ranking.js +58 -2
- package/lib/security-middleware.js +13 -5
- package/lib/store-credit.js +31 -19
- package/lib/storefront.js +129 -20
- package/lib/subscription-controls.js +113 -0
- package/lib/support-tickets.js +113 -53
- package/package.json +1 -1
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,
|
package/lib/asset-manifest.json
CHANGED
package/lib/collections.js
CHANGED
|
@@ -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 (!
|
|
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":
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
var
|
|
832
|
-
matched
|
|
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
|
-
|
|
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
|
}
|
package/lib/compliance-export.js
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
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,
|
|
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
|
];
|
package/lib/customer-portal.js
CHANGED
|
@@ -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
|
package/lib/customer-segments.js
CHANGED
|
@@ -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
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
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
|
-
|
|
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) {
|