@blamejs/blamejs-shop 0.4.18 → 0.4.20
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/admin.js +60 -18
- package/lib/asset-manifest.json +1 -1
- package/lib/cart.js +44 -0
- package/lib/checkout.js +188 -51
- package/lib/click-and-collect.js +30 -15
- package/lib/giftcards.js +60 -0
- package/lib/order.js +111 -1
- package/lib/returns.js +45 -5
- package/lib/storefront.js +37 -0
- package/lib/webhooks.js +55 -5
- package/lib/wishlist-alerts.js +182 -43
- package/lib/wishlist-digest.js +13 -0
- package/package.json +1 -1
package/lib/giftcards.js
CHANGED
|
@@ -346,6 +346,66 @@ function create(opts) {
|
|
|
346
346
|
};
|
|
347
347
|
},
|
|
348
348
|
|
|
349
|
+
// Credit a card's spend back when the order that redeemed it never
|
|
350
|
+
// completed (abandoned / cancelled / refunded). `redeem` debited the
|
|
351
|
+
// card row's balance at checkout; if the order dies, that money must
|
|
352
|
+
// return to the card or the customer's balance is silently gone.
|
|
353
|
+
//
|
|
354
|
+
// Keyed on the ORDER id — the order FSM drives this on its cancel /
|
|
355
|
+
// refund edges (the same transitions that release inventory holds), so
|
|
356
|
+
// reversal is transition-driven, not a separate operator action. Every
|
|
357
|
+
// unreversed redemption against the order is restored.
|
|
358
|
+
//
|
|
359
|
+
// Idempotent + concurrency-safe: each redemption is claimed with
|
|
360
|
+
// `WHERE id = ? AND reversed_at IS NULL` and the rowCount checked, so a
|
|
361
|
+
// double-fire (the stale-order reaper racing a payment-failed webhook,
|
|
362
|
+
// or a re-delivered cancel) credits the balance back exactly once. The
|
|
363
|
+
// balance restore is itself bounded by the card's issued_minor CHECK, so
|
|
364
|
+
// it can never push the card above its original face value. A card that
|
|
365
|
+
// had drained to `redeemed` is reactivated since it now carries balance
|
|
366
|
+
// again. Returns the list of reversed redemptions (empty when there was
|
|
367
|
+
// nothing to reverse — an order that used no gift card, or one already
|
|
368
|
+
// fully reversed).
|
|
369
|
+
reverseRedemption: async function (orderId) {
|
|
370
|
+
_uuid(orderId, "order_id");
|
|
371
|
+
var rows = (await query(
|
|
372
|
+
"SELECT id, giftcard_id, amount_minor FROM giftcard_redemptions " +
|
|
373
|
+
"WHERE order_id = ?1 AND reversed_at IS NULL",
|
|
374
|
+
[orderId],
|
|
375
|
+
)).rows;
|
|
376
|
+
var reversed = [];
|
|
377
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
378
|
+
var red = rows[i];
|
|
379
|
+
var ts = _now();
|
|
380
|
+
// Claim the redemption first — the unreversed predicate is the
|
|
381
|
+
// serialization point so two concurrent reversals can't both credit.
|
|
382
|
+
var claim = await query(
|
|
383
|
+
"UPDATE giftcard_redemptions SET reversed_at = ?1 WHERE id = ?2 AND reversed_at IS NULL",
|
|
384
|
+
[ts, red.id],
|
|
385
|
+
);
|
|
386
|
+
if (Number(claim.rowCount || 0) === 0) continue; // lost the claim
|
|
387
|
+
// Restore the spendable balance on the card row. Capped at the card's
|
|
388
|
+
// issued_minor by the schema CHECK; the amount restored is exactly
|
|
389
|
+
// what this redemption debited, so it can't exceed the face value.
|
|
390
|
+
// Reactivate a card that had drained to `redeemed` — it carries
|
|
391
|
+
// balance again. A `voided`/`expired` card stays in its terminal
|
|
392
|
+
// status (the balance is restored on the row for reconciliation, but
|
|
393
|
+
// a voided/expired card isn't spendable regardless).
|
|
394
|
+
await query(
|
|
395
|
+
"UPDATE giftcards SET balance_minor = balance_minor + ?1, " +
|
|
396
|
+
"status = CASE WHEN status = 'redeemed' THEN 'active' ELSE status END, " +
|
|
397
|
+
"updated_at = ?2 WHERE id = ?3",
|
|
398
|
+
[red.amount_minor, ts, red.giftcard_id],
|
|
399
|
+
);
|
|
400
|
+
reversed.push({
|
|
401
|
+
redemption_id: red.id,
|
|
402
|
+
gift_card_id: red.giftcard_id,
|
|
403
|
+
amount_minor: red.amount_minor,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return reversed;
|
|
407
|
+
},
|
|
408
|
+
|
|
349
409
|
"void": async function (id, opts2) {
|
|
350
410
|
opts2 = opts2 || {};
|
|
351
411
|
_uuid(id, "giftcard id");
|
package/lib/order.js
CHANGED
|
@@ -58,6 +58,25 @@ var ORDER_TRANSITIONS = Object.freeze([
|
|
|
58
58
|
{ from: "delivered", to: "refunded", on: "refund", label: "Refund" },
|
|
59
59
|
]);
|
|
60
60
|
|
|
61
|
+
// Pickup (BOPIS) edges. An in-store collection has no carrier ship leg:
|
|
62
|
+
// the goods sit on the hold shelf and the customer walks in. So a pickup
|
|
63
|
+
// order goes straight from `paid` (or `fulfilling`, if the picker started
|
|
64
|
+
// staging) to `delivered` on a single `mark_picked_up` event — driving it
|
|
65
|
+
// through mark_shipped would record a carrier handoff that never happened.
|
|
66
|
+
// These edges are kept OUT of ORDER_TRANSITIONS (and therefore out of
|
|
67
|
+
// transitionsFrom) so the operator order-detail page doesn't sprout a
|
|
68
|
+
// "mark picked up" button on every paid order — the click-and-collect
|
|
69
|
+
// primitive fires the event when the front-counter operator captures the
|
|
70
|
+
// pickup. They ARE merged into the FSM definition below so the transition
|
|
71
|
+
// is legal: previously markPickedUp tried `mark_delivered` from `paid`,
|
|
72
|
+
// which the FSM refused (delivered is only reachable from shipped) and the
|
|
73
|
+
// caller swallowed — leaving the pickup schedule `picked_up` while the
|
|
74
|
+
// parent order stayed stuck at paid/fulfilling.
|
|
75
|
+
var PICKUP_TRANSITIONS = Object.freeze([
|
|
76
|
+
{ from: "paid", to: "delivered", on: "mark_picked_up" },
|
|
77
|
+
{ from: "fulfilling", to: "delivered", on: "mark_picked_up" },
|
|
78
|
+
]);
|
|
79
|
+
|
|
61
80
|
function _getOrderFsm() {
|
|
62
81
|
if (_orderFsm) return _orderFsm;
|
|
63
82
|
// b.fsm emits audit events under the 'fsm' namespace —
|
|
@@ -76,7 +95,7 @@ function _getOrderFsm() {
|
|
|
76
95
|
refunded: {},
|
|
77
96
|
cancelled: {},
|
|
78
97
|
},
|
|
79
|
-
transitions: ORDER_TRANSITIONS.map(function (t) {
|
|
98
|
+
transitions: ORDER_TRANSITIONS.concat(PICKUP_TRANSITIONS).map(function (t) {
|
|
80
99
|
return { from: t.from, to: t.to, on: t.on };
|
|
81
100
|
}),
|
|
82
101
|
});
|
|
@@ -217,6 +236,28 @@ function create(opts) {
|
|
|
217
236
|
// settlement failures still surface to the audit sink (b.audit.safeEmit
|
|
218
237
|
// below), just not the durable error feed.
|
|
219
238
|
var errorLog = opts.errorLog || null;
|
|
239
|
+
// Optional gift-card handle — when present, the order FSM credits a
|
|
240
|
+
// gift-card spend back when the order dies without completing. A checkout
|
|
241
|
+
// that partially covers payment with a gift card debits the card's balance
|
|
242
|
+
// at confirm; if the order is then cancelled (the stale-pending reaper, an
|
|
243
|
+
// explicit cancel) or refunded (a payment-failed / refund webhook), that
|
|
244
|
+
// debit must return to the card or the customer's balance is silently
|
|
245
|
+
// burned. This mirrors the inventory-hold release: it's transition-driven
|
|
246
|
+
// on the same cancel / refund edges, runs synchronously before the
|
|
247
|
+
// fire-and-forget fan-outs, and is idempotent (reverseRedemption claims
|
|
248
|
+
// each redemption with an unreversed predicate) so a re-delivered webhook
|
|
249
|
+
// can't double-credit. Opt-in like the other handles; absent it, an
|
|
250
|
+
// unwired deploy (or a test with no gift cards) runs unchanged.
|
|
251
|
+
var giftCards = opts.giftCards || null;
|
|
252
|
+
if (giftCards && typeof giftCards.reverseRedemption !== "function") {
|
|
253
|
+
throw new TypeError("order.create: opts.giftCards must expose a reverseRedemption(order_id) method");
|
|
254
|
+
}
|
|
255
|
+
// Optional gift-card ledger handle — when present alongside giftCards, a
|
|
256
|
+
// reversal also writes a refund_to_giftcard credit so the append-only
|
|
257
|
+
// ledger history records the money returning to the card (the card row's
|
|
258
|
+
// balance is authoritative; the ledger is the audit trail surfaced in the
|
|
259
|
+
// admin console).
|
|
260
|
+
var giftCardLedger = opts.giftCardLedger || null;
|
|
220
261
|
// Pagination cursors for listForCustomer are HMAC-tagged via
|
|
221
262
|
// b.pagination so an operator can't hand-craft one to skip past a
|
|
222
263
|
// hidden order or replay across deployments. The secret defaults
|
|
@@ -273,6 +314,60 @@ function create(opts) {
|
|
|
273
314
|
}
|
|
274
315
|
}
|
|
275
316
|
|
|
317
|
+
// Credit back any gift-card spend on an order that died without
|
|
318
|
+
// completing — the cancel / refund edge releasing the customer's money,
|
|
319
|
+
// mirroring how _settleSku releases an inventory hold. reverseRedemption
|
|
320
|
+
// is idempotent (each redemption claimed with an unreversed predicate), so
|
|
321
|
+
// a re-delivered webhook or the reaper racing the cancel can't double-
|
|
322
|
+
// credit. For each restored redemption a refund_to_giftcard ledger credit
|
|
323
|
+
// is written when the ledger is wired so the audit trail records the money
|
|
324
|
+
// returning.
|
|
325
|
+
//
|
|
326
|
+
// drop-silent-with-capture — same discipline as _settleSku: the cancel /
|
|
327
|
+
// refund has already persisted and the webhook driving it MUST return 2xx,
|
|
328
|
+
// so a reversal failure is caught, NOT re-thrown, and surfaced loudly (an
|
|
329
|
+
// `order.giftcard.reversal.error` audit event plus, when the error-log
|
|
330
|
+
// handle is wired, a durable /admin/errors row) for manual reconciliation.
|
|
331
|
+
async function _settleGiftCards(orderId) {
|
|
332
|
+
if (!giftCards) return;
|
|
333
|
+
try {
|
|
334
|
+
var reversed = await giftCards.reverseRedemption(orderId);
|
|
335
|
+
if (giftCardLedger && typeof giftCardLedger.credit === "function") {
|
|
336
|
+
for (var i = 0; i < reversed.length; i += 1) {
|
|
337
|
+
var rev = reversed[i];
|
|
338
|
+
try {
|
|
339
|
+
// allow:money-binding-currency-without-catalog-check — this is a REVERSAL credit of an existing redemption: it replays the exact amount already debited from an already-issued card (whose currency was ISO-4217-validated at giftcards.issue time). No currency is supplied or chosen here — the ledger credit carries no currency field; it inherits the card's. There is no new currency surface to catalog-check.
|
|
340
|
+
await giftCardLedger.credit({
|
|
341
|
+
gift_card_id: rev.gift_card_id,
|
|
342
|
+
amount_minor: rev.amount_minor,
|
|
343
|
+
source: "refund_to_giftcard",
|
|
344
|
+
source_ref: orderId,
|
|
345
|
+
});
|
|
346
|
+
} catch (_ledgerErr) { /* drop-silent — the card-row balance restore above is authoritative; the ledger credit is the audit trail */ }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} catch (e) {
|
|
350
|
+
var message = "order.giftcard-reversal failed — order=" + orderId + ": " + (e && e.message || e);
|
|
351
|
+
try {
|
|
352
|
+
b.audit.safeEmit({
|
|
353
|
+
action: "order.giftcard.reversal.error",
|
|
354
|
+
outcome: "failure",
|
|
355
|
+
metadata: { order_id: orderId, message: (e && e.message) || String(e) },
|
|
356
|
+
});
|
|
357
|
+
} catch (_auditErr) { /* drop-silent — the capture below is the durable record */ }
|
|
358
|
+
if (errorLog && typeof errorLog.captureServerError === "function") {
|
|
359
|
+
try {
|
|
360
|
+
await errorLog.captureServerError({
|
|
361
|
+
route: "/order/" + orderId + "/giftcard-reversal",
|
|
362
|
+
method: "POST",
|
|
363
|
+
status: 500,
|
|
364
|
+
message: message,
|
|
365
|
+
});
|
|
366
|
+
} catch (_logErr) { /* drop-silent — never let the error-feed write mask the original failure */ }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
276
371
|
return {
|
|
277
372
|
TERMINAL_STATES: TERMINAL_STATES,
|
|
278
373
|
|
|
@@ -477,6 +572,21 @@ function create(opts) {
|
|
|
477
572
|
}
|
|
478
573
|
}
|
|
479
574
|
}
|
|
575
|
+
// Gift-card settlement — SYNCHRONOUS, alongside the inventory release.
|
|
576
|
+
// An order that reaches a terminal state WITHOUT completing the sale
|
|
577
|
+
// (cancelled — pending-abandon or post-paid; refunded — payment failed
|
|
578
|
+
// or a refund issued) must return any gift-card spend to the card.
|
|
579
|
+
// Unlike inventory (where a refund deliberately doesn't auto-restock a
|
|
580
|
+
// physical good), the gift-card credit is the customer's own money, so
|
|
581
|
+
// it's released on every death edge. reverseRedemption is idempotent,
|
|
582
|
+
// so a re-delivered webhook or the reaper racing a cancel credits back
|
|
583
|
+
// exactly once. Drop-silent-with-capture (see _settleGiftCards): the
|
|
584
|
+
// transition has already persisted and the webhook must return 2xx, so
|
|
585
|
+
// a reversal failure surfaces loudly for reconciliation rather than
|
|
586
|
+
// 500ing the request.
|
|
587
|
+
if (result.to === "cancelled" || result.to === "refunded") {
|
|
588
|
+
await _settleGiftCards(orderId);
|
|
589
|
+
}
|
|
480
590
|
// Fan-out to merchant webhook subscribers is fire-and-forget. The
|
|
481
591
|
// transition has already persisted; the request must not wait on
|
|
482
592
|
// outbound HTTP, or a slow / unreachable endpoint would block the
|
package/lib/returns.js
CHANGED
|
@@ -376,21 +376,61 @@ function create(opts) {
|
|
|
376
376
|
var refundedAt = _epochOrNull(input.refunded_at, "refunded_at");
|
|
377
377
|
var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
|
|
378
378
|
|
|
379
|
-
var current = await this._currentStatus(rmaId);
|
|
380
|
-
_assertTransition(current, "refund");
|
|
381
|
-
|
|
382
379
|
var ts = _now();
|
|
383
380
|
var rfAt = refundedAt == null ? ts : refundedAt;
|
|
384
|
-
|
|
381
|
+
// Atomic claim: the `AND status = 'received'` predicate is the
|
|
382
|
+
// serialization point. Two concurrent refund POSTs (an operator
|
|
383
|
+
// double-click, two operators on the same RMA) both read 'received'
|
|
384
|
+
// up the stack, but on D1 a single UPDATE is atomic — exactly one
|
|
385
|
+
// matches the row and writes 'refunded'; the loser matches zero rows
|
|
386
|
+
// and is refused. Without this the read-then-write let both pass and
|
|
387
|
+
// both moved the RMA to refunded (and, on the provider-backed path,
|
|
388
|
+
// both issued a refund). A zero-row result distinguishes "already
|
|
389
|
+
// refunded / not received" — surfaced as RMA_TRANSITION_REFUSED so
|
|
390
|
+
// the request layer maps it to 409, the same shape the prior
|
|
391
|
+
// _assertTransition refusal produced.
|
|
392
|
+
var upd = await query(
|
|
385
393
|
"UPDATE return_authorizations SET status = 'refunded', " +
|
|
386
394
|
"refunded_at = ?1, " +
|
|
387
395
|
"operator_notes = CASE WHEN ?2 = '' THEN operator_notes ELSE ?2 END, " +
|
|
388
|
-
"updated_at = ?3 WHERE id = ?4",
|
|
396
|
+
"updated_at = ?3 WHERE id = ?4 AND status = 'received'",
|
|
389
397
|
[rfAt, operatorNotes, ts, rmaId],
|
|
390
398
|
);
|
|
399
|
+
if (Number(upd.rowCount || 0) === 0) {
|
|
400
|
+
// Either the RMA doesn't exist or it isn't in 'received'. Read the
|
|
401
|
+
// current status to surface the right typed refusal (RMA_NOT_FOUND
|
|
402
|
+
// vs RMA_TRANSITION_REFUSED) so the caller's mapping is unchanged.
|
|
403
|
+
var current = await this._currentStatus(rmaId);
|
|
404
|
+
_assertTransition(current, "refund");
|
|
405
|
+
// _assertTransition throws for any non-'received' state; reaching
|
|
406
|
+
// here means the RMA was 'received' a moment ago but a concurrent
|
|
407
|
+
// claim won the row first. Refuse with the transition-refused shape.
|
|
408
|
+
var raced = new Error("returns: refund already claimed for rma " + rmaId);
|
|
409
|
+
raced.code = "RMA_TRANSITION_REFUSED";
|
|
410
|
+
throw raced;
|
|
411
|
+
}
|
|
391
412
|
return await this.get(rmaId);
|
|
392
413
|
},
|
|
393
414
|
|
|
415
|
+
// Revert a refund claim back to 'received'. The provider-backed refund
|
|
416
|
+
// flow claims the RMA (refund() above) BEFORE moving money so a
|
|
417
|
+
// concurrent racer is locked out; if the provider call then fails, the
|
|
418
|
+
// caller releases the claim so a retry can run. Atomic + self-targeting:
|
|
419
|
+
// the `AND status = 'refunded'` predicate makes a double-release (or a
|
|
420
|
+
// release of an RMA a webhook already advanced) a no-op, never an
|
|
421
|
+
// underflow. Returns true when the claim was released, false when there
|
|
422
|
+
// was nothing to release.
|
|
423
|
+
releaseRefundClaim: async function (rmaId) {
|
|
424
|
+
_uuid(rmaId, "rma id");
|
|
425
|
+
var ts = _now();
|
|
426
|
+
var upd = await query(
|
|
427
|
+
"UPDATE return_authorizations SET status = 'received', refunded_at = NULL, " +
|
|
428
|
+
"updated_at = ?1 WHERE id = ?2 AND status = 'refunded'",
|
|
429
|
+
[ts, rmaId],
|
|
430
|
+
);
|
|
431
|
+
return Number(upd.rowCount || 0) === 1;
|
|
432
|
+
},
|
|
433
|
+
|
|
394
434
|
reject: async function (rmaId, input) {
|
|
395
435
|
_uuid(rmaId, "rma id");
|
|
396
436
|
if (!input || typeof input !== "object") {
|
package/lib/storefront.js
CHANGED
|
@@ -13999,6 +13999,16 @@ function mount(router, deps) {
|
|
|
13999
13999
|
// fat-fingered value re-prompts rather than 500-ing checkout.
|
|
14000
14000
|
var code = (e && typeof e.code === "string") ? e.code : "";
|
|
14001
14001
|
var msg = (e && e.message) || "checkout failed";
|
|
14002
|
+
// Lost the single-charge claim: another POST for this cart (a double-
|
|
14003
|
+
// click / second tab) is already converting it. The winner created
|
|
14004
|
+
// (or is creating) the one order; bounce this loser to the cart so it
|
|
14005
|
+
// can't start a second checkout. The winner's redirect carries the
|
|
14006
|
+
// shopper to /pay or /orders — this branch just refuses the duplicate.
|
|
14007
|
+
if (code === "CHECKOUT_IN_PROGRESS") {
|
|
14008
|
+
res.status(303);
|
|
14009
|
+
res.setHeader && res.setHeader("location", "/cart");
|
|
14010
|
+
return res.end ? res.end() : res.send("");
|
|
14011
|
+
}
|
|
14002
14012
|
// A coded gift-card / loyalty / out-of-stock error is something the
|
|
14003
14013
|
// shopper can fix in place — re-render the checkout form with the
|
|
14004
14014
|
// message inline (preserving the cart + their prefilled fields where
|
|
@@ -17968,6 +17978,18 @@ function mount(router, deps) {
|
|
|
17968
17978
|
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
17969
17979
|
var order = await _ownedOrder(req, res, auth); if (!order) return;
|
|
17970
17980
|
var cartCount = await _cartCountForReq(req);
|
|
17981
|
+
// An ineligible order (unpaid, cancelled, or already refunded) never
|
|
17982
|
+
// gets the open-return form — the same window the return button is
|
|
17983
|
+
// gated on. Without this the form renders on, say, a refunded order
|
|
17984
|
+
// and the POST below would mint an RMA the operator could then refund
|
|
17985
|
+
// a SECOND time through the provider.
|
|
17986
|
+
if (!_orderEligibleForReturn(order.status)) {
|
|
17987
|
+
return _send(res, 400, renderReturnForm({
|
|
17988
|
+
order: order, lines: order.lines || [],
|
|
17989
|
+
notice: "This order isn't eligible for a return.",
|
|
17990
|
+
shop_name: shopName, cart_count: cartCount,
|
|
17991
|
+
}));
|
|
17992
|
+
}
|
|
17971
17993
|
_send(res, 200, renderReturnForm({ order: order, lines: order.lines || [], shop_name: shopName, cart_count: cartCount }));
|
|
17972
17994
|
});
|
|
17973
17995
|
|
|
@@ -17976,6 +17998,21 @@ function mount(router, deps) {
|
|
|
17976
17998
|
var order = await _ownedOrder(req, res, auth); if (!order) return;
|
|
17977
17999
|
var body = req.body || {};
|
|
17978
18000
|
var cartCount = await _cartCountForReq(req);
|
|
18001
|
+
// Server-side eligibility gate — the load-bearing check. A return is
|
|
18002
|
+
// only openable while the goods are in the customer's hands and paid
|
|
18003
|
+
// for (paid / fulfilling / shipped / delivered); an unpaid pending
|
|
18004
|
+
// order, a cancelled one, or an already-refunded one is refused. The
|
|
18005
|
+
// form/button gate on the same predicate, but a forged direct POST —
|
|
18006
|
+
// or an order that changed state between the GET and this POST — must
|
|
18007
|
+
// not slip an RMA onto an ineligible order (which would enable a
|
|
18008
|
+
// second provider refund downstream).
|
|
18009
|
+
if (!_orderEligibleForReturn(order.status)) {
|
|
18010
|
+
return _send(res, 400, renderReturnForm({
|
|
18011
|
+
order: order, lines: order.lines || [],
|
|
18012
|
+
notice: "This order isn't eligible for a return.",
|
|
18013
|
+
shop_name: shopName, cart_count: cartCount,
|
|
18014
|
+
}));
|
|
18015
|
+
}
|
|
17979
18016
|
// Build the return lines from the order's own lines (authoritative
|
|
17980
18017
|
// sku/qty), keyed by the checkboxes the customer ticked — never
|
|
17981
18018
|
// trust a client-supplied sku.
|
package/lib/webhooks.js
CHANGED
|
@@ -214,27 +214,59 @@ function create(opts) {
|
|
|
214
214
|
return await _attempt(deliveryId, endpointRow, eventType, payloadJson);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// The marker a refusal row carries in `last_error`. Rows bearing it are
|
|
218
|
+
// EXCLUDED from the rate-limit count — a refusal is the gate's own
|
|
219
|
+
// output, not a real delivery, so counting it would let the gate
|
|
220
|
+
// perpetuate its own throttle: once tripped, the refusal rows alone keep
|
|
221
|
+
// the window "full" and every subsequent send is refused forever even
|
|
222
|
+
// after the real deliveries age out.
|
|
223
|
+
var RATE_LIMITED_MARKER = "rate-limited";
|
|
224
|
+
|
|
217
225
|
async function _checkRateLimit(endpointRow) {
|
|
218
226
|
var limit = endpointRow.rate_limit_per_minute;
|
|
219
227
|
if (typeof limit !== "number" || !isFinite(limit) || limit <= 0) return true;
|
|
220
228
|
var windowStart = nowFn() - RATE_WINDOW_MS;
|
|
221
229
|
var r = await query(
|
|
222
|
-
"SELECT count(*) AS n FROM webhook_deliveries
|
|
223
|
-
|
|
230
|
+
"SELECT count(*) AS n FROM webhook_deliveries " +
|
|
231
|
+
"WHERE endpoint_id = ?1 AND created_at > ?2 " +
|
|
232
|
+
"AND (last_error IS NULL OR last_error != ?3)",
|
|
233
|
+
[endpointRow.id, windowStart, RATE_LIMITED_MARKER],
|
|
224
234
|
);
|
|
225
235
|
var n = (r.rows[0] && (r.rows[0].n != null ? r.rows[0].n : r.rows[0]["count(*)"])) || 0;
|
|
226
236
|
return n < limit;
|
|
227
237
|
}
|
|
228
238
|
|
|
239
|
+
// Surface a single refusal row per endpoint per window so the operator
|
|
240
|
+
// sees the throttle in the admin feed — but DON'T grow the table by one
|
|
241
|
+
// row per suppressed attempt. A flood of suppressed events against a
|
|
242
|
+
// wedged receiver would otherwise write unbounded refusal rows. When a
|
|
243
|
+
// recent refusal row already exists for this endpoint, refresh its
|
|
244
|
+
// last_attempted_at (collapse) instead of inserting another.
|
|
229
245
|
async function _persistRateLimited(endpointRow, eventType, payloadJson) {
|
|
230
|
-
var deliveryId = b.uuid.v7();
|
|
231
246
|
var ts = nowFn();
|
|
247
|
+
var windowStart = ts - RATE_WINDOW_MS;
|
|
248
|
+
var existing = await query(
|
|
249
|
+
"SELECT id FROM webhook_deliveries " +
|
|
250
|
+
"WHERE endpoint_id = ?1 AND last_error = ?2 AND created_at > ?3 " +
|
|
251
|
+
"ORDER BY created_at DESC LIMIT 1",
|
|
252
|
+
[endpointRow.id, RATE_LIMITED_MARKER, windowStart],
|
|
253
|
+
);
|
|
254
|
+
if (existing.rows[0]) {
|
|
255
|
+
var existingId = existing.rows[0].id;
|
|
256
|
+
await query(
|
|
257
|
+
"UPDATE webhook_deliveries SET last_attempted_at = ?1, event_type = ?2, payload_json = ?3 " +
|
|
258
|
+
"WHERE id = ?4",
|
|
259
|
+
[ts, eventType, payloadJson, existingId],
|
|
260
|
+
);
|
|
261
|
+
return await _getDelivery(existingId);
|
|
262
|
+
}
|
|
263
|
+
var deliveryId = b.uuid.v7();
|
|
232
264
|
await query(
|
|
233
265
|
"INSERT INTO webhook_deliveries " +
|
|
234
266
|
"(id, endpoint_id, event_type, payload_json, attempts, last_status, last_error, " +
|
|
235
267
|
" last_attempted_at, created_at) " +
|
|
236
268
|
"VALUES (?1, ?2, ?3, ?4, 0, NULL, ?5, ?6, ?7)",
|
|
237
|
-
[deliveryId, endpointRow.id, eventType, payloadJson,
|
|
269
|
+
[deliveryId, endpointRow.id, eventType, payloadJson, RATE_LIMITED_MARKER, ts, ts],
|
|
238
270
|
);
|
|
239
271
|
return await _getDelivery(deliveryId);
|
|
240
272
|
}
|
|
@@ -311,6 +343,13 @@ function create(opts) {
|
|
|
311
343
|
// per-endpoint feed still surfaces the failure. The DLQ row
|
|
312
344
|
// carries the full payload so replayFromDlq can re-queue
|
|
313
345
|
// without consulting the original delivery.
|
|
346
|
+
//
|
|
347
|
+
// Idempotent per delivery: the INSERT...SELECT writes only when no
|
|
348
|
+
// dead-letter row already exists for this delivery_id. Without it, a
|
|
349
|
+
// manual retry of an already-exhausted delivery dropped a duplicate
|
|
350
|
+
// DLQ row on every click. The migration 0215 UNIQUE(delivery_id) is
|
|
351
|
+
// the schema backstop; this guard avoids relying on a swallowed
|
|
352
|
+
// constraint error.
|
|
314
353
|
var dlqId = b.uuid.v7();
|
|
315
354
|
var firstAttemptedAt;
|
|
316
355
|
var r = await query(
|
|
@@ -322,7 +361,8 @@ function create(opts) {
|
|
|
322
361
|
"INSERT INTO webhook_dlq " +
|
|
323
362
|
"(id, endpoint_id, delivery_id, event_type, payload_json, attempts, " +
|
|
324
363
|
" last_status, last_error, first_attempted_at, last_attempted_at, dropped_at) " +
|
|
325
|
-
"
|
|
364
|
+
"SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11 " +
|
|
365
|
+
"WHERE NOT EXISTS (SELECT 1 FROM webhook_dlq WHERE delivery_id = ?3)",
|
|
326
366
|
[dlqId, endpointRow.id, deliveryId, eventType, payloadJson, attempts,
|
|
327
367
|
lastStatus, lastError, firstAttemptedAt, ts, ts],
|
|
328
368
|
);
|
|
@@ -443,6 +483,16 @@ function create(opts) {
|
|
|
443
483
|
_uuid(deliveryId, "delivery id");
|
|
444
484
|
var d = await _getDelivery(deliveryId);
|
|
445
485
|
if (!d) return null;
|
|
486
|
+
// An exhausted delivery (attempts at/over the max) has already
|
|
487
|
+
// moved to the DLQ — re-attempting it would push attempts past the
|
|
488
|
+
// max and drop a DUPLICATE DLQ row on every click. Answer a no-op
|
|
489
|
+
// (return the row unchanged) so the operator UI shows the existing
|
|
490
|
+
// state; re-queuing an exhausted delivery is `replayFromDlq`, not
|
|
491
|
+
// `retry`. A successfully-delivered row is likewise a no-op.
|
|
492
|
+
var attempts = Number(d.attempts || 0);
|
|
493
|
+
if (attempts >= MAX_ATTEMPTS || d.delivered_at != null) {
|
|
494
|
+
return d;
|
|
495
|
+
}
|
|
446
496
|
var endpoint = await _getEndpoint(d.endpoint_id);
|
|
447
497
|
if (!endpoint) return null;
|
|
448
498
|
return await _attempt(deliveryId, endpoint, d.event_type, d.payload_json);
|