@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 +2 -0
- package/lib/admin.js +385 -2
- package/lib/asset-manifest.json +1 -1
- package/lib/winback-campaigns.js +202 -42
- 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.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\">← 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.
|
package/lib/asset-manifest.json
CHANGED
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