@blamejs/blamejs-shop 0.4.55 → 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 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.56 (2026-06-14) — **Win-back campaigns: re-engage lapsed customers with an escalating sequence of offers.** A new operator console defines win-back campaigns that re-engage customers who have not ordered in a while. A campaign sets a lapse window and an escalating sequence of steps, each with its own delay and an optional coupon. A scheduled pass enrols customers who cross the lapse window and sends each due step through the store's email transport, minting the step's coupon. The send is gated for consent and deliverability: a customer who has withdrawn marketing-email consent or sits on the marketing suppression list is never emailed, and an opted-out customer is removed from the sequence so no later step reaches them — and if the suppression check itself is unavailable the send is held rather than risked. Each step is sent at most once even if two scheduled passes overlap. Campaigns are inert until an operator creates and activates one and the store's email transport is configured. No migration to apply. **Added:** *Win-back campaign console + scheduled send* — Operators can create, list, activate, pause, and archive win-back campaigns from a new loyalty/marketing console screen: a lapse window plus an escalating sequence of steps, each with a delay and an optional coupon (a single-use, per-customer discount code minted when the step sends). A scheduled pass scans for lapsed customers, enrols them, advances each enrolment through the sequence, sends the due step via the store's email transport, and records a delivery log and per-campaign metrics. Every send is consent- and suppression-gated: a customer who withdrew marketing-email consent or is on the marketing suppression list is never emailed and is dropped from the sequence; a suppression-check outage holds the send rather than risking an unwanted message. The scheduled pass is idempotent — a step is sent at most once even if two passes overlap. The feature is inert until a campaign is created and activated and the email transport is configured.
12
+
11
13
  - v0.4.55 (2026-06-14) — **Refresh the vendored blamejs framework to 0.15.12.** Refreshes the vendored blamejs framework from 0.15.11 to 0.15.12, a sweep of defense-in-depth hardening the shop picks up by composing the framework. The body parser no longer echoes a caught exception's internal detail (a filesystem errno and temp path, or a parse hook's thrown message) to the HTTP client — the client gets a generic status phrase while the full detail stays on the server-side audit chain. Filename guarding now strips every reserved character rather than only the first. The boot-time logger escapes bidirectional and control characters on every sink, closing a terminal log-forging and line-reordering vector. Structured-field string values (used by HTTP Message Signatures, Client Hints, and Cache-Control) are now decoded in a single conformant pass, the HTTP Message Signature content-digest check matches in constant time against the exact digest member, and any outbound TLS connection that runs with certificate validation disabled now emits an audit event so the degraded posture is visible. This refresh carries no shop-facing API change and applies no migration; it keeps the bundled framework current and the security posture aligned with the latest release. **Changed:** *Vendored blamejs refreshed to 0.15.12* — The bundled framework is updated to blamejs 0.15.12. It redacts internal error detail from body-parser error responses (the client gets a generic status phrase; full diagnostics stay on the audit chain), strips every reserved character in filename guarding instead of only the first, escapes bidirectional and control characters in the boot logger on every sink (Trojan-Source / log-forging defense), decodes RFC 8941 structured-field strings in a single conformant pass, verifies the HTTP Message Signature content-digest by exact constant-time member match, and emits an audit event whenever an outbound TLS connection is configured to skip certificate validation. No shop API change; the framework's PQC-first crypto, security middleware, and request lifecycle are carried forward as-is.
12
14
 
13
15
  - v0.4.54 (2026-06-14) — **The rewards page now shows what a customer's loyalty tier includes and their progress to the next tier.** A signed-in customer's rewards page now shows their current loyalty tier, how many points remain to reach the next tier (with a progress bar), and the perks their tier includes. Operators author the per-tier perks from a new console screen — free shipping over a threshold, a percent discount, early or exclusive access, priority support, or a birthday bonus. The perks are presented to customers as what their tier includes and that the shop honours them; they are not applied automatically at checkout, so the wording never promises an automatic discount the store doesn't yet apply. No migration to apply. **Added:** *Loyalty tier perks and next-tier progress on the rewards page* — The /account rewards page gains two sections: a tier-progress panel naming the customer's current tier and the points still needed to reach the next one (with a labelled progress bar, or a top-tier acknowledgement), and a list of the perks the customer's tier includes. The tier is resolved from the customer's own loyalty balance; the perks come from the operator-authored tier-benefit definitions. The perks are framed as tier inclusions the shop honours — the copy directs the customer to ask at checkout or contact support to have a perk applied — rather than implying an automatic discount. · *Tier-benefit authoring in the loyalty console* — A new admin screen under the loyalty console lets operators define the perks each tier includes: free shipping (optionally over a minimum order), a percent discount, early access (hours before general release), priority support (an SLA in minutes), exclusive access to a collection, or a birthday bonus. Benefits are created and archived from the screen, each change recorded to the audit trail under the loyalty permission. The screen states that these perks are shown to customers as tier inclusions the shop honours and are not applied automatically at checkout.
