@blamejs/blamejs-shop 0.4.18 → 0.4.19
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 +2 -0
- package/lib/asset-manifest.json +1 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
11
13
|
- v0.4.18 (2026-06-06) — **Staff accounts: per-operator credentials with owner, manager, and viewer roles — the shared admin key becomes break-glass.** The admin console gains multi-operator support. Create staff accounts at the new Operators screen, each with its own password and optional API key, and assign a least-privilege role: owner (everything, including operator management), manager (catalog, orders, customers, marketing), or viewer (read-only). Role enforcement happens at the admin write layer on every state-changing request — a viewer is refused the write itself, not just the menu link. The shared ADMIN_API_KEY keeps working as the bootstrap and break-glass owner credential, so upgrading can never lock a store out; once staff accounts exist, treat it like a recovery key. **Added:** *Operators console with per-operator credentials* — `/admin/operators` creates and disables staff accounts. Each operator gets their own password (Argon2id) and optionally a personal API key for the JSON surface, shown once at creation. The screen lists who created each operator and when; disabling one takes effect on their very next request — the signed-in session re-reads the live account row, so there is no revocation lag. · *Owner / manager / viewer roles, enforced on the write itself* — Every admin mutation passes a single permission gate keyed to the action being performed: owner holds every permission including operator management and settings; manager covers catalog, orders, customers, and marketing; viewer holds none — every POST, PUT, and DELETE is refused with a 403, regardless of how the request arrives (browser form or bearer JSON). Read screens remain available to any authenticated operator. Role-denied attempts are recorded in the audit chain alongside every operator-management action. · *Bootstrap and break-glass: the shared key still works* — `ADMIN_API_KEY` resolves to the owner role exactly as before — a store with no operator rows behaves identically to the previous release, and the first staff account is created while signed in with the shared key. Unknown sign-in attempts burn a real password verification against a fixed dummy hash, so response timing does not reveal whether an account exists.
|
|
12
14
|
|
|
13
15
|
- v0.4.17 (2026-06-06) — **Email campaigns: consent-gated broadcasts to the newsletter list, with one-click unsubscribe and a send ledger that never re-mails.** The admin console gains an Email campaigns screen: author a broadcast, target a mailing audience, preview it, test-send to yourself, and send. Consent is the design center — the recipient set resolves at send time from the newsletter list (the only place a deliverable address exists; customer accounts keep only an email hash), every recipient is re-checked against the unsubscribe flag and the marketing suppression list at the moment their message sends, and every message carries one-click unsubscribe headers plus an in-body link. Sending drains in rate-bounded batches on the scheduled tick, one bad address never aborts a campaign, and a per-recipient ledger makes a resumed send never deliver twice. **Added:** *Campaign console: author, preview, test-send, send* — `/admin/campaigns` lists campaigns with status and delivered / failed / skipped counts, and a campaign editor takes a subject and a Markdown-or-plaintext body. The body renders escape-by-default with an https-only link gate — the same rendering discipline as the blog — so markup or script in a body lands as inert text in the inbox and in the console list. Preview and an operator-addressed test send are available before any real send. · *Consent resolved per recipient, at the send moment* — The reachable-recipient count shown before sending is computed live from the newsletter list, excluding anyone unsubscribed or on the marketing suppression list — and the same two checks run again for each recipient at the moment their message sends, so someone who unsubscribes mid-broadcast is skipped. Customer accounts keep only an email hash by design, so the newsletter list is the only deliverable-address source and the console says exactly how many recipients are reachable. · *One-click unsubscribe on every broadcast* — Every campaign message carries the RFC 8058 `List-Unsubscribe` / `List-Unsubscribe-Post` headers (so mail clients show their native unsubscribe control), an RFC 2919 `List-Id`, and an in-body unsubscribe link through the existing newsletter opt-out flow. · *Rate-bounded, resumable sending* — Sends drain in batches on the scheduled tick with a reserved hourly budget, so a large campaign spreads out rather than bursting. Each recipient's outcome lands in a send ledger keyed uniquely per campaign and recipient — a send interrupted by the rate budget or a restart resumes where it left off and never mails anyone twice. A failing address is counted and shown, never fatal to the rest of the campaign.
|
package/lib/asset-manifest.json
CHANGED
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);
|
package/lib/wishlist-alerts.js
CHANGED
|
@@ -550,16 +550,6 @@ function create(opts) {
|
|
|
550
550
|
|
|
551
551
|
// ---- scanAndDispatch internals ----------------------------------------
|
|
552
552
|
|
|
553
|
-
async function _countAlertsThisWeek(customerId, now) {
|
|
554
|
-
var since = now - MS_PER_WEEK;
|
|
555
|
-
var r = await query(
|
|
556
|
-
"SELECT COUNT(*) AS n FROM wishlist_alerts_sent "
|
|
557
|
-
+ "WHERE customer_id = ?1 AND occurred_at >= ?2",
|
|
558
|
-
[customerId, since],
|
|
559
|
-
);
|
|
560
|
-
return Number((r.rows[0] || {}).n || 0);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
553
|
async function _hasRecentFire(customerId, sku, policySlug, now) {
|
|
564
554
|
var since = now - DEDUPE_WINDOW_MS;
|
|
565
555
|
var r = await query(
|
|
@@ -571,6 +561,81 @@ function create(opts) {
|
|
|
571
561
|
return r.rows.length > 0;
|
|
572
562
|
}
|
|
573
563
|
|
|
564
|
+
// Last-observed state for a (customer, sku, policy) tuple — the memory
|
|
565
|
+
// that turns a current-state read into a transition read. NULL when the
|
|
566
|
+
// scanner has never seen this tuple.
|
|
567
|
+
async function _lastState(customerId, sku, policySlug) {
|
|
568
|
+
var r = await query(
|
|
569
|
+
"SELECT last_in_stock, last_alerted_bps FROM wishlist_alert_state "
|
|
570
|
+
+ "WHERE customer_id = ?1 AND sku = ?2 AND policy_slug = ?3 LIMIT 1",
|
|
571
|
+
[customerId, sku, policySlug],
|
|
572
|
+
);
|
|
573
|
+
var row = r.rows[0];
|
|
574
|
+
if (!row) return { last_in_stock: null, last_alerted_bps: null };
|
|
575
|
+
return {
|
|
576
|
+
last_in_stock: row.last_in_stock == null ? null : Number(row.last_in_stock),
|
|
577
|
+
last_alerted_bps: row.last_alerted_bps == null ? null : Number(row.last_alerted_bps),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Upsert the observed state for a tuple. Only the columns named in
|
|
582
|
+
// `patch` are written; the row's other column keeps its prior value
|
|
583
|
+
// (read once here so the UNIQUE-keyed REPLACE doesn't clobber the
|
|
584
|
+
// sibling column). `inStock` / `alertedBps` are 0/1/null and int/null.
|
|
585
|
+
async function _recordState(customerId, sku, policySlug, patch, now) {
|
|
586
|
+
var prior = await _lastState(customerId, sku, policySlug);
|
|
587
|
+
var inStock = Object.prototype.hasOwnProperty.call(patch, "last_in_stock")
|
|
588
|
+
? patch.last_in_stock
|
|
589
|
+
: prior.last_in_stock;
|
|
590
|
+
var alertedBps = Object.prototype.hasOwnProperty.call(patch, "last_alerted_bps")
|
|
591
|
+
? patch.last_alerted_bps
|
|
592
|
+
: prior.last_alerted_bps;
|
|
593
|
+
// INSERT-or-UPDATE on the UNIQUE(customer_id, sku, policy_slug) key.
|
|
594
|
+
await query(
|
|
595
|
+
"INSERT INTO wishlist_alert_state "
|
|
596
|
+
+ "(customer_id, sku, policy_slug, last_in_stock, last_alerted_bps, updated_at) "
|
|
597
|
+
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6) "
|
|
598
|
+
+ "ON CONFLICT (customer_id, sku, policy_slug) DO UPDATE SET "
|
|
599
|
+
+ "last_in_stock = ?4, last_alerted_bps = ?5, updated_at = ?6",
|
|
600
|
+
[customerId, sku, policySlug, inStock, alertedBps, now],
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Atomic dedupe + weekly-cap CLAIM. Writes the sent-ledger row in a
|
|
605
|
+
// single conditional INSERT that refuses when (a) a fire for the same
|
|
606
|
+
// (customer, sku, policy) already landed inside the dedupe window, or
|
|
607
|
+
// (b) the customer is at/over the weekly cap. Because the guard and the
|
|
608
|
+
// write are one statement, two concurrent sweeps can't both pass the
|
|
609
|
+
// read and then both insert — the second's INSERT...SELECT sees the
|
|
610
|
+
// first's row and matches zero rows. Returns true when this call won
|
|
611
|
+
// the claim. Mirrors the conditional-UPDATE house pattern
|
|
612
|
+
// (catalog.inventory.adjustOnHand / hold).
|
|
613
|
+
async function _claimLedgerRow(id, customerId, policySlug, sku, now, weeklyCap) {
|
|
614
|
+
var dedupeSince = now - DEDUPE_WINDOW_MS;
|
|
615
|
+
var weekSince = now - MS_PER_WEEK;
|
|
616
|
+
// `weeklyCap` null → no cap; encode as a sentinel the SQL treats as
|
|
617
|
+
// "always under cap" by comparing against a value the count can never
|
|
618
|
+
// reach when cap is absent.
|
|
619
|
+
var capActive = weeklyCap != null ? 1 : 0;
|
|
620
|
+
var capValue = weeklyCap != null ? weeklyCap : 0;
|
|
621
|
+
var r = await query(
|
|
622
|
+
"INSERT INTO wishlist_alerts_sent (id, customer_id, policy_slug, sku, channel, occurred_at) "
|
|
623
|
+
+ "SELECT ?1, ?2, ?3, ?4, 'email', ?5 "
|
|
624
|
+
+ "WHERE NOT EXISTS ("
|
|
625
|
+
+ " SELECT 1 FROM wishlist_alerts_sent "
|
|
626
|
+
+ " WHERE customer_id = ?2 AND sku = ?4 AND policy_slug = ?3 AND occurred_at >= ?6"
|
|
627
|
+
+ ") "
|
|
628
|
+
+ "AND ("
|
|
629
|
+
+ " ?7 = 0 OR ("
|
|
630
|
+
+ " SELECT COUNT(*) FROM wishlist_alerts_sent "
|
|
631
|
+
+ " WHERE customer_id = ?2 AND occurred_at >= ?8"
|
|
632
|
+
+ " ) < ?9"
|
|
633
|
+
+ ")",
|
|
634
|
+
[id, customerId, policySlug, sku, now, dedupeSince, capActive, weekSince, capValue],
|
|
635
|
+
);
|
|
636
|
+
return Number(r.rowCount || 0) === 1;
|
|
637
|
+
}
|
|
638
|
+
|
|
574
639
|
// Walk every wishlist entry that has a non-null variant_id. Whole-
|
|
575
640
|
// product (variant_id IS NULL) rows are skipped at v1 — the policy
|
|
576
641
|
// evaluator can't resolve a single sku for them, and the operator-
|
|
@@ -596,9 +661,18 @@ function create(opts) {
|
|
|
596
661
|
return defaultCurrency;
|
|
597
662
|
}
|
|
598
663
|
|
|
599
|
-
// Evaluate a single (entry, policy) pair
|
|
600
|
-
//
|
|
601
|
-
// { fire:
|
|
664
|
+
// Evaluate a single (entry, policy) pair against the TRANSITION from
|
|
665
|
+
// the last-observed state, not the current state in isolation. Returns:
|
|
666
|
+
// { fire: true, sku, payload, state } — dispatch + ledger; `state`
|
|
667
|
+
// is the observed-state patch
|
|
668
|
+
// to persist (always applied,
|
|
669
|
+
// fire or not).
|
|
670
|
+
// { fire: false, reason, sku?, state? } — accounted as `skipped`; a
|
|
671
|
+
// present `state` patch is
|
|
672
|
+
// still persisted so the next
|
|
673
|
+
// sweep reads a fresh baseline.
|
|
674
|
+
// The last-observed { last_in_stock, last_alerted_bps } for the tuple is
|
|
675
|
+
// loaded internally once the sku resolves (null fields when never seen).
|
|
602
676
|
async function _evaluate(entry, policy, now) {
|
|
603
677
|
// Map variant → sku.
|
|
604
678
|
var variant;
|
|
@@ -608,12 +682,14 @@ function create(opts) {
|
|
|
608
682
|
var sku = variant.sku;
|
|
609
683
|
if (typeof sku !== "string" || !sku.length) return { fire: false, reason: "variant_has_no_sku" };
|
|
610
684
|
|
|
685
|
+
var prior = await _lastState(entry.customer_id, sku, policy.slug);
|
|
686
|
+
|
|
611
687
|
if (policy.trigger === "price_drop") {
|
|
612
688
|
var currency = await _resolveCurrency(entry.customer_id);
|
|
613
689
|
var current;
|
|
614
690
|
try { current = await catalog.prices.current(entry.variant_id, currency); }
|
|
615
691
|
catch (_e) { current = null; }
|
|
616
|
-
if (!current) return { fire: false, reason: "no_current_price" };
|
|
692
|
+
if (!current) return { fire: false, reason: "no_current_price", sku: sku };
|
|
617
693
|
var history;
|
|
618
694
|
try { history = await catalog.prices.history(entry.variant_id, currency); }
|
|
619
695
|
catch (_e) { history = []; }
|
|
@@ -624,16 +700,29 @@ function create(opts) {
|
|
|
624
700
|
var h = history[i];
|
|
625
701
|
if (h.effective_until != null) { baseline = h; break; }
|
|
626
702
|
}
|
|
627
|
-
if (!baseline) return { fire: false, reason: "no_baseline_price" };
|
|
703
|
+
if (!baseline) return { fire: false, reason: "no_baseline_price", sku: sku };
|
|
628
704
|
var baseAmt = Number(baseline.amount_minor);
|
|
629
705
|
var curAmt = Number(current.amount_minor);
|
|
630
706
|
if (!(baseAmt > 0) || !(curAmt >= 0) || curAmt >= baseAmt) {
|
|
631
|
-
|
|
707
|
+
// No drop in effect — the price recovered to/above baseline. Clear
|
|
708
|
+
// the last-alerted level so a fresh drop later is a transition.
|
|
709
|
+
return { fire: false, reason: "no_drop", sku: sku, state: { last_alerted_bps: null } };
|
|
632
710
|
}
|
|
633
711
|
// Percent-off in basis points: ((base - cur) / base) * 10000.
|
|
634
712
|
var bps = Math.floor(((baseAmt - curAmt) * 10000) / baseAmt);
|
|
635
713
|
var threshold = Number(policy.threshold.percent_off_bps_min);
|
|
636
|
-
if (bps < threshold)
|
|
714
|
+
if (bps < threshold) {
|
|
715
|
+
// Below the policy gate — not an alertable drop. Don't touch the
|
|
716
|
+
// last-alerted level (a shallow dip after a deep alerted drop
|
|
717
|
+
// shouldn't reset the baseline and re-arm a re-fire).
|
|
718
|
+
return { fire: false, reason: "below_threshold", sku: sku };
|
|
719
|
+
}
|
|
720
|
+
// Transition gate: fire only on a NEW or DEEPER drop than the level
|
|
721
|
+
// we last alerted. A steady drop at the same depth must not re-fire,
|
|
722
|
+
// dedupe window or not.
|
|
723
|
+
if (prior.last_alerted_bps != null && bps <= prior.last_alerted_bps) {
|
|
724
|
+
return { fire: false, reason: "no_deeper_drop", sku: sku };
|
|
725
|
+
}
|
|
637
726
|
return {
|
|
638
727
|
fire: true,
|
|
639
728
|
sku: sku,
|
|
@@ -644,6 +733,12 @@ function create(opts) {
|
|
|
644
733
|
new_amount_minor: curAmt,
|
|
645
734
|
discount_bps: bps,
|
|
646
735
|
},
|
|
736
|
+
// `state` is the always-record observation (none here — recording
|
|
737
|
+
// the alerted level happens only on a committed send, below).
|
|
738
|
+
// `alertedBps` is recorded post-send so a lost claim / failed
|
|
739
|
+
// mailer doesn't suppress a legitimate later re-fire.
|
|
740
|
+
state: {},
|
|
741
|
+
alertedBps: bps,
|
|
647
742
|
};
|
|
648
743
|
}
|
|
649
744
|
|
|
@@ -651,9 +746,14 @@ function create(opts) {
|
|
|
651
746
|
var inv;
|
|
652
747
|
try { inv = await catalog.inventory.get(sku); }
|
|
653
748
|
catch (_e) { inv = null; }
|
|
654
|
-
if (!inv) return { fire: false, reason: "inventory_unresolvable" };
|
|
749
|
+
if (!inv) return { fire: false, reason: "inventory_unresolvable", sku: sku };
|
|
655
750
|
var available = Number(inv.stock_on_hand || 0) - Number(inv.stock_held || 0);
|
|
656
|
-
|
|
751
|
+
var inStockNow = available > 0 ? 1 : 0;
|
|
752
|
+
if (inStockNow === 0) {
|
|
753
|
+
// Out of stock — record the observed state so a later restock is a
|
|
754
|
+
// 0 -> 1 transition.
|
|
755
|
+
return { fire: false, reason: "still_out_of_stock", sku: sku, state: { last_in_stock: 0 } };
|
|
756
|
+
}
|
|
657
757
|
// Optional dep — when wired, suppress if the customer is already
|
|
658
758
|
// subscribed via the PDP "notify me" toggle. The dispatcher can
|
|
659
759
|
// only know the customer's email when we know they're a logged-
|
|
@@ -668,7 +768,19 @@ function create(opts) {
|
|
|
668
768
|
var sub;
|
|
669
769
|
try { sub = await stockAlerts.isSubscribedByCustomer({ customer_id: entry.customer_id, sku: sku }); }
|
|
670
770
|
catch (_e) { sub = null; }
|
|
671
|
-
|
|
771
|
+
// Still record the in-stock observation so the transition baseline
|
|
772
|
+
// tracks reality even when the stockAlerts path owns the send.
|
|
773
|
+
if (sub && sub.subscribed === true) {
|
|
774
|
+
return { fire: false, reason: "duplicate_via_stock_alerts", sku: sku, state: { last_in_stock: 1 } };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// Transition gate: fire only on the out -> in EDGE. A first
|
|
778
|
+
// observation (prior unknown) or a steady in-stock state records the
|
|
779
|
+
// observation but does not fire — there is no restock event to
|
|
780
|
+
// announce when we never saw the item go out.
|
|
781
|
+
var isRestock = prior.last_in_stock === 0;
|
|
782
|
+
if (!isRestock) {
|
|
783
|
+
return { fire: false, reason: "no_restock_transition", sku: sku, state: { last_in_stock: 1 } };
|
|
672
784
|
}
|
|
673
785
|
return {
|
|
674
786
|
fire: true,
|
|
@@ -677,6 +789,7 @@ function create(opts) {
|
|
|
677
789
|
variant_id: entry.variant_id,
|
|
678
790
|
available: available,
|
|
679
791
|
},
|
|
792
|
+
state: { last_in_stock: 1 },
|
|
680
793
|
};
|
|
681
794
|
}
|
|
682
795
|
|
|
@@ -722,8 +835,10 @@ function create(opts) {
|
|
|
722
835
|
no_baseline_price: 0,
|
|
723
836
|
no_current_price: 0,
|
|
724
837
|
no_drop: 0,
|
|
838
|
+
no_deeper_drop: 0,
|
|
725
839
|
below_threshold: 0,
|
|
726
840
|
still_out_of_stock: 0,
|
|
841
|
+
no_restock_transition: 0,
|
|
727
842
|
inventory_unresolvable: 0,
|
|
728
843
|
variant_unresolvable: 0,
|
|
729
844
|
variant_has_no_sku: 0,
|
|
@@ -738,19 +853,25 @@ function create(opts) {
|
|
|
738
853
|
for (var pp = 0; pp < scannablePolicies.length; pp += 1) {
|
|
739
854
|
var policy = scannablePolicies[pp];
|
|
740
855
|
|
|
741
|
-
// Cheapest
|
|
856
|
+
// Cheapest cut first.
|
|
742
857
|
var optedOut = await isUnsubscribedFromTrigger(entry.customer_id, policy.trigger);
|
|
743
858
|
if (optedOut) { skipped += 1; skippedBy.unsubscribed += 1; continue; }
|
|
744
859
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
860
|
+
// Evaluate against the TRANSITION from the last-observed state —
|
|
861
|
+
// a steady in-stock / steady-depth-drop state never re-fires. The
|
|
862
|
+
// sku isn't known until the variant resolves inside _evaluate, so
|
|
863
|
+
// it loads the prior state for the resolved tuple itself.
|
|
864
|
+
var v = await _evaluate(entry, policy, now);
|
|
865
|
+
|
|
866
|
+
// Persist the always-record observation (current in-stock state /
|
|
867
|
+
// a cleared drop) regardless of fire, so the next sweep reads a
|
|
868
|
+
// fresh baseline even when this one didn't send. An empty patch
|
|
869
|
+
// (a price_drop fire — its alerted level is recorded only after a
|
|
870
|
+
// committed send) is a no-op, so skip the write.
|
|
871
|
+
if (v.sku && v.state && Object.keys(v.state).length > 0) {
|
|
872
|
+
await _recordState(entry.customer_id, v.sku, policy.slug, v.state, now);
|
|
750
873
|
}
|
|
751
874
|
|
|
752
|
-
// Evaluate the policy condition.
|
|
753
|
-
var v = await _evaluate(entry, policy, now);
|
|
754
875
|
if (!v.fire) {
|
|
755
876
|
skipped += 1;
|
|
756
877
|
if (skippedBy[v.reason] != null) skippedBy[v.reason] += 1;
|
|
@@ -758,13 +879,10 @@ function create(opts) {
|
|
|
758
879
|
continue;
|
|
759
880
|
}
|
|
760
881
|
|
|
761
|
-
// Per-(customer, sku, policy) recent-fire suppression.
|
|
762
|
-
var dup = await _hasRecentFire(entry.customer_id, v.sku, policy.slug, now);
|
|
763
|
-
if (dup) { skipped += 1; skippedBy.recent_dedupe += 1; continue; }
|
|
764
|
-
|
|
765
882
|
candidates += 1;
|
|
766
883
|
|
|
767
|
-
// Resolve the customer's email. Absent → no_email, skipped
|
|
884
|
+
// Resolve the customer's email. Absent → no_email, skipped — we
|
|
885
|
+
// never claim a ledger row we can't send.
|
|
768
886
|
var customerEmail = null;
|
|
769
887
|
if (emailForCustomer) {
|
|
770
888
|
try { customerEmail = await emailForCustomer(entry.customer_id); }
|
|
@@ -774,6 +892,27 @@ function create(opts) {
|
|
|
774
892
|
skipped += 1; skippedBy.no_email += 1; continue;
|
|
775
893
|
}
|
|
776
894
|
|
|
895
|
+
// Atomic dedupe + weekly-cap CLAIM. The conditional INSERT is the
|
|
896
|
+
// serialization point: two concurrent sweeps can't both pass the
|
|
897
|
+
// dedupe/cap guard and then both write — the loser's INSERT...
|
|
898
|
+
// SELECT sees the winner's row and matches zero rows.
|
|
899
|
+
var id = b.uuid.v7();
|
|
900
|
+
var ts = _monotonicTs(now);
|
|
901
|
+
var claimed = await _claimLedgerRow(
|
|
902
|
+
id, entry.customer_id, policy.slug, v.sku, ts,
|
|
903
|
+
policy.max_alerts_per_week_per_customer,
|
|
904
|
+
);
|
|
905
|
+
if (!claimed) {
|
|
906
|
+
// Lost the claim — either a recent dedupe fire or the weekly
|
|
907
|
+
// cap. Distinguish for accurate accounting (read-only; the
|
|
908
|
+
// mutation already serialized at the INSERT).
|
|
909
|
+
var dup = await _hasRecentFire(entry.customer_id, v.sku, policy.slug, ts);
|
|
910
|
+
skipped += 1;
|
|
911
|
+
if (dup) skippedBy.recent_dedupe += 1;
|
|
912
|
+
else skippedBy.weekly_cap_reached += 1;
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
|
|
777
916
|
// Resolve a product title for the email. Best-effort — falls
|
|
778
917
|
// through to the sku string when the catalog read fails.
|
|
779
918
|
var productTitle = v.sku;
|
|
@@ -810,20 +949,20 @@ function create(opts) {
|
|
|
810
949
|
dispatchOk = true;
|
|
811
950
|
} catch (_e4) {
|
|
812
951
|
// Drop-silent at the row level — a flapping mailer must not
|
|
813
|
-
// poison the rest of the scan.
|
|
952
|
+
// poison the rest of the scan. Release the claimed ledger row
|
|
953
|
+
// so a later sweep retries instead of being suppressed by the
|
|
954
|
+
// dedupe window for a send that never happened.
|
|
955
|
+
await query("DELETE FROM wishlist_alerts_sent WHERE id = ?1", [id]);
|
|
814
956
|
skipped += 1; skippedBy.email_dispatch_failed += 1; continue;
|
|
815
957
|
}
|
|
816
958
|
if (!dispatchOk) continue;
|
|
817
959
|
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
825
|
-
[id, entry.customer_id, policy.slug, v.sku, "email", ts],
|
|
826
|
-
);
|
|
960
|
+
// Committed send — record the alerted depth so the same drop
|
|
961
|
+
// depth is steady on the next sweep (price_drop only; the
|
|
962
|
+
// back_in_stock observation was already recorded above).
|
|
963
|
+
if (v.alertedBps != null) {
|
|
964
|
+
await _recordState(entry.customer_id, v.sku, policy.slug, { last_alerted_bps: v.alertedBps }, ts);
|
|
965
|
+
}
|
|
827
966
|
sent += 1;
|
|
828
967
|
}
|
|
829
968
|
}
|
package/lib/wishlist-digest.js
CHANGED
|
@@ -972,7 +972,20 @@ function create(opts) {
|
|
|
972
972
|
"VALUES (?1, ?2, ?3, ?4)",
|
|
973
973
|
[sentId, enrollment.id, sentItemCount, sentAt],
|
|
974
974
|
);
|
|
975
|
+
// Advance the cadence. The normal step is one period past the
|
|
976
|
+
// current next_dispatch_at — that keeps the schedule aligned to its
|
|
977
|
+
// target weekday / day-of-month when the tick fires on time. But a
|
|
978
|
+
// STALE enrollment (the cron was down, or the customer enrolled long
|
|
979
|
+
// ago) has a next_dispatch_at many periods in the past; advancing it
|
|
980
|
+
// one period at a time would leave it still due, so the very next
|
|
981
|
+
// tick re-fires, and a subscriber gets one digest per tick until the
|
|
982
|
+
// schedule catches up — a flood. Instead, when one period still
|
|
983
|
+
// doesn't clear `now`, snap straight to the next occurrence strictly
|
|
984
|
+
// after `now`. The catch-up costs at most ONE digest, never a storm.
|
|
975
985
|
var nextDispatchAt = _advanceByOnePeriod(schedule, Number(enrollment.next_dispatch_at));
|
|
986
|
+
if (nextDispatchAt <= now) {
|
|
987
|
+
nextDispatchAt = _nextDispatchAt(schedule, now);
|
|
988
|
+
}
|
|
976
989
|
await query(
|
|
977
990
|
"UPDATE wishlist_digest_enrollments SET next_dispatch_at = ?1 WHERE id = ?2",
|
|
978
991
|
[nextDispatchAt, enrollment.id],
|
package/package.json
CHANGED