@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/lib/customers.js
CHANGED
|
@@ -535,6 +535,78 @@ function create(opts) {
|
|
|
535
535
|
return true;
|
|
536
536
|
},
|
|
537
537
|
|
|
538
|
+
// Right-to-erasure auth revocation. A subject-access deletion must
|
|
539
|
+
// leave the customer UNABLE TO SIGN BACK IN — anonymizing the profile
|
|
540
|
+
// row alone is not erasure if every login credential still resolves.
|
|
541
|
+
// This deletes the THREE durable sign-in credentials keyed to the
|
|
542
|
+
// customer and severs the magic-link lookup key in ONE call:
|
|
543
|
+
//
|
|
544
|
+
// * customer_passkeys — every enrolled WebAuthn authenticator
|
|
545
|
+
// (passkey sign-in resolves a credential
|
|
546
|
+
// to this id; gone, the assertion misses).
|
|
547
|
+
// * customer_oauth_identities — every federated link (Google / Apple
|
|
548
|
+
// OIDC resolves byOAuthIdentity to this
|
|
549
|
+
// id; gone, the federated sign-in creates
|
|
550
|
+
// a fresh unrelated account instead).
|
|
551
|
+
// * email_hash -> tombstone — the magic-link / guest-order-claim path
|
|
552
|
+
// resolves byEmailHash; overwriting the
|
|
553
|
+
// hash with a per-id, non-reversible
|
|
554
|
+
// tombstone means no future link for the
|
|
555
|
+
// erased address can ever resolve this row
|
|
556
|
+
// (the address itself is never stored, so
|
|
557
|
+
// there is no plaintext to scrub — only
|
|
558
|
+
// the lookup key to break).
|
|
559
|
+
//
|
|
560
|
+
// The sealed 14-day auth cookie is stateless (no server session row to
|
|
561
|
+
// kill), but with no credential left to re-mint it and no lookup key to
|
|
562
|
+
// re-issue a magic link, the account cannot be re-entered after the
|
|
563
|
+
// cookie expires; the live customer-portal sessions are revoked
|
|
564
|
+
// separately by the caller (customerPortal.revokeAllForCustomer). Each
|
|
565
|
+
// step is idempotent — re-running on an already-erased row deletes
|
|
566
|
+
// nothing more and returns zero counts. dry_run reports the counts the
|
|
567
|
+
// wet run WOULD remove without mutating, so the operator preview shows
|
|
568
|
+
// the blast radius. Returns `{ passkeys, oauth_identities, email_hash_cleared }`.
|
|
569
|
+
eraseAuthForCustomer: async function (id, opts) {
|
|
570
|
+
_uuid(id, "customer id");
|
|
571
|
+
var dryRun = !!(opts && opts.dry_run);
|
|
572
|
+
var pkRows = (await query(
|
|
573
|
+
"SELECT COUNT(*) AS n FROM customer_passkeys WHERE customer_id = ?1", [id],
|
|
574
|
+
)).rows[0];
|
|
575
|
+
var oaRows = (await query(
|
|
576
|
+
"SELECT COUNT(*) AS n FROM customer_oauth_identities WHERE customer_id = ?1", [id],
|
|
577
|
+
)).rows[0];
|
|
578
|
+
var existing = (await query("SELECT email_hash FROM customers WHERE id = ?1", [id])).rows[0];
|
|
579
|
+
var emailHashSet = !!(existing && existing.email_hash && String(existing.email_hash).indexOf("erased:") !== 0);
|
|
580
|
+
if (dryRun) {
|
|
581
|
+
return {
|
|
582
|
+
passkeys: pkRows ? Number(pkRows.n) : 0,
|
|
583
|
+
oauth_identities: oaRows ? Number(oaRows.n) : 0,
|
|
584
|
+
email_hash_cleared: emailHashSet ? 1 : 0,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
var ts = _now();
|
|
588
|
+
var pkDel = await query("DELETE FROM customer_passkeys WHERE customer_id = ?1", [id]);
|
|
589
|
+
var oaDel = await query("DELETE FROM customer_oauth_identities WHERE customer_id = ?1", [id]);
|
|
590
|
+
// Tombstone the lookup key with a per-id, non-reversible value that
|
|
591
|
+
// can never collide with a real namespaceHash digest (those are hex)
|
|
592
|
+
// and can never be re-derived from any email address. Only rewrite a
|
|
593
|
+
// live (non-tombstoned) hash so a re-run is a no-op.
|
|
594
|
+
var emailHashCleared = 0;
|
|
595
|
+
if (emailHashSet) {
|
|
596
|
+
var tombstone = "erased:" + b.crypto.namespaceHash("customer-erased-email", id);
|
|
597
|
+
var upd = await query(
|
|
598
|
+
"UPDATE customers SET email_hash = ?1, updated_at = ?2 WHERE id = ?3 AND email_hash = ?4",
|
|
599
|
+
[tombstone, ts, id, existing.email_hash],
|
|
600
|
+
);
|
|
601
|
+
emailHashCleared = Number((upd && upd.rowCount) || 0);
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
passkeys: Number((pkDel && pkDel.rowCount) || 0),
|
|
605
|
+
oauth_identities: Number((oaDel && oaDel.rowCount) || 0),
|
|
606
|
+
email_hash_cleared: emailHashCleared,
|
|
607
|
+
};
|
|
608
|
+
},
|
|
609
|
+
|
|
538
610
|
// Mutate a customer's editable profile fields. v1 covers display_name
|
|
539
611
|
// only — the one field a customer can safely change without a
|
|
540
612
|
// verification round trip.
|
package/lib/email-campaigns.js
CHANGED
|
@@ -783,7 +783,14 @@ function create(opts) {
|
|
|
783
783
|
var signup = await newsletter.byEmailHash(emailHash);
|
|
784
784
|
if (!signup || !signup.id) return null;
|
|
785
785
|
var issued = await newsletter.issueUnsubscribeToken(signup.id);
|
|
786
|
-
|
|
786
|
+
// The storefront mounts the one-click unsubscribe at GET/POST
|
|
787
|
+
// /unsubscribe (lib/storefront.js + EDGE_POST_PATHS). The token rides
|
|
788
|
+
// in the URL — a mail client's RFC 8058 one-click POST fires at this
|
|
789
|
+
// exact URL with a `List-Unsubscribe=One-Click` body and no token of
|
|
790
|
+
// its own, so the route MUST be the real one and MUST read the token
|
|
791
|
+
// from here. (An earlier `/newsletter/unsubscribe` target had no route
|
|
792
|
+
// behind it — every native one-click POST 404'd and never unsubscribed.)
|
|
793
|
+
var url = unsubscribeBaseUrl + "/unsubscribe?token=" + encodeURIComponent(issued.token);
|
|
787
794
|
// Validate the header SHAPE through the vendored RFC 2369 + RFC 8058
|
|
788
795
|
// guard so a malformed link (non-https, control byte) never reaches
|
|
789
796
|
// the wire — Gmail / Yahoo refuse mail that carries a broken pair.
|
package/lib/gift-card-ledger.js
CHANGED
|
@@ -240,24 +240,53 @@ function create(opts) {
|
|
|
240
240
|
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
241
241
|
if (requested == null) requested = _now();
|
|
242
242
|
|
|
243
|
-
|
|
244
|
-
|
|
243
|
+
// Atomic guarded INSERT — the overdraft check and the row write
|
|
244
|
+
// happen in ONE statement so two concurrent debits can't both
|
|
245
|
+
// read the same balance, both pass the check, and both write
|
|
246
|
+
// (the read-then-write race that corrupted `balance_after_minor`).
|
|
247
|
+
// The latest row's snapshot (balance + occurred_at) is read by
|
|
248
|
+
// correlated scalar subqueries INSIDE the INSERT; the WHERE gates
|
|
249
|
+
// the write on `current_balance >= amount` against that live
|
|
250
|
+
// value, and the inserted `balance_after_minor` / monotonic
|
|
251
|
+
// `occurred_at` are derived from the same subqueries — so on D1
|
|
252
|
+
// (where a single statement is atomic) exactly one of two racing
|
|
253
|
+
// debits lands. rowCount === 0 means the guard refused: either a
|
|
254
|
+
// genuine overdraft or the loser of a race.
|
|
255
|
+
var id = b.uuid.v7();
|
|
256
|
+
var balSub =
|
|
257
|
+
"COALESCE((SELECT balance_after_minor FROM gift_card_ledger " +
|
|
258
|
+
"WHERE gift_card_id = ?2 ORDER BY occurred_at DESC LIMIT 1), 0)";
|
|
259
|
+
var tsSub =
|
|
260
|
+
"(SELECT occurred_at FROM gift_card_ledger " +
|
|
261
|
+
"WHERE gift_card_id = ?2 ORDER BY occurred_at DESC LIMIT 1)";
|
|
262
|
+
var ins = await query(
|
|
263
|
+
"INSERT INTO gift_card_ledger " +
|
|
264
|
+
"(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at) " +
|
|
265
|
+
"SELECT ?1, ?2, 'debit', ?3, NULL, NULL, ?4, " +
|
|
266
|
+
balSub + " - ?3, " +
|
|
267
|
+
"CASE WHEN ?5 > COALESCE(" + tsSub + ", 0) THEN ?5 ELSE COALESCE(" + tsSub + ", 0) + 1 END " +
|
|
268
|
+
"WHERE " + balSub + " >= ?3",
|
|
269
|
+
[id, giftCardId, amount, orderId, requested],
|
|
270
|
+
);
|
|
271
|
+
if (Number(ins.rowCount || 0) === 0) {
|
|
245
272
|
var insufficient = new Error("giftCardLedger.debit: amount exceeds available balance");
|
|
246
273
|
insufficient.code = "GIFT_CARD_LEDGER_INSUFFICIENT_BALANCE";
|
|
247
274
|
throw insufficient;
|
|
248
275
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
var
|
|
252
|
-
|
|
276
|
+
// Re-read the row we just wrote to surface the resolved
|
|
277
|
+
// balance_after / occurred_at without recomputing them client-side.
|
|
278
|
+
var wrote = (await query(
|
|
279
|
+
"SELECT balance_after_minor, occurred_at FROM gift_card_ledger WHERE id = ?1",
|
|
280
|
+
[id],
|
|
281
|
+
)).rows[0];
|
|
253
282
|
return {
|
|
254
283
|
id: id,
|
|
255
284
|
gift_card_id: giftCardId,
|
|
256
285
|
kind: "debit",
|
|
257
286
|
amount_minor: amount,
|
|
258
287
|
order_id: orderId,
|
|
259
|
-
balance_after_minor:
|
|
260
|
-
occurred_at:
|
|
288
|
+
balance_after_minor: wrote ? wrote.balance_after_minor : null,
|
|
289
|
+
occurred_at: wrote ? wrote.occurred_at : requested,
|
|
261
290
|
};
|
|
262
291
|
},
|
|
263
292
|
|
package/lib/gift-registry.js
CHANGED
|
@@ -622,10 +622,12 @@ function create(opts) {
|
|
|
622
622
|
if (item.archived_at != null) {
|
|
623
623
|
throw new TypeError("giftRegistry.purchaseItem: item " + JSON.stringify(itemId) + " has been removed");
|
|
624
624
|
}
|
|
625
|
-
// Refuse over-purchase: aggregate prior purchases + this one
|
|
626
|
-
//
|
|
625
|
+
// Refuse over-purchase: aggregate prior purchases + this one must
|
|
626
|
+
// not exceed `quantity_desired`. The owner's wish-list is
|
|
627
627
|
// authoritative — a fourth blender is not a gift, it's a return
|
|
628
|
-
// pending.
|
|
628
|
+
// pending. The pre-read below only shapes a friendly "remaining N"
|
|
629
|
+
// error for the common (uncontended) case; the AUTHORITATIVE check
|
|
630
|
+
// is the guarded INSERT that follows.
|
|
629
631
|
var priorRes = await query(
|
|
630
632
|
"SELECT COALESCE(SUM(quantity), 0) AS sum FROM gift_registry_purchases " +
|
|
631
633
|
"WHERE registry_slug = ?1 AND item_id = ?2",
|
|
@@ -644,12 +646,48 @@ function create(opts) {
|
|
|
644
646
|
|
|
645
647
|
var id = b.uuid.v7();
|
|
646
648
|
var ts = _now();
|
|
647
|
-
|
|
649
|
+
// Atomic guarded INSERT — the SUM-of-prior-purchases check and the
|
|
650
|
+
// purchase write land in ONE statement so two concurrent buyers
|
|
651
|
+
// can't both read the same prior sum, both pass the pre-check, and
|
|
652
|
+
// both insert (the read-then-write race that let purchases exceed
|
|
653
|
+
// `quantity_desired`). The constraint is re-evaluated against the
|
|
654
|
+
// LIVE purchase sum + the item's live `quantity_desired` inside the
|
|
655
|
+
// INSERT; the item must also still exist and be unarchived. On D1 a
|
|
656
|
+
// single statement is atomic, so exactly one of two racing
|
|
657
|
+
// purchases for the last unit lands. rowCount === 0 means the guard
|
|
658
|
+
// refused — a concurrent purchase claimed the remaining quantity
|
|
659
|
+
// first (or the item was archived between the pre-check and here).
|
|
660
|
+
var ins = await query(
|
|
648
661
|
"INSERT INTO gift_registry_purchases " +
|
|
649
662
|
"(id, registry_slug, item_id, quantity, buyer_customer_id, buyer_message, reveal_buyer, occurred_at) " +
|
|
650
|
-
"
|
|
663
|
+
"SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 " +
|
|
664
|
+
"WHERE EXISTS (" +
|
|
665
|
+
" SELECT 1 FROM gift_registry_items gi " +
|
|
666
|
+
" WHERE gi.id = ?3 AND gi.registry_slug = ?2 AND gi.archived_at IS NULL " +
|
|
667
|
+
" AND (" +
|
|
668
|
+
" SELECT COALESCE(SUM(gp.quantity), 0) FROM gift_registry_purchases gp " +
|
|
669
|
+
" WHERE gp.registry_slug = ?2 AND gp.item_id = ?3" +
|
|
670
|
+
" ) + ?4 <= gi.quantity_desired" +
|
|
671
|
+
")",
|
|
651
672
|
[id, slug, itemId, qty, buyerId, buyerMsg, reveal ? 1 : 0, ts],
|
|
652
673
|
);
|
|
674
|
+
if (Number(ins.rowCount || 0) === 0) {
|
|
675
|
+
// Lost the race (or the item was archived between the pre-check
|
|
676
|
+
// and the guarded INSERT). Recompute the live remaining for an
|
|
677
|
+
// honest message; the write applied nothing.
|
|
678
|
+
var liveSum = Number((await query(
|
|
679
|
+
"SELECT COALESCE(SUM(quantity), 0) AS sum FROM gift_registry_purchases " +
|
|
680
|
+
"WHERE registry_slug = ?1 AND item_id = ?2",
|
|
681
|
+
[slug, itemId],
|
|
682
|
+
)).rows[0].sum || 0);
|
|
683
|
+
var raced = new Error(
|
|
684
|
+
"giftRegistry.purchaseItem: quantity " + qty +
|
|
685
|
+
" exceeds remaining " + Math.max(0, item.quantity_desired - liveSum) +
|
|
686
|
+
" on item " + itemId,
|
|
687
|
+
);
|
|
688
|
+
raced.code = "GIFT_REGISTRY_OVER_PURCHASE";
|
|
689
|
+
throw raced;
|
|
690
|
+
}
|
|
653
691
|
await query(
|
|
654
692
|
"UPDATE gift_registries SET updated_at = ?1 WHERE slug = ?2",
|
|
655
693
|
[ts, slug],
|
|
@@ -648,6 +648,111 @@ function create(opts) {
|
|
|
648
648
|
};
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
+
// ---- reverseForEvent ------------------------------------------------
|
|
652
|
+
|
|
653
|
+
// Reverse every award booked for one (customer, event) — the
|
|
654
|
+
// counterpart to awardForEvent on an order's cancel / refund edge. A
|
|
655
|
+
// paid order awards points; if that order later dies the points must
|
|
656
|
+
// come back off the balance or a buy-then-refund mints free rewards.
|
|
657
|
+
//
|
|
658
|
+
// The earn-log `reversed_at` claim IS the idempotency guard: the
|
|
659
|
+
// `UPDATE ... WHERE reversed_at IS NULL` serializes a concurrent
|
|
660
|
+
// double-fire (a re-delivered webhook, or the stale-order reaper
|
|
661
|
+
// racing a refund) so the points are clawed back exactly once. A
|
|
662
|
+
// never-awarded event (a guest order, or one that never reached paid)
|
|
663
|
+
// claims zero rows and is a natural no-op — no paid-state precondition
|
|
664
|
+
// needed. Returns { reversed_points, clawed_points }: reversed_points
|
|
665
|
+
// is what the awards totalled; clawed_points is what actually came off
|
|
666
|
+
// the balance (floored at zero — a customer may have already spent the
|
|
667
|
+
// points, and the balance can't go negative).
|
|
668
|
+
async function reverseForEvent(input) {
|
|
669
|
+
if (!input || typeof input !== "object") {
|
|
670
|
+
throw new TypeError("loyaltyEarnRules.reverseForEvent: input object required");
|
|
671
|
+
}
|
|
672
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
673
|
+
var triggerEventRef = _triggerEventRef(input.trigger_event_ref);
|
|
674
|
+
|
|
675
|
+
// Atomic claim across every rule that awarded for this event. The
|
|
676
|
+
// unreversed predicate is the serialization point — a row claimed
|
|
677
|
+
// here can't be claimed by a racing reversal, and an already-reversed
|
|
678
|
+
// (or never-awarded) event claims nothing.
|
|
679
|
+
var ts = _now();
|
|
680
|
+
var claim = await query(
|
|
681
|
+
"UPDATE loyalty_earn_log SET reversed_at = ?1 " +
|
|
682
|
+
"WHERE customer_id = ?2 AND trigger_event_ref = ?3 AND reversed_at IS NULL",
|
|
683
|
+
[ts, customerId, triggerEventRef],
|
|
684
|
+
);
|
|
685
|
+
if (Number(claim.rowCount || 0) === 0) {
|
|
686
|
+
return { reversed_points: 0, clawed_points: 0 };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Sum exactly the rows this call claimed (reversed_at === ts pins them
|
|
690
|
+
// to this reversal, not an earlier one against the same event).
|
|
691
|
+
var sumRow = (await query(
|
|
692
|
+
"SELECT COALESCE(SUM(points_awarded), 0) AS earned FROM loyalty_earn_log " +
|
|
693
|
+
"WHERE customer_id = ?1 AND trigger_event_ref = ?2 AND reversed_at = ?3",
|
|
694
|
+
[customerId, triggerEventRef, ts],
|
|
695
|
+
)).rows[0] || { earned: 0 };
|
|
696
|
+
var earned = Number(sumRow.earned || 0);
|
|
697
|
+
|
|
698
|
+
// Claw the earned points back off the running balance, floored at
|
|
699
|
+
// zero. loyalty.adjust refuses an underflow by THROWING an Error with
|
|
700
|
+
// code LOYALTY_INSUFFICIENT_BALANCE; a concurrent spend can shrink the
|
|
701
|
+
// balance between our read and the adjust, so on that refusal we
|
|
702
|
+
// re-read and retry against the smaller balance (≤3 attempts). The
|
|
703
|
+
// non-negative guard inside adjust makes the race safe — the worst
|
|
704
|
+
// case is we claw less, never below zero, never negative. Lifetime is
|
|
705
|
+
// not decremented (adjust's stance — tier never downgrades
|
|
706
|
+
// retroactively). Skip the adjust entirely when there's nothing to
|
|
707
|
+
// claw (adjust requires a non-zero delta).
|
|
708
|
+
var clawed = 0;
|
|
709
|
+
if (loyaltyHandle && typeof loyaltyHandle.adjust === "function"
|
|
710
|
+
&& typeof loyaltyHandle.balance === "function" && earned > 0) {
|
|
711
|
+
try {
|
|
712
|
+
for (var attempt = 0; attempt < 3; attempt += 1) {
|
|
713
|
+
var bal = await loyaltyHandle.balance(customerId);
|
|
714
|
+
var claw = Math.min(earned, Number((bal && bal.balance) || 0));
|
|
715
|
+
if (claw <= 0) break;
|
|
716
|
+
try {
|
|
717
|
+
await loyaltyHandle.adjust({
|
|
718
|
+
customer_id: customerId,
|
|
719
|
+
points: -claw,
|
|
720
|
+
source: "earn-reversal",
|
|
721
|
+
notes: "reversed ref=" + triggerEventRef,
|
|
722
|
+
});
|
|
723
|
+
clawed = claw;
|
|
724
|
+
break;
|
|
725
|
+
} catch (err) {
|
|
726
|
+
// A concurrent spend drained the balance below `claw` between
|
|
727
|
+
// the read and the adjust. Re-read and retry against the new,
|
|
728
|
+
// smaller balance. Any other failure escapes to the claim
|
|
729
|
+
// release below.
|
|
730
|
+
if (!(err && err.code === "LOYALTY_INSUFFICIENT_BALANCE")) throw err;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} catch (clawErr) {
|
|
734
|
+
// The clawback failed for a reason that is NOT the floor-at-zero
|
|
735
|
+
// refusal (a transient DB fault, an unmigrated ledger). Holding
|
|
736
|
+
// the claim would be a silent loss: a retry would see the rows
|
|
737
|
+
// already reversed and no-op while the balance keeps the points.
|
|
738
|
+
// Release the claim so a later reversal can run, then surface the
|
|
739
|
+
// original failure. The release is best-effort — if it ALSO
|
|
740
|
+
// fails, the original error still propagates and the rows stay
|
|
741
|
+
// claimed for manual reconciliation.
|
|
742
|
+
try {
|
|
743
|
+
await query(
|
|
744
|
+
"UPDATE loyalty_earn_log SET reversed_at = NULL " +
|
|
745
|
+
"WHERE customer_id = ?1 AND trigger_event_ref = ?2 AND reversed_at = ?3",
|
|
746
|
+
[customerId, triggerEventRef, ts],
|
|
747
|
+
);
|
|
748
|
+
} catch (_releaseErr) { /* drop-silent — the original failure is the signal */ }
|
|
749
|
+
throw clawErr;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return { reversed_points: earned, clawed_points: clawed };
|
|
754
|
+
}
|
|
755
|
+
|
|
651
756
|
// ---- metricsForRule -------------------------------------------------
|
|
652
757
|
|
|
653
758
|
async function metricsForRule(input) {
|
|
@@ -767,6 +872,7 @@ function create(opts) {
|
|
|
767
872
|
archiveRule: archiveRule,
|
|
768
873
|
evaluateForEvent: evaluateForEvent,
|
|
769
874
|
awardForEvent: awardForEvent,
|
|
875
|
+
reverseForEvent: reverseForEvent,
|
|
770
876
|
metricsForRule: metricsForRule,
|
|
771
877
|
applyBatch: applyBatch,
|
|
772
878
|
};
|
package/lib/loyalty.js
CHANGED
|
@@ -228,6 +228,25 @@ function create(opts) {
|
|
|
228
228
|
);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// SQL fragment that derives the tier from a lifetime-points
|
|
232
|
+
// expression evaluated INSIDE the UPDATE statement, so balance,
|
|
233
|
+
// lifetime, and tier all move in one atomic write off the row's
|
|
234
|
+
// live value rather than a stale snapshot. `lifetimeExpr` is the
|
|
235
|
+
// post-mutation lifetime SQL (e.g. `lifetime_points + ?2`). The
|
|
236
|
+
// operator-tunable thresholds bind as literals — they're validated
|
|
237
|
+
// non-negative integers at factory time, never operator input here,
|
|
238
|
+
// so inlining them keeps the CASE a single self-contained
|
|
239
|
+
// expression without widening the bound-parameter list per call.
|
|
240
|
+
// Mirrors computeTier's highest-threshold-first ladder so the SQL
|
|
241
|
+
// and JS classifications never diverge.
|
|
242
|
+
function _tierCase(lifetimeExpr) {
|
|
243
|
+
return "CASE" +
|
|
244
|
+
" WHEN (" + lifetimeExpr + ") >= " + thresholds.platinum + " THEN 'platinum'" +
|
|
245
|
+
" WHEN (" + lifetimeExpr + ") >= " + thresholds.gold + " THEN 'gold'" +
|
|
246
|
+
" WHEN (" + lifetimeExpr + ") >= " + thresholds.silver + " THEN 'silver'" +
|
|
247
|
+
" ELSE 'bronze' END";
|
|
248
|
+
}
|
|
249
|
+
|
|
231
250
|
return {
|
|
232
251
|
TIERS: TIERS.slice(),
|
|
233
252
|
TX_TYPES: TX_TYPES.slice(),
|
|
@@ -260,24 +279,27 @@ function create(opts) {
|
|
|
260
279
|
|
|
261
280
|
var ts = _now();
|
|
262
281
|
await _ensureAccountRow(customerId, ts);
|
|
282
|
+
// Snapshot the tier ONLY to report `tier_changed` — the balance /
|
|
283
|
+
// lifetime / tier mutation itself is relative-atomic below, so a
|
|
284
|
+
// concurrent earn can't clobber this credit (the lost-update the
|
|
285
|
+
// absolute write suffered). The tier is recomputed in-SQL off the
|
|
286
|
+
// row's live `lifetime_points`, not this stale snapshot.
|
|
263
287
|
var before = await _readAccount(customerId);
|
|
264
|
-
var newLifetime = before.lifetime_points + points;
|
|
265
|
-
var newBalance = before.balance_points + points;
|
|
266
|
-
var newTier = computeTier(newLifetime);
|
|
267
|
-
var tierChanged = newTier !== before.tier;
|
|
268
|
-
|
|
269
288
|
await query(
|
|
270
|
-
"UPDATE loyalty_accounts SET balance_points =
|
|
271
|
-
"
|
|
272
|
-
|
|
289
|
+
"UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
|
|
290
|
+
"lifetime_points = lifetime_points + ?1, " +
|
|
291
|
+
"tier = " + _tierCase("lifetime_points + ?1") + ", " +
|
|
292
|
+
"updated_at = ?2 WHERE customer_id = ?3",
|
|
293
|
+
[points, ts, customerId],
|
|
273
294
|
);
|
|
274
295
|
await _writeTx(customerId, "earn", points, source, orderId, notes, ts);
|
|
275
296
|
|
|
297
|
+
var after = await _readAccount(customerId);
|
|
276
298
|
return {
|
|
277
|
-
balance:
|
|
278
|
-
lifetime:
|
|
279
|
-
tier:
|
|
280
|
-
tier_changed:
|
|
299
|
+
balance: after.balance_points,
|
|
300
|
+
lifetime: after.lifetime_points,
|
|
301
|
+
tier: after.tier,
|
|
302
|
+
tier_changed: after.tier !== before.tier,
|
|
281
303
|
};
|
|
282
304
|
},
|
|
283
305
|
|
|
@@ -335,34 +357,45 @@ function create(opts) {
|
|
|
335
357
|
|
|
336
358
|
var ts = _now();
|
|
337
359
|
await _ensureAccountRow(customerId, ts);
|
|
360
|
+
// Snapshot the pre-adjust tier only to report `tier_changed`. The
|
|
361
|
+
// mutation is relative-atomic with an underflow guard at the SQL
|
|
362
|
+
// tier so two concurrent adjustments can't lose an update or drive
|
|
363
|
+
// the balance negative past each other.
|
|
338
364
|
var before = await _readAccount(customerId);
|
|
339
365
|
|
|
340
|
-
var newBalance = before.balance_points + delta;
|
|
341
|
-
if (newBalance < 0) {
|
|
342
|
-
var ins = new Error("loyalty.adjust: adjustment would underflow balance");
|
|
343
|
-
ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
344
|
-
throw ins;
|
|
345
|
-
}
|
|
346
366
|
// Positive adjustments also increment lifetime — operators
|
|
347
367
|
// crediting a customer for a service recovery should see that
|
|
348
368
|
// credit count toward tier. Negative adjustments do NOT
|
|
349
369
|
// decrement lifetime (otherwise a clawback could downgrade tier
|
|
350
|
-
// retroactively, which is a customer-facing surprise).
|
|
351
|
-
|
|
352
|
-
var
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
370
|
+
// retroactively, which is a customer-facing surprise). The
|
|
371
|
+
// lifetime delta is therefore the positive part of `delta`.
|
|
372
|
+
var lifetimeDelta = delta > 0 ? delta : 0;
|
|
373
|
+
// Conditional UPDATE: the row mutates ONLY when the post-adjust
|
|
374
|
+
// balance stays non-negative, checked against the row's LIVE
|
|
375
|
+
// balance (not the stale snapshot). A racing concurrent adjust
|
|
376
|
+
// that already spent the balance makes this match zero rows, so
|
|
377
|
+
// we surface the same insufficient-balance refusal rather than
|
|
378
|
+
// writing a ledger row that diverges from the account.
|
|
379
|
+
var upd = await query(
|
|
380
|
+
"UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
|
|
381
|
+
"lifetime_points = lifetime_points + ?2, " +
|
|
382
|
+
"tier = " + _tierCase("lifetime_points + ?2") + ", " +
|
|
383
|
+
"updated_at = ?3 WHERE customer_id = ?4 AND balance_points + ?1 >= 0",
|
|
384
|
+
[delta, lifetimeDelta, ts, customerId],
|
|
358
385
|
);
|
|
386
|
+
if (Number(upd.rowCount || 0) === 0) {
|
|
387
|
+
var ins = new Error("loyalty.adjust: adjustment would underflow balance");
|
|
388
|
+
ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
389
|
+
throw ins;
|
|
390
|
+
}
|
|
359
391
|
await _writeTx(customerId, "adjust", delta, source, null, notes, ts);
|
|
360
392
|
|
|
393
|
+
var after = await _readAccount(customerId);
|
|
361
394
|
return {
|
|
362
|
-
balance:
|
|
363
|
-
lifetime:
|
|
364
|
-
tier:
|
|
365
|
-
tier_changed:
|
|
395
|
+
balance: after.balance_points,
|
|
396
|
+
lifetime: after.lifetime_points,
|
|
397
|
+
tier: after.tier,
|
|
398
|
+
tier_changed: after.tier !== before.tier,
|
|
366
399
|
};
|
|
367
400
|
},
|
|
368
401
|
|
package/lib/order-export.js
CHANGED
|
@@ -212,7 +212,8 @@ function _limit(n) {
|
|
|
212
212
|
// RFC-4180 quoting: every cell wrapped in `"`, embedded `"` doubled.
|
|
213
213
|
// We quote unconditionally — the cost is a few extra bytes per cell;
|
|
214
214
|
// the win is that a downstream parser never has to track quote-vs-
|
|
215
|
-
// bare-cell state for a column with mixed shapes.
|
|
215
|
+
// bare-cell state for a column with mixed shapes. The injection
|
|
216
|
+
// neutralization runs first via the shared vendored primitive.
|
|
216
217
|
function _csvCell(value) {
|
|
217
218
|
var s = _coerceCell(value);
|
|
218
219
|
s = _neutralizeInjection(s);
|
|
@@ -227,23 +228,19 @@ function _coerceCell(value) {
|
|
|
227
228
|
return JSON.stringify(value);
|
|
228
229
|
}
|
|
229
230
|
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
231
|
+
// CSV injection neutralization — composes the vendored b.guardCsv.escapeCell
|
|
232
|
+
// (OWASP "CSV Injection" defense). The vendored primitive is the single
|
|
233
|
+
// shared neutralizer across every CSV export surface (order-export +
|
|
234
|
+
// customer-segments). It prefixes a leading TAB when a cell starts with ANY
|
|
235
|
+
// formula-trigger char — `= + - @` AND the tab / CR / LF / pipe / full-width
|
|
236
|
+
// variants a hand-rolled `= + - @`-only check misses. A leading tab renders
|
|
237
|
+
// as invisible whitespace in a spreadsheet, so the cell reads as text and
|
|
238
|
+
// never evaluates as a formula. The earlier in-tree check exempted signed
|
|
239
|
+
// numerics (`+15.00`); the shared primitive prefixes those too (the safe
|
|
240
|
+
// OWASP posture — `-2+3+cmd|…` is a real injection that begins like an
|
|
241
|
+
// amount), which is the more complete behavior this consolidation buys.
|
|
241
242
|
function _neutralizeInjection(s) {
|
|
242
|
-
|
|
243
|
-
var first = s.charAt(0);
|
|
244
|
-
if (first !== "=" && first !== "+" && first !== "-" && first !== "@") return s;
|
|
245
|
-
if ((first === "+" || first === "-") && _NUMERIC_SIGN_RE.test(s)) return s;
|
|
246
|
-
return "'" + s;
|
|
243
|
+
return b.guardCsv.escapeCell(s);
|
|
247
244
|
}
|
|
248
245
|
|
|
249
246
|
function _csvRow(cells) {
|
package/lib/order.js
CHANGED
|
@@ -587,6 +587,30 @@ function create(opts) {
|
|
|
587
587
|
if (result.to === "cancelled" || result.to === "refunded") {
|
|
588
588
|
await _settleGiftCards(orderId);
|
|
589
589
|
}
|
|
590
|
+
// Loyalty earn-reversal fan-out — fire-and-forget, same discipline
|
|
591
|
+
// as the earn-on-purchase block below. On a cancel / refund edge for
|
|
592
|
+
// an order carrying a customer_id, the points awarded when the order
|
|
593
|
+
// went paid are clawed back off the balance (floored at zero), or a
|
|
594
|
+
// buy-then-refund mints free rewards. reverseForEvent claims the
|
|
595
|
+
// earn-log rows with an unreversed predicate, so it is idempotent (a
|
|
596
|
+
// re-delivered cancel webhook or the reaper racing a refund reverses
|
|
597
|
+
// exactly once) and a natural no-op for an order that never earned
|
|
598
|
+
// (a guest order, or one that never reached paid — the never-awarded
|
|
599
|
+
// earn-log is empty). The award is detached so a loyalty failure
|
|
600
|
+
// lives in the loyalty ledger's own audit trail, never as an
|
|
601
|
+
// unhandledRejection and never on the transition's latency.
|
|
602
|
+
if (loyaltyEarnRules && typeof loyaltyEarnRules.reverseForEvent === "function"
|
|
603
|
+
&& (result.to === "cancelled" || result.to === "refunded")
|
|
604
|
+
&& refreshed && refreshed.customer_id) {
|
|
605
|
+
var _revCustomer = refreshed.customer_id;
|
|
606
|
+
var _revOrderId = refreshed.id;
|
|
607
|
+
Promise.resolve().then(function () {
|
|
608
|
+
return loyaltyEarnRules.reverseForEvent({
|
|
609
|
+
customer_id: _revCustomer,
|
|
610
|
+
trigger_event_ref: "order:" + _revOrderId,
|
|
611
|
+
});
|
|
612
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
613
|
+
}
|
|
590
614
|
// Fan-out to merchant webhook subscribers is fire-and-forget. The
|
|
591
615
|
// transition has already persisted; the request must not wait on
|
|
592
616
|
// outbound HTTP, or a slow / unreachable endpoint would block the
|
package/lib/search-ranking.js
CHANGED
|
@@ -700,6 +700,48 @@ function create(opts) {
|
|
|
700
700
|
if (input.session_id != null) {
|
|
701
701
|
sessionHash = _hashSession(_sessionIdRaw(input.session_id, "searchRanking.recordSearchEvent"));
|
|
702
702
|
}
|
|
703
|
+
|
|
704
|
+
// Server-side click attribution. A `click` carries a `?sq=<query>`
|
|
705
|
+
// marker from the result link, but the marker is attacker-
|
|
706
|
+
// controllable: anyone can hit a PDP with `?from=search&sq=dress`
|
|
707
|
+
// and inflate the click count for "dress" without ever having
|
|
708
|
+
// seen — let alone clicked through — a real result list. That let
|
|
709
|
+
// CTR be spoofed and pushed past 100% (more clicks than the
|
|
710
|
+
// impressions that were ever rendered). When the click carries a
|
|
711
|
+
// session, we only record it if THAT session already logged an
|
|
712
|
+
// `impression` for the same (weights_slug, query): the click must
|
|
713
|
+
// descend from a search the same session actually ran. An
|
|
714
|
+
// unattributed click (no matching impression for the session) is
|
|
715
|
+
// dropped — it never reaches the event log, so the rollup can't
|
|
716
|
+
// count it. Clicks without a session (a session-less worker
|
|
717
|
+
// deployment, or a click whose session cookie was lost) can't be
|
|
718
|
+
// attribution-checked, so they record as before; the storefront
|
|
719
|
+
// attaches the session whenever one exists, so the spoof path is
|
|
720
|
+
// closed in the deployed configuration.
|
|
721
|
+
if (eventType === "click" && sessionHash != null) {
|
|
722
|
+
var imp = await query(
|
|
723
|
+
"SELECT 1 FROM search_events " +
|
|
724
|
+
"WHERE weights_slug = ?1 AND query = ?2 AND session_id_hash = ?3 " +
|
|
725
|
+
"AND event_type = 'impression' LIMIT 1",
|
|
726
|
+
[weightsSlug, normalizedQuery, sessionHash]
|
|
727
|
+
);
|
|
728
|
+
if (!imp.rows.length) {
|
|
729
|
+
// No impression for this session + query under this weight
|
|
730
|
+
// set — the click can't be a genuine result-list click-
|
|
731
|
+
// through. Refuse to record it (drop, don't throw — the hot
|
|
732
|
+
// path swallows the result).
|
|
733
|
+
return {
|
|
734
|
+
query: normalizedQuery,
|
|
735
|
+
product_id: productId,
|
|
736
|
+
weights_slug: weightsSlug,
|
|
737
|
+
event_type: eventType,
|
|
738
|
+
position: position,
|
|
739
|
+
recorded: false,
|
|
740
|
+
reason: "click-without-matching-impression",
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
703
745
|
var ts = _now();
|
|
704
746
|
await query(
|
|
705
747
|
"INSERT INTO search_events " +
|
|
@@ -714,6 +756,7 @@ function create(opts) {
|
|
|
714
756
|
event_type: eventType,
|
|
715
757
|
position: position,
|
|
716
758
|
occurred_at: ts,
|
|
759
|
+
recorded: true,
|
|
717
760
|
};
|
|
718
761
|
},
|
|
719
762
|
|
|
@@ -757,6 +800,19 @@ function create(opts) {
|
|
|
757
800
|
else if (row.event_type === "click") clicks = c;
|
|
758
801
|
else if (row.event_type === "purchase") purchases = c;
|
|
759
802
|
}
|
|
803
|
+
// A single rendered result list (one impression) can legitimately
|
|
804
|
+
// yield more than one click — the shopper opens a product, returns
|
|
805
|
+
// to the same list, opens another. So clicks/impressions can
|
|
806
|
+
// exceed 1.0 even on honest data; an unbounded ratio reads as a
|
|
807
|
+
// nonsensical ">100% CTR" on the operator dashboard. Bound the
|
|
808
|
+
// reported CTR at 1.0 so the metric stays interpretable (the raw
|
|
809
|
+
// counts remain available for an operator who wants the unbounded
|
|
810
|
+
// figure). conversion_rate is bounded the same way — purchases are
|
|
811
|
+
// gated by clicks which are gated by impressions, but the bound
|
|
812
|
+
// keeps the displayed rate honest under the same multi-click
|
|
813
|
+
// reality.
|
|
814
|
+
var rawCtr = impressions > 0 ? clicks / impressions : null;
|
|
815
|
+
var rawConv = impressions > 0 ? purchases / impressions : null;
|
|
760
816
|
return {
|
|
761
817
|
weights_slug: weightsSlug,
|
|
762
818
|
from: from,
|
|
@@ -764,8 +820,8 @@ function create(opts) {
|
|
|
764
820
|
impressions: impressions,
|
|
765
821
|
clicks: clicks,
|
|
766
822
|
purchases: purchases,
|
|
767
|
-
ctr:
|
|
768
|
-
conversion_rate:
|
|
823
|
+
ctr: rawCtr == null ? null : Math.min(1, rawCtr),
|
|
824
|
+
conversion_rate: rawConv == null ? null : Math.min(1, rawConv),
|
|
769
825
|
click_to_purchase: clicks > 0 ? purchases / clicks : null,
|
|
770
826
|
};
|
|
771
827
|
},
|