package/lib/admin.js CHANGED
@@ -155,7 +155,7 @@ var _ACTION_PERMISSION = Object.freeze({
155
155
  gift: "catalog.write", preorder: "catalog.write", quantity_discount: "catalog.write",
156
156
  auto_discount: "catalog.write", coupon_policy: "catalog.write",
157
157
  promo_banner: "catalog.write", announcement: "catalog.write", blog: "catalog.write",
158
- email_campaign: "catalog.write", suggestion: "catalog.write", sidebar_widget: "catalog.write",
158
+ email_campaign: "catalog.write", winback_campaign: "catalog.write", suggestion: "catalog.write", sidebar_widget: "catalog.write",
159
159
  page: "catalog.write", help: "catalog.write", survey: "catalog.write",
160
160
  hours: "catalog.write", delivery_holiday: "catalog.write",
161
161
  delivery_transit: "catalog.write", tax_rate: "catalog.write",
@@ -889,6 +889,7 @@ function mount(router, deps) {
889
889
  var convertQuoteToOrder = deps.convertQuoteToOrder || null; // quote → pending-order converter (server.js composition); the console convert action disabled when absent
890
890
  var emailCampaigns = deps.emailCampaigns || null; // consent-gated broadcast/campaign console disabled when absent
891
891
  var mailingAudiences = deps.mailingAudiences || null; // audience picker for the campaign console (target-an-audience dropdown)
892
+ var winback = deps.winback || null; // win-back lapsed-customer campaign console (define/list/activate/archive + metrics + delivery log) disabled when absent
892
893
  // Read-only activity log at /admin/audit. Defaults ON — the framework
893
894
  // audit chain is always booted by createApp, so the screen always has a
894
895
  // data source (unlike the optional primitives above, which default off).
@@ -937,7 +938,7 @@ function mount(router, deps) {
937
938
  // `reports` is always present in the nav (read-only sales summary needs no
938
939
  // extra dep); its route mounts unconditionally and renders an unconfigured
939
940
  // notice when the salesReports primitive isn't wired.
940
- var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, suggestionBox: !!deps.suggestionBox, sidebarWidgets: !!deps.sidebarWidgets, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, operators: !!operatorAccounts, inbox: !!operatorInbox };
941
+ var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, suggestionBox: !!deps.suggestionBox, sidebarWidgets: !!deps.sidebarWidgets, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, winback: !!winback, operators: !!operatorAccounts, inbox: !!operatorInbox };
941
942
 
942
943
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
943
944
 
@@ -6868,6 +6869,220 @@ function mount(router, deps) {
6868
6869
  ));
6869
6870
  }
6870
6871
 
