@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.
- package/CHANGELOG.md +4 -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/customer-import.js +590 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +21 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +951 -0
- package/lib/stock-transfers.js +777 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|