@blamejs/blamejs-shop 0.0.72 → 0.0.75
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 +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.referralLeaderboard
|
|
4
|
+
* @title Referral leaderboard — top-referrer reports + tiered rewards
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Sits on top of the existing referrals primitive (migration 0025).
|
|
8
|
+
* Reads from `referral_codes` + `referral_invitations` to compute:
|
|
9
|
+
*
|
|
10
|
+
* - `topReferrers({ from, to, limit })` — completed funnels per
|
|
11
|
+
* referrer in the [from, to] window, ordered by count DESC,
|
|
12
|
+
* referrer_customer_id ASC for stable ties. Lifetime referral
|
|
13
|
+
* revenue is filled in by an optional `referrals.revenueForCustomer`
|
|
14
|
+
* hook on the injected handle; absent the hook, the field is 0.
|
|
15
|
+
*
|
|
16
|
+
* - `referralTier({ customer_id, as_of? })` — counts completed
|
|
17
|
+
* funnels in the trailing 90 days against the operator-tunable
|
|
18
|
+
* thresholds in `referral_tier_config`. Returns one of
|
|
19
|
+
* bronze / silver / gold / platinum.
|
|
20
|
+
*
|
|
21
|
+
* - `awardLeaderboardBonus({ customer_id, tier, period_label })`
|
|
22
|
+
* — UNIQUE on (customer_id, period_label, tier) is the re-award
|
|
23
|
+
* gate. On a fresh row the composition calls `loyalty.earn`
|
|
24
|
+
* (when wired) for the tier's configured point bonus; on a
|
|
25
|
+
* duplicate, returns `{ status: "already-awarded" }` and
|
|
26
|
+
* NEVER re-invokes loyalty.
|
|
27
|
+
*
|
|
28
|
+
* - `monthlyChampions({ year, month, limit })` — convenience
|
|
29
|
+
* wrapper around topReferrers with the month's UTC window.
|
|
30
|
+
*
|
|
31
|
+
* - `cleanupExpiredBonuses(days)` — sweeps audit-log rows older
|
|
32
|
+
* than `days` days from `referral_leaderboard_bonuses`. The
|
|
33
|
+
* loyalty side is untouched (points already landed in the
|
|
34
|
+
* customer's account); this is a log-retention sweep, not a
|
|
35
|
+
* reversal.
|
|
36
|
+
*
|
|
37
|
+
* Tier thresholds (operator-tunable via `setTierThresholds`):
|
|
38
|
+
*
|
|
39
|
+
* - bronze — 0 completed referrals in trailing 90d
|
|
40
|
+
* - silver — 5
|
|
41
|
+
* - gold — 15
|
|
42
|
+
* - platinum — 40
|
|
43
|
+
*
|
|
44
|
+
* Point bonuses per tier (operator-tunable via factory opts):
|
|
45
|
+
*
|
|
46
|
+
* - bronze — 100 points
|
|
47
|
+
* - silver — 500
|
|
48
|
+
* - gold — 2000
|
|
49
|
+
* - platinum — 10000
|
|
50
|
+
*
|
|
51
|
+
* Monotonic per-process clock: two awards in the same millisecond
|
|
52
|
+
* would tie on `occurred_at` and make a sort-by-timestamp read
|
|
53
|
+
* ambiguous. `_now()` bumps to `prior + 1` on collision so the
|
|
54
|
+
* `historyForCustomer` ORDER BY occurred_at DESC carries a strict
|
|
55
|
+
* per-process ordering.
|
|
56
|
+
*
|
|
57
|
+
* Composes:
|
|
58
|
+
* - `b.uuid.v7` — bonus row ids.
|
|
59
|
+
* - `b.guardUuid` — strict UUID gate on every
|
|
60
|
+
* customer_id at the entry point.
|
|
61
|
+
* - `referrals` (optional) — when wired, `topReferrers`
|
|
62
|
+
* surfaces `lifetime_revenue` via
|
|
63
|
+
* `referrals.revenueForCustomer`.
|
|
64
|
+
* Absent, the field is 0.
|
|
65
|
+
* - `loyalty` (optional) — when wired,
|
|
66
|
+
* `awardLeaderboardBonus` composes
|
|
67
|
+
* `loyalty.earn(...)` for the
|
|
68
|
+
* tier's configured bonus.
|
|
69
|
+
* Absent, the bonus row lands
|
|
70
|
+
* and the operator pays out via
|
|
71
|
+
* their own channel.
|
|
72
|
+
*
|
|
73
|
+
* Storage:
|
|
74
|
+
* - referral_tier_config, referral_leaderboard_bonuses
|
|
75
|
+
* (migration `0182_referral_leaderboard.sql`).
|
|
76
|
+
*
|
|
77
|
+
* @primitive referralLeaderboard
|
|
78
|
+
* @related shop.referrals, shop.loyalty, b.uuid.v7, b.guardUuid
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
var bShop;
|
|
82
|
+
function _b() {
|
|
83
|
+
if (!bShop) bShop = require("./index");
|
|
84
|
+
return bShop.framework;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
var TIERS = ["bronze", "silver", "gold", "platinum"];
|
|
88
|
+
|
|
89
|
+
var DEFAULT_THRESHOLDS = {
|
|
90
|
+
bronze: 0,
|
|
91
|
+
silver: 5,
|
|
92
|
+
gold: 15,
|
|
93
|
+
platinum: 40,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
var DEFAULT_TIER_BONUS_POINTS = {
|
|
97
|
+
bronze: 100,
|
|
98
|
+
silver: 500,
|
|
99
|
+
gold: 2000,
|
|
100
|
+
platinum: 10000,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
var MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
104
|
+
var ROLLING_WINDOW_DAYS = 90;
|
|
105
|
+
|
|
106
|
+
var MAX_LEADERBOARD_LIMIT = 100;
|
|
107
|
+
var DEFAULT_LEADERBOARD_LIMIT = 10;
|
|
108
|
+
|
|
109
|
+
var MAX_PERIOD_LABEL_LEN = 64;
|
|
110
|
+
// `2026-05` / `2026-Q2` / `2026-week-21` / `monthly-2026-05` etc.
|
|
111
|
+
var PERIOD_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}[A-Za-z0-9]$/;
|
|
112
|
+
|
|
113
|
+
var BONUS_LOG_SOURCE = "referral-leaderboard-bonus";
|
|
114
|
+
|
|
115
|
+
// ---- monotonic clock ---------------------------------------------------
|
|
116
|
+
//
|
|
117
|
+
// Operator-driven awards can land in the same millisecond on fast
|
|
118
|
+
// machines. Bumping by 1ms on a tie keeps the timeline strictly
|
|
119
|
+
// increasing so `historyForCustomer` ORDER BY occurred_at returns
|
|
120
|
+
// rows in issue order.
|
|
121
|
+
|
|
122
|
+
var _lastTs = 0;
|
|
123
|
+
function _now() {
|
|
124
|
+
var t = Date.now();
|
|
125
|
+
if (t <= _lastTs) t = _lastTs + 1;
|
|
126
|
+
_lastTs = t;
|
|
127
|
+
return t;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- validators --------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
function _uuid(s, label) {
|
|
133
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
134
|
+
catch (e) {
|
|
135
|
+
throw new TypeError("referralLeaderboard: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _positiveInt(n, label, maxOpt) {
|
|
140
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
141
|
+
throw new TypeError("referralLeaderboard: " + label + " must be a positive integer");
|
|
142
|
+
}
|
|
143
|
+
if (typeof maxOpt === "number" && n > maxOpt) {
|
|
144
|
+
throw new TypeError("referralLeaderboard: " + label + " must be <= " + maxOpt);
|
|
145
|
+
}
|
|
146
|
+
return n;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _nonNegInt(n, label) {
|
|
150
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
151
|
+
throw new TypeError("referralLeaderboard: " + label + " must be a non-negative integer");
|
|
152
|
+
}
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _timestamp(n, label) {
|
|
157
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
158
|
+
throw new TypeError("referralLeaderboard: " + label + " must be a non-negative integer timestamp");
|
|
159
|
+
}
|
|
160
|
+
return n;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _periodLabel(s) {
|
|
164
|
+
if (typeof s !== "string" || !s.length) {
|
|
165
|
+
throw new TypeError("referralLeaderboard: period_label must be a non-empty string");
|
|
166
|
+
}
|
|
167
|
+
if (s.length > MAX_PERIOD_LABEL_LEN) {
|
|
168
|
+
throw new TypeError("referralLeaderboard: period_label must be <= " + MAX_PERIOD_LABEL_LEN + " characters");
|
|
169
|
+
}
|
|
170
|
+
if (!PERIOD_LABEL_RE.test(s)) {
|
|
171
|
+
throw new TypeError("referralLeaderboard: period_label must match /^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$/");
|
|
172
|
+
}
|
|
173
|
+
return s;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _tier(s) {
|
|
177
|
+
if (typeof s !== "string" || TIERS.indexOf(s) === -1) {
|
|
178
|
+
throw new TypeError("referralLeaderboard: tier must be one of " + TIERS.join(", "));
|
|
179
|
+
}
|
|
180
|
+
return s;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function _validateThresholds(t) {
|
|
184
|
+
if (!t || typeof t !== "object") {
|
|
185
|
+
throw new TypeError("referralLeaderboard: thresholds object required");
|
|
186
|
+
}
|
|
187
|
+
for (var i = 0; i < TIERS.length; i += 1) {
|
|
188
|
+
var k = TIERS[i];
|
|
189
|
+
if (!Object.prototype.hasOwnProperty.call(t, k)) {
|
|
190
|
+
throw new TypeError("referralLeaderboard: thresholds." + k + " missing");
|
|
191
|
+
}
|
|
192
|
+
_nonNegInt(t[k], "thresholds." + k);
|
|
193
|
+
}
|
|
194
|
+
// Monotonicity — silver >= bronze, gold >= silver, platinum >= gold.
|
|
195
|
+
// Equal is permitted (the operator may collapse tiers); strictly-
|
|
196
|
+
// decreasing is refused since the tier-resolver assumes the array
|
|
197
|
+
// is sorted by threshold ASC.
|
|
198
|
+
if (t.silver < t.bronze || t.gold < t.silver || t.platinum < t.gold) {
|
|
199
|
+
throw new TypeError("referralLeaderboard: thresholds must be non-decreasing (bronze <= silver <= gold <= platinum)");
|
|
200
|
+
}
|
|
201
|
+
return { bronze: t.bronze, silver: t.silver, gold: t.gold, platinum: t.platinum };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _computeTierFromCount(count, thresholds) {
|
|
205
|
+
// Walk tiers from highest to lowest; first whose threshold the
|
|
206
|
+
// count meets or exceeds wins. Bronze is the floor (threshold 0
|
|
207
|
+
// by default), so the loop always terminates with a placement.
|
|
208
|
+
if (count >= thresholds.platinum) return "platinum";
|
|
209
|
+
if (count >= thresholds.gold) return "gold";
|
|
210
|
+
if (count >= thresholds.silver) return "silver";
|
|
211
|
+
return "bronze";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- factory -----------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
function create(opts) {
|
|
217
|
+
opts = opts || {};
|
|
218
|
+
var query = opts.query;
|
|
219
|
+
if (!query) {
|
|
220
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// referrals is optional — when wired, topReferrers surfaces
|
|
224
|
+
// `lifetime_revenue` via `referrals.revenueForCustomer(customer_id)`.
|
|
225
|
+
// Absent the hook (or the handle entirely), the field falls back to 0
|
|
226
|
+
// so the leaderboard surface stays usable on operator setups that
|
|
227
|
+
// haven't wired revenue tracking yet.
|
|
228
|
+
var referralsHandle = opts.referrals || null;
|
|
229
|
+
if (referralsHandle
|
|
230
|
+
&& typeof referralsHandle !== "object") {
|
|
231
|
+
throw new TypeError("referralLeaderboard.create: opts.referrals must be an object");
|
|
232
|
+
}
|
|
233
|
+
if (referralsHandle
|
|
234
|
+
&& typeof referralsHandle.revenueForCustomer !== "function"
|
|
235
|
+
&& referralsHandle.revenueForCustomer !== undefined) {
|
|
236
|
+
throw new TypeError("referralLeaderboard.create: opts.referrals.revenueForCustomer must be a function when provided");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// loyalty is optional — when wired, awardLeaderboardBonus composes
|
|
240
|
+
// `loyalty.earn(...)` for the configured tier bonus. Absent, the
|
|
241
|
+
// bonus log row still lands; the operator pays out via their own
|
|
242
|
+
// channel (gift card / discount / store credit).
|
|
243
|
+
var loyaltyHandle = opts.loyalty || null;
|
|
244
|
+
if (loyaltyHandle && typeof loyaltyHandle.earn !== "function") {
|
|
245
|
+
throw new TypeError("referralLeaderboard.create: opts.loyalty must expose an earn(input) method");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Tier bonus point values are operator-tunable per factory; the
|
|
249
|
+
// defaults match the loyalty primitive's silver/gold/platinum
|
|
250
|
+
// promotion thresholds so a tier-bonus payout matches the
|
|
251
|
+
// loyalty-tier promotion threshold by default.
|
|
252
|
+
var tierBonusPoints = Object.assign({}, DEFAULT_TIER_BONUS_POINTS);
|
|
253
|
+
if (opts.tierBonusPoints && typeof opts.tierBonusPoints === "object") {
|
|
254
|
+
for (var bi = 0; bi < TIERS.length; bi += 1) {
|
|
255
|
+
var bk = TIERS[bi];
|
|
256
|
+
if (Object.prototype.hasOwnProperty.call(opts.tierBonusPoints, bk)) {
|
|
257
|
+
_nonNegInt(opts.tierBonusPoints[bk], "tierBonusPoints." + bk);
|
|
258
|
+
tierBonusPoints[bk] = opts.tierBonusPoints[bk];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function _readThresholds() {
|
|
264
|
+
var r = await query(
|
|
265
|
+
"SELECT bronze_threshold, silver_threshold, gold_threshold, platinum_threshold " +
|
|
266
|
+
"FROM referral_tier_config WHERE id = 1",
|
|
267
|
+
[],
|
|
268
|
+
);
|
|
269
|
+
if (!r.rows.length) {
|
|
270
|
+
return Object.assign({}, DEFAULT_THRESHOLDS);
|
|
271
|
+
}
|
|
272
|
+
var row = r.rows[0];
|
|
273
|
+
return {
|
|
274
|
+
bronze: Number(row.bronze_threshold),
|
|
275
|
+
silver: Number(row.silver_threshold),
|
|
276
|
+
gold: Number(row.gold_threshold),
|
|
277
|
+
platinum: Number(row.platinum_threshold),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Window-bounded completed-referral counts joined across referral
|
|
282
|
+
// codes + invitations. `from` and `to` are inclusive on
|
|
283
|
+
// `first_purchase_at` (the funnel-complete timestamp from the
|
|
284
|
+
// referrals primitive). Returns one row per referrer with non-zero
|
|
285
|
+
// completions in the window.
|
|
286
|
+
async function _completedCountsInWindow(from, to, limit) {
|
|
287
|
+
var r = await query(
|
|
288
|
+
"SELECT c.referrer_customer_id AS referrer_customer_id, " +
|
|
289
|
+
"COUNT(i.id) AS completed_referrals " +
|
|
290
|
+
"FROM referral_invitations i " +
|
|
291
|
+
"JOIN referral_codes c ON c.id = i.referral_code_id " +
|
|
292
|
+
"WHERE i.first_purchase_at IS NOT NULL " +
|
|
293
|
+
"AND i.first_purchase_at >= ?1 AND i.first_purchase_at <= ?2 " +
|
|
294
|
+
"GROUP BY c.referrer_customer_id " +
|
|
295
|
+
"HAVING COUNT(i.id) > 0 " +
|
|
296
|
+
"ORDER BY completed_referrals DESC, c.referrer_customer_id ASC " +
|
|
297
|
+
"LIMIT ?3",
|
|
298
|
+
[from, to, limit],
|
|
299
|
+
);
|
|
300
|
+
return r.rows.map(function (row) {
|
|
301
|
+
return {
|
|
302
|
+
referrer_customer_id: row.referrer_customer_id,
|
|
303
|
+
completed_referrals: Number(row.completed_referrals || 0),
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function _completedCountForCustomerSince(customerId, sinceTs, untilTs) {
|
|
309
|
+
var r = await query(
|
|
310
|
+
"SELECT COUNT(i.id) AS n FROM referral_invitations i " +
|
|
311
|
+
"JOIN referral_codes c ON c.id = i.referral_code_id " +
|
|
312
|
+
"WHERE c.referrer_customer_id = ?1 " +
|
|
313
|
+
"AND i.first_purchase_at IS NOT NULL " +
|
|
314
|
+
"AND i.first_purchase_at >= ?2 AND i.first_purchase_at <= ?3",
|
|
315
|
+
[customerId, sinceTs, untilTs],
|
|
316
|
+
);
|
|
317
|
+
return Number((r.rows[0] && r.rows[0].n) || 0);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function _lifetimeRevenueFor(customerId) {
|
|
321
|
+
if (!referralsHandle || typeof referralsHandle.revenueForCustomer !== "function") {
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
var v = await referralsHandle.revenueForCustomer(customerId);
|
|
325
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) return 0;
|
|
326
|
+
return v;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// UTC month window. `month` is 1-based to match operator intuition
|
|
330
|
+
// (2026-05 -> month = 5); the Date constructor's 0-based month is
|
|
331
|
+
// an implementation detail of this primitive.
|
|
332
|
+
function _monthWindow(year, month) {
|
|
333
|
+
if (!Number.isInteger(year) || year < 1970 || year > 9999) {
|
|
334
|
+
throw new TypeError("referralLeaderboard: year must be an integer in [1970, 9999]");
|
|
335
|
+
}
|
|
336
|
+
if (!Number.isInteger(month) || month < 1 || month > 12) {
|
|
337
|
+
throw new TypeError("referralLeaderboard: month must be an integer in [1, 12]");
|
|
338
|
+
}
|
|
339
|
+
var from = Date.UTC(year, month - 1, 1, 0, 0, 0, 0);
|
|
340
|
+
// First instant of the next month, minus 1ms = last instant of
|
|
341
|
+
// the requested month.
|
|
342
|
+
var to = Date.UTC(year, month, 1, 0, 0, 0, 0) - 1;
|
|
343
|
+
return { from: from, to: to };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
TIERS: TIERS.slice(),
|
|
348
|
+
DEFAULT_THRESHOLDS: Object.assign({}, DEFAULT_THRESHOLDS),
|
|
349
|
+
DEFAULT_TIER_BONUS_POINTS: Object.assign({}, DEFAULT_TIER_BONUS_POINTS),
|
|
350
|
+
ROLLING_WINDOW_DAYS: ROLLING_WINDOW_DAYS,
|
|
351
|
+
BONUS_LOG_SOURCE: BONUS_LOG_SOURCE,
|
|
352
|
+
|
|
353
|
+
topReferrers: async function (input) {
|
|
354
|
+
if (!input || typeof input !== "object") {
|
|
355
|
+
throw new TypeError("referralLeaderboard.topReferrers: input object required");
|
|
356
|
+
}
|
|
357
|
+
_timestamp(input.from, "from");
|
|
358
|
+
_timestamp(input.to, "to");
|
|
359
|
+
if (input.from > input.to) {
|
|
360
|
+
throw new TypeError("referralLeaderboard.topReferrers: from must be <= to");
|
|
361
|
+
}
|
|
362
|
+
var limit = input.limit == null ? DEFAULT_LEADERBOARD_LIMIT : input.limit;
|
|
363
|
+
_positiveInt(limit, "limit", MAX_LEADERBOARD_LIMIT);
|
|
364
|
+
|
|
365
|
+
var rows = await _completedCountsInWindow(input.from, input.to, limit);
|
|
366
|
+
var out = [];
|
|
367
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
368
|
+
var revenue = await _lifetimeRevenueFor(rows[i].referrer_customer_id);
|
|
369
|
+
out.push({
|
|
370
|
+
referrer_customer_id: rows[i].referrer_customer_id,
|
|
371
|
+
completed_referrals: rows[i].completed_referrals,
|
|
372
|
+
lifetime_revenue: revenue,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
referralTier: async function (input) {
|
|
379
|
+
if (!input || typeof input !== "object") {
|
|
380
|
+
throw new TypeError("referralLeaderboard.referralTier: input object required");
|
|
381
|
+
}
|
|
382
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
383
|
+
var asOf = input.as_of == null ? _now() : _timestamp(input.as_of, "as_of");
|
|
384
|
+
// The rolling-90-day window — `since` is asOf minus the window
|
|
385
|
+
// length. A referral whose `first_purchase_at` lands exactly on
|
|
386
|
+
// `since` still counts; the gate is inclusive on both ends.
|
|
387
|
+
var since = asOf - (ROLLING_WINDOW_DAYS * MS_PER_DAY);
|
|
388
|
+
if (since < 0) since = 0;
|
|
389
|
+
var thresholds = await _readThresholds();
|
|
390
|
+
var count = await _completedCountForCustomerSince(customerId, since, asOf);
|
|
391
|
+
var tier = _computeTierFromCount(count, thresholds);
|
|
392
|
+
return {
|
|
393
|
+
customer_id: customerId,
|
|
394
|
+
tier: tier,
|
|
395
|
+
rolling_completed_referrals: count,
|
|
396
|
+
rolling_window_days: ROLLING_WINDOW_DAYS,
|
|
397
|
+
as_of: asOf,
|
|
398
|
+
thresholds: thresholds,
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
tierThresholds: async function () {
|
|
403
|
+
return await _readThresholds();
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
setTierThresholds: async function (input) {
|
|
407
|
+
var thresholds = _validateThresholds(input);
|
|
408
|
+
var ts = _now();
|
|
409
|
+
// Singleton upsert: id = 1 always. node:sqlite + d1 both honor
|
|
410
|
+
// INSERT OR REPLACE here (PK = 1 collapses to a single row).
|
|
411
|
+
await query(
|
|
412
|
+
"INSERT INTO referral_tier_config " +
|
|
413
|
+
"(id, bronze_threshold, silver_threshold, gold_threshold, platinum_threshold, updated_at) " +
|
|
414
|
+
"VALUES (1, ?1, ?2, ?3, ?4, ?5) " +
|
|
415
|
+
"ON CONFLICT(id) DO UPDATE SET " +
|
|
416
|
+
"bronze_threshold = excluded.bronze_threshold, " +
|
|
417
|
+
"silver_threshold = excluded.silver_threshold, " +
|
|
418
|
+
"gold_threshold = excluded.gold_threshold, " +
|
|
419
|
+
"platinum_threshold = excluded.platinum_threshold, " +
|
|
420
|
+
"updated_at = excluded.updated_at",
|
|
421
|
+
[thresholds.bronze, thresholds.silver, thresholds.gold, thresholds.platinum, ts],
|
|
422
|
+
);
|
|
423
|
+
return Object.assign({}, thresholds, { updated_at: ts });
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
// Composes loyalty.earn (when wired) for the tier's configured
|
|
427
|
+
// bonus, then logs the payout to `referral_leaderboard_bonuses`.
|
|
428
|
+
// UNIQUE on (customer_id, period_label, tier) is the re-award
|
|
429
|
+
// gate — a second call with the same triple returns
|
|
430
|
+
// `{ status: "already-awarded" }` and NEVER re-invokes loyalty.
|
|
431
|
+
//
|
|
432
|
+
// FSM order: the bonus log row is inserted FIRST (so two
|
|
433
|
+
// concurrent callers race on the UNIQUE constraint, not on
|
|
434
|
+
// loyalty.earn). Only the winning insert proceeds to the
|
|
435
|
+
// loyalty.earn call. On loyalty.earn failure, the bonus row is
|
|
436
|
+
// rolled back via DELETE so a retry can re-attempt the full
|
|
437
|
+
// composition — without rollback, a wedged loyalty primitive
|
|
438
|
+
// would permanently lock out the customer from this tier's
|
|
439
|
+
// award.
|
|
440
|
+
awardLeaderboardBonus: async function (input) {
|
|
441
|
+
if (!input || typeof input !== "object") {
|
|
442
|
+
throw new TypeError("referralLeaderboard.awardLeaderboardBonus: input object required");
|
|
443
|
+
}
|
|
444
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
445
|
+
var tier = _tier(input.tier);
|
|
446
|
+
var periodLabel = _periodLabel(input.period_label);
|
|
447
|
+
var pointsBudget = tierBonusPoints[tier];
|
|
448
|
+
|
|
449
|
+
// Existing-row probe (idempotency short-circuit). The UNIQUE
|
|
450
|
+
// constraint is still the source of truth — this is the read-
|
|
451
|
+
// side win for the common "second call is a duplicate" case so
|
|
452
|
+
// we don't allocate a row id + risk a no-op INSERT.
|
|
453
|
+
var existing = await query(
|
|
454
|
+
"SELECT id, customer_id, tier, period_label, points_awarded, occurred_at " +
|
|
455
|
+
"FROM referral_leaderboard_bonuses " +
|
|
456
|
+
"WHERE customer_id = ?1 AND period_label = ?2 AND tier = ?3 LIMIT 1",
|
|
457
|
+
[customerId, periodLabel, tier],
|
|
458
|
+
);
|
|
459
|
+
if (existing.rows.length) {
|
|
460
|
+
var row = existing.rows[0];
|
|
461
|
+
return {
|
|
462
|
+
id: row.id,
|
|
463
|
+
customer_id: row.customer_id,
|
|
464
|
+
tier: row.tier,
|
|
465
|
+
period_label: row.period_label,
|
|
466
|
+
points_awarded: Number(row.points_awarded || 0),
|
|
467
|
+
occurred_at: Number(row.occurred_at || 0),
|
|
468
|
+
status: "already-awarded",
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
var id = _b().uuid.v7();
|
|
473
|
+
var ts = _now();
|
|
474
|
+
try {
|
|
475
|
+
await query(
|
|
476
|
+
"INSERT INTO referral_leaderboard_bonuses " +
|
|
477
|
+
"(id, customer_id, tier, period_label, points_awarded, occurred_at) " +
|
|
478
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
479
|
+
[id, customerId, tier, periodLabel, pointsBudget, ts],
|
|
480
|
+
);
|
|
481
|
+
} catch (e) {
|
|
482
|
+
if (e && e.message && e.message.indexOf("UNIQUE") !== -1) {
|
|
483
|
+
// Lost the race to a concurrent caller — return their row.
|
|
484
|
+
var raced = await query(
|
|
485
|
+
"SELECT id, customer_id, tier, period_label, points_awarded, occurred_at " +
|
|
486
|
+
"FROM referral_leaderboard_bonuses " +
|
|
487
|
+
"WHERE customer_id = ?1 AND period_label = ?2 AND tier = ?3 LIMIT 1",
|
|
488
|
+
[customerId, periodLabel, tier],
|
|
489
|
+
);
|
|
490
|
+
if (raced.rows.length) {
|
|
491
|
+
var rr = raced.rows[0];
|
|
492
|
+
return {
|
|
493
|
+
id: rr.id,
|
|
494
|
+
customer_id: rr.customer_id,
|
|
495
|
+
tier: rr.tier,
|
|
496
|
+
period_label: rr.period_label,
|
|
497
|
+
points_awarded: Number(rr.points_awarded || 0),
|
|
498
|
+
occurred_at: Number(rr.occurred_at || 0),
|
|
499
|
+
status: "already-awarded",
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
throw e;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Compose loyalty.earn for the points budget. On failure, roll
|
|
507
|
+
// back the bonus row so a retry can re-attempt cleanly.
|
|
508
|
+
if (loyaltyHandle && pointsBudget > 0) {
|
|
509
|
+
try {
|
|
510
|
+
await loyaltyHandle.earn({
|
|
511
|
+
customer_id: customerId,
|
|
512
|
+
points: pointsBudget,
|
|
513
|
+
source: BONUS_LOG_SOURCE,
|
|
514
|
+
notes: "tier=" + tier + " period=" + periodLabel,
|
|
515
|
+
});
|
|
516
|
+
} catch (e) {
|
|
517
|
+
await query(
|
|
518
|
+
"DELETE FROM referral_leaderboard_bonuses WHERE id = ?1",
|
|
519
|
+
[id],
|
|
520
|
+
);
|
|
521
|
+
throw e;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
id: id,
|
|
527
|
+
customer_id: customerId,
|
|
528
|
+
tier: tier,
|
|
529
|
+
period_label: periodLabel,
|
|
530
|
+
points_awarded: pointsBudget,
|
|
531
|
+
occurred_at: ts,
|
|
532
|
+
status: "awarded",
|
|
533
|
+
};
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
historyForCustomer: async function (input) {
|
|
537
|
+
if (!input || typeof input !== "object") {
|
|
538
|
+
throw new TypeError("referralLeaderboard.historyForCustomer: input object required");
|
|
539
|
+
}
|
|
540
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
541
|
+
var r = await query(
|
|
542
|
+
"SELECT id, customer_id, tier, period_label, points_awarded, occurred_at " +
|
|
543
|
+
"FROM referral_leaderboard_bonuses " +
|
|
544
|
+
"WHERE customer_id = ?1 " +
|
|
545
|
+
"ORDER BY occurred_at DESC, id DESC",
|
|
546
|
+
[customerId],
|
|
547
|
+
);
|
|
548
|
+
return r.rows.map(function (row) {
|
|
549
|
+
return {
|
|
550
|
+
id: row.id,
|
|
551
|
+
customer_id: row.customer_id,
|
|
552
|
+
tier: row.tier,
|
|
553
|
+
period_label: row.period_label,
|
|
554
|
+
points_awarded: Number(row.points_awarded || 0),
|
|
555
|
+
occurred_at: Number(row.occurred_at || 0),
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
monthlyChampions: async function (input) {
|
|
561
|
+
if (!input || typeof input !== "object") {
|
|
562
|
+
throw new TypeError("referralLeaderboard.monthlyChampions: input object required");
|
|
563
|
+
}
|
|
564
|
+
var win = _monthWindow(input.year, input.month);
|
|
565
|
+
var limit = input.limit == null ? DEFAULT_LEADERBOARD_LIMIT : input.limit;
|
|
566
|
+
_positiveInt(limit, "limit", MAX_LEADERBOARD_LIMIT);
|
|
567
|
+
var rows = await _completedCountsInWindow(win.from, win.to, limit);
|
|
568
|
+
var out = [];
|
|
569
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
570
|
+
var revenue = await _lifetimeRevenueFor(rows[i].referrer_customer_id);
|
|
571
|
+
out.push({
|
|
572
|
+
referrer_customer_id: rows[i].referrer_customer_id,
|
|
573
|
+
completed_referrals: rows[i].completed_referrals,
|
|
574
|
+
lifetime_revenue: revenue,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
year: input.year,
|
|
579
|
+
month: input.month,
|
|
580
|
+
from: win.from,
|
|
581
|
+
to: win.to,
|
|
582
|
+
champions: out,
|
|
583
|
+
};
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
// Retention sweep on the audit-log table — the loyalty side is
|
|
587
|
+
// untouched (points already landed in the customer's account).
|
|
588
|
+
// This deletes bonus log rows older than `days` days; the
|
|
589
|
+
// operator drives the sweep cadence.
|
|
590
|
+
cleanupExpiredBonuses: async function (days) {
|
|
591
|
+
_positiveInt(days, "days");
|
|
592
|
+
var cutoff = _now() - (days * MS_PER_DAY);
|
|
593
|
+
var r = await query(
|
|
594
|
+
"DELETE FROM referral_leaderboard_bonuses WHERE occurred_at < ?1",
|
|
595
|
+
[cutoff],
|
|
596
|
+
);
|
|
597
|
+
return {
|
|
598
|
+
deleted_count: Number(r.rowCount || 0),
|
|
599
|
+
cutoff_at: cutoff,
|
|
600
|
+
};
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
module.exports = {
|
|
606
|
+
create: create,
|
|
607
|
+
TIERS: TIERS,
|
|
608
|
+
DEFAULT_THRESHOLDS: DEFAULT_THRESHOLDS,
|
|
609
|
+
DEFAULT_TIER_BONUS_POINTS: DEFAULT_TIER_BONUS_POINTS,
|
|
610
|
+
ROLLING_WINDOW_DAYS: ROLLING_WINDOW_DAYS,
|
|
611
|
+
BONUS_LOG_SOURCE: BONUS_LOG_SOURCE,
|
|
612
|
+
};
|