6872
+ // ---- win-back campaigns ---------------------------------------------
6873
+ //
6874
+ // Escalating re-engagement sequences for customers who've gone quiet —
6875
+ // the last paid order is N days in the past. The operator defines a
6876
+ // campaign (lapse window + ordered steps, each with an optional
6877
+ // per-step coupon), and the campaign-send-tick cron scans the order
6878
+ // history, enrolls the eligible, and sends the due step through the
6879
+ // SAME suppression + marketing-consent gate every marketing flow uses.
6880
+ // This console is the author/list/activate/archive + metrics/delivery-
6881
+ // log side; the send itself rides the cron (server.js + worker).
6882
+ //
6883
+ // Steps are authored one-per-line as `delay_days | template_slug |
6884
+ // coupon_kind | coupon_value` (the last two optional). defineCampaign
6885
+ // validates the whole shape and THROWS a TypeError on anything bad,
6886
+ // which the create route maps to a 400 / err redirect — no partial
6887
+ // write. Every operator-authored field rendered back is esc()'d in the
6888
+ // render functions below.
6889
+ if (winback) {
6890
+ // Parse the textarea step lines into the steps array the primitive
6891
+ // expects. A blank line is skipped; a malformed line surfaces as a
6892
+ // TypeError so the create route's catch redirects with `?err=steps`
6893
+ // rather than silently dropping a step. `delay_days` is a
6894
+ // non-negative int; `coupon_value` is parsed only when a kind is
6895
+ // present (free_shipping carries no value).
6896
+ function _winbackStepsFromBody(raw) {
6897
+ if (typeof raw !== "string") {
6898
+ throw new TypeError("winback: steps required");
6899
+ }
6900
+ var lines = raw.split(/\r?\n/);
6901
+ var steps = [];
6902
+ for (var i = 0; i < lines.length; i += 1) {
6903
+ var line = lines[i].trim();
6904
+ if (!line) continue;
6905
+ var parts = line.split("|").map(function (p) { return p.trim(); });
6906
+ var delayRaw = parts[0];
6907
+ var delay = parseInt(delayRaw, 10);
6908
+ if (String(delay) !== delayRaw || !Number.isInteger(delay) || delay < 0) {
6909
+ throw new TypeError("winback: step line " + (i + 1) + " — delay_days must be a non-negative integer");
6910
+ }
6911
+ var step = { delay_days: delay, template_slug: parts[1] };
6912
+ if (parts[2]) {
6913
+ step.coupon_kind = parts[2];
6914
+ if (parts[2] !== "free_shipping") {
6915
+ var val = parseInt(parts[3], 10);
6916
+ if (String(val) !== (parts[3] || "") || !Number.isInteger(val)) {
6917
+ throw new TypeError("winback: step line " + (i + 1) + " — coupon_value must be an integer for a " + parts[2] + " coupon");
6918
+ }
6919
+ step.coupon_value = val;
6920
+ }
6921
+ }
6922
+ steps.push(step);
6923
+ }
6924
+ return steps;
6925
+ }
6926
+
6927
+ function _winbackInput(body) {
6928
+ var input = {
6929
+ slug: body.slug,
6930
+ lapse_days_min: body.lapse_days_min != null && body.lapse_days_min !== "" ? parseInt(body.lapse_days_min, 10) : body.lapse_days_min,
6931
+ steps: _winbackStepsFromBody(body.steps),
6932
+ };
6933
+ if (body.lapse_days_max != null && body.lapse_days_max !== "") {
6934
+ input.lapse_days_max = parseInt(body.lapse_days_max, 10);
6935
+ }
6936
+ // Optional audience narrowing — a minimum lifetime order count
6937
+ // keeps a "we miss you" sequence off one-time buyers. Parsed only
6938
+ // when supplied; an empty field clears the predicate.
6939
+ var audience = {};
6940
+ if (body.lifetime_orders_min != null && body.lifetime_orders_min !== "") {
6941
+ audience.lifetime_orders_min = parseInt(body.lifetime_orders_min, 10);
6942
+ }
6943
+ if (Object.keys(audience).length) input.audience_filter = audience;
6944
+ return input;
6945
+ }
6946
+
6947
+ // List — every campaign (active + archived), newest-first.
6948
+ router.get("/admin/winback", _pageOrApi(true,
6949
+ R(async function (_req, res) {
6950
+ _json(res, 200, { campaigns: await winback.listCampaigns({}) });
6951
+ }),
6952
+ async function (req, res) {
6953
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6954
+ _sendHtml(res, 200, renderAdminWinbackList({
6955
+ shop_name: deps.shop_name, nav_available: navAvailable,
6956
+ rows: await winback.listCampaigns({}),
6957
+ can_send: !!winback._deps && !!winback._deps.email,
6958
+ saved: url && url.searchParams.get("saved"),
6959
+ notice: (url && url.searchParams.get("err")) ? _winbackErrNotice(url.searchParams.get("err")) : null,
6960
+ }));
6961
+ },
6962
+ ));
6963
+
6964
+ // New-campaign form — its own GET so a bad submit's err redirect
6965
+ // keeps the operator's context.
6966
+ router.get("/admin/winback/new", _pageOrApi(true,
6967
+ R(async function (_req, res) {
6968
+ _json(res, 200, { coupon_kinds: winback.COUPON_KINDS });
6969
+ }),
6970
+ async function (req, res) {
6971
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6972
+ _sendHtml(res, 200, renderAdminWinbackNew({
6973
+ shop_name: deps.shop_name, nav_available: navAvailable,
6974
+ notice: (url && url.searchParams.get("err")) ? _winbackErrNotice(url.searchParams.get("err")) : null,
6975
+ }));
6976
+ },
6977
+ ));
6978
+
6979
+ // Create / redefine — composes defineCampaign (validates slug,
6980
+ // lapse window, steps, coupons, audience filter; throws TypeError →
6981
+ // 400 / err redirect). Config-time tier: a bad shape never writes.
6982
+ router.post("/admin/winback", _pageOrApi(false,
6983
+ W("winback_campaign.create", async function (req, res) {
6984
+ var c;
6985
+ try { c = await winback.defineCampaign(_winbackInput(req.body || {})); }
6986
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6987
+ _json(res, 201, c);
6988
+ return { id: c.slug };
6989
+ }),
6990
+ async function (req, res) {
6991
+ var input;
6992
+ try { input = _winbackInput(req.body || {}); }
6993
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/winback/new?err=steps"); throw e; }
6994
+ try { await winback.defineCampaign(input); }
6995
+ catch (e) {
6996
+ var n = _safeNotice(e, "winback_campaign.create");
6997
+ if (n.status >= 500) throw e;
6998
+ return _redirect(res, "/admin/winback/new?err=bad");
6999
+ }
7000
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".winback_campaign.create", outcome: "success" });
7001
+ _redirect(res, "/admin/winback?saved=1");
7002
+ },
7003
+ ));
7004
+
7005
+ // Detail — the campaign config + recovery metrics over a trailing
7006
+ // window + the recent delivery log. Content-negotiated.
7007
+ function _winbackMetricsWindow() {
7008
+ var to = Date.now();
7009
+ // 90-day trailing window — wide enough to capture a full
7010
+ // escalation sequence plus the recovery tail.
7011
+ return { from: to - C.TIME.days(90), to: to };
7012
+ }
7013
+ router.get("/admin/winback/:slug", _pageOrApi(true,
7014
+ R(async function (req, res) {
7015
+ var c = await winback.getCampaign(req.params.slug);
7016
+ if (!c) return _problem(res, 404, "winback-campaign-not-found");
7017
+ var win = _winbackMetricsWindow();
7018
+ var metrics = null;
7019
+ try { metrics = await winback.metricsForCampaign({ slug: req.params.slug, from: win.from, to: win.to }); }
7020
+ catch (_e) { metrics = null; }
7021
+ var deliveries = await winback.recentDeliveriesForCampaign({ slug: req.params.slug, limit: 100 });
7022
+ _json(res, 200, Object.assign({}, c, { metrics: metrics, recent_deliveries: deliveries }));
7023
+ }),
7024
+ async function (req, res) {
7025
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7026
+ var c = await winback.getCampaign(req.params.slug);
7027
+ if (!c) return _sendHtml(res, 404, renderAdminWinbackList({
7028
+ shop_name: deps.shop_name, nav_available: navAvailable, rows: [],
7029
+ can_send: !!winback._deps && !!winback._deps.email, notice: "Campaign not found.",
7030
+ }));
7031
+ var win = _winbackMetricsWindow();
7032
+ var metrics = null;
7033
+ try { metrics = await winback.metricsForCampaign({ slug: req.params.slug, from: win.from, to: win.to }); }
7034
+ catch (_e) { metrics = null; }
7035
+ var deliveries = await winback.recentDeliveriesForCampaign({ slug: req.params.slug, limit: 100 });
7036
+ _sendHtml(res, 200, renderAdminWinback({
7037
+ shop_name: deps.shop_name, nav_available: navAvailable,
7038
+ campaign: c, metrics: metrics, deliveries: deliveries,
7039
+ can_send: !!winback._deps && !!winback._deps.email,
7040
+ saved: url && url.searchParams.get("saved"),
7041
+ notice: (url && url.searchParams.get("err")) ? _winbackErrNotice(url.searchParams.get("err")) : null,
7042
+ }));
7043
+ },
7044
+ ));
7045
+
7046
+ // Pause — archives the campaign out of the scan path. Active
7047
+ // enrollments still complete their sequence (the operator told
7048
+ // those customers they'd hear back); only NEW enrollments stop.
7049
+ router.post("/admin/winback/:slug/pause", _pageOrApi(false,
7050
+ W("winback_campaign.pause", async function (req, res) {
7051
+ var c;
7052
+ try { c = await winback.archiveCampaign(req.params.slug); }
7053
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7054
+ _json(res, 200, c);
7055
+ return { id: req.params.slug };
7056
+ }),
7057
+ async function (req, res) {
7058
+ var enc = encodeURIComponent(req.params.slug);
7059
+ try { await winback.archiveCampaign(req.params.slug); }
7060
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/winback/" + enc + "?err=pause"); }
7061
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".winback_campaign.pause", outcome: "success", metadata: { slug: req.params.slug } });
7062
+ _redirect(res, "/admin/winback/" + enc + "?saved=1");
7063
+ },
7064
+ ));
7065
+
7066
+ // Activate — clears the archive flag so the scan path picks the
7067
+ // campaign back up.
7068
+ router.post("/admin/winback/:slug/activate", _pageOrApi(false,
7069
+ W("winback_campaign.activate", async function (req, res) {
7070
+ var c;
7071
+ try { c = await winback.activateCampaign(req.params.slug); }
7072
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7073
+ _json(res, 200, c);
7074
+ return { id: req.params.slug };
7075
+ }),
7076
+ async function (req, res) {
7077
+ var enc = encodeURIComponent(req.params.slug);
7078
+ try { await winback.activateCampaign(req.params.slug); }
7079
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/winback/" + enc + "?err=activate"); }
7080
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".winback_campaign.activate", outcome: "success", metadata: { slug: req.params.slug } });
7081
+ _redirect(res, "/admin/winback/" + enc + "?saved=1");
7082
+ },
7083
+ ));
7084
+ }
7085
+
6871
7086
  // ---- gift wraps -----------------------------------------------------
