@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/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