@blamejs/blamejs-shop 0.4.66 → 0.4.68
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/lib/asset-manifest.json +1 -1
- package/lib/customer-surveys.js +22 -6
- package/lib/loyalty-redemption.js +92 -22
- package/lib/storefront.js +3 -1
- package/lib/support-tickets.js +17 -2
- 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.68 (2026-06-20) — **Redeeming a loyalty reward no longer burns points on a failed mint or exceeds the per-customer cap under load.** Redeeming a reward for points debited the customer's balance, then checked the per-customer redemption cap with a separate count and minted the reward coupon as separate steps. Two problems followed. The cap was a read-then-act check: two redemptions arriving together both read a count under the cap and both succeeded, so a customer could exceed a lifetime cap (for example mint two of a once-ever coupon). And if the coupon mint failed after the points were debited — the coupons primitive erroring, or a transient fault — the points were gone with no redemption row recorded, so no refund path could return them; the customer was charged for a reward they never received. Redemption now claims the cap slot in a single conditional insert that recomputes the live count, so concurrent redemptions can't both exceed the cap, and it reverses the points debit (and removes the claimed row) if the coupon can't be minted, so a failed redemption always leaves the balance whole. No API change. **Fixed:** *Per-customer redemption cap is enforced atomically* — The redemption row is now inserted only if the customer's live active/consumed count for that reward is still under the cap, recomputed inside the insert. Two redemptions racing the last slot resolve to a single winner; the loser is refused with the same cap error and its points are returned. The cheap pre-check is kept as a fast path but is no longer the thing that enforces the limit. · *A failed coupon mint returns the points and leaves no row* — If the coupon can't be minted after the points were debited and the slot claimed, both are rolled back — the points debit is reversed and the claimed redemption row is removed — so the customer's balance is whole and no redemption is left recorded for a reward that was never issued. The points reversal rides the same idempotent restore the checkout path uses, so a retry credits exactly once.
|
|
12
|
+
|
|
13
|
+
- v0.4.67 (2026-06-20) — **Subject-access exports now carry the customer's complete record, not just the first page.** A data-subject access export assembled each domain by reading a single page from that domain's reader. A customer with more than one page in any paginated domain — orders, the loyalty points ledger, product reviews, wishlist entries, survey invitations, or support tickets — silently received a truncated export, which is an incomplete response to a GDPR Article 15 request. Each of those domains is now drained to the end by following the reader's own cursor, so the export carries every row. Readers that already return the whole set in one read (addresses, the consent ledger, subscriptions) were already complete and are unchanged. As part of this, the support-ticket reader keyed on customer id (the one the export uses, since it holds no plaintext email) gained cursor pagination so it can be paged to completion. **Changed:** *supportTickets.listByCustomerId and customerSurveys.invitationsForCustomer return { rows, next_cursor }* — Both customer-scoped readers that the export uses now return a paginated envelope — { rows, next_cursor } — instead of a bare array, so a caller can page a customer's full history to the end. listByCustomerId pages on the (opened_at desc, id desc) tuple (matching the email-keyed listForCustomer); invitationsForCustomer pages on the invitation id. If you call either directly, read the rows off the .rows field and pass the previous next_cursor as the cursor option to continue where the last page ended. **Fixed:** *Access exports drain every paginated domain to completion* — The export now follows each reader's cursor to the end for orders, the loyalty points ledger, product reviews, wishlist entries, survey invitations, and support tickets, concatenating every page. A customer with hundreds of orders or ledger entries receives all of them rather than the most recent hundred. A page-count backstop far above any real per-customer record guards against a misbehaving cursor without ever truncating a genuine export.
|
|
14
|
+
|
|
11
15
|
- v0.4.66 (2026-06-20) — **Inventory-audit finalization can no longer double-apply its stock adjustments.** Finalizing an inventory audit with apply_adjustments writes one shelf adjustment per counted line, then marks the audit finalized. Two failure modes could move a shelf twice. First, if a later line was refused mid-loop — most commonly a negative variance that would have pushed the shelf below its outstanding paid holds — the audit stayed open, but the lines already adjusted had moved; re-running the finalize re-applied those earlier adjustments on top of themselves. Second, two finalize calls overlapping on the same audit both applied every adjustment before either marked the audit finalized. Finalization now claims the audit's transition to finalized in a single conditional write BEFORE touching any shelf, so exactly one finalize applies the adjustments and a concurrent or duplicate call applies nothing. Each adjustment is applied as the difference between the counted variance and what this audit has already moved, so a retry lands only the not-yet-applied remainder and a recount made before the retry is reflected exactly. A finalize that is refused part-way re-opens the audit so the operator can clear the blocker and retry. No API change. **Fixed:** *Finalization claims the audit before moving any shelf* — The transition that marks an audit finalized now lands in one conditional write, gated on the audit still being open or in progress, BEFORE any stock adjustment is written. Two finalize calls racing the same audit — or a duplicate retry overlapping a still-running finalize — resolve to a single winner that applies the adjustments; the loser writes nothing and returns the computed totals. The claim also freezes the audit's scan lines, so the variances being applied can't change underneath the writer. · *Adjustments reconcile against what already landed* — Each shelf adjustment is applied as the counted variance minus what this audit has already moved for that SKU and location. A finalize refused part-way (a respect-holds floor on one line) re-opens the audit; the retry then applies only the remaining difference instead of re-applying lines that already succeeded, and a recount that changed a line's variance before the retry is corrected to the new value rather than left at the old one.
|
|
12
16
|
|
|
13
17
|
- v0.4.65 (2026-06-19) — **Dropship-forwarding and scheduled-export status transitions are now claimed atomically.** Closes two status-transition races in modules that validated the transition with the state-machine primitive but then persisted it with an unconditional update keyed only on the row id. Because the state-machine validation runs in process (each request rebuilds the machine from a fresh row), two concurrent transitions on the same row both passed validation and the second write silently clobbered the first — for example a dropship forwarding being marked shipped while a retry marks it failed, or a scheduled export being cancelled while a worker marks it running. Both now gate the write on the row still being in the state that was validated against (the same single-statement compare-and-swap the order and inventory-receipt lifecycles already use), so exactly one transition lands and a concurrent loser is refused rather than overwriting it. No API change. **Fixed:** *Dropship-forwarding transitions claim the from-state* — Every dropship-forwarding lifecycle edge routes through one helper that validated the transition against the state machine and then wrote UPDATE … WHERE id = ?. The write now also requires the row to still be in the state the transition was validated against (… AND status = <from>) and treats a zero-row result as a lost race (a typed refusal), so two concurrent vendor callbacks (e.g. accept racing ship, or a retry) can't both advance the row and silently overwrite each other. · *Scheduled-export status writers claim the from-state* — The four scheduled-export transitions (cancel, mark-running, mark-complete, mark-failed) validated against the export state machine and then wrote keyed only on the export id. Each now gates the write on the observed from-state and refuses on a zero-row result, so a cancel racing a mark-running (or a duplicate worker callback) no longer last-writer-wins into an illegal state. The state machine still enforces the legal-edge validation; the conditional update is the atomic claim.
|
package/lib/asset-manifest.json
CHANGED
package/lib/customer-surveys.js
CHANGED
|
@@ -651,14 +651,30 @@ function create(opts) {
|
|
|
651
651
|
var custId = _uuid(customerId, "customer_id");
|
|
652
652
|
listOpts = listOpts || {};
|
|
653
653
|
var limit = _limit(listOpts.limit);
|
|
654
|
-
var
|
|
655
|
-
|
|
656
|
-
"
|
|
657
|
-
|
|
658
|
-
|
|
654
|
+
var cursor = listOpts.cursor;
|
|
655
|
+
if (cursor != null && (typeof cursor !== "string" || !cursor.length)) {
|
|
656
|
+
throw new TypeError("customerSurveys.invitationsForCustomer: cursor must be a non-empty string when provided");
|
|
657
|
+
}
|
|
658
|
+
var sql = "SELECT * FROM survey_invitations WHERE customer_id = ?1";
|
|
659
|
+
var params = [custId];
|
|
660
|
+
var idx = 2;
|
|
661
|
+
if (cursor != null) {
|
|
662
|
+
// Cursor is the last-seen invitation id; v7 ids are lexicographically
|
|
663
|
+
// monotonic (issued_at sits in the prefix), so a single `id < cursor`
|
|
664
|
+
// over (id DESC) preserves total ordering — the same idiom
|
|
665
|
+
// responsesForSurvey uses. Returning { rows, next_cursor } lets a
|
|
666
|
+
// caller that needs the customer's COMPLETE invitation set — the
|
|
667
|
+
// subject-access export — page to the end instead of stopping at the
|
|
668
|
+
// first `limit`.
|
|
669
|
+
sql += " AND id < ?" + idx; params.push(cursor); idx += 1;
|
|
670
|
+
}
|
|
671
|
+
sql += " ORDER BY id DESC LIMIT ?" + idx;
|
|
672
|
+
params.push(limit);
|
|
673
|
+
var r = await query(sql, params);
|
|
659
674
|
var out = [];
|
|
660
675
|
for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeInvitation(r.rows[i]));
|
|
661
|
-
|
|
676
|
+
var nextCursor = out.length === limit ? out[out.length - 1].id : null;
|
|
677
|
+
return { rows: out, next_cursor: nextCursor };
|
|
662
678
|
}
|
|
663
679
|
|
|
664
680
|
// ---- submitResponse ---------------------------------------------------
|
|
@@ -260,8 +260,14 @@ function create(opts) {
|
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
var loyalty = opts.loyalty;
|
|
263
|
-
|
|
264
|
-
|
|
263
|
+
// reverseRedemptionById is REQUIRED, not optional: redeemForCustomer
|
|
264
|
+
// debits points before claiming the cap slot + minting the coupon, and
|
|
265
|
+
// reverses that debit if either fails. A handle that can't reverse a
|
|
266
|
+
// burn would silently strand the points on those failure paths, so the
|
|
267
|
+
// contract is enforced at wiring time rather than discovered at runtime.
|
|
268
|
+
if (!loyalty || typeof loyalty.redeem !== "function" || typeof loyalty.adjust !== "function" ||
|
|
269
|
+
typeof loyalty.reverseRedemptionById !== "function") {
|
|
270
|
+
throw new TypeError("loyaltyRedemption: opts.loyalty handle required (must expose redeem + adjust + reverseRedemptionById)");
|
|
265
271
|
}
|
|
266
272
|
|
|
267
273
|
// Optional coupons handle. When wired, expected shape is
|
|
@@ -448,6 +454,22 @@ function create(opts) {
|
|
|
448
454
|
return Number(row.n || row.N || 0);
|
|
449
455
|
}
|
|
450
456
|
|
|
457
|
+
// Best-effort reversal of a points debit when a redemption can't be
|
|
458
|
+
// completed (lost cap claim, or coupon mint failed). loyalty.redeem
|
|
459
|
+
// returns the burn's tx_id; reverseRedemptionById is idempotent (it
|
|
460
|
+
// claims `restored_points` 0 -> spent under a guarded UPDATE) and
|
|
461
|
+
// balance-only, so a double-fire credits exactly once and an unknown /
|
|
462
|
+
// already-reversed tx is a no-op. Its own failure is swallowed so the
|
|
463
|
+
// ORIGINAL error (cap / mint) is what surfaces — the reversal is the
|
|
464
|
+
// recovery, not the reported outcome.
|
|
465
|
+
async function _reverseBurn(burn) {
|
|
466
|
+
try {
|
|
467
|
+
if (burn && burn.tx_id) {
|
|
468
|
+
await loyalty.reverseRedemptionById(burn.tx_id);
|
|
469
|
+
}
|
|
470
|
+
} catch (_e) { /* drop-silent — reversal is best-effort recovery */ }
|
|
471
|
+
}
|
|
472
|
+
|
|
451
473
|
async function redeemForCustomer(input) {
|
|
452
474
|
if (!input || typeof input !== "object") {
|
|
453
475
|
throw new TypeError("loyaltyRedemption.redeemForCustomer: input object required");
|
|
@@ -465,6 +487,11 @@ function create(opts) {
|
|
|
465
487
|
throw nope;
|
|
466
488
|
}
|
|
467
489
|
|
|
490
|
+
// Fast-fail cap pre-check — cheap, and avoids debiting points only to
|
|
491
|
+
// reverse them in the common already-capped case. NOT the real guard:
|
|
492
|
+
// two concurrent redeems both read a count under the cap here, so the
|
|
493
|
+
// atomic claim below (which recomputes the count inside the INSERT) is
|
|
494
|
+
// what actually serializes them.
|
|
468
495
|
if (reward.max_per_customer != null) {
|
|
469
496
|
var count = await _countActiveOrConsumedForCustomer(customerId, rewardSlug);
|
|
470
497
|
if (count >= reward.max_per_customer) {
|
|
@@ -474,10 +501,12 @@ function create(opts) {
|
|
|
474
501
|
}
|
|
475
502
|
}
|
|
476
503
|
|
|
477
|
-
// Debit points via the composed loyalty primitive. The redeem
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
504
|
+
// Debit points via the composed loyalty primitive. The redeem call
|
|
505
|
+
// surfaces LOYALTY_INSUFFICIENT_BALANCE on its own when the customer
|
|
506
|
+
// can't afford the reward; that error propagates as-is (nothing to
|
|
507
|
+
// compensate — no points moved). Capture the burn so a later failure
|
|
508
|
+
// can give the points back.
|
|
509
|
+
var burn = await loyalty.redeem({
|
|
481
510
|
customer_id: customerId,
|
|
482
511
|
points: reward.point_cost,
|
|
483
512
|
notes: "redeem:" + rewardSlug,
|
|
@@ -489,27 +518,68 @@ function create(opts) {
|
|
|
489
518
|
? null
|
|
490
519
|
: ts + (reward.expires_days_after_redemption * MS_PER_DAY);
|
|
491
520
|
|
|
521
|
+
// Atomic per-customer cap claim — the serialization point on the D1
|
|
522
|
+
// bridge (no interactive transactions). Insert the redemption row ONLY
|
|
523
|
+
// if the live active/consumed count is STILL under the cap, recomputed
|
|
524
|
+
// inside the INSERT...SELECT so two concurrent redeems can't both land
|
|
525
|
+
// (the loser changes zero rows). A NULL cap means no limit, so the
|
|
526
|
+
// guard short-circuits true. coupon_code is filled in after the coupon
|
|
527
|
+
// is minted below.
|
|
528
|
+
var claim = await query(
|
|
529
|
+
"INSERT INTO loyalty_redemptions (id, customer_id, reward_slug, points_debited, coupon_code, " +
|
|
530
|
+
"status, redeemed_at, consumed_at, expires_at, order_id, cancel_reason) " +
|
|
531
|
+
"SELECT ?1, ?2, ?3, ?4, NULL, 'active', ?5, NULL, ?6, NULL, NULL " +
|
|
532
|
+
"WHERE ?7 IS NULL OR (SELECT COUNT(*) FROM loyalty_redemptions " +
|
|
533
|
+
"WHERE customer_id = ?2 AND reward_slug = ?3 AND status IN ('active','consumed')) < ?7",
|
|
534
|
+
[redemptionId, customerId, rewardSlug, reward.point_cost, ts, expiresAt, reward.max_per_customer],
|
|
535
|
+
);
|
|
536
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
537
|
+
// Lost the cap claim — another redeem filled the last slot between
|
|
538
|
+
// the pre-check and here. Return the points and refuse, so a raced
|
|
539
|
+
// caller is never charged for a redemption it didn't get.
|
|
540
|
+
await _reverseBurn(burn);
|
|
541
|
+
var capRace = new Error("loyaltyRedemption.redeemForCustomer: customer has reached the per-customer redemption cap");
|
|
542
|
+
capRace.code = "REDEMPTION_CAP_REACHED";
|
|
543
|
+
throw capRace;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Mint the reward coupon now that the slot is claimed. If minting fails
|
|
547
|
+
// (the coupons primitive throws, or returns a malformed code), roll
|
|
548
|
+
// back BOTH the points debit and the claim row — otherwise the customer
|
|
549
|
+
// is charged for a reward they never received and the burn is orphaned
|
|
550
|
+
// (no redemption row for any refund path to key on).
|
|
492
551
|
var couponCode = null;
|
|
493
552
|
if (coupons) {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
553
|
+
try {
|
|
554
|
+
var minted = await coupons.issueSingleUseFromReward({
|
|
555
|
+
customer_id: customerId,
|
|
556
|
+
reward_slug: rewardSlug,
|
|
557
|
+
value_json: reward.value_json,
|
|
558
|
+
expires_at: expiresAt,
|
|
559
|
+
});
|
|
560
|
+
if (!minted || typeof minted.code !== "string" || !minted.code.length) {
|
|
561
|
+
throw new TypeError("loyaltyRedemption.redeemForCustomer: coupons.issueSingleUseFromReward must return { code: string }");
|
|
562
|
+
}
|
|
563
|
+
couponCode = minted.code;
|
|
564
|
+
// Link the minted coupon to the claimed row INSIDE the try, and
|
|
565
|
+
// confirm the write landed (rowCount === 1). A throw OR a silently
|
|
566
|
+
// lost update here rolls the redemption back like a mint failure —
|
|
567
|
+
// otherwise the customer is charged, the cap slot is consumed, and
|
|
568
|
+
// the redemption is left with a NULL coupon_code it can never use.
|
|
569
|
+
var upd = await query(
|
|
570
|
+
"UPDATE loyalty_redemptions SET coupon_code = ?1 WHERE id = ?2",
|
|
571
|
+
[couponCode, redemptionId],
|
|
572
|
+
);
|
|
573
|
+
if (Number(upd.rowCount || 0) !== 1) {
|
|
574
|
+
throw new Error("loyaltyRedemption.redeemForCustomer: failed to persist coupon_code on the claimed redemption");
|
|
575
|
+
}
|
|
576
|
+
} catch (mintErr) {
|
|
577
|
+
await _reverseBurn(burn);
|
|
578
|
+
await query("DELETE FROM loyalty_redemptions WHERE id = ?1", [redemptionId]);
|
|
579
|
+
throw mintErr;
|
|
502
580
|
}
|
|
503
|
-
couponCode = minted.code;
|
|
504
581
|
}
|
|
505
582
|
|
|
506
|
-
await query(
|
|
507
|
-
"INSERT INTO loyalty_redemptions (id, customer_id, reward_slug, points_debited, coupon_code, " +
|
|
508
|
-
"status, redeemed_at, consumed_at, expires_at, order_id, cancel_reason) " +
|
|
509
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, NULL, ?7, NULL, NULL)",
|
|
510
|
-
[redemptionId, customerId, rewardSlug, reward.point_cost, couponCode, ts, expiresAt],
|
|
511
|
-
);
|
|
512
|
-
|
|
513
583
|
return {
|
|
514
584
|
redemption_id: redemptionId,
|
|
515
585
|
coupon_code: couponCode,
|
package/lib/storefront.js
CHANGED
|
@@ -19684,7 +19684,9 @@ function mount(router, deps) {
|
|
|
19684
19684
|
// The customer's own ticket list, scoped to their session customer_id.
|
|
19685
19685
|
router.get("/account/support", async function (req, res) {
|
|
19686
19686
|
var auth = await _supportAuth(req, res); if (!auth) return;
|
|
19687
|
-
|
|
19687
|
+
// First page only — the account list shows the customer's most
|
|
19688
|
+
// recent tickets; listByCustomerId now returns { rows, next_cursor }.
|
|
19689
|
+
var tickets = (await support.listByCustomerId(auth.customer_id, { limit: 100 })).rows;
|
|
19688
19690
|
var cartCount = await _cartCountForReq(req);
|
|
19689
19691
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
19690
19692
|
_send(res, 200, renderSupportList({
|
package/lib/support-tickets.js
CHANGED
|
@@ -821,11 +821,17 @@ function create(opts) {
|
|
|
821
821
|
// storefront, which holds only the session customer id (the raw
|
|
822
822
|
// email is never on disk), can list + ownership-gate a signed-in
|
|
823
823
|
// shopper's tickets without re-deriving the email hash. Composes the
|
|
824
|
-
// same id / status / limit validators as the rest of the surface
|
|
824
|
+
// same id / status / limit validators as the rest of the surface, and
|
|
825
|
+
// (like listForCustomer) returns `{ rows, next_cursor }` on the
|
|
826
|
+
// (opened_at DESC, id DESC) tuple so a caller that needs the customer's
|
|
827
|
+
// COMPLETE ticket set — e.g. the subject-access export, which keys on
|
|
828
|
+
// customer_id and has no email to drive listForCustomer — can page to
|
|
829
|
+
// the end instead of stopping at the first `limit`.
|
|
825
830
|
listByCustomerId: async function (customerId, listOpts) {
|
|
826
831
|
customerId = _uuid(customerId, "customer_id");
|
|
827
832
|
listOpts = listOpts || {};
|
|
828
833
|
var limit = _limit(listOpts.limit);
|
|
834
|
+
var cursorVals = _decodeCursor(listOpts.cursor, "listByCustomerId");
|
|
829
835
|
var where = ["customer_id = ?1"];
|
|
830
836
|
var params = [customerId];
|
|
831
837
|
var idx = 2;
|
|
@@ -834,11 +840,20 @@ function create(opts) {
|
|
|
834
840
|
params.push(_status(listOpts.status_filter, "status_filter"));
|
|
835
841
|
idx += 1;
|
|
836
842
|
}
|
|
843
|
+
if (cursorVals) {
|
|
844
|
+
where.push(
|
|
845
|
+
"(opened_at < ?" + idx + " OR " +
|
|
846
|
+
"(opened_at = ?" + idx + " AND id < ?" + (idx + 1) + "))"
|
|
847
|
+
);
|
|
848
|
+
params.push(cursorVals[0], cursorVals[1]);
|
|
849
|
+
idx += 2;
|
|
850
|
+
}
|
|
837
851
|
params.push(limit);
|
|
838
852
|
var sql = "SELECT * FROM support_tickets WHERE " + where.join(" AND ") +
|
|
839
853
|
" ORDER BY opened_at DESC, id DESC LIMIT ?" + idx;
|
|
840
854
|
var r = await query(sql, params);
|
|
841
|
-
|
|
855
|
+
var rows = r.rows.map(_hydrate);
|
|
856
|
+
return { rows: rows, next_cursor: _encodeNext(rows, limit) };
|
|
842
857
|
},
|
|
843
858
|
|
|
844
859
|
// Operator queue across every assignment state — newest-stale first
|
package/package.json
CHANGED