@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,879 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.customerMerge
|
|
4
|
+
* @title Customer merge — deduplicate accounts onto a canonical id
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Operator-driven consolidation of duplicate customer accounts.
|
|
8
|
+
* The same person can land in the customer table twice — two
|
|
9
|
+
* email addresses (work + personal), two passkey enrollments
|
|
10
|
+
* that never got linked, a guest checkout that later registered
|
|
11
|
+
* under a different address. The merge primitive picks a
|
|
12
|
+
* canonical (target) customer and a duplicate (source), proposes
|
|
13
|
+
* a dry-run plan listing every reparent the merge would
|
|
14
|
+
* commit, then executes the plan atomically: every order /
|
|
15
|
+
* subscription / loyalty-ledger row / review / address /
|
|
16
|
+
* payment-method that points at the source customer is
|
|
17
|
+
* rewritten to point at the target. The source customer row is
|
|
18
|
+
* soft-archived and a redirect marker is recorded so any later
|
|
19
|
+
* read of the source id transparently resolves to the target.
|
|
20
|
+
*
|
|
21
|
+
* Four-stage FSM:
|
|
22
|
+
*
|
|
23
|
+
* proposeMerge - records a {source, target, plan_json}
|
|
24
|
+
* row in `customer_merges` with status
|
|
25
|
+
* `proposed`. No rows reparented; `plan_json`
|
|
26
|
+
* is the frozen snapshot of per-primitive
|
|
27
|
+
* affected-row counts captured against the
|
|
28
|
+
* database AT proposal time.
|
|
29
|
+
*
|
|
30
|
+
* executeMerge - reparents every affected row atomically.
|
|
31
|
+
* Pre-flight all child primitives FIRST (count
|
|
32
|
+
* what we're about to touch) - any pre-flight
|
|
33
|
+
* error surfaces before any UPDATE runs.
|
|
34
|
+
* Then commit all reparents; stamp
|
|
35
|
+
* executed_at / executed_by; insert the
|
|
36
|
+
* redirect marker; the merge row lands in
|
|
37
|
+
* `executed`.
|
|
38
|
+
*
|
|
39
|
+
* rollbackMerge - within `ROLLBACK_WINDOW_MS` (7 days) of
|
|
40
|
+
* execution, reverses every reparent that
|
|
41
|
+
* `executeMerge` wrote, deletes the redirect
|
|
42
|
+
* marker, and lands the merge in
|
|
43
|
+
* `rolled_back`. Past the window, refuses.
|
|
44
|
+
* The rollback uses the SAME per-primitive
|
|
45
|
+
* reparent verbs in reverse — it does NOT
|
|
46
|
+
* restore arbitrary historical state, only
|
|
47
|
+
* the inverse of what THIS merge wrote.
|
|
48
|
+
*
|
|
49
|
+
* cancelMerge - drops a `proposed` plan without ever
|
|
50
|
+
* executing it. Lands the merge in
|
|
51
|
+
* `cancelled`. Idempotent on the cancelled
|
|
52
|
+
* terminal state.
|
|
53
|
+
*
|
|
54
|
+
* Composes (every child primitive is OPTIONAL — wire only what
|
|
55
|
+
* the operator's deployment uses):
|
|
56
|
+
*
|
|
57
|
+
* - opts.customers — `getCustomerById(id)` /
|
|
58
|
+
* `archiveCustomer(id)` /
|
|
59
|
+
* `restoreCustomer(id)`. REQUIRED;
|
|
60
|
+
* the source row's soft-archive +
|
|
61
|
+
* the merge's own customer-existence
|
|
62
|
+
* gate live here.
|
|
63
|
+
* - opts.order — `reparentForCustomer(fromId, toId)`
|
|
64
|
+
* / `countForCustomer(id)`. Optional;
|
|
65
|
+
* absent, the merge plan reports
|
|
66
|
+
* `orders: 0` and skips the reparent.
|
|
67
|
+
* - opts.subscriptions — same shape.
|
|
68
|
+
* - opts.loyalty — same shape.
|
|
69
|
+
* - opts.reviews — same shape.
|
|
70
|
+
* - opts.addresses — same shape.
|
|
71
|
+
* - opts.paymentMethods — same shape.
|
|
72
|
+
*
|
|
73
|
+
* Every child verb's contract:
|
|
74
|
+
* - `countForCustomer(id)` returns an integer (rows that
|
|
75
|
+
* reference this customer id).
|
|
76
|
+
* - `reparentForCustomer(fromId, toId)` rewrites every row
|
|
77
|
+
* pointing at `fromId` to point at `toId`. Returns
|
|
78
|
+
* `{ rowCount }` so the merge can sanity-check the actual
|
|
79
|
+
* writes against the plan.
|
|
80
|
+
*
|
|
81
|
+
* The merge primitive itself owns NO domain rows — it ONLY
|
|
82
|
+
* reparents through these injected verbs. That's the
|
|
83
|
+
* composition story: operators bring their own primitives;
|
|
84
|
+
* this primitive orchestrates the multi-table rewrite + the
|
|
85
|
+
* audit trail + the rollback window.
|
|
86
|
+
*
|
|
87
|
+
* findDuplicateCandidates:
|
|
88
|
+
*
|
|
89
|
+
* Heuristic candidate generation, NOT a primary-key index.
|
|
90
|
+
* The operator's customers primitive exposes a list-by-name +
|
|
91
|
+
* list-by-email-hash surface; this primitive scans for
|
|
92
|
+
* fuzzy-name collisions above a configurable similarity floor
|
|
93
|
+
* (Jaro-Winkler over the lowercased display_name) and surfaces
|
|
94
|
+
* them as candidates. The caller still has to make the merge
|
|
95
|
+
* decision — the candidate list is operator-review material,
|
|
96
|
+
* not auto-confirm material.
|
|
97
|
+
*
|
|
98
|
+
* redirectFor:
|
|
99
|
+
*
|
|
100
|
+
* A canonical-id lookup helper for callers that hold a
|
|
101
|
+
* possibly-stale source_customer_id. Returns the canonical
|
|
102
|
+
* target_customer_id when a redirect exists, else null. Use
|
|
103
|
+
* at the edge of a long-lived URL / cached link to transparently
|
|
104
|
+
* follow a merge.
|
|
105
|
+
*
|
|
106
|
+
* historyForCustomer:
|
|
107
|
+
*
|
|
108
|
+
* Every merge event that mentions a given customer id (as
|
|
109
|
+
* either source OR target). Ordered by created_at DESC. Used
|
|
110
|
+
* by the customer-detail operator console to surface "this
|
|
111
|
+
* account was merged from X on Y" or "Z was merged into this
|
|
112
|
+
* account on Y".
|
|
113
|
+
*
|
|
114
|
+
* listMerges:
|
|
115
|
+
*
|
|
116
|
+
* Audit + dashboard surface. Filters on status + date range.
|
|
117
|
+
* Returns ordered (created_at DESC, id DESC) rows; no cursor —
|
|
118
|
+
* the audit-trail volume is operator-scale (dozens to
|
|
119
|
+
* hundreds per year), so a hard limit cap suffices.
|
|
120
|
+
*
|
|
121
|
+
* Monotonic per-process clock: two operator actions (propose +
|
|
122
|
+
* execute, execute + rollback) can land in the same millisecond
|
|
123
|
+
* on fast machines. `_now` bumps to `prior + 1` on collision so
|
|
124
|
+
* the (created_at DESC, id DESC) listing + the
|
|
125
|
+
* historyForCustomer timeline read carry a strict per-process
|
|
126
|
+
* ordering.
|
|
127
|
+
*
|
|
128
|
+
* Composes:
|
|
129
|
+
* - `b.uuid.v7` - merge row ids
|
|
130
|
+
* - `b.guardUuid.sanitize` - strict UUID gate on every
|
|
131
|
+
* customer id reaching this
|
|
132
|
+
* primitive
|
|
133
|
+
*
|
|
134
|
+
* Surface:
|
|
135
|
+
* - findDuplicateCandidates({ limit, similarity_min })
|
|
136
|
+
* - proposeMerge({ source_customer_id, target_customer_id,
|
|
137
|
+
* requested_by })
|
|
138
|
+
* - executeMerge({ merge_id, executed_by })
|
|
139
|
+
* - rollbackMerge({ merge_id, reason })
|
|
140
|
+
* - cancelMerge({ merge_id, reason })
|
|
141
|
+
* - getMerge(merge_id)
|
|
142
|
+
* - historyForCustomer(customer_id)
|
|
143
|
+
* - listMerges({ status?, from?, to?, limit? })
|
|
144
|
+
* - redirectFor(customer_id)
|
|
145
|
+
*
|
|
146
|
+
* Storage:
|
|
147
|
+
* - customer_merges, customer_merge_redirects
|
|
148
|
+
* (migration `0194_customer_merge.sql`).
|
|
149
|
+
*
|
|
150
|
+
* @primitive customerMerge
|
|
151
|
+
* @related b.uuid.v7, b.guardUuid, shop.customers, shop.order,
|
|
152
|
+
* shop.subscriptions, shop.loyalty, shop.reviews,
|
|
153
|
+
* shop.addresses, shop.paymentMethods
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
var MAX_LIST_LIMIT = 200;
|
|
157
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
158
|
+
var MAX_CANDIDATE_LIMIT = 200;
|
|
159
|
+
var DEFAULT_CAND_LIMIT = 25;
|
|
160
|
+
var MAX_REASON_LEN = 280;
|
|
161
|
+
var ROLLBACK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
162
|
+
var DEFAULT_SIMILARITY = 0.85;
|
|
163
|
+
var MIN_SIMILARITY = 0.50;
|
|
164
|
+
var MAX_SIMILARITY = 1.00;
|
|
165
|
+
|
|
166
|
+
var MERGE_STATUSES = Object.freeze([
|
|
167
|
+
"proposed", "executed", "rolled_back", "cancelled",
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
// Child-primitive registry — the merge orchestrates these by
|
|
171
|
+
// rewriting every row that points at a source_customer_id to
|
|
172
|
+
// point at the target_customer_id. Each entry is { key: opts-
|
|
173
|
+
// key, plan: plan-field-name }. The plan field is what surfaces
|
|
174
|
+
// on the dry-run + the listMerges payload, so it's stable
|
|
175
|
+
// operator-facing naming.
|
|
176
|
+
var CHILD_PRIMITIVES = Object.freeze([
|
|
177
|
+
{ key: "order", plan: "orders" },
|
|
178
|
+
{ key: "subscriptions", plan: "subscriptions" },
|
|
179
|
+
{ key: "loyalty", plan: "loyalty_entries" },
|
|
180
|
+
{ key: "reviews", plan: "reviews" },
|
|
181
|
+
{ key: "addresses", plan: "addresses" },
|
|
182
|
+
{ key: "paymentMethods", plan: "payment_methods" },
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
var bShop;
|
|
186
|
+
function _b() {
|
|
187
|
+
if (!bShop) bShop = require("./index");
|
|
188
|
+
return bShop.framework;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- monotonic clock ---------------------------------------------------
|
|
192
|
+
//
|
|
193
|
+
// Operator-driven writes (propose immediately followed by execute,
|
|
194
|
+
// execute immediately followed by rollback in an operator console)
|
|
195
|
+
// can land in the same millisecond on fast machines. Bumping by
|
|
196
|
+
// 1ms on a tie keeps the timeline strictly increasing so a sort-
|
|
197
|
+
// by-timestamp read returns events in the order they were issued.
|
|
198
|
+
|
|
199
|
+
var _lastTs = 0;
|
|
200
|
+
function _now() {
|
|
201
|
+
var t = Date.now();
|
|
202
|
+
if (t <= _lastTs) t = _lastTs + 1;
|
|
203
|
+
_lastTs = t;
|
|
204
|
+
return t;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- validators --------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
function _customerId(s, label) {
|
|
210
|
+
try {
|
|
211
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
212
|
+
} catch (e) {
|
|
213
|
+
throw new TypeError("customerMerge: " + label + " - " + (e && e.message || "invalid UUID"));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _mergeId(s) {
|
|
218
|
+
try {
|
|
219
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
220
|
+
} catch (e) {
|
|
221
|
+
throw new TypeError("customerMerge: merge_id - " + (e && e.message || "invalid UUID"));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _operatorId(s, label) {
|
|
226
|
+
if (typeof s !== "string" || !s.length || s.length > 200) {
|
|
227
|
+
throw new TypeError("customerMerge: " + label + " must be a non-empty string <= 200 characters");
|
|
228
|
+
}
|
|
229
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
230
|
+
throw new TypeError("customerMerge: " + label + " contains control bytes");
|
|
231
|
+
}
|
|
232
|
+
return s;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function _reason(s, label) {
|
|
236
|
+
if (typeof s !== "string" || !s.length) {
|
|
237
|
+
throw new TypeError("customerMerge: " + label + " must be a non-empty string");
|
|
238
|
+
}
|
|
239
|
+
if (s.length > MAX_REASON_LEN) {
|
|
240
|
+
throw new TypeError("customerMerge: " + label + " must be <= " + MAX_REASON_LEN + " characters");
|
|
241
|
+
}
|
|
242
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
|
|
243
|
+
throw new TypeError("customerMerge: " + label + " contains control bytes");
|
|
244
|
+
}
|
|
245
|
+
return s;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function _limit(n, max, def, label) {
|
|
249
|
+
if (n == null) return def;
|
|
250
|
+
if (!Number.isInteger(n) || n <= 0 || n > max) {
|
|
251
|
+
throw new TypeError("customerMerge: " + label + " must be an integer 1..." + max);
|
|
252
|
+
}
|
|
253
|
+
return n;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function _similarity(n) {
|
|
257
|
+
if (n == null) return DEFAULT_SIMILARITY;
|
|
258
|
+
if (typeof n !== "number" || !isFinite(n) || n < MIN_SIMILARITY || n > MAX_SIMILARITY) {
|
|
259
|
+
throw new TypeError("customerMerge: similarity_min must be a number in [" +
|
|
260
|
+
MIN_SIMILARITY + ", " + MAX_SIMILARITY + "]");
|
|
261
|
+
}
|
|
262
|
+
return n;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function _timestampRange(from, to, label) {
|
|
266
|
+
if (from != null && (!Number.isInteger(from) || from < 0)) {
|
|
267
|
+
throw new TypeError("customerMerge." + label + ": from must be a non-negative integer (ms epoch)");
|
|
268
|
+
}
|
|
269
|
+
if (to != null && (!Number.isInteger(to) || to < 0)) {
|
|
270
|
+
throw new TypeError("customerMerge." + label + ": to must be a non-negative integer (ms epoch)");
|
|
271
|
+
}
|
|
272
|
+
if (from != null && to != null && from > to) {
|
|
273
|
+
throw new TypeError("customerMerge." + label + ": from must be <= to");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function _status(s) {
|
|
278
|
+
if (typeof s !== "string" || MERGE_STATUSES.indexOf(s) === -1) {
|
|
279
|
+
throw new TypeError("customerMerge: status must be one of " + MERGE_STATUSES.join(", "));
|
|
280
|
+
}
|
|
281
|
+
return s;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---- Jaro-Winkler similarity -------------------------------------------
|
|
285
|
+
//
|
|
286
|
+
// In-process string-similarity scorer for the candidate-finder.
|
|
287
|
+
// Operates on lowercased ASCII. The Jaro distance counts matching
|
|
288
|
+
// characters within a window of half-the-max-length minus one;
|
|
289
|
+
// Jaro-Winkler boosts the score for shared leading prefixes.
|
|
290
|
+
//
|
|
291
|
+
// Returns 0.0 .. 1.0. Two identical strings score 1.0; two
|
|
292
|
+
// strings with no common characters score 0.0.
|
|
293
|
+
|
|
294
|
+
function _jaroWinkler(a, b) {
|
|
295
|
+
if (a === b) return 1;
|
|
296
|
+
if (!a.length || !b.length) return 0;
|
|
297
|
+
|
|
298
|
+
var aLen = a.length;
|
|
299
|
+
var bLen = b.length;
|
|
300
|
+
var matchWindow = Math.max(0, Math.floor(Math.max(aLen, bLen) / 2) - 1);
|
|
301
|
+
|
|
302
|
+
var aMatches = new Array(aLen);
|
|
303
|
+
var bMatches = new Array(bLen);
|
|
304
|
+
var matches = 0;
|
|
305
|
+
|
|
306
|
+
for (var i = 0; i < aLen; i += 1) {
|
|
307
|
+
var lo = Math.max(0, i - matchWindow);
|
|
308
|
+
var hi = Math.min(bLen - 1, i + matchWindow);
|
|
309
|
+
for (var j = lo; j <= hi; j += 1) {
|
|
310
|
+
if (bMatches[j]) continue;
|
|
311
|
+
if (a.charAt(i) !== b.charAt(j)) continue;
|
|
312
|
+
aMatches[i] = true;
|
|
313
|
+
bMatches[j] = true;
|
|
314
|
+
matches += 1;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (matches === 0) return 0;
|
|
319
|
+
|
|
320
|
+
// Count transpositions: matched characters in a, scanned in
|
|
321
|
+
// order against matched characters in b. Mismatched pairs are
|
|
322
|
+
// transpositions / 2.
|
|
323
|
+
var transpositions = 0;
|
|
324
|
+
var k = 0;
|
|
325
|
+
for (var ii = 0; ii < aLen; ii += 1) {
|
|
326
|
+
if (!aMatches[ii]) continue;
|
|
327
|
+
while (!bMatches[k]) k += 1;
|
|
328
|
+
if (a.charAt(ii) !== b.charAt(k)) transpositions += 1;
|
|
329
|
+
k += 1;
|
|
330
|
+
}
|
|
331
|
+
transpositions = transpositions / 2;
|
|
332
|
+
|
|
333
|
+
var jaro = (matches / aLen + matches / bLen + (matches - transpositions) / matches) / 3;
|
|
334
|
+
|
|
335
|
+
// Winkler boost: up to 4 leading characters of shared prefix.
|
|
336
|
+
var prefix = 0;
|
|
337
|
+
var prefixCap = Math.min(4, Math.min(aLen, bLen));
|
|
338
|
+
for (var p = 0; p < prefixCap; p += 1) {
|
|
339
|
+
if (a.charAt(p) !== b.charAt(p)) break;
|
|
340
|
+
prefix += 1;
|
|
341
|
+
}
|
|
342
|
+
return jaro + prefix * 0.1 * (1 - jaro);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function _normalizeName(s) {
|
|
346
|
+
if (typeof s !== "string") return "";
|
|
347
|
+
return s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- hydration ---------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
function _hydrateMerge(row) {
|
|
353
|
+
if (!row) return null;
|
|
354
|
+
var plan;
|
|
355
|
+
try { plan = JSON.parse(row.plan_json || "{}"); }
|
|
356
|
+
catch (_e) { plan = {}; }
|
|
357
|
+
return {
|
|
358
|
+
id: row.id,
|
|
359
|
+
source_customer_id: row.source_customer_id,
|
|
360
|
+
target_customer_id: row.target_customer_id,
|
|
361
|
+
status: row.status,
|
|
362
|
+
plan: plan,
|
|
363
|
+
requested_by: row.requested_by,
|
|
364
|
+
executed_at: row.executed_at == null ? null : Number(row.executed_at),
|
|
365
|
+
executed_by: row.executed_by == null ? null : row.executed_by,
|
|
366
|
+
rolled_back_at: row.rolled_back_at == null ? null : Number(row.rolled_back_at),
|
|
367
|
+
rollback_reason: row.rollback_reason == null ? null : row.rollback_reason,
|
|
368
|
+
cancelled_at: row.cancelled_at == null ? null : Number(row.cancelled_at),
|
|
369
|
+
cancel_reason: row.cancel_reason == null ? null : row.cancel_reason,
|
|
370
|
+
created_at: Number(row.created_at),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function _hydrateRedirect(row) {
|
|
375
|
+
if (!row) return null;
|
|
376
|
+
return {
|
|
377
|
+
source_customer_id: row.source_customer_id,
|
|
378
|
+
target_customer_id: row.target_customer_id,
|
|
379
|
+
merge_id: row.merge_id,
|
|
380
|
+
executed_at: Number(row.executed_at),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---- child-primitive contract validation ------------------------------
|
|
385
|
+
//
|
|
386
|
+
// Every wired child primitive must expose two verbs:
|
|
387
|
+
// countForCustomer(customer_id) -> Promise<integer>
|
|
388
|
+
// reparentForCustomer(fromId, toId) -> Promise<{ rowCount: integer }>
|
|
389
|
+
// Absent either when the handle IS supplied is a config-time bug —
|
|
390
|
+
// throw at create() so the operator catches the typo at boot.
|
|
391
|
+
|
|
392
|
+
function _validateChild(handle, key) {
|
|
393
|
+
if (!handle) return;
|
|
394
|
+
if (typeof handle.countForCustomer !== "function") {
|
|
395
|
+
throw new TypeError("customerMerge.create: opts." + key +
|
|
396
|
+
" must expose a countForCustomer(customer_id) method");
|
|
397
|
+
}
|
|
398
|
+
if (typeof handle.reparentForCustomer !== "function") {
|
|
399
|
+
throw new TypeError("customerMerge.create: opts." + key +
|
|
400
|
+
" must expose a reparentForCustomer(fromId, toId) method");
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function _validateCustomers(handle) {
|
|
405
|
+
if (typeof handle.getCustomerById !== "function") {
|
|
406
|
+
throw new TypeError("customerMerge.create: opts.customers must expose a getCustomerById(id) method");
|
|
407
|
+
}
|
|
408
|
+
if (typeof handle.archiveCustomer !== "function") {
|
|
409
|
+
throw new TypeError("customerMerge.create: opts.customers must expose an archiveCustomer(id) method");
|
|
410
|
+
}
|
|
411
|
+
if (typeof handle.restoreCustomer !== "function") {
|
|
412
|
+
throw new TypeError("customerMerge.create: opts.customers must expose a restoreCustomer(id) method");
|
|
413
|
+
}
|
|
414
|
+
if (typeof handle.listForCandidates !== "function") {
|
|
415
|
+
throw new TypeError("customerMerge.create: opts.customers must expose a listForCandidates({ limit }) method");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---- factory -----------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
function create(opts) {
|
|
422
|
+
opts = opts || {};
|
|
423
|
+
var query = opts.query;
|
|
424
|
+
if (!query) {
|
|
425
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// customers is REQUIRED — the merge gates both customer-exists
|
|
429
|
+
// checks AND the source-archive call through this handle.
|
|
430
|
+
if (!opts.customers || typeof opts.customers !== "object") {
|
|
431
|
+
throw new TypeError("customerMerge.create: opts.customers is required");
|
|
432
|
+
}
|
|
433
|
+
var customers = opts.customers;
|
|
434
|
+
_validateCustomers(customers);
|
|
435
|
+
|
|
436
|
+
// Every other child is optional.
|
|
437
|
+
var children = {};
|
|
438
|
+
for (var i = 0; i < CHILD_PRIMITIVES.length; i += 1) {
|
|
439
|
+
var spec = CHILD_PRIMITIVES[i];
|
|
440
|
+
var handle = opts[spec.key];
|
|
441
|
+
_validateChild(handle, spec.key);
|
|
442
|
+
children[spec.key] = handle || null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function _getMergeRow(id) {
|
|
446
|
+
var r = await query("SELECT * FROM customer_merges WHERE id = ?1", [id]);
|
|
447
|
+
return r.rows[0] || null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function _getRedirectRow(sourceId) {
|
|
451
|
+
var r = await query(
|
|
452
|
+
"SELECT * FROM customer_merge_redirects WHERE source_customer_id = ?1",
|
|
453
|
+
[sourceId],
|
|
454
|
+
);
|
|
455
|
+
return r.rows[0] || null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Walk every wired child primitive and tally the affected-row
|
|
459
|
+
// counts. The returned plan is the dry-run snapshot for
|
|
460
|
+
// proposeMerge and the verification footprint for executeMerge /
|
|
461
|
+
// rollbackMerge.
|
|
462
|
+
async function _countPlanForSource(sourceId) {
|
|
463
|
+
var plan = {};
|
|
464
|
+
var total = 0;
|
|
465
|
+
for (var c = 0; c < CHILD_PRIMITIVES.length; c += 1) {
|
|
466
|
+
var spec = CHILD_PRIMITIVES[c];
|
|
467
|
+
var handle = children[spec.key];
|
|
468
|
+
if (!handle) {
|
|
469
|
+
plan[spec.plan] = 0;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
var n = await handle.countForCustomer(sourceId);
|
|
473
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
474
|
+
throw new TypeError("customerMerge: opts." + spec.key +
|
|
475
|
+
".countForCustomer returned non-integer (" + JSON.stringify(n) + ")");
|
|
476
|
+
}
|
|
477
|
+
plan[spec.plan] = n;
|
|
478
|
+
total += n;
|
|
479
|
+
}
|
|
480
|
+
plan.total_rows = total;
|
|
481
|
+
return plan;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Reparent in dependency-naive order: every wired child gets the
|
|
485
|
+
// reparent call. Returns the actual row-count snapshot so the
|
|
486
|
+
// caller can sanity-check against the proposeMerge plan.
|
|
487
|
+
async function _reparentAll(sourceId, targetId) {
|
|
488
|
+
var actual = {};
|
|
489
|
+
var total = 0;
|
|
490
|
+
for (var c = 0; c < CHILD_PRIMITIVES.length; c += 1) {
|
|
491
|
+
var spec = CHILD_PRIMITIVES[c];
|
|
492
|
+
var handle = children[spec.key];
|
|
493
|
+
if (!handle) {
|
|
494
|
+
actual[spec.plan] = 0;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
var res = await handle.reparentForCustomer(sourceId, targetId);
|
|
498
|
+
var rowCount = res && Number.isInteger(res.rowCount) ? res.rowCount : 0;
|
|
499
|
+
actual[spec.plan] = rowCount;
|
|
500
|
+
total += rowCount;
|
|
501
|
+
}
|
|
502
|
+
actual.total_rows = total;
|
|
503
|
+
return actual;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
|
|
508
|
+
MERGE_STATUSES: MERGE_STATUSES.slice(),
|
|
509
|
+
ROLLBACK_WINDOW_MS: ROLLBACK_WINDOW_MS,
|
|
510
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
511
|
+
MAX_CANDIDATE_LIMIT: MAX_CANDIDATE_LIMIT,
|
|
512
|
+
MAX_REASON_LEN: MAX_REASON_LEN,
|
|
513
|
+
DEFAULT_SIMILARITY: DEFAULT_SIMILARITY,
|
|
514
|
+
|
|
515
|
+
// Heuristic duplicate finder. Pulls a batch of recent customer
|
|
516
|
+
// rows through the customers primitive's listForCandidates({
|
|
517
|
+
// limit }) verb and pairs them by lowercased-name Jaro-Winkler
|
|
518
|
+
// similarity. Returns the pairs above `similarity_min`,
|
|
519
|
+
// ordered by similarity DESC. Already-merged source ids
|
|
520
|
+
// (i.e. rows with an existing redirect marker) are excluded
|
|
521
|
+
// from the candidate set so a confirmed merge doesn't re-
|
|
522
|
+
// surface as a "possible duplicate."
|
|
523
|
+
findDuplicateCandidates: async function (input) {
|
|
524
|
+
input = input || {};
|
|
525
|
+
var limit = _limit(input.limit, MAX_CANDIDATE_LIMIT, DEFAULT_CAND_LIMIT, "limit");
|
|
526
|
+
var similarityMin = _similarity(input.similarity_min);
|
|
527
|
+
|
|
528
|
+
var scanLimit = Math.min(MAX_CANDIDATE_LIMIT, Math.max(limit * 4, 100));
|
|
529
|
+
var listed = await customers.listForCandidates({ limit: scanLimit });
|
|
530
|
+
var rows = (listed && Array.isArray(listed.rows)) ? listed.rows : [];
|
|
531
|
+
|
|
532
|
+
// Filter out already-redirected source ids (each one is by
|
|
533
|
+
// definition no longer canonical and surfacing it as a
|
|
534
|
+
// "potential duplicate" is noise).
|
|
535
|
+
var redirectRows = (await query(
|
|
536
|
+
"SELECT source_customer_id FROM customer_merge_redirects", [],
|
|
537
|
+
)).rows;
|
|
538
|
+
var redirected = Object.create(null);
|
|
539
|
+
for (var r = 0; r < redirectRows.length; r += 1) {
|
|
540
|
+
redirected[redirectRows[r].source_customer_id] = true;
|
|
541
|
+
}
|
|
542
|
+
var pool = [];
|
|
543
|
+
for (var k = 0; k < rows.length; k += 1) {
|
|
544
|
+
var row = rows[k];
|
|
545
|
+
if (!row || !row.id) continue;
|
|
546
|
+
if (redirected[row.id]) continue;
|
|
547
|
+
var norm = _normalizeName(row.display_name || "");
|
|
548
|
+
if (!norm.length) continue;
|
|
549
|
+
pool.push({ id: row.id, name: norm, display_name: row.display_name });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
var candidates = [];
|
|
553
|
+
for (var a = 0; a < pool.length; a += 1) {
|
|
554
|
+
for (var b = a + 1; b < pool.length; b += 1) {
|
|
555
|
+
var score = _jaroWinkler(pool[a].name, pool[b].name);
|
|
556
|
+
if (score >= similarityMin) {
|
|
557
|
+
// Stable order — smaller id first so the same pair
|
|
558
|
+
// surfaces identically across runs.
|
|
559
|
+
var pair;
|
|
560
|
+
if (pool[a].id < pool[b].id) {
|
|
561
|
+
pair = { a_id: pool[a].id, a_display_name: pool[a].display_name,
|
|
562
|
+
b_id: pool[b].id, b_display_name: pool[b].display_name,
|
|
563
|
+
similarity: score };
|
|
564
|
+
} else {
|
|
565
|
+
pair = { a_id: pool[b].id, a_display_name: pool[b].display_name,
|
|
566
|
+
b_id: pool[a].id, b_display_name: pool[a].display_name,
|
|
567
|
+
similarity: score };
|
|
568
|
+
}
|
|
569
|
+
candidates.push(pair);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
candidates.sort(function (x, y) {
|
|
574
|
+
if (y.similarity !== x.similarity) return y.similarity - x.similarity;
|
|
575
|
+
if (x.a_id < y.a_id) return -1;
|
|
576
|
+
if (x.a_id > y.a_id) return 1;
|
|
577
|
+
if (x.b_id < y.b_id) return -1;
|
|
578
|
+
if (x.b_id > y.b_id) return 1;
|
|
579
|
+
return 0;
|
|
580
|
+
});
|
|
581
|
+
return candidates.slice(0, limit);
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
// Record a dry-run merge plan. Both customer ids must exist
|
|
585
|
+
// and be distinct. The source must not already have a
|
|
586
|
+
// redirect (an already-merged source can't be re-merged), and
|
|
587
|
+
// the target must not be an already-merged source itself
|
|
588
|
+
// (chained redirects refused — operators always merge ONTO a
|
|
589
|
+
// canonical id). An existing `proposed` plan for the same
|
|
590
|
+
// (source, target) pair is refused; cancel it first or
|
|
591
|
+
// re-use it.
|
|
592
|
+
proposeMerge: async function (input) {
|
|
593
|
+
if (!input || typeof input !== "object") {
|
|
594
|
+
throw new TypeError("customerMerge.proposeMerge: input object required");
|
|
595
|
+
}
|
|
596
|
+
var sourceId = _customerId(input.source_customer_id, "source_customer_id");
|
|
597
|
+
var targetId = _customerId(input.target_customer_id, "target_customer_id");
|
|
598
|
+
var requestedBy = _operatorId(input.requested_by, "requested_by");
|
|
599
|
+
|
|
600
|
+
if (sourceId === targetId) {
|
|
601
|
+
throw new TypeError("customerMerge.proposeMerge: source_customer_id and target_customer_id must differ");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
var sourceCustomer = await customers.getCustomerById(sourceId);
|
|
605
|
+
if (!sourceCustomer) {
|
|
606
|
+
var srcErr = new Error("customerMerge.proposeMerge: source_customer_id " + sourceId + " not found");
|
|
607
|
+
srcErr.code = "CUSTOMER_MERGE_SOURCE_NOT_FOUND";
|
|
608
|
+
throw srcErr;
|
|
609
|
+
}
|
|
610
|
+
var targetCustomer = await customers.getCustomerById(targetId);
|
|
611
|
+
if (!targetCustomer) {
|
|
612
|
+
var tgtErr = new Error("customerMerge.proposeMerge: target_customer_id " + targetId + " not found");
|
|
613
|
+
tgtErr.code = "CUSTOMER_MERGE_TARGET_NOT_FOUND";
|
|
614
|
+
throw tgtErr;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
var existingRedirect = await _getRedirectRow(sourceId);
|
|
618
|
+
if (existingRedirect) {
|
|
619
|
+
var redErr = new Error("customerMerge.proposeMerge: source_customer_id " + sourceId +
|
|
620
|
+
" is already merged into " + existingRedirect.target_customer_id);
|
|
621
|
+
redErr.code = "CUSTOMER_MERGE_SOURCE_ALREADY_MERGED";
|
|
622
|
+
throw redErr;
|
|
623
|
+
}
|
|
624
|
+
var targetAsSource = await _getRedirectRow(targetId);
|
|
625
|
+
if (targetAsSource) {
|
|
626
|
+
var chainErr = new Error("customerMerge.proposeMerge: target_customer_id " + targetId +
|
|
627
|
+
" is itself merged into " + targetAsSource.target_customer_id +
|
|
628
|
+
" - chained redirects refused");
|
|
629
|
+
chainErr.code = "CUSTOMER_MERGE_TARGET_IS_REDIRECT";
|
|
630
|
+
throw chainErr;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Refuse a duplicate proposed-state plan for the same pair.
|
|
634
|
+
var existingProposed = (await query(
|
|
635
|
+
"SELECT id FROM customer_merges " +
|
|
636
|
+
"WHERE source_customer_id = ?1 AND target_customer_id = ?2 AND status = 'proposed'",
|
|
637
|
+
[sourceId, targetId],
|
|
638
|
+
)).rows[0];
|
|
639
|
+
if (existingProposed) {
|
|
640
|
+
var dupErr = new Error("customerMerge.proposeMerge: a proposed plan already exists for " +
|
|
641
|
+
"(source=" + sourceId + ", target=" + targetId + "): merge_id=" + existingProposed.id);
|
|
642
|
+
dupErr.code = "CUSTOMER_MERGE_DUPLICATE_PROPOSAL";
|
|
643
|
+
throw dupErr;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
var plan = await _countPlanForSource(sourceId);
|
|
647
|
+
var id = _b().uuid.v7();
|
|
648
|
+
var ts = _now();
|
|
649
|
+
await query(
|
|
650
|
+
"INSERT INTO customer_merges " +
|
|
651
|
+
"(id, source_customer_id, target_customer_id, status, plan_json, requested_by, created_at) " +
|
|
652
|
+
"VALUES (?1, ?2, ?3, 'proposed', ?4, ?5, ?6)",
|
|
653
|
+
[id, sourceId, targetId, JSON.stringify(plan), requestedBy, ts],
|
|
654
|
+
);
|
|
655
|
+
return _hydrateMerge(await _getMergeRow(id));
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
// Reparent every child row. Pre-flight all primitives FIRST
|
|
659
|
+
// (the count walk surfaces any handle-level error before any
|
|
660
|
+
// UPDATE runs); then commit every reparent; then archive the
|
|
661
|
+
// source customer; then insert the redirect marker; then stamp
|
|
662
|
+
// executed_at + executed_by + lands the merge in executed.
|
|
663
|
+
executeMerge: async function (input) {
|
|
664
|
+
if (!input || typeof input !== "object") {
|
|
665
|
+
throw new TypeError("customerMerge.executeMerge: input object required");
|
|
666
|
+
}
|
|
667
|
+
var mergeId = _mergeId(input.merge_id);
|
|
668
|
+
var executedBy = _operatorId(input.executed_by, "executed_by");
|
|
669
|
+
|
|
670
|
+
var row = await _getMergeRow(mergeId);
|
|
671
|
+
if (!row) {
|
|
672
|
+
var nfErr = new Error("customerMerge.executeMerge: merge_id " + mergeId + " not found");
|
|
673
|
+
nfErr.code = "CUSTOMER_MERGE_NOT_FOUND";
|
|
674
|
+
throw nfErr;
|
|
675
|
+
}
|
|
676
|
+
if (row.status !== "proposed") {
|
|
677
|
+
var stErr = new Error("customerMerge.executeMerge: merge_id " + mergeId +
|
|
678
|
+
" is " + row.status + ", only proposed merges can be executed");
|
|
679
|
+
stErr.code = "CUSTOMER_MERGE_NOT_PROPOSED";
|
|
680
|
+
throw stErr;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Pre-flight: recount the plan. If the actual current state
|
|
684
|
+
// doesn't match the captured plan, we refuse — the operator
|
|
685
|
+
// re-proposes (state drifted between proposal and execute).
|
|
686
|
+
var freshPlan = await _countPlanForSource(row.source_customer_id);
|
|
687
|
+
var capturedPlan;
|
|
688
|
+
try { capturedPlan = JSON.parse(row.plan_json || "{}"); }
|
|
689
|
+
catch (_e) { capturedPlan = {}; }
|
|
690
|
+
if (freshPlan.total_rows !== capturedPlan.total_rows) {
|
|
691
|
+
var drErr = new Error("customerMerge.executeMerge: plan drifted since proposal " +
|
|
692
|
+
"(proposed total=" + capturedPlan.total_rows + ", actual=" + freshPlan.total_rows +
|
|
693
|
+
") - re-propose");
|
|
694
|
+
drErr.code = "CUSTOMER_MERGE_PLAN_DRIFTED";
|
|
695
|
+
throw drErr;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Commit every reparent.
|
|
699
|
+
var actual = await _reparentAll(row.source_customer_id, row.target_customer_id);
|
|
700
|
+
|
|
701
|
+
// Archive the source customer + insert the redirect marker.
|
|
702
|
+
await customers.archiveCustomer(row.source_customer_id);
|
|
703
|
+
var ts = _now();
|
|
704
|
+
await query(
|
|
705
|
+
"INSERT INTO customer_merge_redirects " +
|
|
706
|
+
"(source_customer_id, target_customer_id, merge_id, executed_at) " +
|
|
707
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
708
|
+
[row.source_customer_id, row.target_customer_id, mergeId, ts],
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
// Land the merge in executed; freeze the actual reparent
|
|
712
|
+
// counts on plan_json so rollback has the exact footprint.
|
|
713
|
+
var sealed = Object.assign({}, capturedPlan, { actual: actual });
|
|
714
|
+
await query(
|
|
715
|
+
"UPDATE customer_merges SET status = 'executed', plan_json = ?1, " +
|
|
716
|
+
"executed_at = ?2, executed_by = ?3 WHERE id = ?4",
|
|
717
|
+
[JSON.stringify(sealed), ts, executedBy, mergeId],
|
|
718
|
+
);
|
|
719
|
+
return _hydrateMerge(await _getMergeRow(mergeId));
|
|
720
|
+
},
|
|
721
|
+
|
|
722
|
+
// Reverse every reparent. Within ROLLBACK_WINDOW_MS of the
|
|
723
|
+
// execute timestamp. Past the window, refuses outright — the
|
|
724
|
+
// assumption is that downstream systems (analytics, reporting,
|
|
725
|
+
// operator notifications) have observed the merge and any
|
|
726
|
+
// mass-reverse would create a "rollback storm" of stale-data
|
|
727
|
+
// side effects.
|
|
728
|
+
rollbackMerge: async function (input) {
|
|
729
|
+
if (!input || typeof input !== "object") {
|
|
730
|
+
throw new TypeError("customerMerge.rollbackMerge: input object required");
|
|
731
|
+
}
|
|
732
|
+
var mergeId = _mergeId(input.merge_id);
|
|
733
|
+
var reason = _reason(input.reason, "reason");
|
|
734
|
+
|
|
735
|
+
var row = await _getMergeRow(mergeId);
|
|
736
|
+
if (!row) {
|
|
737
|
+
var nfErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId + " not found");
|
|
738
|
+
nfErr.code = "CUSTOMER_MERGE_NOT_FOUND";
|
|
739
|
+
throw nfErr;
|
|
740
|
+
}
|
|
741
|
+
if (row.status !== "executed") {
|
|
742
|
+
var stErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId +
|
|
743
|
+
" is " + row.status + ", only executed merges can be rolled back");
|
|
744
|
+
stErr.code = "CUSTOMER_MERGE_NOT_EXECUTED";
|
|
745
|
+
throw stErr;
|
|
746
|
+
}
|
|
747
|
+
var now = _now();
|
|
748
|
+
if (now - Number(row.executed_at) > ROLLBACK_WINDOW_MS) {
|
|
749
|
+
var winErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId +
|
|
750
|
+
" executed " + Math.floor((now - Number(row.executed_at)) / (24 * 60 * 60 * 1000)) +
|
|
751
|
+
" days ago, past the " + (ROLLBACK_WINDOW_MS / (24 * 60 * 60 * 1000)) +
|
|
752
|
+
"-day rollback window");
|
|
753
|
+
winErr.code = "CUSTOMER_MERGE_ROLLBACK_WINDOW_EXPIRED";
|
|
754
|
+
throw winErr;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Reverse every reparent (target -> source).
|
|
758
|
+
await _reparentAll(row.target_customer_id, row.source_customer_id);
|
|
759
|
+
|
|
760
|
+
// Restore the source customer + drop the redirect marker.
|
|
761
|
+
await customers.restoreCustomer(row.source_customer_id);
|
|
762
|
+
await query(
|
|
763
|
+
"DELETE FROM customer_merge_redirects WHERE source_customer_id = ?1",
|
|
764
|
+
[row.source_customer_id],
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
await query(
|
|
768
|
+
"UPDATE customer_merges SET status = 'rolled_back', " +
|
|
769
|
+
"rolled_back_at = ?1, rollback_reason = ?2 WHERE id = ?3",
|
|
770
|
+
[now, reason, mergeId],
|
|
771
|
+
);
|
|
772
|
+
return _hydrateMerge(await _getMergeRow(mergeId));
|
|
773
|
+
},
|
|
774
|
+
|
|
775
|
+
// Drop a proposed plan without ever executing it. Idempotent
|
|
776
|
+
// on the terminal cancelled state (re-cancelling returns the
|
|
777
|
+
// already-cancelled row).
|
|
778
|
+
cancelMerge: async function (input) {
|
|
779
|
+
if (!input || typeof input !== "object") {
|
|
780
|
+
throw new TypeError("customerMerge.cancelMerge: input object required");
|
|
781
|
+
}
|
|
782
|
+
var mergeId = _mergeId(input.merge_id);
|
|
783
|
+
var reason = _reason(input.reason, "reason");
|
|
784
|
+
var row = await _getMergeRow(mergeId);
|
|
785
|
+
if (!row) {
|
|
786
|
+
var nfErr = new Error("customerMerge.cancelMerge: merge_id " + mergeId + " not found");
|
|
787
|
+
nfErr.code = "CUSTOMER_MERGE_NOT_FOUND";
|
|
788
|
+
throw nfErr;
|
|
789
|
+
}
|
|
790
|
+
if (row.status === "cancelled") {
|
|
791
|
+
return _hydrateMerge(row);
|
|
792
|
+
}
|
|
793
|
+
if (row.status !== "proposed") {
|
|
794
|
+
var stErr = new Error("customerMerge.cancelMerge: merge_id " + mergeId +
|
|
795
|
+
" is " + row.status + ", only proposed merges can be cancelled");
|
|
796
|
+
stErr.code = "CUSTOMER_MERGE_NOT_PROPOSED";
|
|
797
|
+
throw stErr;
|
|
798
|
+
}
|
|
799
|
+
var ts = _now();
|
|
800
|
+
await query(
|
|
801
|
+
"UPDATE customer_merges SET status = 'cancelled', " +
|
|
802
|
+
"cancelled_at = ?1, cancel_reason = ?2 WHERE id = ?3",
|
|
803
|
+
[ts, reason, mergeId],
|
|
804
|
+
);
|
|
805
|
+
return _hydrateMerge(await _getMergeRow(mergeId));
|
|
806
|
+
},
|
|
807
|
+
|
|
808
|
+
// Single-merge read. Returns null on miss.
|
|
809
|
+
getMerge: async function (mergeId) {
|
|
810
|
+
mergeId = _mergeId(mergeId);
|
|
811
|
+
return _hydrateMerge(await _getMergeRow(mergeId));
|
|
812
|
+
},
|
|
813
|
+
|
|
814
|
+
// Every merge that mentions a customer id (as source OR
|
|
815
|
+
// target). Sorted (created_at DESC, id DESC).
|
|
816
|
+
historyForCustomer: async function (customerId) {
|
|
817
|
+
customerId = _customerId(customerId, "customer_id");
|
|
818
|
+
var r = await query(
|
|
819
|
+
"SELECT * FROM customer_merges " +
|
|
820
|
+
"WHERE source_customer_id = ?1 OR target_customer_id = ?1 " +
|
|
821
|
+
"ORDER BY created_at DESC, id DESC",
|
|
822
|
+
[customerId],
|
|
823
|
+
);
|
|
824
|
+
return r.rows.map(_hydrateMerge);
|
|
825
|
+
},
|
|
826
|
+
|
|
827
|
+
// Audit listing. Filters: status + created_at range. Sorted
|
|
828
|
+
// (created_at DESC, id DESC). No cursor — operator-scale
|
|
829
|
+
// audit volume is bounded (dozens to hundreds per year).
|
|
830
|
+
listMerges: async function (listOpts) {
|
|
831
|
+
listOpts = listOpts || {};
|
|
832
|
+
var limit = _limit(listOpts.limit, MAX_LIST_LIMIT, DEFAULT_LIST_LIMIT, "limit");
|
|
833
|
+
_timestampRange(listOpts.from, listOpts.to, "listMerges");
|
|
834
|
+
|
|
835
|
+
var where = [];
|
|
836
|
+
var params = [];
|
|
837
|
+
var idx = 1;
|
|
838
|
+
if (listOpts.status != null) {
|
|
839
|
+
var status = _status(listOpts.status);
|
|
840
|
+
where.push("status = ?" + idx);
|
|
841
|
+
params.push(status);
|
|
842
|
+
idx += 1;
|
|
843
|
+
}
|
|
844
|
+
if (listOpts.from != null) {
|
|
845
|
+
where.push("created_at >= ?" + idx);
|
|
846
|
+
params.push(listOpts.from);
|
|
847
|
+
idx += 1;
|
|
848
|
+
}
|
|
849
|
+
if (listOpts.to != null) {
|
|
850
|
+
where.push("created_at <= ?" + idx);
|
|
851
|
+
params.push(listOpts.to);
|
|
852
|
+
idx += 1;
|
|
853
|
+
}
|
|
854
|
+
var sql = "SELECT * FROM customer_merges";
|
|
855
|
+
if (where.length) sql += " WHERE " + where.join(" AND ");
|
|
856
|
+
sql += " ORDER BY created_at DESC, id DESC LIMIT ?" + idx;
|
|
857
|
+
params.push(limit);
|
|
858
|
+
var r = await query(sql, params);
|
|
859
|
+
return r.rows.map(_hydrateMerge);
|
|
860
|
+
},
|
|
861
|
+
|
|
862
|
+
// Resolve a possibly-stale source_customer_id to the canonical
|
|
863
|
+
// target. Returns the redirect row when one exists, else null.
|
|
864
|
+
redirectFor: async function (customerId) {
|
|
865
|
+
customerId = _customerId(customerId, "customer_id");
|
|
866
|
+
return _hydrateRedirect(await _getRedirectRow(customerId));
|
|
867
|
+
},
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
module.exports = {
|
|
872
|
+
create: create,
|
|
873
|
+
MERGE_STATUSES: MERGE_STATUSES.slice(),
|
|
874
|
+
ROLLBACK_WINDOW_MS: ROLLBACK_WINDOW_MS,
|
|
875
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
876
|
+
MAX_CANDIDATE_LIMIT: MAX_CANDIDATE_LIMIT,
|
|
877
|
+
MAX_REASON_LEN: MAX_REASON_LEN,
|
|
878
|
+
DEFAULT_SIMILARITY: DEFAULT_SIMILARITY,
|
|
879
|
+
};
|