@blamejs/blamejs-shop 0.0.59 → 0.0.61
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 +4 -0
- package/lib/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-import.js +590 -0
- package/lib/customer-portal.js +359 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/experiments.js +697 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/index.js +25 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +945 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
package/lib/tenants.js
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.tenants
|
|
4
|
+
* @title Tenants primitive — multi-store directory + host routing
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* One deployment hosts N branded shops. Each shop is a "tenant"
|
|
8
|
+
* with its own primary domain, optional alt domains (apex / www,
|
|
9
|
+
* legacy hostnames during migration), a default currency + locale,
|
|
10
|
+
* a theme slug, and a lifecycle status (active / paused / archived).
|
|
11
|
+
*
|
|
12
|
+
* The Worker's request entry-point consults `resolveByHost(host)`
|
|
13
|
+
* on every inbound request: returns the tenant row whose
|
|
14
|
+
* `tenant_domains` set contains the host (lowercased, exact-match —
|
|
15
|
+
* the operator owns DNS so wildcard routing is out of scope), or
|
|
16
|
+
* null when no match exists. Archived tenants do not resolve
|
|
17
|
+
* (decommissioned shop's URL goes to 404 / not-found); paused
|
|
18
|
+
* tenants do resolve so the Worker can render a "store temporarily
|
|
19
|
+
* unavailable" page without losing the lookup.
|
|
20
|
+
*
|
|
21
|
+
* Lifecycle FSM:
|
|
22
|
+
* active <-> paused (pauseTenant / resumeTenant)
|
|
23
|
+
* active|paused -> archived (archiveTenant, terminal)
|
|
24
|
+
*
|
|
25
|
+
* Composes:
|
|
26
|
+
* - `b.guardUuid` — UUID-shape validation for ids
|
|
27
|
+
* - `b.uuid.v7` — row ids
|
|
28
|
+
*
|
|
29
|
+
* Surface:
|
|
30
|
+
* defineTenant({ slug, name, primary_domain, alt_domains?,
|
|
31
|
+
* default_currency, default_locale, theme_slug,
|
|
32
|
+
* status? })
|
|
33
|
+
* addDomain(tenant_slug, domain)
|
|
34
|
+
* removeDomain(tenant_slug, domain)
|
|
35
|
+
* setPrimaryDomain(tenant_slug, domain)
|
|
36
|
+
* pauseTenant(tenant_slug, { reason? }) // reason is operator-
|
|
37
|
+
* // facing log breadcrumb,
|
|
38
|
+
* // not persisted (the
|
|
39
|
+
* // tenants row carries
|
|
40
|
+
* // the FSM, audit lives
|
|
41
|
+
* // in the order/admin
|
|
42
|
+
* // timelines)
|
|
43
|
+
* resumeTenant(tenant_slug)
|
|
44
|
+
* archiveTenant(tenant_slug)
|
|
45
|
+
* get(slug) / getById(id)
|
|
46
|
+
* resolveByHost(host)
|
|
47
|
+
* listTenants({ status? })
|
|
48
|
+
* update(slug, patch)
|
|
49
|
+
* stats()
|
|
50
|
+
*
|
|
51
|
+
* Storage:
|
|
52
|
+
* - `tenants` + `tenant_domains` (migration `0063_tenants.sql`).
|
|
53
|
+
*
|
|
54
|
+
* @primitive tenants
|
|
55
|
+
* @related b.guardUuid, b.uuid
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
var MAX_SLUG_LEN = 64;
|
|
59
|
+
var MAX_NAME_LEN = 200;
|
|
60
|
+
var MAX_DOMAIN_LEN = 255;
|
|
61
|
+
var MAX_LOCALE_LEN = 35; // BCP-47 caps well below this
|
|
62
|
+
var MAX_ALT_DOMAINS = 32; // sanity cap on a single defineTenant call
|
|
63
|
+
|
|
64
|
+
var STATUSES = ["active", "paused", "archived"];
|
|
65
|
+
|
|
66
|
+
// Slug: lowercase alphanumerics + dash/underscore; first and last
|
|
67
|
+
// chars must be alphanumeric so the slug round-trips cleanly in URLs
|
|
68
|
+
// and log lines. Empty slug + a single character ("x") are both
|
|
69
|
+
// allowed by the spec — the regex makes the single-char case the
|
|
70
|
+
// edge.
|
|
71
|
+
var SLUG_RE = /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/;
|
|
72
|
+
|
|
73
|
+
// Domain: lowercase host name. First char alphanumeric so leading
|
|
74
|
+
// dots / dashes don't slip through; trailing TLD must be at least two
|
|
75
|
+
// ASCII letters so a bare `localhost` style is refused as a tenant
|
|
76
|
+
// domain (use the operator console for that).
|
|
77
|
+
var DOMAIN_RE = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/;
|
|
78
|
+
|
|
79
|
+
// Locale: BCP-47-ish — letters/digits separated by single hyphens.
|
|
80
|
+
// Generous enough for `en`, `en-US`, `zh-Hant-HK`, refused for
|
|
81
|
+
// embedded whitespace / underscores.
|
|
82
|
+
var LOCALE_RE = /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/;
|
|
83
|
+
|
|
84
|
+
// Currency: ISO 4217 (uppercase 3-letter).
|
|
85
|
+
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
86
|
+
|
|
87
|
+
// Theme slug: same shape as the theme primitive's own gate
|
|
88
|
+
// (`^[a-z0-9][a-z0-9-]{0,63}$`). Re-derive locally rather than
|
|
89
|
+
// importing so the tenants module stays leaf-level.
|
|
90
|
+
var THEME_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
91
|
+
|
|
92
|
+
// Control bytes + zero-width / direction-override family. The name
|
|
93
|
+
// renders in operator dashboards; embedded control / direction-
|
|
94
|
+
// override bytes are a slipping-class for visual-spoofing attacks
|
|
95
|
+
// downstream.
|
|
96
|
+
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
97
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
98
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
var ALLOWED_UPDATE_COLUMNS = Object.freeze([
|
|
102
|
+
"name", "default_currency", "default_locale", "theme_slug",
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
// Lazy framework handle — matches the pattern used by every other
|
|
106
|
+
// shop primitive; avoids the require cycle that would arise from
|
|
107
|
+
// importing `./index` at module-eval time.
|
|
108
|
+
var bShop;
|
|
109
|
+
function _b() {
|
|
110
|
+
if (!bShop) bShop = require("./index");
|
|
111
|
+
return bShop.framework;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- validators ---------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function _slug(s, label) {
|
|
117
|
+
label = label || "slug";
|
|
118
|
+
if (typeof s !== "string") {
|
|
119
|
+
throw new TypeError("tenants: " + label + " must be a string");
|
|
120
|
+
}
|
|
121
|
+
if (s.length === 0 || s.length > MAX_SLUG_LEN) {
|
|
122
|
+
throw new TypeError("tenants: " + label + " must be 1.." + MAX_SLUG_LEN + " characters");
|
|
123
|
+
}
|
|
124
|
+
if (!SLUG_RE.test(s)) {
|
|
125
|
+
throw new TypeError("tenants: " + label + " must match /^[a-z0-9][a-z0-9_-]*[a-z0-9]$/ (single-char alphanumerics also allowed)");
|
|
126
|
+
}
|
|
127
|
+
return s;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _name(s) {
|
|
131
|
+
if (typeof s !== "string") {
|
|
132
|
+
throw new TypeError("tenants: name must be a string");
|
|
133
|
+
}
|
|
134
|
+
var trimmed = s.trim();
|
|
135
|
+
if (!trimmed.length) {
|
|
136
|
+
throw new TypeError("tenants: name must be non-empty after trim");
|
|
137
|
+
}
|
|
138
|
+
if (s.length > MAX_NAME_LEN) {
|
|
139
|
+
throw new TypeError("tenants: name must be <= " + MAX_NAME_LEN + " characters");
|
|
140
|
+
}
|
|
141
|
+
if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
142
|
+
throw new TypeError("tenants: name contains control / zero-width bytes");
|
|
143
|
+
}
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _domain(s, label) {
|
|
148
|
+
label = label || "domain";
|
|
149
|
+
if (typeof s !== "string") {
|
|
150
|
+
throw new TypeError("tenants: " + label + " must be a string");
|
|
151
|
+
}
|
|
152
|
+
if (!s.length || s.length > MAX_DOMAIN_LEN) {
|
|
153
|
+
throw new TypeError("tenants: " + label + " must be 1.." + MAX_DOMAIN_LEN + " characters");
|
|
154
|
+
}
|
|
155
|
+
var lowered = s.toLowerCase();
|
|
156
|
+
if (!DOMAIN_RE.test(lowered)) {
|
|
157
|
+
throw new TypeError("tenants: " + label + " must match /^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$/");
|
|
158
|
+
}
|
|
159
|
+
return lowered;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _currency(s) {
|
|
163
|
+
if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
|
|
164
|
+
throw new TypeError("tenants: default_currency must be a 3-letter uppercase ISO-4217 code");
|
|
165
|
+
}
|
|
166
|
+
return s;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _locale(s) {
|
|
170
|
+
if (typeof s !== "string") {
|
|
171
|
+
throw new TypeError("tenants: default_locale must be a string");
|
|
172
|
+
}
|
|
173
|
+
if (!s.length || s.length > MAX_LOCALE_LEN) {
|
|
174
|
+
throw new TypeError("tenants: default_locale must be 1.." + MAX_LOCALE_LEN + " characters");
|
|
175
|
+
}
|
|
176
|
+
if (!LOCALE_RE.test(s)) {
|
|
177
|
+
throw new TypeError("tenants: default_locale must be a BCP-47-shaped tag (letters/digits separated by single hyphens)");
|
|
178
|
+
}
|
|
179
|
+
return s;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function _themeSlug(s) {
|
|
183
|
+
if (typeof s !== "string" || !THEME_SLUG_RE.test(s)) {
|
|
184
|
+
throw new TypeError("tenants: theme_slug must match /^[a-z0-9][a-z0-9-]{0,63}$/");
|
|
185
|
+
}
|
|
186
|
+
return s;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _status(s) {
|
|
190
|
+
if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
|
|
191
|
+
throw new TypeError("tenants: status must be one of " + STATUSES.join(", "));
|
|
192
|
+
}
|
|
193
|
+
return s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _uuid(s, label) {
|
|
197
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
198
|
+
catch (e) { throw new TypeError("tenants: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function _now() { return Date.now(); }
|
|
202
|
+
|
|
203
|
+
// ---- factory ------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
function create(opts) {
|
|
206
|
+
opts = opts || {};
|
|
207
|
+
var query = opts.query;
|
|
208
|
+
if (!query) {
|
|
209
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function _tenantBySlug(slug) {
|
|
213
|
+
var r = await query("SELECT * FROM tenants WHERE slug = ?1", [slug]);
|
|
214
|
+
return r.rows[0] || null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function _tenantById(id) {
|
|
218
|
+
var r = await query("SELECT * FROM tenants WHERE id = ?1", [id]);
|
|
219
|
+
return r.rows[0] || null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function _domainsFor(tenantId) {
|
|
223
|
+
var r = await query(
|
|
224
|
+
"SELECT * FROM tenant_domains WHERE tenant_id = ?1 ORDER BY is_primary DESC, added_at ASC, domain ASC",
|
|
225
|
+
[tenantId]
|
|
226
|
+
);
|
|
227
|
+
return r.rows;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function _hydrate(row) {
|
|
231
|
+
if (!row) return null;
|
|
232
|
+
var domains = await _domainsFor(row.id);
|
|
233
|
+
var primary = null;
|
|
234
|
+
var alt = [];
|
|
235
|
+
for (var i = 0; i < domains.length; i += 1) {
|
|
236
|
+
if (Number(domains[i].is_primary) === 1) {
|
|
237
|
+
primary = domains[i].domain;
|
|
238
|
+
} else {
|
|
239
|
+
alt.push(domains[i].domain);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
id: row.id,
|
|
244
|
+
slug: row.slug,
|
|
245
|
+
name: row.name,
|
|
246
|
+
default_currency: row.default_currency,
|
|
247
|
+
default_locale: row.default_locale,
|
|
248
|
+
theme_slug: row.theme_slug,
|
|
249
|
+
status: row.status,
|
|
250
|
+
paused_at: row.paused_at == null ? null : Number(row.paused_at),
|
|
251
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
252
|
+
created_at: Number(row.created_at),
|
|
253
|
+
updated_at: Number(row.updated_at),
|
|
254
|
+
primary_domain: primary,
|
|
255
|
+
alt_domains: alt,
|
|
256
|
+
domains: domains.map(function (d) {
|
|
257
|
+
return {
|
|
258
|
+
domain: d.domain,
|
|
259
|
+
is_primary: Number(d.is_primary) === 1,
|
|
260
|
+
added_at: Number(d.added_at),
|
|
261
|
+
};
|
|
262
|
+
}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function _insertDomain(tenantId, domain, isPrimary, ts) {
|
|
267
|
+
var id = _b().uuid.v7();
|
|
268
|
+
try {
|
|
269
|
+
await query(
|
|
270
|
+
"INSERT INTO tenant_domains (id, tenant_id, domain, is_primary, added_at) " +
|
|
271
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
272
|
+
[id, tenantId, domain, isPrimary ? 1 : 0, ts]
|
|
273
|
+
);
|
|
274
|
+
} catch (e) {
|
|
275
|
+
if (e && e.message && e.message.indexOf("UNIQUE") !== -1) {
|
|
276
|
+
var dupe = new Error("tenants: domain '" + domain + "' is already registered");
|
|
277
|
+
dupe.code = "TENANT_DOMAIN_DUPLICATE";
|
|
278
|
+
throw dupe;
|
|
279
|
+
}
|
|
280
|
+
throw e;
|
|
281
|
+
}
|
|
282
|
+
return id;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
STATUSES: STATUSES.slice(),
|
|
287
|
+
SLUG_RE: SLUG_RE,
|
|
288
|
+
DOMAIN_RE: DOMAIN_RE,
|
|
289
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
290
|
+
MAX_NAME_LEN: MAX_NAME_LEN,
|
|
291
|
+
MAX_DOMAIN_LEN: MAX_DOMAIN_LEN,
|
|
292
|
+
MAX_LOCALE_LEN: MAX_LOCALE_LEN,
|
|
293
|
+
MAX_ALT_DOMAINS: MAX_ALT_DOMAINS,
|
|
294
|
+
|
|
295
|
+
defineTenant: async function (input) {
|
|
296
|
+
if (!input || typeof input !== "object") {
|
|
297
|
+
throw new TypeError("tenants.defineTenant: input object required");
|
|
298
|
+
}
|
|
299
|
+
var slug = _slug(input.slug, "slug");
|
|
300
|
+
var name = _name(input.name);
|
|
301
|
+
var primaryDomain = _domain(input.primary_domain, "primary_domain");
|
|
302
|
+
var defaultCurrency = _currency(input.default_currency);
|
|
303
|
+
var defaultLocale = _locale(input.default_locale);
|
|
304
|
+
var themeSlug = _themeSlug(input.theme_slug);
|
|
305
|
+
var status = input.status == null ? "active" : _status(input.status);
|
|
306
|
+
|
|
307
|
+
var altDomains = [];
|
|
308
|
+
if (input.alt_domains != null) {
|
|
309
|
+
if (!Array.isArray(input.alt_domains)) {
|
|
310
|
+
throw new TypeError("tenants.defineTenant: alt_domains must be an array or null");
|
|
311
|
+
}
|
|
312
|
+
if (input.alt_domains.length > MAX_ALT_DOMAINS) {
|
|
313
|
+
throw new TypeError("tenants.defineTenant: alt_domains must be <= " + MAX_ALT_DOMAINS + " entries");
|
|
314
|
+
}
|
|
315
|
+
for (var i = 0; i < input.alt_domains.length; i += 1) {
|
|
316
|
+
var d = _domain(input.alt_domains[i], "alt_domains[" + i + "]");
|
|
317
|
+
if (d === primaryDomain) {
|
|
318
|
+
throw new TypeError("tenants.defineTenant: alt_domains[" + i + "] duplicates primary_domain");
|
|
319
|
+
}
|
|
320
|
+
if (altDomains.indexOf(d) !== -1) {
|
|
321
|
+
throw new TypeError("tenants.defineTenant: alt_domains[" + i + "] is duplicated within alt_domains");
|
|
322
|
+
}
|
|
323
|
+
altDomains.push(d);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Slug + primary domain uniqueness enforced by the SQL layer.
|
|
328
|
+
// Surface a typed error instead of leaking the SQL message.
|
|
329
|
+
var existing = await _tenantBySlug(slug);
|
|
330
|
+
if (existing) {
|
|
331
|
+
var dupe = new Error("tenants.defineTenant: slug '" + slug + "' is already registered");
|
|
332
|
+
dupe.code = "TENANT_SLUG_DUPLICATE";
|
|
333
|
+
throw dupe;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
var id = _b().uuid.v7();
|
|
337
|
+
var ts = _now();
|
|
338
|
+
try {
|
|
339
|
+
await query(
|
|
340
|
+
"INSERT INTO tenants " +
|
|
341
|
+
"(id, slug, name, default_currency, default_locale, theme_slug, " +
|
|
342
|
+
" status, paused_at, archived_at, created_at, updated_at) " +
|
|
343
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, NULL, ?8, ?8)",
|
|
344
|
+
[id, slug, name, defaultCurrency, defaultLocale, themeSlug, status, ts]
|
|
345
|
+
);
|
|
346
|
+
} catch (e) {
|
|
347
|
+
if (e && e.message && e.message.indexOf("UNIQUE") !== -1) {
|
|
348
|
+
var slugDupe = new Error("tenants.defineTenant: slug '" + slug + "' is already registered");
|
|
349
|
+
slugDupe.code = "TENANT_SLUG_DUPLICATE";
|
|
350
|
+
throw slugDupe;
|
|
351
|
+
}
|
|
352
|
+
throw e;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await _insertDomain(id, primaryDomain, true, ts);
|
|
356
|
+
for (var j = 0; j < altDomains.length; j += 1) {
|
|
357
|
+
await _insertDomain(id, altDomains[j], false, ts);
|
|
358
|
+
}
|
|
359
|
+
return await _hydrate(await _tenantById(id));
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
addDomain: async function (tenantSlug, domain) {
|
|
363
|
+
var slug = _slug(tenantSlug, "tenant_slug");
|
|
364
|
+
var lowered = _domain(domain, "domain");
|
|
365
|
+
var row = await _tenantBySlug(slug);
|
|
366
|
+
if (!row) {
|
|
367
|
+
var miss = new Error("tenants.addDomain: tenant '" + slug + "' not found");
|
|
368
|
+
miss.code = "TENANT_NOT_FOUND";
|
|
369
|
+
throw miss;
|
|
370
|
+
}
|
|
371
|
+
if (row.status === "archived") {
|
|
372
|
+
var arch = new Error("tenants.addDomain: tenant '" + slug + "' is archived");
|
|
373
|
+
arch.code = "TENANT_ARCHIVED";
|
|
374
|
+
throw arch;
|
|
375
|
+
}
|
|
376
|
+
await _insertDomain(row.id, lowered, false, _now());
|
|
377
|
+
await query("UPDATE tenants SET updated_at = ?1 WHERE id = ?2", [_now(), row.id]);
|
|
378
|
+
return await _hydrate(await _tenantById(row.id));
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
removeDomain: async function (tenantSlug, domain) {
|
|
382
|
+
var slug = _slug(tenantSlug, "tenant_slug");
|
|
383
|
+
var lowered = _domain(domain, "domain");
|
|
384
|
+
var row = await _tenantBySlug(slug);
|
|
385
|
+
if (!row) {
|
|
386
|
+
var miss = new Error("tenants.removeDomain: tenant '" + slug + "' not found");
|
|
387
|
+
miss.code = "TENANT_NOT_FOUND";
|
|
388
|
+
throw miss;
|
|
389
|
+
}
|
|
390
|
+
var d = await query(
|
|
391
|
+
"SELECT * FROM tenant_domains WHERE tenant_id = ?1 AND domain = ?2",
|
|
392
|
+
[row.id, lowered]
|
|
393
|
+
);
|
|
394
|
+
if (!d.rows.length) {
|
|
395
|
+
var dm = new Error("tenants.removeDomain: domain '" + lowered + "' is not registered to tenant '" + slug + "'");
|
|
396
|
+
dm.code = "TENANT_DOMAIN_NOT_FOUND";
|
|
397
|
+
throw dm;
|
|
398
|
+
}
|
|
399
|
+
if (Number(d.rows[0].is_primary) === 1) {
|
|
400
|
+
var pri = new Error("tenants.removeDomain: refused — '" + lowered + "' is the primary domain; setPrimaryDomain to another first");
|
|
401
|
+
pri.code = "TENANT_DOMAIN_PRIMARY_REFUSED";
|
|
402
|
+
throw pri;
|
|
403
|
+
}
|
|
404
|
+
await query("DELETE FROM tenant_domains WHERE id = ?1", [d.rows[0].id]);
|
|
405
|
+
await query("UPDATE tenants SET updated_at = ?1 WHERE id = ?2", [_now(), row.id]);
|
|
406
|
+
return await _hydrate(await _tenantById(row.id));
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
setPrimaryDomain: async function (tenantSlug, domain) {
|
|
410
|
+
var slug = _slug(tenantSlug, "tenant_slug");
|
|
411
|
+
var lowered = _domain(domain, "domain");
|
|
412
|
+
var row = await _tenantBySlug(slug);
|
|
413
|
+
if (!row) {
|
|
414
|
+
var miss = new Error("tenants.setPrimaryDomain: tenant '" + slug + "' not found");
|
|
415
|
+
miss.code = "TENANT_NOT_FOUND";
|
|
416
|
+
throw miss;
|
|
417
|
+
}
|
|
418
|
+
var d = await query(
|
|
419
|
+
"SELECT * FROM tenant_domains WHERE tenant_id = ?1 AND domain = ?2",
|
|
420
|
+
[row.id, lowered]
|
|
421
|
+
);
|
|
422
|
+
if (!d.rows.length) {
|
|
423
|
+
var dm = new Error("tenants.setPrimaryDomain: domain '" + lowered + "' is not registered to tenant '" + slug + "'");
|
|
424
|
+
dm.code = "TENANT_DOMAIN_NOT_FOUND";
|
|
425
|
+
throw dm;
|
|
426
|
+
}
|
|
427
|
+
// Demote every other domain on this tenant before promoting the
|
|
428
|
+
// target. Two writes inside the same query stream keeps the
|
|
429
|
+
// invariant (exactly one primary per tenant) regardless of
|
|
430
|
+
// whether the underlying engine batches.
|
|
431
|
+
await query(
|
|
432
|
+
"UPDATE tenant_domains SET is_primary = 0 WHERE tenant_id = ?1 AND domain != ?2",
|
|
433
|
+
[row.id, lowered]
|
|
434
|
+
);
|
|
435
|
+
await query(
|
|
436
|
+
"UPDATE tenant_domains SET is_primary = 1 WHERE tenant_id = ?1 AND domain = ?2",
|
|
437
|
+
[row.id, lowered]
|
|
438
|
+
);
|
|
439
|
+
await query("UPDATE tenants SET updated_at = ?1 WHERE id = ?2", [_now(), row.id]);
|
|
440
|
+
return await _hydrate(await _tenantById(row.id));
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
pauseTenant: async function (tenantSlug) {
|
|
444
|
+
var slug = _slug(tenantSlug, "tenant_slug");
|
|
445
|
+
var row = await _tenantBySlug(slug);
|
|
446
|
+
if (!row) return null;
|
|
447
|
+
if (row.status === "archived") {
|
|
448
|
+
var arch = new Error("tenants.pauseTenant: refused — tenant '" + slug + "' is archived");
|
|
449
|
+
arch.code = "TENANT_TRANSITION_REFUSED";
|
|
450
|
+
throw arch;
|
|
451
|
+
}
|
|
452
|
+
if (row.status === "paused") {
|
|
453
|
+
return await _hydrate(row);
|
|
454
|
+
}
|
|
455
|
+
var ts = _now();
|
|
456
|
+
await query(
|
|
457
|
+
"UPDATE tenants SET status = 'paused', paused_at = ?1, updated_at = ?1 WHERE id = ?2",
|
|
458
|
+
[ts, row.id]
|
|
459
|
+
);
|
|
460
|
+
return await _hydrate(await _tenantById(row.id));
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
resumeTenant: async function (tenantSlug) {
|
|
464
|
+
var slug = _slug(tenantSlug, "tenant_slug");
|
|
465
|
+
var row = await _tenantBySlug(slug);
|
|
466
|
+
if (!row) return null;
|
|
467
|
+
if (row.status === "archived") {
|
|
468
|
+
var arch = new Error("tenants.resumeTenant: refused — tenant '" + slug + "' is archived");
|
|
469
|
+
arch.code = "TENANT_TRANSITION_REFUSED";
|
|
470
|
+
throw arch;
|
|
471
|
+
}
|
|
472
|
+
if (row.status === "active") {
|
|
473
|
+
return await _hydrate(row);
|
|
474
|
+
}
|
|
475
|
+
var ts = _now();
|
|
476
|
+
await query(
|
|
477
|
+
"UPDATE tenants SET status = 'active', paused_at = NULL, updated_at = ?1 WHERE id = ?2",
|
|
478
|
+
[ts, row.id]
|
|
479
|
+
);
|
|
480
|
+
return await _hydrate(await _tenantById(row.id));
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
archiveTenant: async function (tenantSlug) {
|
|
484
|
+
var slug = _slug(tenantSlug, "tenant_slug");
|
|
485
|
+
var row = await _tenantBySlug(slug);
|
|
486
|
+
if (!row) return null;
|
|
487
|
+
if (row.status === "archived") {
|
|
488
|
+
return await _hydrate(row);
|
|
489
|
+
}
|
|
490
|
+
var ts = _now();
|
|
491
|
+
await query(
|
|
492
|
+
"UPDATE tenants SET status = 'archived', archived_at = ?1, updated_at = ?1 WHERE id = ?2",
|
|
493
|
+
[ts, row.id]
|
|
494
|
+
);
|
|
495
|
+
return await _hydrate(await _tenantById(row.id));
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
get: async function (tenantSlug) {
|
|
499
|
+
var slug = _slug(tenantSlug, "tenant_slug");
|
|
500
|
+
return await _hydrate(await _tenantBySlug(slug));
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
getById: async function (tenantId) {
|
|
504
|
+
var id = _uuid(tenantId, "tenant_id");
|
|
505
|
+
return await _hydrate(await _tenantById(id));
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
// Nearest-domain match: case-insensitive exact match against the
|
|
509
|
+
// tenant_domains set. Archived tenants do NOT resolve — their
|
|
510
|
+
// domains stop serving once archived. Paused tenants DO resolve
|
|
511
|
+
// so the Worker can render a "store temporarily unavailable" page
|
|
512
|
+
// and keep the lookup deterministic.
|
|
513
|
+
resolveByHost: async function (host) {
|
|
514
|
+
if (typeof host !== "string" || !host.length) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
// Strip an optional port suffix (":443", ":8787") — operator-
|
|
518
|
+
// facing log lines sometimes carry it; the tenant_domains table
|
|
519
|
+
// never does.
|
|
520
|
+
var stripped = host;
|
|
521
|
+
var colon = stripped.indexOf(":");
|
|
522
|
+
if (colon !== -1) stripped = stripped.slice(0, colon);
|
|
523
|
+
var lowered = stripped.toLowerCase();
|
|
524
|
+
if (!DOMAIN_RE.test(lowered)) {
|
|
525
|
+
// resolveByHost is on the request hot path — return null
|
|
526
|
+
// instead of throwing so a junk Host header on a bot probe
|
|
527
|
+
// doesn't surface as a stack trace.
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
var r = await query(
|
|
531
|
+
"SELECT t.* FROM tenant_domains d JOIN tenants t ON t.id = d.tenant_id " +
|
|
532
|
+
"WHERE d.domain = ?1 AND t.status != 'archived' LIMIT 1",
|
|
533
|
+
[lowered]
|
|
534
|
+
);
|
|
535
|
+
if (!r.rows.length) return null;
|
|
536
|
+
return await _hydrate(r.rows[0]);
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
listTenants: async function (listOpts) {
|
|
540
|
+
listOpts = listOpts || {};
|
|
541
|
+
var sql, params;
|
|
542
|
+
if (listOpts.status != null) {
|
|
543
|
+
var s = _status(listOpts.status);
|
|
544
|
+
sql = "SELECT * FROM tenants WHERE status = ?1 ORDER BY created_at DESC, slug ASC";
|
|
545
|
+
params = [s];
|
|
546
|
+
} else {
|
|
547
|
+
sql = "SELECT * FROM tenants ORDER BY created_at DESC, slug ASC";
|
|
548
|
+
params = [];
|
|
549
|
+
}
|
|
550
|
+
var r = await query(sql, params);
|
|
551
|
+
var out = [];
|
|
552
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
553
|
+
out.push(await _hydrate(r.rows[i]));
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
update: async function (tenantSlug, patch) {
|
|
559
|
+
var slug = _slug(tenantSlug, "tenant_slug");
|
|
560
|
+
if (!patch || typeof patch !== "object") {
|
|
561
|
+
throw new TypeError("tenants.update: patch object required");
|
|
562
|
+
}
|
|
563
|
+
var keys = Object.keys(patch);
|
|
564
|
+
if (!keys.length) {
|
|
565
|
+
throw new TypeError("tenants.update: patch must contain at least one column");
|
|
566
|
+
}
|
|
567
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
568
|
+
if (ALLOWED_UPDATE_COLUMNS.indexOf(keys[i]) === -1) {
|
|
569
|
+
throw new TypeError("tenants.update: column '" + keys[i] + "' not updatable");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
var row = await _tenantBySlug(slug);
|
|
573
|
+
if (!row) return null;
|
|
574
|
+
if (row.status === "archived") {
|
|
575
|
+
var arch = new Error("tenants.update: refused — tenant '" + slug + "' is archived");
|
|
576
|
+
arch.code = "TENANT_ARCHIVED";
|
|
577
|
+
throw arch;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
var sets = [];
|
|
581
|
+
var params = [];
|
|
582
|
+
var idx = 1;
|
|
583
|
+
function _set(col, val) {
|
|
584
|
+
sets.push(col + " = ?" + idx);
|
|
585
|
+
params.push(val);
|
|
586
|
+
idx += 1;
|
|
587
|
+
}
|
|
588
|
+
if (patch.name != null) _set("name", _name(patch.name));
|
|
589
|
+
if (patch.default_currency != null) _set("default_currency", _currency(patch.default_currency));
|
|
590
|
+
if (patch.default_locale != null) _set("default_locale", _locale(patch.default_locale));
|
|
591
|
+
if (patch.theme_slug != null) _set("theme_slug", _themeSlug(patch.theme_slug));
|
|
592
|
+
|
|
593
|
+
var ts = _now();
|
|
594
|
+
_set("updated_at", ts);
|
|
595
|
+
params.push(row.id);
|
|
596
|
+
var sql = "UPDATE tenants SET " + sets.join(", ") + " WHERE id = ?" + idx;
|
|
597
|
+
await query(sql, params);
|
|
598
|
+
return await _hydrate(await _tenantById(row.id));
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
// Operator dashboard — shape:
|
|
602
|
+
// {
|
|
603
|
+
// active_count, paused_count, archived_count,
|
|
604
|
+
// total_domains,
|
|
605
|
+
// per_tenant: [{ tenant_id, slug, status, domain_count }, ...],
|
|
606
|
+
// }
|
|
607
|
+
//
|
|
608
|
+
// `per_tenant` is the per-shop rollup hint the order / customer
|
|
609
|
+
// primitives join against for "orders by tenant" / "customers by
|
|
610
|
+
// tenant" pages. The order + customer rollups themselves live in
|
|
611
|
+
// those primitives (this primitive's stats() only carries what
|
|
612
|
+
// the tenants directory knows — the domain count + status).
|
|
613
|
+
stats: async function () {
|
|
614
|
+
var counts = await query(
|
|
615
|
+
"SELECT status, COUNT(*) AS n FROM tenants GROUP BY status",
|
|
616
|
+
[]
|
|
617
|
+
);
|
|
618
|
+
var summary = { active: 0, paused: 0, archived: 0 };
|
|
619
|
+
for (var i = 0; i < counts.rows.length; i += 1) {
|
|
620
|
+
var row = counts.rows[i];
|
|
621
|
+
if (summary[row.status] != null) {
|
|
622
|
+
summary[row.status] = Number(row.n);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
var totalDomains = await query(
|
|
626
|
+
"SELECT COUNT(*) AS n FROM tenant_domains",
|
|
627
|
+
[]
|
|
628
|
+
);
|
|
629
|
+
var perTenant = await query(
|
|
630
|
+
"SELECT t.id AS tenant_id, t.slug AS slug, t.status AS status, " +
|
|
631
|
+
" COUNT(d.id) AS domain_count " +
|
|
632
|
+
"FROM tenants t LEFT JOIN tenant_domains d ON d.tenant_id = t.id " +
|
|
633
|
+
"GROUP BY t.id, t.slug, t.status " +
|
|
634
|
+
"ORDER BY t.created_at DESC, t.slug ASC",
|
|
635
|
+
[]
|
|
636
|
+
);
|
|
637
|
+
return {
|
|
638
|
+
active_count: summary.active,
|
|
639
|
+
paused_count: summary.paused,
|
|
640
|
+
archived_count: summary.archived,
|
|
641
|
+
total_domains: Number((totalDomains.rows[0] && totalDomains.rows[0].n) || 0),
|
|
642
|
+
per_tenant: perTenant.rows.map(function (r) {
|
|
643
|
+
return {
|
|
644
|
+
tenant_id: r.tenant_id,
|
|
645
|
+
slug: r.slug,
|
|
646
|
+
status: r.status,
|
|
647
|
+
domain_count: Number(r.domain_count || 0),
|
|
648
|
+
};
|
|
649
|
+
}),
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
module.exports = {
|
|
656
|
+
create: create,
|
|
657
|
+
STATUSES: STATUSES.slice(),
|
|
658
|
+
SLUG_RE: SLUG_RE,
|
|
659
|
+
DOMAIN_RE: DOMAIN_RE,
|
|
660
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
661
|
+
MAX_NAME_LEN: MAX_NAME_LEN,
|
|
662
|
+
MAX_DOMAIN_LEN: MAX_DOMAIN_LEN,
|
|
663
|
+
MAX_LOCALE_LEN: MAX_LOCALE_LEN,
|
|
664
|
+
MAX_ALT_DOMAINS: MAX_ALT_DOMAINS,
|
|
665
|
+
};
|