@blamejs/blamejs-shop 0.0.65 → 0.0.70
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 +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -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-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.themeAssets
|
|
4
|
+
* @title Theme assets — operator-uploaded artifacts referenced by themes
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Operators upload theme artifacts (fonts, logo variants, hero
|
|
8
|
+
* images, favicons, banner / og / icon images, plus a `custom`
|
|
9
|
+
* escape hatch) and assign them to a theme. The render-time picker
|
|
10
|
+
* dispatches off the `kind` enum and the `theme_slug` assignment:
|
|
11
|
+
* the active theme's header asks for `kind=logo`, the storefront
|
|
12
|
+
* resolves the single logo assigned to the active theme.
|
|
13
|
+
*
|
|
14
|
+
* Distinct from the `theme` primitive — that one manages the active
|
|
15
|
+
* theme slug + template directory. This is the asset catalog +
|
|
16
|
+
* content-addressed storage references.
|
|
17
|
+
*
|
|
18
|
+
* Each asset carries:
|
|
19
|
+
* - a stable URL-friendly `slug` (PK),
|
|
20
|
+
* - a `kind` token from a closed enum (font / logo / hero_image /
|
|
21
|
+
* favicon / banner_image / og_image / icon / custom),
|
|
22
|
+
* - an IANA `content_type` (e.g. `font/woff2`, `image/svg+xml`),
|
|
23
|
+
* - a `sha3_512` digest of the asset bytes — validated as a
|
|
24
|
+
* 128-char lowercase hex shape at the app layer; backs the
|
|
25
|
+
* upload de-dupe lookup,
|
|
26
|
+
* - a non-negative `byte_size`,
|
|
27
|
+
* - a `source_url` — https-only or /-rooted internal path,
|
|
28
|
+
* validated through `b.safeUrl` at write time,
|
|
29
|
+
* - optional `alt_text` for image kinds,
|
|
30
|
+
* - optional `theme_slug` — NULL while the asset sits in the
|
|
31
|
+
* library; set by `assignToTheme` when the operator wires it
|
|
32
|
+
* into a theme,
|
|
33
|
+
* - `archived_at` — soft delete; archived assets stay in the
|
|
34
|
+
* table so a clone or audit can resolve the slug but drop out
|
|
35
|
+
* of `assetsForTheme` / `assetsByKind`,
|
|
36
|
+
* - `impression_count` — denormalized counter the hot-path
|
|
37
|
+
* `recordImpression` bumps; `metricsForAsset` reads it in
|
|
38
|
+
* addition to scanning the impression-event log.
|
|
39
|
+
*
|
|
40
|
+
* Composes:
|
|
41
|
+
* - `b.safeUrl.parse` — `source_url` is HTTPS-only at the app
|
|
42
|
+
* layer (or a /-rooted absolute path for storefront-internal
|
|
43
|
+
* routes). javascript: / data: / vbscript: refused before the
|
|
44
|
+
* row is persisted.
|
|
45
|
+
* - Per-factory monotonic clock — guarantees `created_at` /
|
|
46
|
+
* `updated_at` / `occurred_at` are strictly increasing so a
|
|
47
|
+
* tied `created_at` still sorts deterministically when two
|
|
48
|
+
* writes land in the same millisecond.
|
|
49
|
+
*
|
|
50
|
+
* Surface:
|
|
51
|
+
* - `create({ query? })` — factory. `query` is optional; absent
|
|
52
|
+
* it, the primitive talks to `b.externalDb.query` directly.
|
|
53
|
+
* - `registerAsset({ slug, kind, content_type, sha3_512,
|
|
54
|
+
* byte_size, source_url, alt_text?,
|
|
55
|
+
* theme_slug? })` — insert an asset row.
|
|
56
|
+
* - `getAsset(slug)` — single row, any archive state.
|
|
57
|
+
* - `assetsForTheme(theme_slug)` — enumerate active assets
|
|
58
|
+
* assigned to a given theme.
|
|
59
|
+
* - `assetsByKind({ kind, theme_slug? })` — enumerate active
|
|
60
|
+
* assets by kind. `theme_slug` filter is optional; when
|
|
61
|
+
* omitted, returns every active asset of that kind regardless
|
|
62
|
+
* of theme assignment (the asset library scan).
|
|
63
|
+
* - `updateAsset(slug, patch)` — patch any of `content_type /
|
|
64
|
+
* source_url / alt_text / byte_size / sha3_512`.
|
|
65
|
+
* - `archiveAsset(slug)` — stamp `archived_at`. Idempotent-
|
|
66
|
+
* refusal on re-archive.
|
|
67
|
+
* - `assignToTheme({ slug, theme_slug })` — swap an asset's
|
|
68
|
+
* theme assignment. Pass `theme_slug: null` to detach.
|
|
69
|
+
* - `recordImpression(slug)` — drop-silent hot-path counter
|
|
70
|
+
* bump on the storefront render that resolves the asset.
|
|
71
|
+
* - `metricsForAsset({ slug, from, to })` — windowed impression
|
|
72
|
+
* count + lifetime denormalized count.
|
|
73
|
+
* - `cleanupOrphans({ days_idle })` — archive every active asset
|
|
74
|
+
* with `theme_slug IS NULL` whose `updated_at` is older than
|
|
75
|
+
* `now - days_idle * 86400000`. Returns the count of archived
|
|
76
|
+
* rows.
|
|
77
|
+
*
|
|
78
|
+
* Storage:
|
|
79
|
+
* - `theme_assets` + `theme_asset_impressions`
|
|
80
|
+
* (migration `0131_theme_assets.sql`).
|
|
81
|
+
*
|
|
82
|
+
* @primitive themeAssets
|
|
83
|
+
* @related b.safeUrl, b.uuid.v7
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
var MAX_SLUG_LEN = 80;
|
|
87
|
+
var MAX_THEME_SLUG_LEN = 80;
|
|
88
|
+
var MAX_CONTENT_TYPE_LEN = 128;
|
|
89
|
+
var MAX_SOURCE_URL_LEN = 2048;
|
|
90
|
+
var MAX_ALT_TEXT_LEN = 500;
|
|
91
|
+
var SHA3_512_HEX_LEN = 128;
|
|
92
|
+
|
|
93
|
+
var ALLOWED_KINDS = Object.freeze([
|
|
94
|
+
"font",
|
|
95
|
+
"logo",
|
|
96
|
+
"hero_image",
|
|
97
|
+
"favicon",
|
|
98
|
+
"banner_image",
|
|
99
|
+
"og_image",
|
|
100
|
+
"icon",
|
|
101
|
+
"custom",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
105
|
+
"content_type",
|
|
106
|
+
"source_url",
|
|
107
|
+
"alt_text",
|
|
108
|
+
"byte_size",
|
|
109
|
+
"sha3_512",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
// Slug shape mirrors the rest of the storefront primitives — alnum
|
|
113
|
+
// leading character, alnum + dot + hyphen + underscore tail, capped
|
|
114
|
+
// length. Reaches operator logs + admin URLs.
|
|
115
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
116
|
+
var THEME_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
117
|
+
|
|
118
|
+
// Lowercase hex, exactly 128 chars for a SHA3-512 digest. The
|
|
119
|
+
// content-addressed lookup is byte-exact, so the canonical form is
|
|
120
|
+
// lowercase — uppercase / mixed-case inputs refused so the upload
|
|
121
|
+
// pipeline doesn't end up with two index entries for the same bytes.
|
|
122
|
+
var SHA3_512_HEX_RE = /^[0-9a-f]{128}$/;
|
|
123
|
+
|
|
124
|
+
// IANA media type shape — `type/subtype` with the standard token
|
|
125
|
+
// alphabet on each side, optional `; param=value` suffix. The intent
|
|
126
|
+
// isn't full RFC 7231 conformance — it's "refuse obvious junk before
|
|
127
|
+
// the storefront tries to set a Content-Type header from it".
|
|
128
|
+
var CONTENT_TYPE_RE = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+(?:\s*;\s*[A-Za-z0-9!#$&^_.+-]+\s*=\s*(?:"[^"\x00-\x1f]*"|[A-Za-z0-9!#$&^_.+-]+))*$/;
|
|
129
|
+
|
|
130
|
+
// Refuse C0 control bytes + DEL in operator-authored text fields.
|
|
131
|
+
var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
|
|
132
|
+
|
|
133
|
+
// Zero-width / direction-override family — mirrors the rest of the
|
|
134
|
+
// shop primitives. Spelled with \u-escapes so ESLint's
|
|
135
|
+
// no-irregular-whitespace stays happy.
|
|
136
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
137
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
var MS_PER_DAY = 86400000;
|
|
141
|
+
|
|
142
|
+
var bShop;
|
|
143
|
+
function _b() {
|
|
144
|
+
if (!bShop) bShop = require("./index");
|
|
145
|
+
return bShop.framework;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- validators ---------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
function _slug(s, label) {
|
|
151
|
+
label = label || "slug";
|
|
152
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
153
|
+
throw new TypeError(
|
|
154
|
+
"themeAssets: " + label +
|
|
155
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return s;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _themeSlug(s) {
|
|
162
|
+
if (s == null) return null;
|
|
163
|
+
if (typeof s !== "string" || !THEME_SLUG_RE.test(s)) {
|
|
164
|
+
throw new TypeError(
|
|
165
|
+
"themeAssets: theme_slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ " +
|
|
166
|
+
"(<= " + MAX_THEME_SLUG_LEN + " chars) or be null"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _kind(s) {
|
|
173
|
+
if (typeof s !== "string" || ALLOWED_KINDS.indexOf(s) === -1) {
|
|
174
|
+
throw new TypeError(
|
|
175
|
+
"themeAssets: kind must be one of " + JSON.stringify(ALLOWED_KINDS)
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return s;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function _contentType(s) {
|
|
182
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_CONTENT_TYPE_LEN) {
|
|
183
|
+
throw new TypeError(
|
|
184
|
+
"themeAssets: content_type must be a non-empty IANA media type <= " +
|
|
185
|
+
MAX_CONTENT_TYPE_LEN + " chars"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (CONTROL_BYTE_LINE_RE.test(s)) {
|
|
189
|
+
throw new TypeError("themeAssets: content_type contains control bytes");
|
|
190
|
+
}
|
|
191
|
+
if (!CONTENT_TYPE_RE.test(s)) {
|
|
192
|
+
throw new TypeError(
|
|
193
|
+
"themeAssets: content_type " + JSON.stringify(s) +
|
|
194
|
+
" is not a valid `type/subtype` shape"
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return s;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _sha3_512(s) {
|
|
201
|
+
if (typeof s !== "string" || s.length !== SHA3_512_HEX_LEN) {
|
|
202
|
+
throw new TypeError(
|
|
203
|
+
"themeAssets: sha3_512 must be a " + SHA3_512_HEX_LEN +
|
|
204
|
+
"-char lowercase hex string"
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (!SHA3_512_HEX_RE.test(s)) {
|
|
208
|
+
throw new TypeError(
|
|
209
|
+
"themeAssets: sha3_512 must be lowercase hex (0-9a-f), 128 chars"
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return s;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function _byteSize(n) {
|
|
216
|
+
if (typeof n !== "number" || !isFinite(n) || n < 0 || Math.floor(n) !== n) {
|
|
217
|
+
throw new TypeError(
|
|
218
|
+
"themeAssets: byte_size must be a non-negative integer"
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return n;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// `source_url` — HTTPS-only via `b.safeUrl.parse` OR a /-rooted
|
|
225
|
+
// absolute path for storefront-internal routes. Protocol-relative
|
|
226
|
+
// `//host/...` URLs are refused — the operator must commit to https
|
|
227
|
+
// explicitly so a CDN mis-config can't downgrade the asset link.
|
|
228
|
+
// javascript: / data: / vbscript: are refused by safeUrl's default
|
|
229
|
+
// protocol allowlist.
|
|
230
|
+
function _sourceUrl(s) {
|
|
231
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_SOURCE_URL_LEN) {
|
|
232
|
+
throw new TypeError(
|
|
233
|
+
"themeAssets: source_url must be a non-empty string <= " +
|
|
234
|
+
MAX_SOURCE_URL_LEN + " chars"
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
238
|
+
throw new TypeError("themeAssets: source_url contains control / zero-width bytes");
|
|
239
|
+
}
|
|
240
|
+
if (s.charCodeAt(0) === 47 /* "/" */) {
|
|
241
|
+
if (s.length > 1 && s.charCodeAt(1) === 47) {
|
|
242
|
+
throw new TypeError(
|
|
243
|
+
"themeAssets: source_url protocol-relative `//host/...` refused — " +
|
|
244
|
+
"use absolute https:// or a /-rooted absolute path"
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (s.indexOf("..") !== -1) {
|
|
248
|
+
throw new TypeError("themeAssets: source_url path must not contain '..'");
|
|
249
|
+
}
|
|
250
|
+
return s;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
_b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
|
|
254
|
+
} catch (e) {
|
|
255
|
+
throw new TypeError(
|
|
256
|
+
"themeAssets: source_url — " +
|
|
257
|
+
(e && e.message || "must be https:// or a /-rooted absolute path")
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return s;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _altText(s) {
|
|
264
|
+
if (s == null) return null;
|
|
265
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_ALT_TEXT_LEN) {
|
|
266
|
+
throw new TypeError(
|
|
267
|
+
"themeAssets: alt_text must be a non-empty string <= " +
|
|
268
|
+
MAX_ALT_TEXT_LEN + " chars (or null)"
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (CONTROL_BYTE_LINE_RE.test(s)) {
|
|
272
|
+
throw new TypeError("themeAssets: alt_text contains control bytes (incl. CR/LF)");
|
|
273
|
+
}
|
|
274
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
275
|
+
throw new TypeError("themeAssets: alt_text contains zero-width / direction-override characters");
|
|
276
|
+
}
|
|
277
|
+
return s;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function _epochMs(n, label) {
|
|
281
|
+
if (typeof n !== "number" || !isFinite(n) || n < 0 || Math.floor(n) !== n) {
|
|
282
|
+
throw new TypeError(
|
|
283
|
+
"themeAssets: " + label + " must be a non-negative integer epoch-ms"
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return n;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function _window(from, to) {
|
|
290
|
+
_epochMs(from, "from");
|
|
291
|
+
_epochMs(to, "to");
|
|
292
|
+
if (to <= from) {
|
|
293
|
+
throw new TypeError(
|
|
294
|
+
"themeAssets: to (" + to + ") must be > from (" + from + ")"
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
return { from: from, to: to };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Per-factory monotonic clock — guarantees `created_at` /
|
|
301
|
+
// `updated_at` / `occurred_at` are strictly increasing so a tied
|
|
302
|
+
// `created_at` still sorts deterministically when two writes land in
|
|
303
|
+
// the same millisecond. Mirrors the shape used by cookie-consent /
|
|
304
|
+
// discount-analytics / sms-dispatcher / etc.
|
|
305
|
+
var _lastTs = 0;
|
|
306
|
+
function _now() {
|
|
307
|
+
var t = Date.now();
|
|
308
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
309
|
+
_lastTs = t;
|
|
310
|
+
return t;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- row hydration ------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
function _hydrateRow(r) {
|
|
316
|
+
if (!r) return null;
|
|
317
|
+
return {
|
|
318
|
+
slug: r.slug,
|
|
319
|
+
kind: r.kind,
|
|
320
|
+
content_type: r.content_type,
|
|
321
|
+
sha3_512: r.sha3_512,
|
|
322
|
+
byte_size: Number(r.byte_size),
|
|
323
|
+
source_url: r.source_url,
|
|
324
|
+
alt_text: r.alt_text == null ? null : r.alt_text,
|
|
325
|
+
theme_slug: r.theme_slug == null ? null : r.theme_slug,
|
|
326
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
327
|
+
impression_count: Number(r.impression_count),
|
|
328
|
+
created_at: Number(r.created_at),
|
|
329
|
+
updated_at: Number(r.updated_at),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- factory ------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
function create(opts) {
|
|
336
|
+
opts = opts || {};
|
|
337
|
+
var query = opts.query;
|
|
338
|
+
if (!query) {
|
|
339
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// -- registerAsset ----------------------------------------------------
|
|
343
|
+
|
|
344
|
+
async function registerAsset(input) {
|
|
345
|
+
if (!input || typeof input !== "object") {
|
|
346
|
+
throw new TypeError("themeAssets.registerAsset: input object required");
|
|
347
|
+
}
|
|
348
|
+
var slug = _slug(input.slug);
|
|
349
|
+
var kind = _kind(input.kind);
|
|
350
|
+
var contentType = _contentType(input.content_type);
|
|
351
|
+
var sha3 = _sha3_512(input.sha3_512);
|
|
352
|
+
var byteSize = _byteSize(input.byte_size);
|
|
353
|
+
var sourceUrl = _sourceUrl(input.source_url);
|
|
354
|
+
var altText = _altText(input.alt_text);
|
|
355
|
+
var themeSlug = _themeSlug(input.theme_slug);
|
|
356
|
+
|
|
357
|
+
var ts = _now();
|
|
358
|
+
try {
|
|
359
|
+
await query(
|
|
360
|
+
"INSERT INTO theme_assets " +
|
|
361
|
+
"(slug, kind, content_type, sha3_512, byte_size, source_url, " +
|
|
362
|
+
" alt_text, theme_slug, archived_at, impression_count, " +
|
|
363
|
+
" created_at, updated_at) " +
|
|
364
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, 0, ?9, ?9)",
|
|
365
|
+
[slug, kind, contentType, sha3, byteSize, sourceUrl,
|
|
366
|
+
altText, themeSlug, ts],
|
|
367
|
+
);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
var msg = (e && e.message || "").toLowerCase();
|
|
370
|
+
if (msg.indexOf("unique") !== -1 || msg.indexOf("primary key") !== -1) {
|
|
371
|
+
throw new TypeError(
|
|
372
|
+
"themeAssets.registerAsset: slug " + JSON.stringify(slug) +
|
|
373
|
+
" already exists"
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
throw e;
|
|
377
|
+
}
|
|
378
|
+
return await getAsset(slug);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// -- getAsset ---------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
async function getAsset(slug) {
|
|
384
|
+
_slug(slug);
|
|
385
|
+
var r = (await query(
|
|
386
|
+
"SELECT * FROM theme_assets WHERE slug = ?1 LIMIT 1",
|
|
387
|
+
[slug],
|
|
388
|
+
)).rows[0];
|
|
389
|
+
return _hydrateRow(r);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// -- assetsForTheme ---------------------------------------------------
|
|
393
|
+
|
|
394
|
+
async function assetsForTheme(themeSlug) {
|
|
395
|
+
if (typeof themeSlug !== "string" || !THEME_SLUG_RE.test(themeSlug)) {
|
|
396
|
+
throw new TypeError(
|
|
397
|
+
"themeAssets.assetsForTheme: theme_slug must match " +
|
|
398
|
+
"/^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_THEME_SLUG_LEN + " chars)"
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
var rows = (await query(
|
|
402
|
+
"SELECT * FROM theme_assets " +
|
|
403
|
+
" WHERE theme_slug = ?1 AND archived_at IS NULL " +
|
|
404
|
+
" ORDER BY kind ASC, created_at ASC, slug ASC",
|
|
405
|
+
[themeSlug],
|
|
406
|
+
)).rows;
|
|
407
|
+
return rows.map(_hydrateRow);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// -- assetsByKind -----------------------------------------------------
|
|
411
|
+
|
|
412
|
+
async function assetsByKind(listOpts) {
|
|
413
|
+
if (!listOpts || typeof listOpts !== "object") {
|
|
414
|
+
throw new TypeError("themeAssets.assetsByKind: input object required");
|
|
415
|
+
}
|
|
416
|
+
var kind = _kind(listOpts.kind);
|
|
417
|
+
var hasTheme = listOpts.theme_slug !== undefined;
|
|
418
|
+
var themeSlug = hasTheme ? _themeSlug(listOpts.theme_slug) : undefined;
|
|
419
|
+
|
|
420
|
+
var sql = "SELECT * FROM theme_assets WHERE kind = ?1 AND archived_at IS NULL";
|
|
421
|
+
var params = [kind];
|
|
422
|
+
if (hasTheme) {
|
|
423
|
+
if (themeSlug == null) {
|
|
424
|
+
sql += " AND theme_slug IS NULL";
|
|
425
|
+
} else {
|
|
426
|
+
sql += " AND theme_slug = ?2";
|
|
427
|
+
params.push(themeSlug);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
sql += " ORDER BY created_at ASC, slug ASC";
|
|
431
|
+
var rows = (await query(sql, params)).rows;
|
|
432
|
+
return rows.map(_hydrateRow);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// -- updateAsset ------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
async function updateAsset(slug, patch) {
|
|
438
|
+
_slug(slug);
|
|
439
|
+
if (!patch || typeof patch !== "object") {
|
|
440
|
+
throw new TypeError("themeAssets.updateAsset: patch object required");
|
|
441
|
+
}
|
|
442
|
+
var keys = Object.keys(patch);
|
|
443
|
+
if (!keys.length) {
|
|
444
|
+
throw new TypeError(
|
|
445
|
+
"themeAssets.updateAsset: patch must include at least one column"
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
var current = await getAsset(slug);
|
|
450
|
+
if (!current) {
|
|
451
|
+
throw new TypeError(
|
|
452
|
+
"themeAssets.updateAsset: slug " + JSON.stringify(slug) + " not found"
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
if (current.archived_at != null) {
|
|
456
|
+
throw new TypeError(
|
|
457
|
+
"themeAssets.updateAsset: slug " + JSON.stringify(slug) +
|
|
458
|
+
" is archived — register a new asset to replace it"
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
var sets = [];
|
|
463
|
+
var params = [];
|
|
464
|
+
var idx = 1;
|
|
465
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
466
|
+
var col = keys[i];
|
|
467
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
468
|
+
throw new TypeError(
|
|
469
|
+
"themeAssets.updateAsset: unsupported column " + JSON.stringify(col)
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
if (col === "content_type") {
|
|
473
|
+
sets.push("content_type = ?" + idx);
|
|
474
|
+
params.push(_contentType(patch[col]));
|
|
475
|
+
} else if (col === "source_url") {
|
|
476
|
+
sets.push("source_url = ?" + idx);
|
|
477
|
+
params.push(_sourceUrl(patch[col]));
|
|
478
|
+
} else if (col === "alt_text") {
|
|
479
|
+
sets.push("alt_text = ?" + idx);
|
|
480
|
+
params.push(_altText(patch[col]));
|
|
481
|
+
} else if (col === "byte_size") {
|
|
482
|
+
sets.push("byte_size = ?" + idx);
|
|
483
|
+
params.push(_byteSize(patch[col]));
|
|
484
|
+
} else /* sha3_512 */ {
|
|
485
|
+
sets.push("sha3_512 = ?" + idx);
|
|
486
|
+
params.push(_sha3_512(patch[col]));
|
|
487
|
+
}
|
|
488
|
+
idx += 1;
|
|
489
|
+
}
|
|
490
|
+
sets.push("updated_at = ?" + idx);
|
|
491
|
+
params.push(_now());
|
|
492
|
+
idx += 1;
|
|
493
|
+
|
|
494
|
+
params.push(slug);
|
|
495
|
+
var r = await query(
|
|
496
|
+
"UPDATE theme_assets SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
497
|
+
params,
|
|
498
|
+
);
|
|
499
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
500
|
+
throw new TypeError(
|
|
501
|
+
"themeAssets.updateAsset: slug " + JSON.stringify(slug) + " not found"
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
return await getAsset(slug);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// -- archiveAsset -----------------------------------------------------
|
|
508
|
+
|
|
509
|
+
async function archiveAsset(slug) {
|
|
510
|
+
_slug(slug);
|
|
511
|
+
var current = await getAsset(slug);
|
|
512
|
+
if (!current) {
|
|
513
|
+
throw new TypeError(
|
|
514
|
+
"themeAssets.archiveAsset: slug " + JSON.stringify(slug) + " not found"
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
if (current.archived_at != null) {
|
|
518
|
+
throw new TypeError(
|
|
519
|
+
"themeAssets.archiveAsset: slug " + JSON.stringify(slug) +
|
|
520
|
+
" is already archived"
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
var ts = _now();
|
|
524
|
+
var r = await query(
|
|
525
|
+
"UPDATE theme_assets SET archived_at = ?1, updated_at = ?1 " +
|
|
526
|
+
" WHERE slug = ?2 AND archived_at IS NULL",
|
|
527
|
+
[ts, slug],
|
|
528
|
+
);
|
|
529
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
530
|
+
throw new TypeError(
|
|
531
|
+
"themeAssets.archiveAsset: slug " + JSON.stringify(slug) +
|
|
532
|
+
" transition race"
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
return await getAsset(slug);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// -- assignToTheme ----------------------------------------------------
|
|
539
|
+
//
|
|
540
|
+
// Swap an asset's theme assignment. Pass `theme_slug: null` to
|
|
541
|
+
// detach (returning the asset to the library). Refuses to swap an
|
|
542
|
+
// archived asset — the operator has to register a new asset to
|
|
543
|
+
// replace it.
|
|
544
|
+
|
|
545
|
+
async function assignToTheme(input) {
|
|
546
|
+
if (!input || typeof input !== "object") {
|
|
547
|
+
throw new TypeError("themeAssets.assignToTheme: input object required");
|
|
548
|
+
}
|
|
549
|
+
var slug = _slug(input.slug);
|
|
550
|
+
if (!Object.prototype.hasOwnProperty.call(input, "theme_slug")) {
|
|
551
|
+
throw new TypeError(
|
|
552
|
+
"themeAssets.assignToTheme: theme_slug required (string or null)"
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
var themeSlug = _themeSlug(input.theme_slug);
|
|
556
|
+
|
|
557
|
+
var current = await getAsset(slug);
|
|
558
|
+
if (!current) {
|
|
559
|
+
throw new TypeError(
|
|
560
|
+
"themeAssets.assignToTheme: slug " + JSON.stringify(slug) + " not found"
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
if (current.archived_at != null) {
|
|
564
|
+
throw new TypeError(
|
|
565
|
+
"themeAssets.assignToTheme: slug " + JSON.stringify(slug) +
|
|
566
|
+
" is archived — register a new asset to replace it"
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
var ts = _now();
|
|
570
|
+
var r = await query(
|
|
571
|
+
"UPDATE theme_assets SET theme_slug = ?1, updated_at = ?2 " +
|
|
572
|
+
" WHERE slug = ?3 AND archived_at IS NULL",
|
|
573
|
+
[themeSlug, ts, slug],
|
|
574
|
+
);
|
|
575
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
576
|
+
throw new TypeError(
|
|
577
|
+
"themeAssets.assignToTheme: slug " + JSON.stringify(slug) +
|
|
578
|
+
" transition race"
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
return await getAsset(slug);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// -- recordImpression -------------------------------------------------
|
|
585
|
+
//
|
|
586
|
+
// Drop-silent on bad slug / missing row / archived row — runs on
|
|
587
|
+
// the hot storefront request path (the asset resolves on every
|
|
588
|
+
// page render that surfaces it). Throwing here would crash the
|
|
589
|
+
// response the counter is observing.
|
|
590
|
+
|
|
591
|
+
async function recordImpression(slug) {
|
|
592
|
+
if (typeof slug !== "string" || !SLUG_RE.test(slug)) {
|
|
593
|
+
return { recorded: false };
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
var b = _b();
|
|
597
|
+
var ts = _now();
|
|
598
|
+
var id = b.uuid.v7();
|
|
599
|
+
var bump = await query(
|
|
600
|
+
"UPDATE theme_assets SET impression_count = impression_count + 1, " +
|
|
601
|
+
" updated_at = ?1 WHERE slug = ?2 AND archived_at IS NULL",
|
|
602
|
+
[ts, slug],
|
|
603
|
+
);
|
|
604
|
+
if (Number(bump.rowCount || 0) === 0) return { recorded: false };
|
|
605
|
+
await query(
|
|
606
|
+
"INSERT INTO theme_asset_impressions (id, asset_slug, occurred_at) " +
|
|
607
|
+
" VALUES (?1, ?2, ?3)",
|
|
608
|
+
[id, slug, ts],
|
|
609
|
+
);
|
|
610
|
+
return { recorded: true, id: id };
|
|
611
|
+
} catch (_e) {
|
|
612
|
+
return { recorded: false };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// -- metricsForAsset --------------------------------------------------
|
|
617
|
+
|
|
618
|
+
async function metricsForAsset(input) {
|
|
619
|
+
if (!input || typeof input !== "object") {
|
|
620
|
+
throw new TypeError("themeAssets.metricsForAsset: input object required");
|
|
621
|
+
}
|
|
622
|
+
var slug = _slug(input.slug);
|
|
623
|
+
var window = _window(input.from, input.to);
|
|
624
|
+
|
|
625
|
+
var asset = await getAsset(slug);
|
|
626
|
+
if (!asset) {
|
|
627
|
+
throw new TypeError(
|
|
628
|
+
"themeAssets.metricsForAsset: slug " + JSON.stringify(slug) + " not found"
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
var row = (await query(
|
|
632
|
+
"SELECT COUNT(*) AS impressions " +
|
|
633
|
+
" FROM theme_asset_impressions " +
|
|
634
|
+
" WHERE asset_slug = ?1 AND occurred_at >= ?2 AND occurred_at < ?3",
|
|
635
|
+
[slug, window.from, window.to],
|
|
636
|
+
)).rows[0] || {};
|
|
637
|
+
return {
|
|
638
|
+
slug: slug,
|
|
639
|
+
from: window.from,
|
|
640
|
+
to: window.to,
|
|
641
|
+
impressions: Number(row.impressions) || 0,
|
|
642
|
+
lifetime_impressions: asset.impression_count,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// -- cleanupOrphans ---------------------------------------------------
|
|
647
|
+
//
|
|
648
|
+
// Archive every active asset with `theme_slug IS NULL` whose
|
|
649
|
+
// `updated_at` is older than `now - days_idle * 86400000`. Returns
|
|
650
|
+
// the count of archived rows. The intent is the "asset library
|
|
651
|
+
// hygiene" path: assets the operator uploaded but never assigned
|
|
652
|
+
// to a theme accumulate over time; this primitive batch-archives
|
|
653
|
+
// the stale ones without touching anything that's currently in
|
|
654
|
+
// use.
|
|
655
|
+
|
|
656
|
+
async function cleanupOrphans(input) {
|
|
657
|
+
if (!input || typeof input !== "object") {
|
|
658
|
+
throw new TypeError("themeAssets.cleanupOrphans: input object required");
|
|
659
|
+
}
|
|
660
|
+
var days = input.days_idle;
|
|
661
|
+
if (typeof days !== "number" || !isFinite(days) || days < 0 || Math.floor(days) !== days) {
|
|
662
|
+
throw new TypeError(
|
|
663
|
+
"themeAssets.cleanupOrphans: days_idle must be a non-negative integer"
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
var ts = _now();
|
|
667
|
+
var cutoff = ts - days * MS_PER_DAY;
|
|
668
|
+
var r = await query(
|
|
669
|
+
"UPDATE theme_assets SET archived_at = ?1, updated_at = ?1 " +
|
|
670
|
+
" WHERE archived_at IS NULL " +
|
|
671
|
+
" AND theme_slug IS NULL " +
|
|
672
|
+
" AND updated_at < ?2",
|
|
673
|
+
[ts, cutoff],
|
|
674
|
+
);
|
|
675
|
+
return { archived: Number(r.rowCount || 0) };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
680
|
+
MAX_THEME_SLUG_LEN: MAX_THEME_SLUG_LEN,
|
|
681
|
+
MAX_CONTENT_TYPE_LEN: MAX_CONTENT_TYPE_LEN,
|
|
682
|
+
MAX_SOURCE_URL_LEN: MAX_SOURCE_URL_LEN,
|
|
683
|
+
MAX_ALT_TEXT_LEN: MAX_ALT_TEXT_LEN,
|
|
684
|
+
SHA3_512_HEX_LEN: SHA3_512_HEX_LEN,
|
|
685
|
+
ALLOWED_KINDS: ALLOWED_KINDS,
|
|
686
|
+
ALLOWED_PATCH_COLUMNS: ALLOWED_PATCH_COLUMNS,
|
|
687
|
+
|
|
688
|
+
registerAsset: registerAsset,
|
|
689
|
+
getAsset: getAsset,
|
|
690
|
+
assetsForTheme: assetsForTheme,
|
|
691
|
+
assetsByKind: assetsByKind,
|
|
692
|
+
updateAsset: updateAsset,
|
|
693
|
+
archiveAsset: archiveAsset,
|
|
694
|
+
assignToTheme: assignToTheme,
|
|
695
|
+
recordImpression: recordImpression,
|
|
696
|
+
metricsForAsset: metricsForAsset,
|
|
697
|
+
cleanupOrphans: cleanupOrphans,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
module.exports = {
|
|
702
|
+
create: create,
|
|
703
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
704
|
+
MAX_THEME_SLUG_LEN: MAX_THEME_SLUG_LEN,
|
|
705
|
+
MAX_CONTENT_TYPE_LEN: MAX_CONTENT_TYPE_LEN,
|
|
706
|
+
MAX_SOURCE_URL_LEN: MAX_SOURCE_URL_LEN,
|
|
707
|
+
MAX_ALT_TEXT_LEN: MAX_ALT_TEXT_LEN,
|
|
708
|
+
SHA3_512_HEX_LEN: SHA3_512_HEX_LEN,
|
|
709
|
+
ALLOWED_KINDS: ALLOWED_KINDS,
|
|
710
|
+
ALLOWED_PATCH_COLUMNS: ALLOWED_PATCH_COLUMNS,
|
|
711
|
+
};
|