6872
7087
  //
6873
7088
  // The operator-defined gift-wrap catalog: define / update / archive a wrap
@@ -14709,6 +14924,7 @@ var ADMIN_NAV_ITEMS = [
14709
14924
  { key: "suggestions", href: "/admin/suggestions", label: "Suggestion box", requires: "suggestionBox" },
14710
14925
  { key: "sidebar-widgets", href: "/admin/sidebar-widgets", label: "Sidebar widgets", requires: "sidebarWidgets" },
14711
14926
  { key: "campaigns", href: "/admin/campaigns", label: "Email campaigns", requires: "emailCampaigns" },
14927
+ { key: "winback", href: "/admin/winback", label: "Win-back", requires: "winback" },
14712
14928
  { key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
14713
14929
  { key: "help", href: "/admin/help", label: "Help center", requires: "knowledgeBase" },
14714
14930
  { key: "pages", href: "/admin/pages", label: "Pages", requires: "storefrontPages" },
@@ -18089,6 +18305,173 @@ function renderAdminCampaign(opts) {
18089
18305
  return _spliceRaw(html, "RAW_PREVIEW_BODY", previewBody);
18090
18306
  }
18091
18307
 
18308
+ // ---- win-back campaign console renders --------------------------------
18309
+
18310
+ function _winbackErrNotice(code) {
18311
+ if (code === "bad") return "Check the campaign — a slug, a lapse window (days), and at least one step are all required.";
18312
+ if (code === "steps") return "Check the steps — each line is `days | template | coupon_kind | coupon_value`; days must be a whole number and a percent/fixed coupon needs a value.";
18313
+ if (code === "pause") return "That campaign can't be paused from its current state.";
18314
+ if (code === "activate") return "That campaign can't be activated from its current state.";
18315
+ return "That action couldn't be completed.";
18316
+ }
18317
+
18318
+ function _winbackStatusPill(archivedAt) {
18319
+ return archivedAt == null
18320
+ ? "<span class=\"status-pill status-pill--ok\">active</span>"
18321
+ : "<span class=\"status-pill status-pill--muted\">paused</span>";
18322
+ }
18323
+
18324
+ // Render the steps array as a readable column — "0d · we-miss-you",
18325
+ // "7d · 10% off", "14d · free shipping". Every authored field escaped.
18326
+ function _winbackStepsSummary(steps) {
18327
+ if (!Array.isArray(steps) || !steps.length) return "<span class=\"meta\">—</span>";
18328
+ var parts = steps.map(function (s) {
18329
+ var label = _htmlEscape(String(s.delay_days)) + "d · " + _htmlEscape(String(s.template_slug));
18330
+ if (s.coupon_kind === "percent") label += " (" + _htmlEscape(String(s.coupon_value)) + "% off)";
18331
+ else if (s.coupon_kind === "fixed") label += " (" + _htmlEscape(String(s.coupon_value)) + " off)";
18332
+ else if (s.coupon_kind === "free_shipping") label += " (free shipping)";
18333
+ return label;
18334
+ });
18335
+ return parts.join("<br>");
18336
+ }
18337
+
18338
+ function renderAdminWinbackList(opts) {
18339
+ opts = opts || {};
18340
+ var rows = opts.rows || [];
18341
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Campaign saved.</div>" : "";
18342
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
18343
+
18344
+ // Honesty banner — win-back only emails when a deliverable-address
18345
+ // source is wired. Customer email is hash-only, so the default deploy
18346
+ // sends nothing: the sequence runs (enrolls, advances, logs) but no
18347
+ // mail leaves until the operator wires an address lookup + SMTP.
18348
+ var sendBanner = opts.can_send
18349
+ ? ""
18350
+ : "<div class=\"banner banner--warn\">Sending is dormant on this deployment: customer email is stored hash-only, so no win-back mail is sent until an SMTP transport and a customer-address lookup are wired. " +
18351
+ "Campaigns can still be authored, activated, and tracked — and the sequence enrolls + advances — but no email leaves until then.</div>";
18352
+
18353
+ var tableRows = rows.map(function (c) {
18354
+ var enc = encodeURIComponent(c.slug);
18355
+ var window = _htmlEscape(String(c.lapse_days_min)) + (c.lapse_days_max == null ? "+" : "–" + _htmlEscape(String(c.lapse_days_max))) + " days";
18356
+ return "<tr>" +
18357
+ "<td><a href=\"/admin/winback/" + enc + "\"><code>" + _htmlEscape(c.slug) + "</code></a></td>" +
18358
+ "<td>" + window + "</td>" +
18359
+ "<td>" + _htmlEscape(String((c.steps || []).length)) + "</td>" +
18360
+ "<td>" + _winbackStatusPill(c.archived_at) + "</td>" +
18361
+ "</tr>";
18362
+ }).join("");
18363
+
18364
+ var list = rows.length
18365
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Lapse window</th><th scope=\"col\">Steps</th><th scope=\"col\">Status</th></tr></thead><tbody>" + tableRows + "</tbody></table>") + "</div>"
18366
+ : "<p class=\"empty\">No win-back campaigns yet. Create one to re-engage customers who've gone quiet.</p>";
18367
+
18368
+ var body =
18369
+ "<section><h2>Win-back campaigns</h2>" +
18370
+ "<p class=\"meta\">Re-engage customers whose last paid order is N days old with an escalating sequence of offers. " +
18371
+ "The cron scans the order history, enrolls the eligible, and sends each step to ONLY marketing-consented, non-suppressed addresses — consent is re-checked at send time.</p>" +
18372
+ saved + notice + sendBanner +
18373
+ "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/winback/new\">New campaign</a></div>" +
18374
+ list +
18375
+ "</section>";
18376
+ return _renderAdminShell(opts.shop_name, "Win-back campaigns", body, "winback", opts.nav_available);
18377
+ }
18378
+
18379
+ function renderAdminWinbackNew(opts) {
18380
+ opts = opts || {};
18381
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
18382
+ var placeholder = "0 | we-miss-you\n7 | ten-pct-off | percent | 10\n14 | last-call | percent | 20";
18383
+
18384
+ var body =
18385
+ "<section class=\"mw-42\"><h2>New win-back campaign</h2>" + notice +
18386
+ "<form method=\"post\" action=\"/admin/winback\">" +
18387
+ _setupField("Campaign slug", "slug", "", "text", "Lowercase id, e.g. we-miss-you.", " maxlength=\"64\" required") +
18388
+ _setupField("Lapse window — minimum days", "lapse_days_min", "", "number", "Only customers whose last paid order is at least this many days old qualify.", " min=\"1\" required") +
18389
+ _setupField("Lapse window — maximum days", "lapse_days_max", "", "number", "Optional ceiling — skip customers gone longer than this. Leave blank for no ceiling.", " min=\"1\"") +
18390
+ _setupField("Minimum lifetime orders", "lifetime_orders_min", "", "number", "Optional — only target repeat buyers with at least this many paid orders.", " min=\"1\"") +
18391
+ "<label class=\"form-field\"><span>Steps</span>" +
18392
+ "<textarea name=\"steps\" rows=\"6\" maxlength=\"4000\" placeholder=\"" + _htmlEscape(placeholder) + "\" required></textarea>" +
18393
+ "<small>One step per line: <code>days | template_slug | coupon_kind | coupon_value</code>. " +
18394
+ "Coupon is optional; kinds are <code>percent</code>, <code>fixed</code>, <code>free_shipping</code> " +
18395
+ "(free_shipping carries no value). Days are the offset from enrollment and must not decrease.</small></label>" +
18396
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Create campaign</button>" +
18397
+ "<a class=\"btn btn--ghost\" href=\"/admin/winback\">Cancel</a></div>" +
18398
+ "</form>" +
18399
+ "</section>";
18400
+ return _renderAdminShell(opts.shop_name, "New win-back campaign", body, "winback", opts.nav_available);
18401
+ }
18402
+
18403
+ function renderAdminWinback(opts) {
18404
+ opts = opts || {};
18405
+ var c = opts.campaign;
18406
+ var enc = encodeURIComponent(c.slug);
18407
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Campaign updated.</div>" : "";
18408
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
18409
+
18410
+ var window = _htmlEscape(String(c.lapse_days_min)) + (c.lapse_days_max == null ? "+" : "–" + _htmlEscape(String(c.lapse_days_max))) + " days";
18411
+ var aud = c.audience_filter || {};
18412
+ var audLine = aud.lifetime_orders_min != null
18413
+ ? "<p class=\"meta\">Audience: at least " + _htmlEscape(String(aud.lifetime_orders_min)) + " lifetime paid order(s).</p>"
18414
+ : "";
18415
+
18416
+ var stepsPanel = "<div class=\"panel\"><h3 class=\"subhead\">Steps</h3>" +
18417
+ "<p>" + _winbackStepsSummary(c.steps) + "</p></div>";
18418
+
18419
+ // Recovery metrics over the trailing window. recovery_rate is a 0..1
18420
+ // fraction; render it as a one-decimal percentage.
18421
+ var m = opts.metrics;
18422
+ var ratePct = m ? (Number(m.recovery_rate) * 100).toFixed(1) : "0.0";
18423
+ var metricsPanel = m
18424
+ ? "<div class=\"panel\"><h3 class=\"subhead\">Recovery (last 90 days)</h3>" +
18425
+ "<ul class=\"kv-list\">" +
18426
+ "<li><span>Enrolled</span><strong>" + _htmlEscape(String(m.total)) + "</strong></li>" +
18427
+ "<li><span>Recovered</span><strong>" + _htmlEscape(String(m.counts.recovered)) + "</strong></li>" +
18428
+ "<li><span>Active</span><strong>" + _htmlEscape(String(m.counts.active)) + "</strong></li>" +
18429
+ "<li><span>Exhausted</span><strong>" + _htmlEscape(String(m.counts.exhausted)) + "</strong></li>" +
18430
+ "<li><span>Cancelled</span><strong>" + _htmlEscape(String(m.counts.cancelled)) + "</strong></li>" +
18431
+ "<li><span>Recovery rate</span><strong>" + _htmlEscape(ratePct) + "%</strong></li>" +
18432
+ "<li><span>Deliveries</span><strong>" + _htmlEscape(String(m.total_deliveries)) + "</strong></li>" +
18433
+ "</ul></div>"
18434
+ : "<div class=\"panel\"><p class=\"meta\">Metrics unavailable.</p></div>";
18435
+
18436
+ // Recent delivery log — which customer received which step + coupon
18437
+ // at which time. customer_id + coupon_code are escaped.
18438
+ var deliveries = opts.deliveries || [];
18439
+ var logRows = deliveries.map(function (d) {
18440
+ return "<tr>" +
18441
+ "<td>" + _htmlEscape(new Date(d.delivered_at).toISOString()) + "</td>" +
18442
+ "<td><code>" + _htmlEscape(String(d.customer_id)) + "</code></td>" +
18443
+ "<td>" + _htmlEscape(String(d.step_index)) + "</td>" +
18444
+ "<td>" + (d.coupon_code ? "<code>" + _htmlEscape(d.coupon_code) + "</code>" : "<span class=\"meta\">—</span>") + "</td>" +
18445
+ "</tr>";
18446
+ }).join("");
18447
+ var logPanel = deliveries.length
18448
+ ? "<div class=\"panel\"><h3 class=\"subhead\">Recent deliveries</h3>" +
18449
+ _tableWrap("<table><thead><tr><th scope=\"col\">Sent</th><th scope=\"col\">Customer</th><th scope=\"col\">Step</th><th scope=\"col\">Coupon</th></tr></thead><tbody>" + logRows + "</tbody></table>") + "</div>"
18450
+ : "<div class=\"panel\"><h3 class=\"subhead\">Recent deliveries</h3><p class=\"empty\">No deliveries yet.</p></div>";
18451
+
18452
+ // Pause / activate toggle on the campaign's archive state.
18453
+ var toggle = c.archived_at == null
18454
+ ? "<form method=\"post\" action=\"/admin/winback/" + enc + "/pause\" class=\"form-inline\">" +
18455
+ "<button class=\"btn btn--ghost\" type=\"submit\">Pause campaign</button></form>"
18456
+ : "<form method=\"post\" action=\"/admin/winback/" + enc + "/activate\" class=\"form-inline\">" +
18457
+ "<button class=\"btn\" type=\"submit\">Activate campaign</button></form>";
18458
+
18459
+ var sendNote = opts.can_send
18460
+ ? ""
18461
+ : "<p class=\"meta\">Sending is dormant: no deliverable-address source is wired, so the sequence enrolls and advances but no mail leaves.</p>";
18462
+
18463
+ var meta = "<p class=\"meta\">Lapse window " + window + " · " + _winbackStatusPill(c.archived_at) + "</p>";
18464
+
18465
+ var body =
18466
+ "<section><h2><code>" + _htmlEscape(c.slug) + "</code></h2>" + meta + audLine +
18467
+ saved + notice +
18468
+ stepsPanel + metricsPanel + logPanel +
18469
+ "<div class=\"panel\"><h3 class=\"subhead\">Status</h3>" + toggle + sendNote + "</div>" +
18470
+ "<div class=\"actions-row mt\"><a class=\"btn btn--ghost\" href=\"/admin/winback\">&larr; All campaigns</a></div>" +
18471
+ "</section>";
18472
+ return _renderAdminShell(opts.shop_name, c.slug, body, "winback", opts.nav_available);
18473
+ }
18474
+
18092
18475
  // Location→location transfer console: the open form + the open-transfer
18093
18476
  // queue with the FSM action legal from each row's status. Reasons,
18094
18477
  // carriers, and tracking numbers are operator free text — escaped.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.55",
2
+ "version": "0.4.56",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -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
- // drop-silentsuppressions outage errs toward sending
873
- // (the operator's mailer is the next gate).
959
+ // FAIL-CLOSEDa 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
- // Mint a coupon for the step when applicable + route the
891
- // send through the operator's email primitive. The actual
892
- // send is a best-effort hook; failure here doesn't block the
893
- // delivery row (the operator's worker can retry from the
894
- // enrollment row).
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 delivery row + FSM advance still
914
- // commit; the operator's mailer retries from the
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
- // Idempotency: if a delivery row already exists for this
920
- // (enrollment, step_index) pair we don't write a second
921
- // one. The UNIQUE index on (enrollment_id, step_index)
922
- // backstops this with a hard constraint at the storage
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.55",
3
+ "version": "0.4.56",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {