@blamejs/blamejs-shop 0.4.54 → 0.4.56
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 +385 -2
- package/lib/asset-manifest.json +1 -1
- package/lib/vendor/MANIFEST.json +41 -35
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/SECURITY.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +10 -2
- package/lib/vendor/blamejs/examples/wiki/lib/html-entities.js +24 -0
- package/lib/vendor/blamejs/examples/wiki/lib/symbol-index.js +7 -5
- package/lib/vendor/blamejs/examples/wiki/test/e2e.js +9 -1
- package/lib/vendor/blamejs/examples/wiki/test/validate-nav-coverage.js +2 -8
- package/lib/vendor/blamejs/lib/acme.js +7 -11
- package/lib/vendor/blamejs/lib/client-hints.js +3 -1
- package/lib/vendor/blamejs/lib/cluster.js +4 -2
- package/lib/vendor/blamejs/lib/guard-filename.js +6 -2
- package/lib/vendor/blamejs/lib/http-client-cache.js +3 -1
- package/lib/vendor/blamejs/lib/http-message-signature.js +25 -8
- package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +12 -1
- package/lib/vendor/blamejs/lib/log-stream-syslog.js +6 -0
- package/lib/vendor/blamejs/lib/log.js +24 -2
- package/lib/vendor/blamejs/lib/mail.js +5 -0
- package/lib/vendor/blamejs/lib/middleware/body-parser.js +48 -6
- package/lib/vendor/blamejs/lib/network-dns.js +22 -26
- package/lib/vendor/blamejs/lib/network-heartbeat.js +3 -3
- package/lib/vendor/blamejs/lib/network-proxy.js +3 -7
- package/lib/vendor/blamejs/lib/network-tls.js +34 -13
- package/lib/vendor/blamejs/lib/network.js +2 -6
- package/lib/vendor/blamejs/lib/notify.js +7 -12
- package/lib/vendor/blamejs/lib/seeders.js +5 -10
- package/lib/vendor/blamejs/lib/structured-fields.js +38 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.12.json +47 -0
- package/lib/vendor/blamejs/test/00-primitives.js +24 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-error-redaction.test.js +74 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -8
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-filename.test.js +11 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/http-message-signature.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/log-stream-otlp-grpc.test.js +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-tls.test.js +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/structured-fields.test.js +14 -0
- package/lib/winback-campaigns.js +202 -42
- package/package.json +1 -1
|
@@ -185,6 +185,32 @@ async function testValidationRejectsBadUrl() {
|
|
|
185
185
|
check("missing url throws", threw && threw.code === "BAD_OPT");
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// The insecure-TLS audit must only fire on an actual TLS session. An h2c
|
|
189
|
+
// endpoint (http://, cleartext HTTP/2) creates no TLS session, so allowInsecure
|
|
190
|
+
// there skips no certificate and must NOT emit tls.insecure_skip_verify — a
|
|
191
|
+
// false security/compliance event. https:// + allowInsecure still emits it.
|
|
192
|
+
async function testInsecureTlsAuditGatedToHttps() {
|
|
193
|
+
var observability = require("../../lib/observability");
|
|
194
|
+
function capture(cfg) {
|
|
195
|
+
var events = [];
|
|
196
|
+
observability.setTap(function (name) { if (name === "tls.insecure_skip_verify") events.push(name); });
|
|
197
|
+
var session;
|
|
198
|
+
try { session = grpc._makeClient(cfg); }
|
|
199
|
+
finally { observability.setTap(null); }
|
|
200
|
+
if (session) {
|
|
201
|
+
session.on("error", function () { /* dead address — expected */ });
|
|
202
|
+
try { session.destroy(); } catch (_e) { /* best-effort */ }
|
|
203
|
+
}
|
|
204
|
+
return events.length;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
var h2c = capture({ url: "http://127.0.0.1:1", allowInsecure: true, allowedProtocols: ["http:", "https:"] });
|
|
208
|
+
check("h2c (cleartext) endpoint with allowInsecure emits NO insecure-TLS audit", h2c === 0);
|
|
209
|
+
|
|
210
|
+
var tls = capture({ url: "https://127.0.0.1:1", allowInsecure: true, allowedProtocols: ["http:", "https:"] });
|
|
211
|
+
check("https endpoint with allowInsecure DOES emit the insecure-TLS audit", tls === 1);
|
|
212
|
+
}
|
|
213
|
+
|
|
188
214
|
async function run() {
|
|
189
215
|
await testFramingShape();
|
|
190
216
|
await testEncodeLogRecord();
|
|
@@ -192,6 +218,7 @@ async function run() {
|
|
|
192
218
|
await testGrpcRoundTrip();
|
|
193
219
|
await testGrpcServerErrorTrailer();
|
|
194
220
|
await testValidationRejectsBadUrl();
|
|
221
|
+
await testInsecureTlsAuditGatedToHttps();
|
|
195
222
|
}
|
|
196
223
|
|
|
197
224
|
module.exports = { run: run };
|
|
@@ -422,7 +422,38 @@ function testPkixHostShape() {
|
|
|
422
422
|
err2 && err2.code === "tls/pkix-hostname-mismatch");
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
+
// v0.15.12 (#143) — an outbound TLS connection that honors rejectUnauthorized:
|
|
426
|
+
// false (operator opt-in to disable peer-cert validation) must emit an audit +
|
|
427
|
+
// observability event so the degraded posture is observable. Capture the event
|
|
428
|
+
// through the real operator tap (observability.setTap) — observability has no
|
|
429
|
+
// `emit`, so the emit must land on the safeEvent → tap path that an operator
|
|
430
|
+
// actually wires (the live connect path is covered in the integration suite
|
|
431
|
+
// alongside tls.classical_downgrade).
|
|
432
|
+
function testInsecureTlsAudit() {
|
|
433
|
+
var nt = b.network.tls;
|
|
434
|
+
check("auditInsecureTls is exported", typeof nt.auditInsecureTls === "function");
|
|
435
|
+
|
|
436
|
+
var observability = require("../../lib/observability");
|
|
437
|
+
var captured = [];
|
|
438
|
+
observability.setTap(function (name, value, labels) { captured.push({ name: name, labels: labels }); });
|
|
439
|
+
try {
|
|
440
|
+
nt.auditInsecureTls({ host: "peer.example", port: 8443, source: "network.tls.connectWithEch" });
|
|
441
|
+
} finally {
|
|
442
|
+
observability.setTap(null);
|
|
443
|
+
}
|
|
444
|
+
var ev = captured.filter(function (c) { return c.name === "tls.insecure_skip_verify"; });
|
|
445
|
+
check("auditInsecureTls emits tls.insecure_skip_verify", ev.length >= 1);
|
|
446
|
+
check("audit event carries host/port/source",
|
|
447
|
+
ev.length >= 1 && ev[0].labels.host === "peer.example" &&
|
|
448
|
+
ev[0].labels.port === 8443 && ev[0].labels.source === "network.tls.connectWithEch");
|
|
449
|
+
|
|
450
|
+
var threw = false;
|
|
451
|
+
try { nt.auditInsecureTls(null); } catch (_e) { threw = true; }
|
|
452
|
+
check("auditInsecureTls is drop-silent on bad input (never throws into a connect)", threw === false);
|
|
453
|
+
}
|
|
454
|
+
|
|
425
455
|
async function run() {
|
|
456
|
+
testInsecureTlsAudit();
|
|
426
457
|
testEchSurface();
|
|
427
458
|
testEchParseDraft22();
|
|
428
459
|
testEchParseAcceptsBase64();
|
|
@@ -156,6 +156,20 @@ function testUnquoteSfString() {
|
|
|
156
156
|
b.structuredFields.unquoteSfString("bare-token") === "bare-token");
|
|
157
157
|
check("unquoteSfString: unterminated quote returns null",
|
|
158
158
|
b.structuredFields.unquoteSfString('"oops') === null);
|
|
159
|
+
// v0.15.12 (#77) — adjacent / repeated escapes the old two-pass .replace()
|
|
160
|
+
// decode mangled. unquoteSfString routes through the single-pass
|
|
161
|
+
// unescapeSfStringBody; the two-pass form returned a DOUBLED backslash for a
|
|
162
|
+
// lone escaped backslash.
|
|
163
|
+
check("unquoteSfString: lone escaped backslash decodes to a single backslash",
|
|
164
|
+
b.structuredFields.unquoteSfString('"\\\\"') === "\\");
|
|
165
|
+
check("unquoteSfString: escaped backslash adjacent to escaped quote",
|
|
166
|
+
b.structuredFields.unquoteSfString('"\\\\\\""') === "\\\"");
|
|
167
|
+
check("unescapeSfStringBody: lone escaped backslash -> single",
|
|
168
|
+
b.structuredFields.unescapeSfStringBody("\\\\") === "\\");
|
|
169
|
+
check("unescapeSfStringBody: two escaped backslashes -> two",
|
|
170
|
+
b.structuredFields.unescapeSfStringBody("\\\\\\\\") === "\\\\");
|
|
171
|
+
check("unescapeSfStringBody: non-string passthrough",
|
|
172
|
+
b.structuredFields.unescapeSfStringBody(42) === 42);
|
|
159
173
|
check("unquoteSfString: empty returns empty",
|
|
160
174
|
b.structuredFields.unquoteSfString("") === "");
|
|
161
175
|
check("unquoteSfString: whitespace-only returns empty",
|
package/lib/winback-campaigns.js
CHANGED
|
@@ -592,6 +592,93 @@ function create(opts) {
|
|
|
592
592
|
return _rowToCampaign(await _getCampaignRow(slug));
|
|
593
593
|
},
|
|
594
594
|
|
|
595
|
+
// Re-activate an archived campaign — clears `archived_at` so the
|
|
596
|
+
// scan path picks it up again. The inverse of `archiveCampaign`.
|
|
597
|
+
// Idempotent: re-activating an already-active campaign is a no-op
|
|
598
|
+
// that returns the current shape. Throws when the campaign doesn't
|
|
599
|
+
// exist (config-time tier — an operator acting on a typo'd slug
|
|
600
|
+
// should hear about it).
|
|
601
|
+
activateCampaign: async function (slug, activateOpts) {
|
|
602
|
+
_validateSlug(slug, "slug");
|
|
603
|
+
activateOpts = activateOpts || {};
|
|
604
|
+
var now = activateOpts.now == null ? _now() : activateOpts.now;
|
|
605
|
+
_validateNonNegInt(now, "now");
|
|
606
|
+
var existing = await _getCampaignRow(slug);
|
|
607
|
+
if (!existing) {
|
|
608
|
+
throw new TypeError(
|
|
609
|
+
"winbackCampaigns.activateCampaign: campaign '" + slug + "' not found"
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
if (existing.archived_at == null) {
|
|
613
|
+
return _rowToCampaign(existing);
|
|
614
|
+
}
|
|
615
|
+
await query(
|
|
616
|
+
"UPDATE winback_campaigns SET archived_at = NULL, updated_at = ?1 WHERE slug = ?2",
|
|
617
|
+
[now, slug],
|
|
618
|
+
);
|
|
619
|
+
return _rowToCampaign(await _getCampaignRow(slug));
|
|
620
|
+
},
|
|
621
|
+
|
|
622
|
+
// List every campaign, newest-first, with an optional status
|
|
623
|
+
// filter ("active" | "archived"). The operator console reads this
|
|
624
|
+
// for the campaign table; absent a filter every campaign returns.
|
|
625
|
+
listCampaigns: async function (listOpts) {
|
|
626
|
+
listOpts = listOpts || {};
|
|
627
|
+
var status = listOpts.status;
|
|
628
|
+
var sql = "SELECT * FROM winback_campaigns";
|
|
629
|
+
if (status === "active") {
|
|
630
|
+
sql += " WHERE archived_at IS NULL";
|
|
631
|
+
} else if (status === "archived") {
|
|
632
|
+
sql += " WHERE archived_at IS NOT NULL";
|
|
633
|
+
} else if (status != null) {
|
|
634
|
+
throw new TypeError(
|
|
635
|
+
"winbackCampaigns.listCampaigns: status must be 'active' or 'archived' when supplied"
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
sql += " ORDER BY created_at DESC";
|
|
639
|
+
var rows = (await query(sql, [])).rows;
|
|
640
|
+
var out = [];
|
|
641
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_rowToCampaign(rows[i]));
|
|
642
|
+
return out;
|
|
643
|
+
},
|
|
644
|
+
|
|
645
|
+
// Read the most-recent deliveries across a campaign's enrollments,
|
|
646
|
+
// newest-first, bounded by `limit` (default 100). Joins each
|
|
647
|
+
// delivery to its enrollment so the console can show which
|
|
648
|
+
// customer received which step + coupon at which time. The
|
|
649
|
+
// operator-facing delivery-log panel reads this.
|
|
650
|
+
recentDeliveriesForCampaign: async function (input) {
|
|
651
|
+
if (!input || typeof input !== "object") {
|
|
652
|
+
throw new TypeError("winbackCampaigns.recentDeliveriesForCampaign: input object required");
|
|
653
|
+
}
|
|
654
|
+
var slug = _validateSlug(input.slug, "slug");
|
|
655
|
+
var limit = input.limit == null ? 100 : input.limit;
|
|
656
|
+
_validatePositiveInt(limit, "limit");
|
|
657
|
+
if (limit > 500) limit = 500;
|
|
658
|
+
var rows = (await query(
|
|
659
|
+
"SELECT d.id AS id, d.enrollment_id AS enrollment_id, " +
|
|
660
|
+
" d.step_index AS step_index, d.coupon_code AS coupon_code, " +
|
|
661
|
+
" d.delivered_at AS delivered_at, e.customer_id AS customer_id " +
|
|
662
|
+
"FROM winback_deliveries d " +
|
|
663
|
+
"JOIN winback_enrollments e ON e.id = d.enrollment_id " +
|
|
664
|
+
"WHERE e.campaign_slug = ?1 " +
|
|
665
|
+
"ORDER BY d.delivered_at DESC, d.step_index DESC LIMIT ?2",
|
|
666
|
+
[slug, limit],
|
|
667
|
+
)).rows;
|
|
668
|
+
var out = [];
|
|
669
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
670
|
+
out.push({
|
|
671
|
+
id: rows[i].id,
|
|
672
|
+
enrollment_id: rows[i].enrollment_id,
|
|
673
|
+
customer_id: rows[i].customer_id,
|
|
674
|
+
step_index: Number(rows[i].step_index),
|
|
675
|
+
coupon_code: rows[i].coupon_code || null,
|
|
676
|
+
delivered_at: Number(rows[i].delivered_at),
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return out;
|
|
680
|
+
},
|
|
681
|
+
|
|
595
682
|
// Scan the operator's order history for customers whose last
|
|
596
683
|
// paid order landed inside the lapse window for each active
|
|
597
684
|
// campaign + who aren't already enrolled. Returns a flat array
|
|
@@ -869,8 +956,12 @@ function create(opts) {
|
|
|
869
956
|
suppressionReason = sup.suppression_type || "suppressed";
|
|
870
957
|
}
|
|
871
958
|
} catch (_e) {
|
|
872
|
-
//
|
|
873
|
-
//
|
|
959
|
+
// FAIL-CLOSED — a suppression-list outage must NOT let a
|
|
960
|
+
// marketing send slip past. Treat the unavailable check as a
|
|
961
|
+
// hit and cancel the enrollment rather than email an address
|
|
962
|
+
// that may have opted out.
|
|
963
|
+
suppressed = true;
|
|
964
|
+
suppressionReason = "suppression-check-unavailable";
|
|
874
965
|
}
|
|
875
966
|
}
|
|
876
967
|
|
|
@@ -887,11 +978,45 @@ function create(opts) {
|
|
|
887
978
|
continue;
|
|
888
979
|
}
|
|
889
980
|
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
//
|
|
981
|
+
// ATOMIC CLAIM — advance the FSM FIRST, so the conditional UPDATE
|
|
982
|
+
// is the single point that decides which of two overlapping ticks
|
|
983
|
+
// owns this step. The send + coupon mint happen ONLY after a
|
|
984
|
+
// winning claim (changes === 1), so a step is at-most-once: a
|
|
985
|
+
// marketing message is better missed than doubled, and a
|
|
986
|
+
// concurrent tick can't double-send or double-mint. The loser
|
|
987
|
+
// (changes === 0) skips — the winner already owns the step.
|
|
988
|
+
var nextIdx = stepIdx + 1;
|
|
989
|
+
var claim;
|
|
990
|
+
if (nextIdx >= steps.length) {
|
|
991
|
+
claim = await query(
|
|
992
|
+
"UPDATE winback_enrollments SET " +
|
|
993
|
+
"status = 'exhausted', current_step_index = ?1, " +
|
|
994
|
+
"next_step_at = NULL, updated_at = ?2 " +
|
|
995
|
+
"WHERE id = ?3 AND status = 'active' AND current_step_index = ?4",
|
|
996
|
+
[nextIdx, now, enr.id, stepIdx],
|
|
997
|
+
);
|
|
998
|
+
} else {
|
|
999
|
+
var createdAt = Number(enr.created_at);
|
|
1000
|
+
var nextAt = _nextStepAt(steps, createdAt, nextIdx);
|
|
1001
|
+
claim = await query(
|
|
1002
|
+
"UPDATE winback_enrollments SET " +
|
|
1003
|
+
"current_step_index = ?1, next_step_at = ?2, updated_at = ?3 " +
|
|
1004
|
+
"WHERE id = ?4 AND status = 'active' AND current_step_index = ?5",
|
|
1005
|
+
[nextIdx, nextAt, now, enr.id, stepIdx],
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
var claimed = Number((claim && (claim.changes != null ? claim.changes : claim.rowCount)) || 0);
|
|
1009
|
+
if (claimed !== 1) {
|
|
1010
|
+
// A concurrent tick already advanced this step — it owns the
|
|
1011
|
+
// send; skip without re-sending or re-minting.
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Claimed. Mint the per-step coupon (the primitive's core step
|
|
1016
|
+
// action, independent of whether an address resolved — the
|
|
1017
|
+
// delivery row records the coupon for a mailer retry), then route
|
|
1018
|
+
// the send through the operator's email primitive. The send is
|
|
1019
|
+
// best-effort: a failure doesn't roll back the claim.
|
|
895
1020
|
var couponCode = await _resolveCouponForStep(step, customerId);
|
|
896
1021
|
if (
|
|
897
1022
|
email &&
|
|
@@ -910,22 +1035,15 @@ function create(opts) {
|
|
|
910
1035
|
},
|
|
911
1036
|
});
|
|
912
1037
|
} catch (_e) {
|
|
913
|
-
// drop-silent — the
|
|
914
|
-
//
|
|
915
|
-
// delivery row's coupon_code if it minted one.
|
|
1038
|
+
// drop-silent — the claim already committed; the operator's
|
|
1039
|
+
// mailer retries from the delivery row's coupon_code if any.
|
|
916
1040
|
}
|
|
917
1041
|
}
|
|
918
1042
|
|
|
919
|
-
//
|
|
920
|
-
// (
|
|
921
|
-
//
|
|
922
|
-
|
|
923
|
-
// layer.
|
|
924
|
-
var prior = await query(
|
|
925
|
-
"SELECT id FROM winback_deliveries WHERE enrollment_id = ?1 AND step_index = ?2 LIMIT 1",
|
|
926
|
-
[enr.id, stepIdx],
|
|
927
|
-
);
|
|
928
|
-
if (!prior.rows[0]) {
|
|
1043
|
+
// Record the delivery. Only the claiming tick reaches here, so the
|
|
1044
|
+
// UNIQUE(enrollment_id, step_index) index can't collide; the
|
|
1045
|
+
// try/catch is a defensive backstop that never strands the batch.
|
|
1046
|
+
try {
|
|
929
1047
|
var deliveryId = b.uuid.v7();
|
|
930
1048
|
await query(
|
|
931
1049
|
"INSERT INTO winback_deliveries " +
|
|
@@ -933,28 +1051,7 @@ function create(opts) {
|
|
|
933
1051
|
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
934
1052
|
[deliveryId, enr.id, stepIdx, couponCode, now],
|
|
935
1053
|
);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
// Advance the FSM.
|
|
939
|
-
var nextIdx = stepIdx + 1;
|
|
940
|
-
if (nextIdx >= steps.length) {
|
|
941
|
-
await query(
|
|
942
|
-
"UPDATE winback_enrollments SET " +
|
|
943
|
-
"status = 'exhausted', current_step_index = ?1, " +
|
|
944
|
-
"next_step_at = NULL, updated_at = ?2 " +
|
|
945
|
-
"WHERE id = ?3 AND status = 'active' AND current_step_index = ?4",
|
|
946
|
-
[nextIdx, now, enr.id, stepIdx],
|
|
947
|
-
);
|
|
948
|
-
} else {
|
|
949
|
-
var createdAt = Number(enr.created_at);
|
|
950
|
-
var nextAt = _nextStepAt(steps, createdAt, nextIdx);
|
|
951
|
-
await query(
|
|
952
|
-
"UPDATE winback_enrollments SET " +
|
|
953
|
-
"current_step_index = ?1, next_step_at = ?2, updated_at = ?3 " +
|
|
954
|
-
"WHERE id = ?4 AND status = 'active' AND current_step_index = ?5",
|
|
955
|
-
[nextIdx, nextAt, now, enr.id, stepIdx],
|
|
956
|
-
);
|
|
957
|
-
}
|
|
1054
|
+
} catch (_e) { /* drop-silent — the claim already owns the step; an audit-row collision cannot cause a double-send */ }
|
|
958
1055
|
|
|
959
1056
|
deliveries.push({
|
|
960
1057
|
enrollment_id: enr.id,
|
|
@@ -1274,6 +1371,69 @@ function create(opts) {
|
|
|
1274
1371
|
};
|
|
1275
1372
|
},
|
|
1276
1373
|
|
|
1374
|
+
// One bounded cron pass over the already-wired instance: scan the
|
|
1375
|
+
// order history for lapsed customers, enroll the eligible ones,
|
|
1376
|
+
// then dispatch every due step through the suppression/consent-
|
|
1377
|
+
// gated send path. Composes `scanForLapsedCustomers` +
|
|
1378
|
+
// `enrollCustomer` + `dispatchTick` so the operator's worker fires
|
|
1379
|
+
// a single call per tick rather than re-deriving the loop.
|
|
1380
|
+
//
|
|
1381
|
+
// Drop-silent per recipient: the dispatch loop already swallows a
|
|
1382
|
+
// single send / resolver / coupon failure so one bad address never
|
|
1383
|
+
// strands the batch; enroll failures are caught per-candidate here
|
|
1384
|
+
// (a UNIQUE-collision from a concurrent tick is a no-op, not a
|
|
1385
|
+
// batch-killer). The whole pass is idempotent — re-running it (or
|
|
1386
|
+
// an overlapping tick) cannot double-enroll a customer (UNIQUE
|
|
1387
|
+
// (customer_id, campaign_slug) + the existing-row return) nor
|
|
1388
|
+
// double-send a step (UNIQUE (enrollment_id, step_index) + the
|
|
1389
|
+
// conditional FSM advance).
|
|
1390
|
+
runTick: async function (runTickOpts) {
|
|
1391
|
+
runTickOpts = runTickOpts || {};
|
|
1392
|
+
var now = runTickOpts.now == null ? _now() : runTickOpts.now;
|
|
1393
|
+
_validateNonNegInt(now, "now");
|
|
1394
|
+
var batchSize = runTickOpts.batch_size == null ? 500 : runTickOpts.batch_size;
|
|
1395
|
+
_validatePositiveInt(batchSize, "batch_size");
|
|
1396
|
+
var resolveEmail = runTickOpts.resolveEmail || null;
|
|
1397
|
+
if (resolveEmail != null && typeof resolveEmail !== "function") {
|
|
1398
|
+
throw new TypeError(
|
|
1399
|
+
"winbackCampaigns.runTick: resolveEmail must be a function (enrollment) => Promise<string|null>"
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
var candidates = await this.scanForLapsedCustomers({
|
|
1404
|
+
as_of: now,
|
|
1405
|
+
max_batch: batchSize,
|
|
1406
|
+
});
|
|
1407
|
+
var enrolledN = 0;
|
|
1408
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
1409
|
+
try {
|
|
1410
|
+
await this.enrollCustomer({
|
|
1411
|
+
campaign_slug: candidates[i].campaign_slug,
|
|
1412
|
+
customer_id: candidates[i].customer_id,
|
|
1413
|
+
now: now,
|
|
1414
|
+
});
|
|
1415
|
+
enrolledN += 1;
|
|
1416
|
+
} catch (_e) {
|
|
1417
|
+
// drop-silent — a concurrent tick that already enrolled this
|
|
1418
|
+
// (customer, campaign) loses the UNIQUE race; the row exists,
|
|
1419
|
+
// so this pass simply moves on. Any other per-candidate fault
|
|
1420
|
+
// (a vanished campaign mid-scan) likewise must not strand the
|
|
1421
|
+
// rest of the batch.
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
var dispatch = await this.dispatchTick({
|
|
1425
|
+
now: now,
|
|
1426
|
+
batch_size: batchSize,
|
|
1427
|
+
resolveEmail: resolveEmail,
|
|
1428
|
+
});
|
|
1429
|
+
return {
|
|
1430
|
+
now: now,
|
|
1431
|
+
candidates: candidates.length,
|
|
1432
|
+
enrolled: enrolledN,
|
|
1433
|
+
dispatched: dispatch.dispatched,
|
|
1434
|
+
};
|
|
1435
|
+
},
|
|
1436
|
+
|
|
1277
1437
|
// Expose the optional deps so a wiring sanity check can assert
|
|
1278
1438
|
// they reached the factory.
|
|
1279
1439
|
_deps: {
|
package/package.json
CHANGED