@blamejs/blamejs-shop 0.0.53 → 0.0.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.
@@ -0,0 +1,649 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.referrals
4
+ * @title Referrals primitive — refer-a-friend with two-sided rewards
5
+ *
6
+ * @intro
7
+ * A referrer (an existing customer) issues an 8-character code via
8
+ * `issueCode`. The shop generates the code from a confusion-
9
+ * resistant alphabet (no 0/O/I/1 ambiguities) over
10
+ * `b.crypto.generateBytes` — 256 % 32 === 0 so each byte modulo-32
11
+ * lands on a uniform alphabet index with no rejection sampling
12
+ * needed. The plaintext code is the public handle; lookup is case-
13
+ * insensitive, storage is uppercase.
14
+ *
15
+ * One active code per referrer is enforced — rotating means
16
+ * disabling the existing row before a new one issues. Historical
17
+ * invitations still resolve to their original code so the audit
18
+ * trail stays intact across rotations.
19
+ *
20
+ * The referred-friend's email is NEVER persisted plaintext. Only
21
+ * `b.crypto.namespaceHash("referral-referee", normalized_email)`
22
+ * reaches storage. Dedupe is on (code_id, email_hash) so the same
23
+ * friend invited twice by the same referrer collapses to a single
24
+ * invitation row.
25
+ *
26
+ * Funnel transitions:
27
+ * issueCode -> referral_codes row, status="active"
28
+ * invite -> invitation row, reward_status="pending"
29
+ * trackVisit -> visited_at populated
30
+ * trackSignup -> signed_up_at + signed_up_customer_id populated
31
+ * trackPurchase -> first_purchase_at + first_order_id populated,
32
+ * reward_status -> "both-rewarded",
33
+ * referrals_count bumped on the code row
34
+ * rewardReferrer / rewardReferee -> records the operator's chosen
35
+ * reward instrument id (gift card / discount /
36
+ * credit) for ledger reconciliation
37
+ *
38
+ * The reward instrument itself (gift card, discount code, ledger
39
+ * credit) is a separate primitive — this surface only records the
40
+ * id the operator handed out so a future audit can join across
41
+ * tables.
42
+ *
43
+ * Composition:
44
+ * var refs = bShop.referrals.create({ query: q });
45
+ * var { code, link } = await refs.issueCode({
46
+ * referrer_customer_id: customerId,
47
+ * });
48
+ * // referrer shares `link` (e.g. https://blamejs.shop/?ref=<code>)
49
+ * await refs.invite({ code: code, referee_email: "alice@example.com" });
50
+ * await refs.trackVisit({ code: code });
51
+ * await refs.trackSignup({ code: code, customer_id: newCustomerId });
52
+ * await refs.trackPurchase({ customer_id: newCustomerId, order_id: orderId });
53
+ * await refs.rewardReferrer(invitationId, { reward_id: giftcardId });
54
+ * await refs.rewardReferee(invitationId, { reward_id: giftcardId });
55
+ */
56
+
57
+ var bShop;
58
+ function _b() {
59
+ if (!bShop) bShop = require("./index");
60
+ return bShop.framework;
61
+ }
62
+
63
+ var REFEREE_NAMESPACE = "referral-referee";
64
+
65
+ // Alphabet excludes 0/O/I/1 so a code spoken aloud / shared as a URL
66
+ // fragment doesn't collapse into ambiguous characters. 32 glyphs
67
+ // means each byte modulo-32 lands on a uniform draw (256 % 32 === 0
68
+ // — no modulo-bias correction needed).
69
+ var DEFAULT_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
70
+ var CODE_LEN = 8;
71
+
72
+ var DEFAULT_LINK_BASE = "https://blamejs.shop/?ref=";
73
+
74
+ var CODE_STATUSES = ["active", "disabled"];
75
+ var REWARD_STATUSES = ["pending", "referrer-rewarded", "both-rewarded", "expired", "voided"];
76
+
77
+ // ---- validators ---------------------------------------------------------
78
+
79
+ function _uuid(s, label) {
80
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
81
+ catch (e) { throw new TypeError("referrals: " + label + " — " + (e && e.message || "invalid UUID")); }
82
+ }
83
+
84
+ function _normalizeEmail(input) {
85
+ if (typeof input !== "string" || !input.length) {
86
+ throw new TypeError("referrals: referee_email must be a non-empty string");
87
+ }
88
+ var guardEmail = _b().guardEmail;
89
+ var report;
90
+ try {
91
+ report = guardEmail.validate(input, { profile: "strict" });
92
+ } catch (e) {
93
+ throw new TypeError("referrals: referee_email — " + (e && e.message || "invalid email"));
94
+ }
95
+ if (!report || report.ok === false) {
96
+ var first = (report && report.issues && report.issues[0]) || {};
97
+ throw new TypeError("referrals: referee_email — " + (first.snippet || first.ruleId || "refused at strict profile"));
98
+ }
99
+ var canonical;
100
+ try {
101
+ canonical = guardEmail.sanitize(input, { profile: "strict" });
102
+ } catch (e) {
103
+ throw new TypeError("referrals: referee_email — " + (e && e.message || "refused"));
104
+ }
105
+ // Full lowercase before hashing. RFC 5321 leaves local-part case
106
+ // sensitive, but referral dedupe is about "did this human already
107
+ // get invited via this code"; treating `FRIEND@example.com` and
108
+ // `friend@example.com` as the same recipient matches operator
109
+ // intuition. Aligns with the newsletter primitive's posture.
110
+ return canonical.toLowerCase();
111
+ }
112
+
113
+ function _now() { return Date.now(); }
114
+
115
+ // ---- code generation + canonicalization ---------------------------------
116
+
117
+ function _makeCodeGenerator(alphabet) {
118
+ if (typeof alphabet !== "string" || alphabet.length !== 32) {
119
+ throw new TypeError("referrals: codeAlphabet must be exactly 32 characters (uniform-draw over a random byte)");
120
+ }
121
+ // Guard against operator-supplied alphabets that contain duplicates
122
+ // (which would skew the draw) or lowercase letters (storage is
123
+ // uppercase; lowercase chars would never match a lookup).
124
+ var seen = {};
125
+ for (var i = 0; i < alphabet.length; i += 1) {
126
+ var ch = alphabet.charAt(i);
127
+ if (seen[ch]) {
128
+ throw new TypeError("referrals: codeAlphabet must not contain duplicate characters");
129
+ }
130
+ if (ch !== ch.toUpperCase()) {
131
+ throw new TypeError("referrals: codeAlphabet must be uppercase (codes are stored uppercase)");
132
+ }
133
+ seen[ch] = true;
134
+ }
135
+ return function () {
136
+ var buf = _b().crypto.generateBytes(CODE_LEN);
137
+ var out = "";
138
+ for (var j = 0; j < CODE_LEN; j += 1) {
139
+ out += alphabet.charAt(buf[j] & 31);
140
+ }
141
+ return out;
142
+ };
143
+ }
144
+
145
+ // Canonicalize an operator-supplied lookup string into the storage
146
+ // form. Strips ASCII whitespace + hyphens (forgiving for hand-typed
147
+ // codes pasted from email), uppercases, refuses control bytes /
148
+ // unicode whitespace / non-alphabet glyphs.
149
+ function _canonicalCode(input, alphabetRe) {
150
+ if (typeof input !== "string" || !input.length) {
151
+ throw new TypeError("referrals: code must be a non-empty string");
152
+ }
153
+ var stripped = input.replace(/[-\s]+/g, "").toUpperCase();
154
+ if (stripped.length !== CODE_LEN) {
155
+ throw new TypeError("referrals: code must be " + CODE_LEN + " alphabet characters");
156
+ }
157
+ if (!alphabetRe.test(stripped)) {
158
+ throw new TypeError("referrals: code contains characters outside the referral alphabet");
159
+ }
160
+ return stripped;
161
+ }
162
+
163
+ function _alphabetRe(alphabet) {
164
+ // Escape any regex-special characters from the alphabet — the
165
+ // default alphabet has none, but an operator-supplied 32-char set
166
+ // could legally include `]` or `-`.
167
+ var escaped = alphabet.replace(/[\\\]^-]/g, "\\$&");
168
+ return new RegExp("^[" + escaped + "]+$");
169
+ }
170
+
171
+ // ---- factory ------------------------------------------------------------
172
+
173
+ function create(opts) {
174
+ opts = opts || {};
175
+ var query = opts.query;
176
+ if (!query) {
177
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
178
+ }
179
+ var alphabet = opts.codeAlphabet || DEFAULT_ALPHABET;
180
+ var generateCode = _makeCodeGenerator(alphabet);
181
+ var alphabetRe = _alphabetRe(alphabet);
182
+ var linkBase = opts.linkBase || DEFAULT_LINK_BASE;
183
+ if (typeof linkBase !== "string" || !linkBase.length) {
184
+ throw new TypeError("referrals: linkBase must be a non-empty string");
185
+ }
186
+
187
+ // Idempotent invitation upsert. Two callers (the referrer and a
188
+ // retry) sending the same (code, referee_email) collapse to a
189
+ // single row; the returned id is the existing one on the second
190
+ // call.
191
+ async function _findInvitationByCodeAndEmail(codeId, emailHash) {
192
+ var r = await query(
193
+ "SELECT id, reward_status FROM referral_invitations " +
194
+ "WHERE referral_code_id = ?1 AND referred_email_hash = ?2 LIMIT 1",
195
+ [codeId, emailHash],
196
+ );
197
+ return r.rows[0] || null;
198
+ }
199
+
200
+ async function _activeCodeByReferrer(referrerId) {
201
+ var r = await query(
202
+ "SELECT id, code, status FROM referral_codes " +
203
+ "WHERE referrer_customer_id = ?1 AND status = 'active' LIMIT 1",
204
+ [referrerId],
205
+ );
206
+ return r.rows[0] || null;
207
+ }
208
+
209
+ async function _codeRowByCode(code) {
210
+ var canonical = _canonicalCode(code, alphabetRe);
211
+ var r = await query(
212
+ "SELECT id, referrer_customer_id, code, status, referrals_count, created_at, updated_at " +
213
+ "FROM referral_codes WHERE code = ?1 LIMIT 1",
214
+ [canonical],
215
+ );
216
+ return r.rows[0] || null;
217
+ }
218
+
219
+ function _formatLink(code) {
220
+ return linkBase + code;
221
+ }
222
+
223
+ return {
224
+ REFEREE_NAMESPACE: REFEREE_NAMESPACE,
225
+ CODE_ALPHABET: alphabet,
226
+ CODE_LEN: CODE_LEN,
227
+ CODE_STATUSES: CODE_STATUSES,
228
+ REWARD_STATUSES: REWARD_STATUSES,
229
+
230
+ issueCode: async function (input) {
231
+ if (!input || typeof input !== "object") {
232
+ throw new TypeError("referrals.issueCode: input object required");
233
+ }
234
+ var referrerId = _uuid(input.referrer_customer_id, "referrer_customer_id");
235
+ // One active code per customer. Rotating means the operator
236
+ // calls `disableCode` first; the audit trail of historical
237
+ // invitations is preserved against the original row.
238
+ var existing = await _activeCodeByReferrer(referrerId);
239
+ if (existing) {
240
+ var err = new Error("referrals.issueCode: referrer already has an active code");
241
+ err.code = "REFERRAL_CODE_ALREADY_ACTIVE";
242
+ throw err;
243
+ }
244
+ var id = _b().uuid.v7();
245
+ // Generation collisions on an 8-char/32-alphabet space are
246
+ // ~32^8 = 2^40 — vanishingly unlikely, but the UNIQUE constraint
247
+ // on `code` is the safety net. Retry on collision up to a small
248
+ // bound; any caller that exhausts it surfaces the underlying
249
+ // error rather than spinning indefinitely.
250
+ var ts = _now();
251
+ var code;
252
+ var attempts = 0;
253
+ var lastErr;
254
+ while (attempts < 5) {
255
+ attempts += 1;
256
+ code = generateCode();
257
+ try {
258
+ await query(
259
+ "INSERT INTO referral_codes (id, referrer_customer_id, code, status, referrals_count, created_at, updated_at) " +
260
+ "VALUES (?1, ?2, ?3, 'active', 0, ?4, ?4)",
261
+ [id, referrerId, code, ts],
262
+ );
263
+ lastErr = null;
264
+ break;
265
+ } catch (e) {
266
+ lastErr = e;
267
+ // UNIQUE-violation: regenerate. Any other error propagates.
268
+ if (!e || !e.message || e.message.indexOf("UNIQUE") === -1) {
269
+ throw e;
270
+ }
271
+ }
272
+ }
273
+ if (lastErr) throw lastErr;
274
+ return {
275
+ id: id,
276
+ code: code,
277
+ link: _formatLink(code),
278
+ };
279
+ },
280
+
281
+ // Operator-side invitation tracking. Stores only the email hash;
282
+ // raw address never reaches storage. Idempotent on (code, email).
283
+ invite: async function (input) {
284
+ if (!input || typeof input !== "object") {
285
+ throw new TypeError("referrals.invite: input object required");
286
+ }
287
+ var row = await _codeRowByCode(input.code);
288
+ if (!row) {
289
+ var miss = new Error("referrals.invite: code not recognized");
290
+ miss.code = "REFERRAL_CODE_NOT_FOUND";
291
+ throw miss;
292
+ }
293
+ if (row.status !== "active") {
294
+ var inactive = new Error("referrals.invite: code is " + row.status);
295
+ inactive.code = "REFERRAL_CODE_NOT_ACTIVE";
296
+ throw inactive;
297
+ }
298
+ var normalized = _normalizeEmail(input.referee_email);
299
+ var emailHash = _b().crypto.namespaceHash(REFEREE_NAMESPACE, normalized);
300
+ var existing = await _findInvitationByCodeAndEmail(row.id, emailHash);
301
+ if (existing) {
302
+ return {
303
+ id: existing.id,
304
+ referral_code_id: row.id,
305
+ referred_email_hash: emailHash,
306
+ status: "dedup",
307
+ };
308
+ }
309
+ var id = _b().uuid.v7();
310
+ var ts = _now();
311
+ await query(
312
+ "INSERT INTO referral_invitations " +
313
+ "(id, referral_code_id, referred_email_hash, invited_at, reward_status) " +
314
+ "VALUES (?1, ?2, ?3, ?4, 'pending')",
315
+ [id, row.id, emailHash, ts],
316
+ );
317
+ return {
318
+ id: id,
319
+ referral_code_id: row.id,
320
+ referred_email_hash: emailHash,
321
+ status: "new",
322
+ };
323
+ },
324
+
325
+ // Recorded when someone lands with `?ref=<code>`. Bumps every
326
+ // pending invitation on this code so the operator's funnel-stat
327
+ // queries reflect the visit. Idempotent: re-running keeps the
328
+ // first `visited_at` (we never overwrite a populated column).
329
+ trackVisit: async function (input) {
330
+ if (!input || typeof input !== "object") {
331
+ throw new TypeError("referrals.trackVisit: input object required");
332
+ }
333
+ var row = await _codeRowByCode(input.code);
334
+ if (!row) {
335
+ var miss = new Error("referrals.trackVisit: code not recognized");
336
+ miss.code = "REFERRAL_CODE_NOT_FOUND";
337
+ throw miss;
338
+ }
339
+ var ts = _now();
340
+ var r = await query(
341
+ "UPDATE referral_invitations SET visited_at = ?1 " +
342
+ "WHERE referral_code_id = ?2 AND visited_at IS NULL",
343
+ [ts, row.id],
344
+ );
345
+ return {
346
+ referral_code_id: row.id,
347
+ updated: Number(r.rowCount || 0),
348
+ };
349
+ },
350
+
351
+ // Recorded when the referred friend creates an account. Matches
352
+ // the most recent pending invitation on the code that doesn't
353
+ // already have a signed_up_customer_id pinned. Returns null if
354
+ // no matching invitation exists (the friend may have arrived
355
+ // organically — caller decides whether to treat as a miss).
356
+ trackSignup: async function (input) {
357
+ if (!input || typeof input !== "object") {
358
+ throw new TypeError("referrals.trackSignup: input object required");
359
+ }
360
+ var customerId = _uuid(input.customer_id, "customer_id");
361
+ var row = await _codeRowByCode(input.code);
362
+ if (!row) {
363
+ var miss = new Error("referrals.trackSignup: code not recognized");
364
+ miss.code = "REFERRAL_CODE_NOT_FOUND";
365
+ throw miss;
366
+ }
367
+ var ts = _now();
368
+ // Pin the signup to the oldest pending invitation under this
369
+ // code that doesn't have a customer attached yet — the same
370
+ // friend can be invited by multiple referrers, but only one
371
+ // (the code they actually landed through) gets the funnel
372
+ // attribution.
373
+ var candidate = await query(
374
+ "SELECT id FROM referral_invitations " +
375
+ "WHERE referral_code_id = ?1 AND signed_up_customer_id IS NULL " +
376
+ "ORDER BY invited_at ASC LIMIT 1",
377
+ [row.id],
378
+ );
379
+ if (!candidate.rows.length) {
380
+ // No pending invitation — record nothing. The caller can
381
+ // surface this if they want to gate signup-bonuses to known
382
+ // referrals.
383
+ return null;
384
+ }
385
+ var invitationId = candidate.rows[0].id;
386
+ await query(
387
+ "UPDATE referral_invitations " +
388
+ "SET signed_up_at = ?1, signed_up_customer_id = ?2 " +
389
+ "WHERE id = ?3",
390
+ [ts, customerId, invitationId],
391
+ );
392
+ var updated = await query(
393
+ "SELECT id, referral_code_id, signed_up_customer_id, signed_up_at, reward_status " +
394
+ "FROM referral_invitations WHERE id = ?1",
395
+ [invitationId],
396
+ );
397
+ return updated.rows[0] || null;
398
+ },
399
+
400
+ // Recorded when a referred customer makes their first qualifying
401
+ // purchase. Transitions reward_status pending -> both-rewarded
402
+ // (the funnel-complete marker; the operator still has to issue
403
+ // the actual reward instrument via rewardReferrer / rewardReferee)
404
+ // and bumps the referrer's referrals_count. Idempotent: a second
405
+ // purchase by the same customer is a no-op (`first_purchase_at`
406
+ // is set only once).
407
+ trackPurchase: async function (input) {
408
+ if (!input || typeof input !== "object") {
409
+ throw new TypeError("referrals.trackPurchase: input object required");
410
+ }
411
+ var customerId = _uuid(input.customer_id, "customer_id");
412
+ var orderId = _uuid(input.order_id, "order_id");
413
+
414
+ var r = await query(
415
+ "SELECT id, referral_code_id, reward_status, first_purchase_at " +
416
+ "FROM referral_invitations WHERE signed_up_customer_id = ?1 " +
417
+ "ORDER BY signed_up_at ASC LIMIT 1",
418
+ [customerId],
419
+ );
420
+ if (!r.rows.length) return null;
421
+ var inv = r.rows[0];
422
+ if (inv.first_purchase_at != null) {
423
+ // Already recorded the first qualifying purchase — second
424
+ // and later purchases don't re-trigger the reward funnel.
425
+ return {
426
+ id: inv.id,
427
+ reward_status: inv.reward_status,
428
+ first_purchase_at: inv.first_purchase_at,
429
+ status: "already-recorded",
430
+ };
431
+ }
432
+ var ts = _now();
433
+ await query(
434
+ "UPDATE referral_invitations " +
435
+ "SET first_purchase_at = ?1, first_order_id = ?2, " +
436
+ "reward_status = 'both-rewarded' " +
437
+ "WHERE id = ?3",
438
+ [ts, orderId, inv.id],
439
+ );
440
+ // Bump the referrer's running count. `referrals_count` is the
441
+ // leaderboard key; it counts completed funnels (a friend who
442
+ // signed up but never purchased doesn't count).
443
+ await query(
444
+ "UPDATE referral_codes " +
445
+ "SET referrals_count = referrals_count + 1, updated_at = ?1 " +
446
+ "WHERE id = ?2",
447
+ [ts, inv.referral_code_id],
448
+ );
449
+ var after = await query(
450
+ "SELECT id, referral_code_id, reward_status, first_purchase_at, first_order_id " +
451
+ "FROM referral_invitations WHERE id = ?1",
452
+ [inv.id],
453
+ );
454
+ var out = after.rows[0] || null;
455
+ if (out) out.status = "completed";
456
+ return out;
457
+ },
458
+
459
+ // Operator records that the referrer payout has been issued.
460
+ // `reward_id` is opaque (operator's gift-card id, discount-code
461
+ // id, ledger-credit id — whatever instrument they chose). The
462
+ // FSM accepts transitions from any non-terminal state so the
463
+ // operator can pay out before or after the friend's purchase if
464
+ // their reward policy says so.
465
+ rewardReferrer: async function (invitationId, rewardOpts) {
466
+ var id = _uuid(invitationId, "invitation_id");
467
+ if (!rewardOpts || typeof rewardOpts !== "object") {
468
+ throw new TypeError("referrals.rewardReferrer: opts object with reward_id required");
469
+ }
470
+ if (typeof rewardOpts.reward_id !== "string" || !rewardOpts.reward_id.length) {
471
+ throw new TypeError("referrals.rewardReferrer: reward_id must be a non-empty string");
472
+ }
473
+ var r = await query(
474
+ "SELECT id, reward_status, referrer_reward_id, referee_reward_id " +
475
+ "FROM referral_invitations WHERE id = ?1",
476
+ [id],
477
+ );
478
+ if (!r.rows.length) return null;
479
+ var inv = r.rows[0];
480
+ if (inv.reward_status === "expired" || inv.reward_status === "voided") {
481
+ var terminal = new Error("referrals.rewardReferrer: invitation is " + inv.reward_status);
482
+ terminal.code = "REFERRAL_INVITATION_TERMINAL";
483
+ throw terminal;
484
+ }
485
+ // FSM: pending -> referrer-rewarded; if the referee has already
486
+ // been rewarded, transition to both-rewarded (the operator's
487
+ // chosen order doesn't matter).
488
+ var nextStatus = inv.referee_reward_id ? "both-rewarded" : "referrer-rewarded";
489
+ await query(
490
+ "UPDATE referral_invitations " +
491
+ "SET referrer_reward_id = ?1, reward_status = ?2 " +
492
+ "WHERE id = ?3",
493
+ [rewardOpts.reward_id, nextStatus, id],
494
+ );
495
+ var after = await query(
496
+ "SELECT id, reward_status, referrer_reward_id, referee_reward_id " +
497
+ "FROM referral_invitations WHERE id = ?1",
498
+ [id],
499
+ );
500
+ return after.rows[0] || null;
501
+ },
502
+
503
+ rewardReferee: async function (invitationId, rewardOpts) {
504
+ var id = _uuid(invitationId, "invitation_id");
505
+ if (!rewardOpts || typeof rewardOpts !== "object") {
506
+ throw new TypeError("referrals.rewardReferee: opts object with reward_id required");
507
+ }
508
+ if (typeof rewardOpts.reward_id !== "string" || !rewardOpts.reward_id.length) {
509
+ throw new TypeError("referrals.rewardReferee: reward_id must be a non-empty string");
510
+ }
511
+ var r = await query(
512
+ "SELECT id, reward_status, referrer_reward_id, referee_reward_id " +
513
+ "FROM referral_invitations WHERE id = ?1",
514
+ [id],
515
+ );
516
+ if (!r.rows.length) return null;
517
+ var inv = r.rows[0];
518
+ if (inv.reward_status === "expired" || inv.reward_status === "voided") {
519
+ var terminal = new Error("referrals.rewardReferee: invitation is " + inv.reward_status);
520
+ terminal.code = "REFERRAL_INVITATION_TERMINAL";
521
+ throw terminal;
522
+ }
523
+ var nextStatus = inv.referrer_reward_id ? "both-rewarded" : inv.reward_status;
524
+ // If the referrer hasn't been rewarded yet, the referee payout
525
+ // alone doesn't advance the funnel state — only both-sides
526
+ // payment lands on "both-rewarded". This keeps the leaderboard
527
+ // and stats coherent: a referee paid early without the referrer
528
+ // ever being paid stays visible in the operator's `referee
529
+ // payments outstanding` queue.
530
+ await query(
531
+ "UPDATE referral_invitations " +
532
+ "SET referee_reward_id = ?1, reward_status = ?2 " +
533
+ "WHERE id = ?3",
534
+ [rewardOpts.reward_id, nextStatus, id],
535
+ );
536
+ var after = await query(
537
+ "SELECT id, reward_status, referrer_reward_id, referee_reward_id " +
538
+ "FROM referral_invitations WHERE id = ?1",
539
+ [id],
540
+ );
541
+ return after.rows[0] || null;
542
+ },
543
+
544
+ // Operator lookup. Case-insensitive (lookup folds + uppercases
545
+ // before the SQL query); returns null on miss.
546
+ byCode: async function (code) {
547
+ return await _codeRowByCode(code);
548
+ },
549
+
550
+ // Operator action — flip an active code to "disabled". A
551
+ // referrer can then call issueCode to mint a new one. Returns
552
+ // the post-state row, or null if the id doesn't resolve.
553
+ disableCode: async function (codeId) {
554
+ var id = _uuid(codeId, "code_id");
555
+ var ts = _now();
556
+ var r = await query(
557
+ "UPDATE referral_codes SET status = 'disabled', updated_at = ?1 WHERE id = ?2",
558
+ [ts, id],
559
+ );
560
+ if (Number(r.rowCount || 0) === 0) return null;
561
+ var after = await query(
562
+ "SELECT id, referrer_customer_id, code, status, referrals_count, created_at, updated_at " +
563
+ "FROM referral_codes WHERE id = ?1",
564
+ [id],
565
+ );
566
+ return after.rows[0] || null;
567
+ },
568
+
569
+ // Aggregate funnel stats for one referrer across every code
570
+ // they've ever held. Operators surface this on the referrer's
571
+ // account page ("you've invited N friends, M have purchased,
572
+ // your rewards: X").
573
+ statsForReferrer: async function (customerId) {
574
+ var id = _uuid(customerId, "customer_id");
575
+ var codes = await query(
576
+ "SELECT id, code, status, referrals_count, created_at " +
577
+ "FROM referral_codes WHERE referrer_customer_id = ?1",
578
+ [id],
579
+ );
580
+ var totalCompleted = 0;
581
+ for (var i = 0; i < codes.rows.length; i += 1) {
582
+ totalCompleted += Number(codes.rows[i].referrals_count || 0);
583
+ }
584
+ var counts = await query(
585
+ "SELECT COUNT(*) AS total, " +
586
+ "SUM(CASE WHEN visited_at IS NOT NULL THEN 1 ELSE 0 END) AS visited, " +
587
+ "SUM(CASE WHEN signed_up_at IS NOT NULL THEN 1 ELSE 0 END) AS signed_up, " +
588
+ "SUM(CASE WHEN first_purchase_at IS NOT NULL THEN 1 ELSE 0 END) AS purchased, " +
589
+ "SUM(CASE WHEN referrer_reward_id IS NOT NULL THEN 1 ELSE 0 END) AS referrer_rewarded, " +
590
+ "SUM(CASE WHEN referee_reward_id IS NOT NULL THEN 1 ELSE 0 END) AS referee_rewarded " +
591
+ "FROM referral_invitations WHERE referral_code_id IN (" +
592
+ "SELECT id FROM referral_codes WHERE referrer_customer_id = ?1)",
593
+ [id],
594
+ );
595
+ var row = counts.rows[0] || {};
596
+ return {
597
+ customer_id: id,
598
+ codes: codes.rows,
599
+ completed_referrals: totalCompleted,
600
+ invitations_total: Number(row.total || 0),
601
+ invitations_visited: Number(row.visited || 0),
602
+ invitations_signed_up: Number(row.signed_up || 0),
603
+ invitations_purchased: Number(row.purchased || 0),
604
+ referrer_rewarded: Number(row.referrer_rewarded || 0),
605
+ referee_rewarded: Number(row.referee_rewarded || 0),
606
+ };
607
+ },
608
+
609
+ // Top-N referrers by completed referrals (referrals_count is the
610
+ // funnel-complete counter; signups without a first purchase don't
611
+ // count). Defaults to 10; capped at 100 so an operator can't
612
+ // accidentally page through the entire customer base via the
613
+ // public storefront route.
614
+ leaderboard: async function (input) {
615
+ input = input || {};
616
+ var limit = input.limit == null ? 10 : input.limit;
617
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0 || limit > 100) {
618
+ throw new TypeError("referrals.leaderboard: limit must be a positive integer <= 100");
619
+ }
620
+ var r = await query(
621
+ "SELECT referrer_customer_id, SUM(referrals_count) AS completed_referrals " +
622
+ "FROM referral_codes GROUP BY referrer_customer_id " +
623
+ "HAVING SUM(referrals_count) > 0 " +
624
+ "ORDER BY completed_referrals DESC, referrer_customer_id ASC " +
625
+ "LIMIT ?1",
626
+ [limit],
627
+ );
628
+ // Coerce SUM() result to a Number — `node:sqlite` returns BIGINT
629
+ // for SUM() over INTEGER on some Node minor versions, plain
630
+ // Number on others. Normalizing here keeps the operator's
631
+ // downstream JSON serializer from emitting BigInt syntax.
632
+ return r.rows.map(function (row) {
633
+ return {
634
+ referrer_customer_id: row.referrer_customer_id,
635
+ completed_referrals: Number(row.completed_referrals || 0),
636
+ };
637
+ });
638
+ },
639
+ };
640
+ }
641
+
642
+ module.exports = {
643
+ create: create,
644
+ REFEREE_NAMESPACE: REFEREE_NAMESPACE,
645
+ CODE_ALPHABET: DEFAULT_ALPHABET,
646
+ CODE_LEN: CODE_LEN,
647
+ CODE_STATUSES: CODE_STATUSES,
648
+ REWARD_STATUSES: REWARD_STATUSES,
649
+ };