@blamejs/blamejs-shop 0.0.60 → 0.0.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,863 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.storefrontDashboards
4
+ * @title Storefront dashboards — operator-curated widget layouts
5
+ *
6
+ * @intro
7
+ * Operators curate dashboard layouts that compose multiple data
8
+ * sources into a single read-only view. Each dashboard is a named
9
+ * arrangement of widgets (revenue chart, top products, recent
10
+ * orders, inventory low-stock, cart abandonment, etc.) — the
11
+ * storage holds only the layout + widget descriptors; the live
12
+ * data is pulled from existing shop primitives at render time.
13
+ *
14
+ * Each dashboard carries:
15
+ * - a stable URL-friendly `slug` (PK),
16
+ * - a `title` shown in the dashboard header,
17
+ * - a `layout` token from a closed enum
18
+ * (grid_2col / grid_3col / single_column / freeform),
19
+ * - an ordered `widgets` array of `{ id, kind, config }`
20
+ * descriptors — `kind` is one of a closed enum
21
+ * (revenue_chart, top_products, top_customers,
22
+ * inventory_low_stock, cart_abandonment, order_status_funnel,
23
+ * recent_orders, aov_trend, refund_rate),
24
+ * - optional `owner_type` (operator | tenant) + `owner_id` —
25
+ * both nullable; nullable owner means "global default",
26
+ * owner_type = 'operator' + null owner_id means "operator
27
+ * catalog", owner_type = 'tenant' + owner_id = UUID means
28
+ * "tenant catalog",
29
+ * - an `archived_at` instant — archived dashboards stay in the
30
+ * table so a clone or audit can resolve the slug, but they're
31
+ * excluded from `listDashboards` unless the caller asks for
32
+ * them explicitly.
33
+ *
34
+ * Composes:
35
+ * - the four injected data sources — `salesReports`, `analytics`,
36
+ * `cartAbandonment`, `inventoryAlerts` — each optional.
37
+ * `renderDashboard` walks the widget list and, for each widget,
38
+ * picks the data source that owns the metric and calls the
39
+ * matching method. When a widget's data source isn't injected,
40
+ * the rendered widget carries `data: null` so the dashboard
41
+ * still renders a slot for the operator to wire later.
42
+ *
43
+ * Surface:
44
+ * - `create({ query?, salesReports?, analytics?, cartAbandonment?,
45
+ * inventoryAlerts? })` — factory. `query` is optional;
46
+ * absent it, the primitive talks to `b.externalDb.query`
47
+ * directly. The four data sources are optional; widgets that
48
+ * map to a missing source render with `data: null`.
49
+ * - `defineDashboard({ slug, title, layout, widgets,
50
+ * owner_type?, owner_id? })` — insert a
51
+ * dashboard row. Widgets are validated for shape (each is a
52
+ * `{ id, kind, config }` with `id` URL-friendly, `kind` from
53
+ * the enum, `config` an object) before they reach the
54
+ * database. Duplicate widget ids inside one dashboard refused.
55
+ * - `getDashboard(slug)` — single row, any archive state.
56
+ * - `listDashboards({ owner_type?, owner_id? })` — enumerate
57
+ * active (non-archived) dashboards in the matching ownership
58
+ * scope. Both owner fields optional; omitting both returns
59
+ * every active dashboard.
60
+ * - `updateDashboard(slug, patch)` — patch any of
61
+ * `title / layout / widgets`. Each call re-validates the
62
+ * widgets array if it's in the patch.
63
+ * - `archiveDashboard(slug)` — stamp `archived_at`. Idempotent
64
+ * once archived (re-archiving is refused). Archived dashboards
65
+ * drop out of `listDashboards`.
66
+ * - `cloneDashboard({ source_slug, new_slug, new_title?,
67
+ * new_owner? })` — copy a dashboard's layout
68
+ * + widgets to a new slug. The new dashboard starts un-
69
+ * archived even if the source was archived. `new_title`
70
+ * defaults to the source title; `new_owner` is an optional
71
+ * `{ owner_type, owner_id }` pair — absent, the clone inherits
72
+ * the source's ownership.
73
+ * - `renderDashboard({ slug, from, to, locale? })` — async;
74
+ * reads the dashboard by slug and walks its widgets, calling
75
+ * each widget's data source against the `{ from, to }` window.
76
+ * Returns `{ widgets: [{ id, kind, data, locale_strings }] }`.
77
+ * The `locale_strings` map carries the widget's display
78
+ * strings (title + axis labels) localized to `locale` (or to
79
+ * `"en"` if `locale` is omitted). When the widget's data
80
+ * source isn't injected, `data` is null. The slug must exist
81
+ * and must not be archived (an archived dashboard refuses to
82
+ * render — the operator must restore-by-clone first).
83
+ *
84
+ * Storage:
85
+ * - `storefront_dashboards` (migration `0091_storefront_dashboards.sql`).
86
+ *
87
+ * @primitive storefrontDashboards
88
+ * @related b.template.escapeHtml, b.guardUuid
89
+ */
90
+
91
+ var MAX_SLUG_LEN = 80;
92
+ var MAX_TITLE_LEN = 200;
93
+ var MAX_WIDGET_ID_LEN = 80;
94
+ var MAX_WIDGETS_PER_DASHBOARD = 24;
95
+ var MAX_LOCALE_LEN = 35;
96
+
97
+ var ALLOWED_LAYOUTS = Object.freeze([
98
+ "grid_2col",
99
+ "grid_3col",
100
+ "single_column",
101
+ "freeform",
102
+ ]);
103
+
104
+ var ALLOWED_WIDGET_KINDS = Object.freeze([
105
+ "revenue_chart",
106
+ "top_products",
107
+ "top_customers",
108
+ "inventory_low_stock",
109
+ "cart_abandonment",
110
+ "order_status_funnel",
111
+ "recent_orders",
112
+ "aov_trend",
113
+ "refund_rate",
114
+ ]);
115
+
116
+ var ALLOWED_OWNER_TYPES = Object.freeze([
117
+ "operator",
118
+ "tenant",
119
+ ]);
120
+
121
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
122
+ "title",
123
+ "layout",
124
+ "widgets",
125
+ ]);
126
+
127
+ // Slug + widget id shape mirrors the rest of the storefront
128
+ // primitives — alnum leading character, alnum + dot + hyphen +
129
+ // underscore tail, capped length. The slug reaches operator logs +
130
+ // the admin URL `/admin/dashboards/<slug>`, so the shape stays
131
+ // narrow.
132
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
133
+ var WIDGET_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
134
+
135
+ // BCP-47 locale shape — language (2-3 letters) + optional subtags.
136
+ // Lowercase the input before matching against this regex so the
137
+ // fallback walker sees a stable canonical form.
138
+ var LOCALE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/;
139
+
140
+ // Refuse C0 control bytes + DEL in operator-authored text fields.
141
+ var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
142
+
143
+ // Zero-width / direction-override family — mirrors the rest of the
144
+ // shop primitives. Spelled with \u-escapes so ESLint's
145
+ // no-irregular-whitespace stays happy.
146
+ var ZERO_WIDTH_RE = new RegExp(
147
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
148
+ );
149
+
150
+ var bShop;
151
+ function _b() {
152
+ if (!bShop) bShop = require("./index");
153
+ return bShop.framework;
154
+ }
155
+
156
+ // ---- validators ---------------------------------------------------------
157
+
158
+ function _slug(s, label) {
159
+ label = label || "slug";
160
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
161
+ throw new TypeError(
162
+ "storefrontDashboards: " + label +
163
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)"
164
+ );
165
+ }
166
+ return s;
167
+ }
168
+
169
+ function _title(s) {
170
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
171
+ throw new TypeError("storefrontDashboards: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
172
+ }
173
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
174
+ throw new TypeError("storefrontDashboards: title contains control bytes (incl. CR/LF)");
175
+ }
176
+ if (ZERO_WIDTH_RE.test(s)) {
177
+ throw new TypeError("storefrontDashboards: title contains zero-width / direction-override characters");
178
+ }
179
+ return s;
180
+ }
181
+
182
+ function _layout(s) {
183
+ if (typeof s !== "string" || ALLOWED_LAYOUTS.indexOf(s) === -1) {
184
+ throw new TypeError("storefrontDashboards: layout must be one of " + JSON.stringify(ALLOWED_LAYOUTS));
185
+ }
186
+ return s;
187
+ }
188
+
189
+ function _widgetKind(s) {
190
+ if (typeof s !== "string" || ALLOWED_WIDGET_KINDS.indexOf(s) === -1) {
191
+ throw new TypeError(
192
+ "storefrontDashboards: widget kind must be one of " +
193
+ JSON.stringify(ALLOWED_WIDGET_KINDS)
194
+ );
195
+ }
196
+ return s;
197
+ }
198
+
199
+ function _widgetId(s) {
200
+ if (typeof s !== "string" || !WIDGET_ID_RE.test(s)) {
201
+ throw new TypeError(
202
+ "storefrontDashboards: widget id must match " +
203
+ "/^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_WIDGET_ID_LEN + " chars)"
204
+ );
205
+ }
206
+ return s;
207
+ }
208
+
209
+ function _widgetConfig(c) {
210
+ if (c == null) return {};
211
+ if (typeof c !== "object" || Array.isArray(c)) {
212
+ throw new TypeError("storefrontDashboards: widget config must be a plain object");
213
+ }
214
+ // Round-trip through JSON to verify the config is JSON-safe
215
+ // (functions / Symbols / undefined-valued keys / BigInt / cycles
216
+ // are all refused). The serialized form is what lands in
217
+ // widgets_json; a value we can't round-trip is not storable.
218
+ var serialized;
219
+ try {
220
+ serialized = JSON.stringify(c);
221
+ } catch (e) {
222
+ throw new TypeError(
223
+ "storefrontDashboards: widget config is not JSON-serializable: " +
224
+ (e && e.message || "unknown")
225
+ );
226
+ }
227
+ if (typeof serialized !== "string") {
228
+ throw new TypeError("storefrontDashboards: widget config did not serialize to a string");
229
+ }
230
+ // Reject configs that JSON.stringify-ed to undefined (raw
231
+ // function / Symbol values would have surfaced before this, but a
232
+ // top-level Symbol slips through). Belt-and-braces.
233
+ return JSON.parse(serialized);
234
+ }
235
+
236
+ function _widgets(arr) {
237
+ if (!Array.isArray(arr)) {
238
+ throw new TypeError("storefrontDashboards: widgets must be an array");
239
+ }
240
+ if (!arr.length) {
241
+ throw new TypeError("storefrontDashboards: widgets must contain at least one entry");
242
+ }
243
+ if (arr.length > MAX_WIDGETS_PER_DASHBOARD) {
244
+ throw new TypeError(
245
+ "storefrontDashboards: widgets array exceeds " +
246
+ MAX_WIDGETS_PER_DASHBOARD + "-entry cap"
247
+ );
248
+ }
249
+ var seen = Object.create(null);
250
+ var out = [];
251
+ for (var i = 0; i < arr.length; i += 1) {
252
+ var w = arr[i];
253
+ if (!w || typeof w !== "object" || Array.isArray(w)) {
254
+ throw new TypeError(
255
+ "storefrontDashboards: widgets[" + i + "] must be a plain object"
256
+ );
257
+ }
258
+ var id = _widgetId(w.id);
259
+ var kind = _widgetKind(w.kind);
260
+ var config = _widgetConfig(w.config);
261
+ if (seen[id]) {
262
+ throw new TypeError(
263
+ "storefrontDashboards: duplicate widget id " + JSON.stringify(id) +
264
+ " inside dashboard"
265
+ );
266
+ }
267
+ seen[id] = true;
268
+ out.push({ id: id, kind: kind, config: config });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ function _ownerType(s) {
274
+ if (s == null) return null;
275
+ if (typeof s !== "string" || ALLOWED_OWNER_TYPES.indexOf(s) === -1) {
276
+ throw new TypeError(
277
+ "storefrontDashboards: owner_type must be one of " +
278
+ JSON.stringify(ALLOWED_OWNER_TYPES) + " (or null)"
279
+ );
280
+ }
281
+ return s;
282
+ }
283
+
284
+ function _ownerId(s) {
285
+ if (s == null) return null;
286
+ try {
287
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
288
+ } catch (e) {
289
+ throw new TypeError(
290
+ "storefrontDashboards: owner_id — " + (e && e.message || "invalid UUID")
291
+ );
292
+ }
293
+ }
294
+
295
+ function _locale(s) {
296
+ if (s == null) return "en";
297
+ if (typeof s !== "string" || !s.length || s.length > MAX_LOCALE_LEN) {
298
+ throw new TypeError(
299
+ "storefrontDashboards: locale must be a non-empty BCP-47 string <= " +
300
+ MAX_LOCALE_LEN + " chars"
301
+ );
302
+ }
303
+ var canonical = s.toLowerCase();
304
+ if (!LOCALE_RE.test(canonical)) {
305
+ throw new TypeError(
306
+ "storefrontDashboards: locale " + JSON.stringify(s) +
307
+ " is not a valid BCP-47 shape"
308
+ );
309
+ }
310
+ return canonical;
311
+ }
312
+
313
+ function _epochMs(n, label) {
314
+ if (typeof n !== "number" || !isFinite(n) || n < 0 || Math.floor(n) !== n) {
315
+ throw new TypeError(
316
+ "storefrontDashboards: " + label +
317
+ " must be a non-negative integer epoch-ms"
318
+ );
319
+ }
320
+ return n;
321
+ }
322
+
323
+ function _window(from, to) {
324
+ _epochMs(from, "from");
325
+ _epochMs(to, "to");
326
+ if (to <= from) {
327
+ throw new TypeError(
328
+ "storefrontDashboards: to (" + to + ") must be > from (" + from + ")"
329
+ );
330
+ }
331
+ return { from: from, to: to };
332
+ }
333
+
334
+ function _now() { return Date.now(); }
335
+
336
+ // ---- row hydration ------------------------------------------------------
337
+
338
+ function _hydrateRow(r) {
339
+ if (!r) return null;
340
+ // widgets_json was written by us via JSON.stringify of a validated
341
+ // widgets array; parsing it back can't realistically fail, but a
342
+ // SQL-layer corruption would land as a noisy error here. That's
343
+ // the right tier (config-tier — a corrupted dashboard row is an
344
+ // operator bug worth surfacing).
345
+ var widgets;
346
+ try {
347
+ widgets = JSON.parse(r.widgets_json);
348
+ } catch (e) {
349
+ throw new TypeError(
350
+ "storefrontDashboards: dashboard " + JSON.stringify(r.slug) +
351
+ " has corrupt widgets_json — " + (e && e.message || "parse failed")
352
+ );
353
+ }
354
+ return {
355
+ slug: r.slug,
356
+ title: r.title,
357
+ layout: r.layout,
358
+ widgets: widgets,
359
+ owner_type: r.owner_type == null ? null : r.owner_type,
360
+ owner_id: r.owner_id == null ? null : r.owner_id,
361
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
362
+ created_at: Number(r.created_at),
363
+ updated_at: Number(r.updated_at),
364
+ };
365
+ }
366
+
367
+ // ---- widget localization ------------------------------------------------
368
+ //
369
+ // Each widget kind ships a tiny set of display strings that the
370
+ // dashboard admin UI renders alongside the data. These are not
371
+ // operator-authored — they're framework-authored chrome, and the
372
+ // primitive carries an English baseline plus a small "es" / "fr"
373
+ // catalogue so the multi-locale tenant case isn't a v1-deferred
374
+ // stub. The catalogue is keyed on the canonical BCP-47 locale and
375
+ // walks the fallback chain right-to-left until it lands on a key
376
+ // that exists — e.g. `fr-ca` → `fr-ca` → `fr` → `en`.
377
+
378
+ var WIDGET_STRINGS = Object.freeze({
379
+ en: {
380
+ revenue_chart: { title: "Revenue", x_axis: "Day", y_axis: "Revenue" },
381
+ top_products: { title: "Top products", column_label: "Product" },
382
+ top_customers: { title: "Top customers", column_label: "Customer" },
383
+ inventory_low_stock: { title: "Low stock", column_label: "SKU" },
384
+ cart_abandonment: { title: "Cart abandonment", column_label: "Cart" },
385
+ order_status_funnel: { title: "Order funnel", column_label: "Status" },
386
+ recent_orders: { title: "Recent orders", column_label: "Order" },
387
+ aov_trend: { title: "Average order value", x_axis: "Day", y_axis: "AOV" },
388
+ refund_rate: { title: "Refund rate", x_axis: "Day", y_axis: "Refunds" },
389
+ },
390
+ es: {
391
+ revenue_chart: { title: "Ingresos", x_axis: "Día", y_axis: "Ingresos" },
392
+ top_products: { title: "Productos principales", column_label: "Producto" },
393
+ top_customers: { title: "Clientes principales", column_label: "Cliente" },
394
+ inventory_low_stock: { title: "Inventario bajo", column_label: "SKU" },
395
+ cart_abandonment: { title: "Carritos abandonados", column_label: "Carrito" },
396
+ order_status_funnel: { title: "Embudo de pedidos", column_label: "Estado" },
397
+ recent_orders: { title: "Pedidos recientes", column_label: "Pedido" },
398
+ aov_trend: { title: "Valor medio del pedido", x_axis: "Día", y_axis: "VMP" },
399
+ refund_rate: { title: "Tasa de reembolsos", x_axis: "Día", y_axis: "Reembolsos" },
400
+ },
401
+ fr: {
402
+ revenue_chart: { title: "Revenu", x_axis: "Jour", y_axis: "Revenu" },
403
+ top_products: { title: "Meilleurs produits", column_label: "Produit" },
404
+ top_customers: { title: "Meilleurs clients", column_label: "Client" },
405
+ inventory_low_stock: { title: "Stock faible", column_label: "SKU" },
406
+ cart_abandonment: { title: "Paniers abandonnés", column_label: "Panier" },
407
+ order_status_funnel: { title: "Entonnoir de commandes", column_label: "Statut" },
408
+ recent_orders: { title: "Commandes récentes", column_label: "Commande" },
409
+ aov_trend: { title: "Valeur moyenne", x_axis: "Jour", y_axis: "VM" },
410
+ refund_rate: { title: "Taux de remboursement", x_axis: "Jour", y_axis: "Remboursements" },
411
+ },
412
+ });
413
+
414
+ function _localeFallbackChain(locale) {
415
+ var chain = [];
416
+ var cur = locale;
417
+ while (cur && cur.length) {
418
+ chain.push(cur);
419
+ var dash = cur.lastIndexOf("-");
420
+ if (dash === -1) break;
421
+ cur = cur.slice(0, dash);
422
+ }
423
+ if (chain.indexOf("en") === -1) chain.push("en");
424
+ return chain;
425
+ }
426
+
427
+ function _localeStringsFor(kind, locale) {
428
+ var chain = _localeFallbackChain(locale);
429
+ for (var i = 0; i < chain.length; i += 1) {
430
+ var bucket = WIDGET_STRINGS[chain[i]];
431
+ if (bucket && bucket[kind]) return bucket[kind];
432
+ }
433
+ return WIDGET_STRINGS.en[kind];
434
+ }
435
+
436
+ // ---- factory ------------------------------------------------------------
437
+
438
+ function create(opts) {
439
+ opts = opts || {};
440
+ var query = opts.query;
441
+ if (!query) {
442
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
443
+ }
444
+ var salesReports = opts.salesReports || null;
445
+ var analytics = opts.analytics || null;
446
+ var cartAbandonment = opts.cartAbandonment || null;
447
+ var inventoryAlerts = opts.inventoryAlerts || null;
448
+
449
+ // -- defineDashboard --------------------------------------------------
450
+
451
+ async function defineDashboard(input) {
452
+ if (!input || typeof input !== "object") {
453
+ throw new TypeError("storefrontDashboards.defineDashboard: input object required");
454
+ }
455
+ var slug = _slug(input.slug);
456
+ var title = _title(input.title);
457
+ var layout = _layout(input.layout);
458
+ var widgets = _widgets(input.widgets);
459
+ var ownerType = _ownerType(input.owner_type);
460
+ var ownerId = _ownerId(input.owner_id);
461
+
462
+ // owner_id without owner_type is refused — an orphaned owner_id
463
+ // can't be filtered consistently by the catalog list.
464
+ if (ownerId != null && ownerType == null) {
465
+ throw new TypeError(
466
+ "storefrontDashboards.defineDashboard: owner_id requires owner_type"
467
+ );
468
+ }
469
+
470
+ var ts = _now();
471
+ try {
472
+ await query(
473
+ "INSERT INTO storefront_dashboards " +
474
+ "(slug, title, layout, widgets_json, owner_type, owner_id, " +
475
+ " archived_at, created_at, updated_at) " +
476
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
477
+ [slug, title, layout, JSON.stringify(widgets), ownerType, ownerId, ts],
478
+ );
479
+ } catch (e) {
480
+ // Surface a duplicate-slug collision with a clear message;
481
+ // anything else propagates as-is.
482
+ var msg = (e && e.message || "").toLowerCase();
483
+ if (msg.indexOf("unique") !== -1 || msg.indexOf("primary key") !== -1) {
484
+ throw new TypeError(
485
+ "storefrontDashboards.defineDashboard: slug " + JSON.stringify(slug) +
486
+ " already exists"
487
+ );
488
+ }
489
+ throw e;
490
+ }
491
+ return await getDashboard(slug);
492
+ }
493
+
494
+ // -- getDashboard / listDashboards -----------------------------------
495
+
496
+ async function getDashboard(slug) {
497
+ _slug(slug);
498
+ var r = (await query(
499
+ "SELECT * FROM storefront_dashboards WHERE slug = ?1 LIMIT 1",
500
+ [slug],
501
+ )).rows[0];
502
+ return _hydrateRow(r);
503
+ }
504
+
505
+ async function listDashboards(listOpts) {
506
+ listOpts = listOpts || {};
507
+ var hasOwnerType = listOpts.owner_type !== undefined;
508
+ var hasOwnerId = listOpts.owner_id !== undefined;
509
+ var ownerType = hasOwnerType ? _ownerType(listOpts.owner_type) : undefined;
510
+ var ownerId = hasOwnerId ? _ownerId(listOpts.owner_id) : undefined;
511
+
512
+ var clauses = ["archived_at IS NULL"];
513
+ var params = [];
514
+ var idx = 1;
515
+ if (hasOwnerType) {
516
+ if (ownerType == null) {
517
+ clauses.push("owner_type IS NULL");
518
+ } else {
519
+ clauses.push("owner_type = ?" + idx);
520
+ params.push(ownerType);
521
+ idx += 1;
522
+ }
523
+ }
524
+ if (hasOwnerId) {
525
+ if (ownerId == null) {
526
+ clauses.push("owner_id IS NULL");
527
+ } else {
528
+ clauses.push("owner_id = ?" + idx);
529
+ params.push(ownerId);
530
+ idx += 1;
531
+ }
532
+ }
533
+ var sql = "SELECT * FROM storefront_dashboards WHERE " +
534
+ clauses.join(" AND ") +
535
+ " ORDER BY created_at ASC, slug ASC";
536
+ var rows = (await query(sql, params)).rows;
537
+ return rows.map(_hydrateRow);
538
+ }
539
+
540
+ // -- updateDashboard --------------------------------------------------
541
+
542
+ async function updateDashboard(slug, patch) {
543
+ _slug(slug);
544
+ if (!patch || typeof patch !== "object") {
545
+ throw new TypeError("storefrontDashboards.updateDashboard: patch object required");
546
+ }
547
+ var keys = Object.keys(patch);
548
+ if (!keys.length) {
549
+ throw new TypeError(
550
+ "storefrontDashboards.updateDashboard: patch must include at least one column"
551
+ );
552
+ }
553
+
554
+ var current = await getDashboard(slug);
555
+ if (!current) {
556
+ throw new TypeError(
557
+ "storefrontDashboards.updateDashboard: slug " + JSON.stringify(slug) + " not found"
558
+ );
559
+ }
560
+ if (current.archived_at != null) {
561
+ throw new TypeError(
562
+ "storefrontDashboards.updateDashboard: slug " + JSON.stringify(slug) +
563
+ " is archived — clone it to a new slug to edit"
564
+ );
565
+ }
566
+
567
+ var sets = [];
568
+ var params = [];
569
+ var idx = 1;
570
+ for (var i = 0; i < keys.length; i += 1) {
571
+ var col = keys[i];
572
+ if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
573
+ throw new TypeError(
574
+ "storefrontDashboards.updateDashboard: unsupported column " + JSON.stringify(col)
575
+ );
576
+ }
577
+ if (col === "title") {
578
+ sets.push("title = ?" + idx);
579
+ params.push(_title(patch[col]));
580
+ idx += 1;
581
+ } else if (col === "layout") {
582
+ sets.push("layout = ?" + idx);
583
+ params.push(_layout(patch[col]));
584
+ idx += 1;
585
+ } else /* widgets */ {
586
+ sets.push("widgets_json = ?" + idx);
587
+ params.push(JSON.stringify(_widgets(patch[col])));
588
+ idx += 1;
589
+ }
590
+ }
591
+ sets.push("updated_at = ?" + idx);
592
+ params.push(_now());
593
+ idx += 1;
594
+
595
+ params.push(slug);
596
+ var r = await query(
597
+ "UPDATE storefront_dashboards SET " + sets.join(", ") + " WHERE slug = ?" + idx,
598
+ params,
599
+ );
600
+ if (Number(r.rowCount || 0) === 0) {
601
+ throw new TypeError(
602
+ "storefrontDashboards.updateDashboard: slug " + JSON.stringify(slug) + " not found"
603
+ );
604
+ }
605
+ return await getDashboard(slug);
606
+ }
607
+
608
+ // -- archiveDashboard -------------------------------------------------
609
+
610
+ async function archiveDashboard(slug) {
611
+ _slug(slug);
612
+ var current = await getDashboard(slug);
613
+ if (!current) {
614
+ throw new TypeError(
615
+ "storefrontDashboards.archiveDashboard: slug " + JSON.stringify(slug) + " not found"
616
+ );
617
+ }
618
+ if (current.archived_at != null) {
619
+ throw new TypeError(
620
+ "storefrontDashboards.archiveDashboard: slug " + JSON.stringify(slug) +
621
+ " is already archived"
622
+ );
623
+ }
624
+ var ts = _now();
625
+ var r = await query(
626
+ "UPDATE storefront_dashboards SET archived_at = ?1, updated_at = ?1 " +
627
+ "WHERE slug = ?2 AND archived_at IS NULL",
628
+ [ts, slug],
629
+ );
630
+ if (Number(r.rowCount || 0) === 0) {
631
+ throw new TypeError(
632
+ "storefrontDashboards.archiveDashboard: slug " + JSON.stringify(slug) + " transition race"
633
+ );
634
+ }
635
+ return await getDashboard(slug);
636
+ }
637
+
638
+ // -- cloneDashboard ---------------------------------------------------
639
+ //
640
+ // Copy a dashboard's layout + widgets to a new slug. The clone
641
+ // starts un-archived even if the source was archived (the clone
642
+ // path is how an operator brings an archived dashboard back into
643
+ // the active catalog without touching the original). `new_title`
644
+ // defaults to the source title; `new_owner` is an optional
645
+ // `{ owner_type, owner_id }` override.
646
+
647
+ async function cloneDashboard(input) {
648
+ if (!input || typeof input !== "object") {
649
+ throw new TypeError("storefrontDashboards.cloneDashboard: input object required");
650
+ }
651
+ var sourceSlug = _slug(input.source_slug, "source_slug");
652
+ var newSlug = _slug(input.new_slug, "new_slug");
653
+ if (sourceSlug === newSlug) {
654
+ throw new TypeError(
655
+ "storefrontDashboards.cloneDashboard: new_slug must differ from source_slug"
656
+ );
657
+ }
658
+
659
+ var source = await getDashboard(sourceSlug);
660
+ if (!source) {
661
+ throw new TypeError(
662
+ "storefrontDashboards.cloneDashboard: source_slug " +
663
+ JSON.stringify(sourceSlug) + " not found"
664
+ );
665
+ }
666
+
667
+ var newTitle = input.new_title == null ? source.title : _title(input.new_title);
668
+
669
+ var ownerType;
670
+ var ownerId;
671
+ if (input.new_owner === undefined || input.new_owner === null) {
672
+ ownerType = source.owner_type;
673
+ ownerId = source.owner_id;
674
+ } else {
675
+ if (typeof input.new_owner !== "object" || Array.isArray(input.new_owner)) {
676
+ throw new TypeError(
677
+ "storefrontDashboards.cloneDashboard: new_owner must be an object " +
678
+ "with { owner_type, owner_id } (or null)"
679
+ );
680
+ }
681
+ ownerType = _ownerType(input.new_owner.owner_type);
682
+ ownerId = _ownerId(input.new_owner.owner_id);
683
+ if (ownerId != null && ownerType == null) {
684
+ throw new TypeError(
685
+ "storefrontDashboards.cloneDashboard: new_owner.owner_id requires owner_type"
686
+ );
687
+ }
688
+ }
689
+
690
+ var ts = _now();
691
+ try {
692
+ await query(
693
+ "INSERT INTO storefront_dashboards " +
694
+ "(slug, title, layout, widgets_json, owner_type, owner_id, " +
695
+ " archived_at, created_at, updated_at) " +
696
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
697
+ [
698
+ newSlug, newTitle, source.layout,
699
+ JSON.stringify(source.widgets),
700
+ ownerType, ownerId, ts,
701
+ ],
702
+ );
703
+ } catch (e) {
704
+ var msg = (e && e.message || "").toLowerCase();
705
+ if (msg.indexOf("unique") !== -1 || msg.indexOf("primary key") !== -1) {
706
+ throw new TypeError(
707
+ "storefrontDashboards.cloneDashboard: new_slug " + JSON.stringify(newSlug) +
708
+ " already exists"
709
+ );
710
+ }
711
+ throw e;
712
+ }
713
+ return await getDashboard(newSlug);
714
+ }
715
+
716
+ // -- renderDashboard --------------------------------------------------
717
+ //
718
+ // Walk the dashboard's widget list and, for each widget, pick the
719
+ // data source that owns the metric and call the matching method.
720
+ // When a widget's data source isn't injected, the rendered widget
721
+ // carries `data: null` so the dashboard still renders a slot.
722
+
723
+ async function _renderWidget(widget, window, locale) {
724
+ var kind = widget.kind;
725
+ var cfg = widget.config || {};
726
+ var localeStrings = _localeStringsFor(kind, locale);
727
+ var out = {
728
+ id: widget.id,
729
+ kind: kind,
730
+ data: null,
731
+ locale_strings: localeStrings,
732
+ };
733
+ if (kind === "revenue_chart") {
734
+ if (salesReports && typeof salesReports.revenueByDay === "function") {
735
+ out.data = await salesReports.revenueByDay({
736
+ from: window.from,
737
+ to: window.to,
738
+ currency: cfg.currency,
739
+ });
740
+ }
741
+ } else if (kind === "top_products") {
742
+ if (salesReports && typeof salesReports.topProducts === "function") {
743
+ out.data = await salesReports.topProducts({
744
+ from: window.from,
745
+ to: window.to,
746
+ currency: cfg.currency,
747
+ limit: cfg.limit,
748
+ });
749
+ }
750
+ } else if (kind === "top_customers") {
751
+ if (salesReports && typeof salesReports.topCustomers === "function") {
752
+ out.data = await salesReports.topCustomers({
753
+ from: window.from,
754
+ to: window.to,
755
+ currency: cfg.currency,
756
+ limit: cfg.limit,
757
+ });
758
+ }
759
+ } else if (kind === "order_status_funnel") {
760
+ if (salesReports && typeof salesReports.funnel === "function") {
761
+ out.data = await salesReports.funnel({
762
+ from: window.from,
763
+ to: window.to,
764
+ });
765
+ }
766
+ } else if (kind === "aov_trend") {
767
+ if (salesReports && typeof salesReports.aov === "function") {
768
+ out.data = await salesReports.aov({
769
+ from: window.from,
770
+ to: window.to,
771
+ });
772
+ }
773
+ } else if (kind === "refund_rate") {
774
+ if (salesReports && typeof salesReports.refundRate === "function") {
775
+ out.data = await salesReports.refundRate({
776
+ from: window.from,
777
+ to: window.to,
778
+ });
779
+ }
780
+ } else if (kind === "recent_orders") {
781
+ if (analytics && typeof analytics.recentOrders === "function") {
782
+ out.data = await analytics.recentOrders({ limit: cfg.limit });
783
+ }
784
+ } else if (kind === "cart_abandonment") {
785
+ if (cartAbandonment && typeof cartAbandonment.recentDetections === "function") {
786
+ out.data = await cartAbandonment.recentDetections({
787
+ limit: cfg.limit,
788
+ status: cfg.status,
789
+ });
790
+ }
791
+ } else if (kind === "inventory_low_stock") {
792
+ if (inventoryAlerts && typeof inventoryAlerts.list === "function") {
793
+ out.data = await inventoryAlerts.list({
794
+ limit: cfg.limit,
795
+ });
796
+ }
797
+ }
798
+ return out;
799
+ }
800
+
801
+ async function renderDashboard(input) {
802
+ if (!input || typeof input !== "object") {
803
+ throw new TypeError("storefrontDashboards.renderDashboard: input object required");
804
+ }
805
+ var slug = _slug(input.slug);
806
+ var window = _window(input.from, input.to);
807
+ var locale = _locale(input.locale);
808
+
809
+ var dashboard = await getDashboard(slug);
810
+ if (!dashboard) {
811
+ throw new TypeError(
812
+ "storefrontDashboards.renderDashboard: slug " + JSON.stringify(slug) + " not found"
813
+ );
814
+ }
815
+ if (dashboard.archived_at != null) {
816
+ throw new TypeError(
817
+ "storefrontDashboards.renderDashboard: slug " + JSON.stringify(slug) +
818
+ " is archived — clone it to render"
819
+ );
820
+ }
821
+
822
+ var rendered = [];
823
+ for (var i = 0; i < dashboard.widgets.length; i += 1) {
824
+ rendered.push(await _renderWidget(dashboard.widgets[i], window, locale));
825
+ }
826
+ return {
827
+ slug: dashboard.slug,
828
+ title: dashboard.title,
829
+ layout: dashboard.layout,
830
+ locale: locale,
831
+ widgets: rendered,
832
+ };
833
+ }
834
+
835
+ return {
836
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
837
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
838
+ MAX_WIDGET_ID_LEN: MAX_WIDGET_ID_LEN,
839
+ MAX_WIDGETS_PER_DASHBOARD: MAX_WIDGETS_PER_DASHBOARD,
840
+ ALLOWED_LAYOUTS: ALLOWED_LAYOUTS,
841
+ ALLOWED_WIDGET_KINDS: ALLOWED_WIDGET_KINDS,
842
+ ALLOWED_OWNER_TYPES: ALLOWED_OWNER_TYPES,
843
+
844
+ defineDashboard: defineDashboard,
845
+ getDashboard: getDashboard,
846
+ listDashboards: listDashboards,
847
+ updateDashboard: updateDashboard,
848
+ archiveDashboard: archiveDashboard,
849
+ cloneDashboard: cloneDashboard,
850
+ renderDashboard: renderDashboard,
851
+ };
852
+ }
853
+
854
+ module.exports = {
855
+ create: create,
856
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
857
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
858
+ MAX_WIDGET_ID_LEN: MAX_WIDGET_ID_LEN,
859
+ MAX_WIDGETS_PER_DASHBOARD: MAX_WIDGETS_PER_DASHBOARD,
860
+ ALLOWED_LAYOUTS: ALLOWED_LAYOUTS,
861
+ ALLOWED_WIDGET_KINDS: ALLOWED_WIDGET_KINDS,
862
+ ALLOWED_OWNER_TYPES: ALLOWED_OWNER_TYPES,
863
+ };