@blamejs/blamejs-shop 0.0.64 → 0.0.66
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/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/business-hours.js +980 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +30 -0
- package/lib/metered-usage.js +782 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/preorder.js +595 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/site-redirects.js +690 -0
- package/lib/split-shipments.js +773 -0
- package/lib/theme-assets.js +711 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.customerRoles
|
|
4
|
+
* @title Customer roles — B2B company-customer + employee logins
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* The `customers` primitive (lib/customers.js, migration 0006)
|
|
8
|
+
* models a single passkey-enrolled buyer per row. That shape fits
|
|
9
|
+
* direct-to-consumer storefronts cleanly. It does NOT fit B2B,
|
|
10
|
+
* where the buying entity is a company and several humans log in
|
|
11
|
+
* on its behalf, each carrying a different authority — a junior
|
|
12
|
+
* buyer who can drop items into the cart but cannot submit the
|
|
13
|
+
* purchase order, a manager who can submit but cannot exceed a
|
|
14
|
+
* payment-terms threshold, an admin who can mint and revoke
|
|
15
|
+
* employee logins. This primitive layers that role-and-capability
|
|
16
|
+
* structure on top of `customers` without modifying the underlying
|
|
17
|
+
* table.
|
|
18
|
+
*
|
|
19
|
+
* Composition rule: a "company customer" is just a regular row in
|
|
20
|
+
* `customers` (the operator decides whether it represents an
|
|
21
|
+
* organization or an individual). An "employee customer" is
|
|
22
|
+
* another regular row in `customers`. The link between them is
|
|
23
|
+
* `customer_role_assignments`, which carries the role slug that
|
|
24
|
+
* gates what the employee can do for the company.
|
|
25
|
+
*
|
|
26
|
+
* Roles are operator-authored. Each role names a capability set
|
|
27
|
+
* drawn from a closed enum:
|
|
28
|
+
*
|
|
29
|
+
* can_view_orders — see existing orders for the company
|
|
30
|
+
* can_place_order — submit a new order on behalf
|
|
31
|
+
* can_approve_order — sign off on an order that another
|
|
32
|
+
* employee staged (the audit row
|
|
33
|
+
* lands in `customer_order_approvals`
|
|
34
|
+
* via `recordOrderApproval`)
|
|
35
|
+
* can_manage_users — assign / unassign other employees
|
|
36
|
+
* can_view_pricing — see operator-confidential prices
|
|
37
|
+
* (negotiated discounts, contract
|
|
38
|
+
* rates, B2B price lists)
|
|
39
|
+
* can_apply_payment_terms — flip an order to net-30 / net-60
|
|
40
|
+
* instead of pay-now
|
|
41
|
+
* can_request_quote — open a sales-channel quote
|
|
42
|
+
* can_view_invoices — pull historical invoices for the
|
|
43
|
+
* company
|
|
44
|
+
*
|
|
45
|
+
* The enum is closed at this layer — `defineRole` refuses any
|
|
46
|
+
* capability token outside the allow-list, so a typo doesn't
|
|
47
|
+
* silently produce a role that grants nothing. Adding a new
|
|
48
|
+
* capability is a code change here, plus the operator re-defining
|
|
49
|
+
* the affected roles.
|
|
50
|
+
*
|
|
51
|
+
* Surface:
|
|
52
|
+
*
|
|
53
|
+
* - defineRole({ slug, title, capabilities: [...] })
|
|
54
|
+
* Create a role. `slug` is the stable operator handle
|
|
55
|
+
* (UNIQUE). Redefinition is refused — operators mutate via
|
|
56
|
+
* `updateRole`.
|
|
57
|
+
*
|
|
58
|
+
* - assignRole({ company_customer_id, employee_customer_id,
|
|
59
|
+
* role_slug })
|
|
60
|
+
* Attach the employee to the company at the named role. The
|
|
61
|
+
* pair (company, employee) is UNIQUE — re-assigning the same
|
|
62
|
+
* employee replaces the prior role row. Archived roles
|
|
63
|
+
* refuse new assignments; existing assignments keep
|
|
64
|
+
* resolving `hasCapability` (so an in-flight quote doesn't
|
|
65
|
+
* lose authority while operators rotate role definitions).
|
|
66
|
+
*
|
|
67
|
+
* - unassignRole({ company_customer_id, employee_customer_id })
|
|
68
|
+
* Remove the role binding. Idempotent — re-removing returns
|
|
69
|
+
* false. The employee's `customers` row is NOT touched; only
|
|
70
|
+
* the role assignment goes away.
|
|
71
|
+
*
|
|
72
|
+
* - rolesForEmployee({ employee_customer_id, company_customer_id? })
|
|
73
|
+
* Returns the assignment rows the employee carries. With
|
|
74
|
+
* `company_customer_id` set, scopes to that single pairing.
|
|
75
|
+
* Useful for "switch active company" UIs.
|
|
76
|
+
*
|
|
77
|
+
* - employeesForCompany(company_customer_id)
|
|
78
|
+
* Returns every assignment row the company has, employee +
|
|
79
|
+
* role joined into one row per employee.
|
|
80
|
+
*
|
|
81
|
+
* - hasCapability({ employee_customer_id, company_customer_id,
|
|
82
|
+
* capability })
|
|
83
|
+
* The fast-path gate. Returns true iff the (company,
|
|
84
|
+
* employee) pair has an assignment, the role row exists, and
|
|
85
|
+
* the role's capability list contains the requested
|
|
86
|
+
* capability. Missing assignment, missing role, archived
|
|
87
|
+
* role's capability stripped — every miss returns false.
|
|
88
|
+
*
|
|
89
|
+
* - getRole(slug) / listRoles({ active_only?, limit? }) /
|
|
90
|
+
* updateRole(slug, patch) / archiveRole(slug)
|
|
91
|
+
* Read + mutate role definitions. `updateRole` accepts
|
|
92
|
+
* `title` and / or `capabilities`; nothing else is allowed
|
|
93
|
+
* (slug is immutable).
|
|
94
|
+
*
|
|
95
|
+
* - recordOrderApproval({ order_id, approved_by, role_slug })
|
|
96
|
+
* Audit hook for role-gated actions. The caller composes
|
|
97
|
+
* `hasCapability(can_approve_order)` then this — the
|
|
98
|
+
* primitive doesn't double-check capability so the same
|
|
99
|
+
* function can record any role-gated audit (the role slug
|
|
100
|
+
* is captured so the auditor sees which authority the
|
|
101
|
+
* employee invoked under). Append-only.
|
|
102
|
+
*
|
|
103
|
+
* Storage:
|
|
104
|
+
* - `customer_roles`, `customer_role_assignments`,
|
|
105
|
+
* `customer_order_approvals` (migration
|
|
106
|
+
* `0101_customer_roles.sql`).
|
|
107
|
+
*
|
|
108
|
+
* @primitive customerRoles
|
|
109
|
+
* @related customers, order, operatorAuditLog
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
// ---- constants ----------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
var MAX_SLUG_LEN = 80;
|
|
115
|
+
var MAX_TITLE_LEN = 200;
|
|
116
|
+
var MAX_LIST_LIMIT = 200;
|
|
117
|
+
|
|
118
|
+
// Slug shape matches the rest of the codebase — alnum-leading, alnum +
|
|
119
|
+
// hyphen + underscore + dot, capped length.
|
|
120
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
121
|
+
|
|
122
|
+
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
123
|
+
|
|
124
|
+
// Closed capability enum. Adding a new capability is a code change
|
|
125
|
+
// here + the operator re-defines the affected roles.
|
|
126
|
+
var CAPABILITIES = Object.freeze([
|
|
127
|
+
"can_view_orders",
|
|
128
|
+
"can_place_order",
|
|
129
|
+
"can_approve_order",
|
|
130
|
+
"can_manage_users",
|
|
131
|
+
"can_view_pricing",
|
|
132
|
+
"can_apply_payment_terms",
|
|
133
|
+
"can_request_quote",
|
|
134
|
+
"can_view_invoices",
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze(["title", "capabilities"]);
|
|
138
|
+
|
|
139
|
+
var bShop;
|
|
140
|
+
function _b() {
|
|
141
|
+
if (!bShop) bShop = require("./index");
|
|
142
|
+
return bShop.framework;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- validators ---------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function _slug(s, label) {
|
|
148
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
149
|
+
throw new TypeError("customerRoles: " + (label || "slug") +
|
|
150
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
|
|
151
|
+
}
|
|
152
|
+
return s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _title(s) {
|
|
156
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
157
|
+
throw new TypeError("customerRoles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
158
|
+
}
|
|
159
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
160
|
+
throw new TypeError("customerRoles: title must not contain control bytes");
|
|
161
|
+
}
|
|
162
|
+
return s;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _capability(s) {
|
|
166
|
+
if (typeof s !== "string" || CAPABILITIES.indexOf(s) === -1) {
|
|
167
|
+
throw new TypeError("customerRoles: capability " + JSON.stringify(s) +
|
|
168
|
+
" is not in the allow-list (" + CAPABILITIES.join(", ") + ")");
|
|
169
|
+
}
|
|
170
|
+
return s;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _capabilities(arr) {
|
|
174
|
+
if (!Array.isArray(arr) || !arr.length) {
|
|
175
|
+
throw new TypeError("customerRoles: capabilities must be a non-empty array of allow-list tokens");
|
|
176
|
+
}
|
|
177
|
+
var seen = Object.create(null);
|
|
178
|
+
var out = [];
|
|
179
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
180
|
+
var c = _capability(arr[i]);
|
|
181
|
+
if (seen[c]) {
|
|
182
|
+
throw new TypeError("customerRoles: capabilities contains duplicate " + JSON.stringify(c));
|
|
183
|
+
}
|
|
184
|
+
seen[c] = true;
|
|
185
|
+
out.push(c);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function _uuid(s, label) {
|
|
191
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
192
|
+
catch (e) { throw new TypeError("customerRoles: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function _opaqueId(s, label) {
|
|
196
|
+
if (typeof s !== "string" || !s.length || s.length > 200) {
|
|
197
|
+
throw new TypeError("customerRoles: " + label + " must be a non-empty string (<= 200 chars)");
|
|
198
|
+
}
|
|
199
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
200
|
+
throw new TypeError("customerRoles: " + label + " must not contain control bytes");
|
|
201
|
+
}
|
|
202
|
+
return s;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _now() { return Date.now(); }
|
|
206
|
+
|
|
207
|
+
// ---- row hydration ------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
function _safeParseArray(s) {
|
|
210
|
+
if (s == null) return [];
|
|
211
|
+
try {
|
|
212
|
+
var parsed = JSON.parse(s);
|
|
213
|
+
if (Array.isArray(parsed)) return parsed;
|
|
214
|
+
return [];
|
|
215
|
+
} catch (_e) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function _hydrateRole(r) {
|
|
221
|
+
if (!r) return null;
|
|
222
|
+
// Re-filter capabilities through the allow-list on read so a
|
|
223
|
+
// hand-edited or migration-era row that carries a stale token is
|
|
224
|
+
// silently dropped instead of polluting the operator surface.
|
|
225
|
+
var raw = _safeParseArray(r.capabilities_json);
|
|
226
|
+
var caps = [];
|
|
227
|
+
for (var i = 0; i < raw.length; i += 1) {
|
|
228
|
+
if (typeof raw[i] === "string" && CAPABILITIES.indexOf(raw[i]) !== -1) {
|
|
229
|
+
caps.push(raw[i]);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
slug: r.slug,
|
|
234
|
+
title: r.title,
|
|
235
|
+
capabilities: caps,
|
|
236
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
237
|
+
created_at: Number(r.created_at),
|
|
238
|
+
updated_at: Number(r.updated_at),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _hydrateAssignment(r) {
|
|
243
|
+
if (!r) return null;
|
|
244
|
+
return {
|
|
245
|
+
id: r.id,
|
|
246
|
+
company_customer_id: r.company_customer_id,
|
|
247
|
+
employee_customer_id: r.employee_customer_id,
|
|
248
|
+
role_slug: r.role_slug,
|
|
249
|
+
assigned_at: Number(r.assigned_at),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function _hydrateApproval(r) {
|
|
254
|
+
if (!r) return null;
|
|
255
|
+
return {
|
|
256
|
+
id: r.id,
|
|
257
|
+
order_id: r.order_id,
|
|
258
|
+
approved_by: r.approved_by,
|
|
259
|
+
role_slug: r.role_slug,
|
|
260
|
+
occurred_at: Number(r.occurred_at),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- factory ------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
function create(opts) {
|
|
267
|
+
opts = opts || {};
|
|
268
|
+
var query = opts.query;
|
|
269
|
+
if (!query) {
|
|
270
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
271
|
+
}
|
|
272
|
+
// `customers` integration is optional — the primitive enforces UUID
|
|
273
|
+
// shape on every id parameter, but if the operator wires the live
|
|
274
|
+
// `customers` primitive in we additionally verify the referenced
|
|
275
|
+
// customer row exists before assigning. Tests pass a stub that
|
|
276
|
+
// satisfies the `.get(id)` contract; production wires the real
|
|
277
|
+
// `bShop.customers.create(...)` instance.
|
|
278
|
+
var customers = opts.customers || null;
|
|
279
|
+
|
|
280
|
+
async function _requireCustomer(id, label) {
|
|
281
|
+
if (!customers) return;
|
|
282
|
+
var row = await customers.get(id);
|
|
283
|
+
if (!row) {
|
|
284
|
+
throw new TypeError("customerRoles: " + label + " " + JSON.stringify(id) + " not found in customers");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- defineRole ----------------------------------------------------
|
|
289
|
+
|
|
290
|
+
async function defineRole(input) {
|
|
291
|
+
if (!input || typeof input !== "object") {
|
|
292
|
+
throw new TypeError("customerRoles.defineRole: input object required");
|
|
293
|
+
}
|
|
294
|
+
var slug = _slug(input.slug);
|
|
295
|
+
var title = _title(input.title);
|
|
296
|
+
var caps = _capabilities(input.capabilities);
|
|
297
|
+
|
|
298
|
+
var existing = (await query(
|
|
299
|
+
"SELECT slug FROM customer_roles WHERE slug = ?1 LIMIT 1",
|
|
300
|
+
[slug],
|
|
301
|
+
)).rows[0];
|
|
302
|
+
if (existing) {
|
|
303
|
+
throw new TypeError("customerRoles.defineRole: slug " + JSON.stringify(slug) +
|
|
304
|
+
" already exists - use updateRole");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
var ts = _now();
|
|
308
|
+
await query(
|
|
309
|
+
"INSERT INTO customer_roles (slug, title, capabilities_json, archived_at, created_at, updated_at) " +
|
|
310
|
+
"VALUES (?1, ?2, ?3, NULL, ?4, ?4)",
|
|
311
|
+
[slug, title, JSON.stringify(caps), ts],
|
|
312
|
+
);
|
|
313
|
+
return await getRole(slug);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- getRole / listRoles -------------------------------------------
|
|
317
|
+
|
|
318
|
+
async function getRole(slug) {
|
|
319
|
+
_slug(slug);
|
|
320
|
+
var r = (await query(
|
|
321
|
+
"SELECT * FROM customer_roles WHERE slug = ?1 LIMIT 1",
|
|
322
|
+
[slug],
|
|
323
|
+
)).rows[0];
|
|
324
|
+
return _hydrateRole(r);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function listRoles(listOpts) {
|
|
328
|
+
listOpts = listOpts || {};
|
|
329
|
+
var activeOnly = false;
|
|
330
|
+
if (listOpts.active_only != null) {
|
|
331
|
+
if (typeof listOpts.active_only !== "boolean") {
|
|
332
|
+
throw new TypeError("customerRoles.listRoles: active_only must be a boolean");
|
|
333
|
+
}
|
|
334
|
+
activeOnly = listOpts.active_only;
|
|
335
|
+
}
|
|
336
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
337
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
338
|
+
throw new TypeError("customerRoles.listRoles: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
339
|
+
}
|
|
340
|
+
var sql, params;
|
|
341
|
+
if (activeOnly) {
|
|
342
|
+
sql = "SELECT * FROM customer_roles WHERE archived_at IS NULL " +
|
|
343
|
+
"ORDER BY created_at ASC, slug ASC LIMIT ?1";
|
|
344
|
+
params = [limit];
|
|
345
|
+
} else {
|
|
346
|
+
sql = "SELECT * FROM customer_roles ORDER BY created_at ASC, slug ASC LIMIT ?1";
|
|
347
|
+
params = [limit];
|
|
348
|
+
}
|
|
349
|
+
var rows = (await query(sql, params)).rows;
|
|
350
|
+
var out = [];
|
|
351
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRole(rows[i]));
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---- updateRole ----------------------------------------------------
|
|
356
|
+
|
|
357
|
+
async function updateRole(slug, patch) {
|
|
358
|
+
_slug(slug);
|
|
359
|
+
if (!patch || typeof patch !== "object") {
|
|
360
|
+
throw new TypeError("customerRoles.updateRole: patch object required");
|
|
361
|
+
}
|
|
362
|
+
var keys = Object.keys(patch);
|
|
363
|
+
if (!keys.length) {
|
|
364
|
+
throw new TypeError("customerRoles.updateRole: patch must include at least one column");
|
|
365
|
+
}
|
|
366
|
+
var current = await getRole(slug);
|
|
367
|
+
if (!current) {
|
|
368
|
+
throw new TypeError("customerRoles.updateRole: slug " + JSON.stringify(slug) + " not found");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
var sets = [];
|
|
372
|
+
var params = [];
|
|
373
|
+
var idx = 1;
|
|
374
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
375
|
+
var col = keys[i];
|
|
376
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
377
|
+
throw new TypeError("customerRoles.updateRole: unsupported column " + JSON.stringify(col));
|
|
378
|
+
}
|
|
379
|
+
if (col === "title") {
|
|
380
|
+
sets.push("title = ?" + idx);
|
|
381
|
+
params.push(_title(patch[col]));
|
|
382
|
+
} else /* capabilities */ {
|
|
383
|
+
sets.push("capabilities_json = ?" + idx);
|
|
384
|
+
params.push(JSON.stringify(_capabilities(patch[col])));
|
|
385
|
+
}
|
|
386
|
+
idx += 1;
|
|
387
|
+
}
|
|
388
|
+
sets.push("updated_at = ?" + idx);
|
|
389
|
+
params.push(_now());
|
|
390
|
+
idx += 1;
|
|
391
|
+
params.push(slug);
|
|
392
|
+
|
|
393
|
+
var r = await query(
|
|
394
|
+
"UPDATE customer_roles SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
395
|
+
params,
|
|
396
|
+
);
|
|
397
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
398
|
+
throw new TypeError("customerRoles.updateRole: slug " + JSON.stringify(slug) + " not found");
|
|
399
|
+
}
|
|
400
|
+
return await getRole(slug);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---- archiveRole ---------------------------------------------------
|
|
404
|
+
|
|
405
|
+
async function archiveRole(slug) {
|
|
406
|
+
_slug(slug);
|
|
407
|
+
var current = await getRole(slug);
|
|
408
|
+
if (!current) {
|
|
409
|
+
throw new TypeError("customerRoles.archiveRole: slug " + JSON.stringify(slug) + " not found");
|
|
410
|
+
}
|
|
411
|
+
// Idempotent — re-archive returns the existing tombstone.
|
|
412
|
+
if (current.archived_at != null) return current;
|
|
413
|
+
var ts = _now();
|
|
414
|
+
await query(
|
|
415
|
+
"UPDATE customer_roles SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
|
|
416
|
+
[ts, slug],
|
|
417
|
+
);
|
|
418
|
+
return await getRole(slug);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---- assignRole ----------------------------------------------------
|
|
422
|
+
|
|
423
|
+
async function assignRole(input) {
|
|
424
|
+
if (!input || typeof input !== "object") {
|
|
425
|
+
throw new TypeError("customerRoles.assignRole: input object required");
|
|
426
|
+
}
|
|
427
|
+
var companyId = _opaqueId(input.company_customer_id, "company_customer_id");
|
|
428
|
+
var employeeId = _opaqueId(input.employee_customer_id, "employee_customer_id");
|
|
429
|
+
if (companyId === employeeId) {
|
|
430
|
+
throw new TypeError("customerRoles.assignRole: company_customer_id and employee_customer_id must differ");
|
|
431
|
+
}
|
|
432
|
+
var roleSlug = _slug(input.role_slug, "role_slug");
|
|
433
|
+
|
|
434
|
+
var role = await getRole(roleSlug);
|
|
435
|
+
if (!role) {
|
|
436
|
+
throw new TypeError("customerRoles.assignRole: role " + JSON.stringify(roleSlug) + " not found");
|
|
437
|
+
}
|
|
438
|
+
if (role.archived_at != null) {
|
|
439
|
+
throw new TypeError("customerRoles.assignRole: role " + JSON.stringify(roleSlug) +
|
|
440
|
+
" is archived - new assignments are refused");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
await _requireCustomer(companyId, "company_customer_id");
|
|
444
|
+
await _requireCustomer(employeeId, "employee_customer_id");
|
|
445
|
+
|
|
446
|
+
var ts = _now();
|
|
447
|
+
var existing = (await query(
|
|
448
|
+
"SELECT id FROM customer_role_assignments " +
|
|
449
|
+
"WHERE company_customer_id = ?1 AND employee_customer_id = ?2 LIMIT 1",
|
|
450
|
+
[companyId, employeeId],
|
|
451
|
+
)).rows[0];
|
|
452
|
+
if (existing) {
|
|
453
|
+
// Replace-on-conflict — the operator changes an employee's role
|
|
454
|
+
// by calling assignRole again. The id + assigned_at refresh so
|
|
455
|
+
// the audit trail captures the rotation.
|
|
456
|
+
await query(
|
|
457
|
+
"UPDATE customer_role_assignments SET role_slug = ?1, assigned_at = ?2 WHERE id = ?3",
|
|
458
|
+
[roleSlug, ts, existing.id],
|
|
459
|
+
);
|
|
460
|
+
var updated = (await query(
|
|
461
|
+
"SELECT * FROM customer_role_assignments WHERE id = ?1",
|
|
462
|
+
[existing.id],
|
|
463
|
+
)).rows[0];
|
|
464
|
+
return _hydrateAssignment(updated);
|
|
465
|
+
}
|
|
466
|
+
var id = _b().uuid.v7();
|
|
467
|
+
await query(
|
|
468
|
+
"INSERT INTO customer_role_assignments " +
|
|
469
|
+
"(id, company_customer_id, employee_customer_id, role_slug, assigned_at) " +
|
|
470
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
471
|
+
[id, companyId, employeeId, roleSlug, ts],
|
|
472
|
+
);
|
|
473
|
+
return {
|
|
474
|
+
id: id,
|
|
475
|
+
company_customer_id: companyId,
|
|
476
|
+
employee_customer_id: employeeId,
|
|
477
|
+
role_slug: roleSlug,
|
|
478
|
+
assigned_at: ts,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---- unassignRole --------------------------------------------------
|
|
483
|
+
|
|
484
|
+
async function unassignRole(input) {
|
|
485
|
+
if (!input || typeof input !== "object") {
|
|
486
|
+
throw new TypeError("customerRoles.unassignRole: input object required");
|
|
487
|
+
}
|
|
488
|
+
var companyId = _opaqueId(input.company_customer_id, "company_customer_id");
|
|
489
|
+
var employeeId = _opaqueId(input.employee_customer_id, "employee_customer_id");
|
|
490
|
+
var r = await query(
|
|
491
|
+
"DELETE FROM customer_role_assignments " +
|
|
492
|
+
"WHERE company_customer_id = ?1 AND employee_customer_id = ?2",
|
|
493
|
+
[companyId, employeeId],
|
|
494
|
+
);
|
|
495
|
+
return Number(r.rowCount || 0) > 0;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---- rolesForEmployee ----------------------------------------------
|
|
499
|
+
|
|
500
|
+
async function rolesForEmployee(input) {
|
|
501
|
+
if (!input || typeof input !== "object") {
|
|
502
|
+
throw new TypeError("customerRoles.rolesForEmployee: input object required");
|
|
503
|
+
}
|
|
504
|
+
var employeeId = _opaqueId(input.employee_customer_id, "employee_customer_id");
|
|
505
|
+
var rows;
|
|
506
|
+
if (input.company_customer_id != null) {
|
|
507
|
+
var companyId = _opaqueId(input.company_customer_id, "company_customer_id");
|
|
508
|
+
rows = (await query(
|
|
509
|
+
"SELECT * FROM customer_role_assignments " +
|
|
510
|
+
"WHERE employee_customer_id = ?1 AND company_customer_id = ?2 " +
|
|
511
|
+
"ORDER BY assigned_at ASC",
|
|
512
|
+
[employeeId, companyId],
|
|
513
|
+
)).rows;
|
|
514
|
+
} else {
|
|
515
|
+
rows = (await query(
|
|
516
|
+
"SELECT * FROM customer_role_assignments WHERE employee_customer_id = ?1 " +
|
|
517
|
+
"ORDER BY assigned_at ASC",
|
|
518
|
+
[employeeId],
|
|
519
|
+
)).rows;
|
|
520
|
+
}
|
|
521
|
+
var out = [];
|
|
522
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateAssignment(rows[i]));
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---- employeesForCompany ------------------------------------------
|
|
527
|
+
|
|
528
|
+
async function employeesForCompany(companyId) {
|
|
529
|
+
var id = _opaqueId(companyId, "company_customer_id");
|
|
530
|
+
var rows = (await query(
|
|
531
|
+
"SELECT * FROM customer_role_assignments WHERE company_customer_id = ?1 " +
|
|
532
|
+
"ORDER BY assigned_at ASC",
|
|
533
|
+
[id],
|
|
534
|
+
)).rows;
|
|
535
|
+
var out = [];
|
|
536
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateAssignment(rows[i]));
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ---- hasCapability -------------------------------------------------
|
|
541
|
+
|
|
542
|
+
async function hasCapability(input) {
|
|
543
|
+
if (!input || typeof input !== "object") {
|
|
544
|
+
throw new TypeError("customerRoles.hasCapability: input object required");
|
|
545
|
+
}
|
|
546
|
+
var companyId = _opaqueId(input.company_customer_id, "company_customer_id");
|
|
547
|
+
var employeeId = _opaqueId(input.employee_customer_id, "employee_customer_id");
|
|
548
|
+
var capability = _capability(input.capability);
|
|
549
|
+
|
|
550
|
+
var asn = (await query(
|
|
551
|
+
"SELECT role_slug FROM customer_role_assignments " +
|
|
552
|
+
"WHERE company_customer_id = ?1 AND employee_customer_id = ?2 LIMIT 1",
|
|
553
|
+
[companyId, employeeId],
|
|
554
|
+
)).rows[0];
|
|
555
|
+
if (!asn) return false;
|
|
556
|
+
|
|
557
|
+
var role = await getRole(asn.role_slug);
|
|
558
|
+
if (!role) return false;
|
|
559
|
+
// Archived roles still resolve — the in-flight authority that an
|
|
560
|
+
// employee already carries is honoured. New assignments to an
|
|
561
|
+
// archived role are refused upstream in `assignRole`.
|
|
562
|
+
return role.capabilities.indexOf(capability) !== -1;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---- recordOrderApproval ------------------------------------------
|
|
566
|
+
|
|
567
|
+
async function recordOrderApproval(input) {
|
|
568
|
+
if (!input || typeof input !== "object") {
|
|
569
|
+
throw new TypeError("customerRoles.recordOrderApproval: input object required");
|
|
570
|
+
}
|
|
571
|
+
var orderId = _opaqueId(input.order_id, "order_id");
|
|
572
|
+
var approvedBy = _opaqueId(input.approved_by, "approved_by");
|
|
573
|
+
var roleSlug = _slug(input.role_slug, "role_slug");
|
|
574
|
+
|
|
575
|
+
var role = await getRole(roleSlug);
|
|
576
|
+
if (!role) {
|
|
577
|
+
throw new TypeError("customerRoles.recordOrderApproval: role " +
|
|
578
|
+
JSON.stringify(roleSlug) + " not found");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
var id = _b().uuid.v7();
|
|
582
|
+
var ts = _now();
|
|
583
|
+
await query(
|
|
584
|
+
"INSERT INTO customer_order_approvals (id, order_id, approved_by, role_slug, occurred_at) " +
|
|
585
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
586
|
+
[id, orderId, approvedBy, roleSlug, ts],
|
|
587
|
+
);
|
|
588
|
+
return {
|
|
589
|
+
id: id,
|
|
590
|
+
order_id: orderId,
|
|
591
|
+
approved_by: approvedBy,
|
|
592
|
+
role_slug: roleSlug,
|
|
593
|
+
occurred_at: ts,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function listOrderApprovals(input) {
|
|
598
|
+
if (!input || typeof input !== "object") {
|
|
599
|
+
throw new TypeError("customerRoles.listOrderApprovals: input object required");
|
|
600
|
+
}
|
|
601
|
+
var orderId = _opaqueId(input.order_id, "order_id");
|
|
602
|
+
var limit = input.limit == null ? 50 : input.limit;
|
|
603
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
604
|
+
throw new TypeError("customerRoles.listOrderApprovals: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
605
|
+
}
|
|
606
|
+
var rows = (await query(
|
|
607
|
+
"SELECT * FROM customer_order_approvals WHERE order_id = ?1 " +
|
|
608
|
+
"ORDER BY occurred_at DESC LIMIT ?2",
|
|
609
|
+
[orderId, limit],
|
|
610
|
+
)).rows;
|
|
611
|
+
var out = [];
|
|
612
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateApproval(rows[i]));
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
CAPABILITIES: CAPABILITIES,
|
|
618
|
+
defineRole: defineRole,
|
|
619
|
+
getRole: getRole,
|
|
620
|
+
listRoles: listRoles,
|
|
621
|
+
updateRole: updateRole,
|
|
622
|
+
archiveRole: archiveRole,
|
|
623
|
+
assignRole: assignRole,
|
|
624
|
+
unassignRole: unassignRole,
|
|
625
|
+
rolesForEmployee: rolesForEmployee,
|
|
626
|
+
employeesForCompany: employeesForCompany,
|
|
627
|
+
hasCapability: hasCapability,
|
|
628
|
+
recordOrderApproval: recordOrderApproval,
|
|
629
|
+
listOrderApprovals: listOrderApprovals,
|
|
630
|
+
// Exposed for tests that want to skip the live customers integration.
|
|
631
|
+
_uuid: _uuid,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
module.exports = {
|
|
636
|
+
create: create,
|
|
637
|
+
CAPABILITIES: CAPABILITIES,
|
|
638
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
639
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
640
|
+
};
|