@blamejs/blamejs-shop 0.0.66 → 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 +8 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -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/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +35 -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/pixel-events.js +995 -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/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -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,777 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.assemblyInstructions
|
|
4
|
+
* @title Per-SKU assembly / care / unboxing instructions
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Operator authors per-SKU instructions in one of four shapes
|
|
8
|
+
* (PDF link / video link / inline Markdown / external link); the
|
|
9
|
+
* customer accesses them from their order-detail page, the order
|
|
10
|
+
* confirmation email, or the customer portal. Every view is
|
|
11
|
+
* stamped to a per-instruction log that feeds support metrics
|
|
12
|
+
* ("the customer never opened the assembly guide before
|
|
13
|
+
* opening the ticket — surface the link in the first response")
|
|
14
|
+
* and the "unviewed orders" nudge ("we shipped you a desk five
|
|
15
|
+
* days ago and you haven't opened the assembly PDF").
|
|
16
|
+
*
|
|
17
|
+
* The shape:
|
|
18
|
+
*
|
|
19
|
+
* var ai = b.shop.assemblyInstructions.create({
|
|
20
|
+
* query: q,
|
|
21
|
+
* catalog: catalogPrimitive, // optional — sku existence check
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // Operator authors a PDF guide for a SKU.
|
|
25
|
+
* await ai.defineInstruction({
|
|
26
|
+
* sku: "DESK-OAK-72IN",
|
|
27
|
+
* kind: "pdf",
|
|
28
|
+
* content_url: "https://cdn.example.com/guides/desk-oak-72in-v3.pdf",
|
|
29
|
+
* locale: "en",
|
|
30
|
+
* version: 1,
|
|
31
|
+
* published: true,
|
|
32
|
+
* });
|
|
33
|
+
*
|
|
34
|
+
* // Customer hits the order page; the storefront calls:
|
|
35
|
+
* var inst = await ai.getInstruction({ sku: "DESK-OAK-72IN", locale: "fr" });
|
|
36
|
+
* // → falls back to the English row when no FR version exists.
|
|
37
|
+
*
|
|
38
|
+
* await ai.recordView({
|
|
39
|
+
* instruction_id: inst.id,
|
|
40
|
+
* order_id: "<order-uuid>",
|
|
41
|
+
* customer_id: "<customer-uuid>",
|
|
42
|
+
* session_id: "<opaque-session-cookie>",
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* Verbs:
|
|
46
|
+
* defineInstruction — append a new (sku, locale, version) row.
|
|
47
|
+
* Versions per (sku, locale) are operator-numbered so the
|
|
48
|
+
* operator's CMS can stage v2 alongside v1 and flip the
|
|
49
|
+
* `published` bit on its own schedule. Same version number
|
|
50
|
+
* refuses with a typed error so a sloppy CMS doesn't
|
|
51
|
+
* silently overwrite history.
|
|
52
|
+
*
|
|
53
|
+
* getInstruction — look up the published row at the requested
|
|
54
|
+
* locale; fall back to the canonical English row when none
|
|
55
|
+
* exists. Returns `null` when no published row exists in
|
|
56
|
+
* any locale.
|
|
57
|
+
*
|
|
58
|
+
* listForSku — every non-archived row for a SKU. Used by the
|
|
59
|
+
* operator's CMS to render the version history table.
|
|
60
|
+
*
|
|
61
|
+
* archiveInstruction — flip `archived_at` so future getInstruction
|
|
62
|
+
* / listForSku ignore the row. The view log is preserved so
|
|
63
|
+
* support metrics survive a SKU's retirement.
|
|
64
|
+
*
|
|
65
|
+
* publishVersion — flip `published = 1` on the supplied row and
|
|
66
|
+
* `published = 0` on every prior published row in the same
|
|
67
|
+
* (sku, locale). The operator's "promote v2 to live" verb.
|
|
68
|
+
*
|
|
69
|
+
* recordView — append a view row. At least one of order_id /
|
|
70
|
+
* customer_id / session_id must be present; the session id is
|
|
71
|
+
* namespace-hashed so the raw cookie value never lands in the
|
|
72
|
+
* audit log.
|
|
73
|
+
*
|
|
74
|
+
* viewsForCustomer — every view by a customer, ordered newest-
|
|
75
|
+
* first. Powers the support agent's "did they open the guide?"
|
|
76
|
+
* lookup.
|
|
77
|
+
*
|
|
78
|
+
* popularInstructions — top-N most-viewed instructions in a
|
|
79
|
+
* window. Powers the operator's "which guides are pulling
|
|
80
|
+
* traffic?" dashboard.
|
|
81
|
+
*
|
|
82
|
+
* unviewedForCustomer — orders whose line-item SKUs have
|
|
83
|
+
* published instructions the customer hasn't opened within
|
|
84
|
+
* `days` of the order. Powers the "you haven't opened the
|
|
85
|
+
* assembly guide for the desk you ordered last week" nudge.
|
|
86
|
+
*
|
|
87
|
+
* Composition:
|
|
88
|
+
* - b.uuid.v7 — instruction + view row ids
|
|
89
|
+
* - b.guardUuid — customer / order id sanitization
|
|
90
|
+
* - b.crypto.namespaceHash — session id hashed before write
|
|
91
|
+
* - b.safeUrl.parse — content_url is https-only
|
|
92
|
+
* - b.template.escapeHtml — markdown render escapes every text run
|
|
93
|
+
* - catalog (optional) — held as a read-only marker so a future
|
|
94
|
+
* verb can refuse unknown SKUs at definition time without a
|
|
95
|
+
* factory-shape break
|
|
96
|
+
*
|
|
97
|
+
* Three-tier input validation: every public verb is a config-time
|
|
98
|
+
* entry point (defineInstruction / archiveInstruction /
|
|
99
|
+
* publishVersion) or a defensive request-shape reader
|
|
100
|
+
* (recordView / getInstruction / listForSku / viewsForCustomer /
|
|
101
|
+
* popularInstructions / unviewedForCustomer). All shapes throw
|
|
102
|
+
* on bad input; no drop-silent hot paths.
|
|
103
|
+
*
|
|
104
|
+
* @primitive assemblyInstructions
|
|
105
|
+
* @related b.uuid, b.guardUuid, b.crypto.namespaceHash, b.safeUrl, b.template.escapeHtml
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
var bShop;
|
|
109
|
+
function _b() {
|
|
110
|
+
if (!bShop) bShop = require("./index");
|
|
111
|
+
return bShop.framework;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- constants ----------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
117
|
+
var LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
118
|
+
var SESSION_ID_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
119
|
+
var ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
120
|
+
var KINDS = Object.freeze(["pdf", "video", "markdown", "external_link"]);
|
|
121
|
+
var DEFAULT_LOCALE = "en";
|
|
122
|
+
var MAX_URL_LEN = 2048;
|
|
123
|
+
var MAX_MARKDOWN_LEN = 64 * 1024;
|
|
124
|
+
var MAX_VERSION = 1000000;
|
|
125
|
+
var MAX_LIMIT = 500;
|
|
126
|
+
var DEFAULT_LIMIT = 50;
|
|
127
|
+
var MAX_DAYS = 3650;
|
|
128
|
+
var SESSION_NAMESPACE = "assembly-instructions-session";
|
|
129
|
+
var DAY_MS = 24 * 60 * 60 * 1000;
|
|
130
|
+
|
|
131
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
132
|
+
//
|
|
133
|
+
// Two instructions / views written inside the same millisecond would
|
|
134
|
+
// otherwise collide on the v7-uuid timestamp prefix and tie on
|
|
135
|
+
// (occurred_at, id) ordering. The monotonic step guarantees a strict
|
|
136
|
+
// increase so the popularInstructions + viewsForCustomer reads return
|
|
137
|
+
// a deterministic order without depending on the v7 sub-ms counter.
|
|
138
|
+
var _lastTs = 0;
|
|
139
|
+
function _now() {
|
|
140
|
+
var t = Date.now();
|
|
141
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
142
|
+
_lastTs = t;
|
|
143
|
+
return t;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- validators ---------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
function _sku(s) {
|
|
149
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
150
|
+
throw new TypeError("assembly-instructions: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
151
|
+
}
|
|
152
|
+
return s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _kind(s) {
|
|
156
|
+
if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
|
|
157
|
+
throw new TypeError("assembly-instructions: kind must be one of " + KINDS.join(", "));
|
|
158
|
+
}
|
|
159
|
+
return s;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _locale(s) {
|
|
163
|
+
if (typeof s !== "string" || !LOCALE_RE.test(s)) {
|
|
164
|
+
throw new TypeError("assembly-instructions: locale must be BCP-47-ish (e.g. 'en' / 'en-US' / 'pt-BR')");
|
|
165
|
+
}
|
|
166
|
+
var parts = s.split("-");
|
|
167
|
+
parts[0] = parts[0].toLowerCase();
|
|
168
|
+
return parts.join("-");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _version(n) {
|
|
172
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_VERSION) {
|
|
173
|
+
throw new TypeError("assembly-instructions: version must be a positive integer <= " + MAX_VERSION);
|
|
174
|
+
}
|
|
175
|
+
return n;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _id(s, label) {
|
|
179
|
+
if (typeof s !== "string" || !ID_RE.test(s)) {
|
|
180
|
+
throw new TypeError("assembly-instructions: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
181
|
+
}
|
|
182
|
+
return s;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _uuid(s, label) {
|
|
186
|
+
try {
|
|
187
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
188
|
+
} catch (e) {
|
|
189
|
+
throw new TypeError("assembly-instructions: " + label + " - " + (e && e.message || "invalid UUID"));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _sessionId(s) {
|
|
194
|
+
if (typeof s !== "string" || !SESSION_ID_RE.test(s)) {
|
|
195
|
+
throw new TypeError("assembly-instructions: session_id must be 16-128 chars of [A-Za-z0-9_-]");
|
|
196
|
+
}
|
|
197
|
+
return s;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _hashSession(s) {
|
|
201
|
+
return _b().crypto.namespaceHash(SESSION_NAMESPACE, s);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// content_url is HTTPS-only - PDFs / videos / external_link rows all
|
|
205
|
+
// resolve to public origins, and the operator's CDN policy refuses
|
|
206
|
+
// http:// references. Any other protocol (data:, javascript:, mailto:,
|
|
207
|
+
// ftp:) is refused by safeUrl's protocol allowlist.
|
|
208
|
+
function _contentUrl(s) {
|
|
209
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_URL_LEN) {
|
|
210
|
+
throw new TypeError("assembly-instructions: content_url must be a non-empty string <= " + MAX_URL_LEN + " chars");
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
_b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
|
|
214
|
+
} catch (e) {
|
|
215
|
+
throw new TypeError("assembly-instructions: content_url - " + (e && e.message || "https-only URL required"));
|
|
216
|
+
}
|
|
217
|
+
return s;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function _markdown(s) {
|
|
221
|
+
if (typeof s !== "string" || !s.length) {
|
|
222
|
+
throw new TypeError("assembly-instructions: content_markdown must be a non-empty string");
|
|
223
|
+
}
|
|
224
|
+
if (s.length > MAX_MARKDOWN_LEN) {
|
|
225
|
+
throw new TypeError("assembly-instructions: content_markdown must be <= " + MAX_MARKDOWN_LEN + " bytes");
|
|
226
|
+
}
|
|
227
|
+
return s;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function _epochMs(n, label) {
|
|
231
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
232
|
+
throw new TypeError("assembly-instructions: " + label + " must be a non-negative integer (epoch ms)");
|
|
233
|
+
}
|
|
234
|
+
return n;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function _limit(n) {
|
|
238
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
239
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
|
|
240
|
+
throw new TypeError("assembly-instructions: limit must be an integer in 1.." + MAX_LIMIT);
|
|
241
|
+
}
|
|
242
|
+
return n;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _days(n) {
|
|
246
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_DAYS) {
|
|
247
|
+
throw new TypeError("assembly-instructions: days must be a positive integer <= " + MAX_DAYS);
|
|
248
|
+
}
|
|
249
|
+
return n;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- markdown rendering -------------------------------------------------
|
|
253
|
+
//
|
|
254
|
+
// Minimal in-process Markdown subset - headings, paragraphs, lists.
|
|
255
|
+
// Every text run is HTML-escaped via `b.template.escapeHtml`; raw HTML
|
|
256
|
+
// in the body is never passed through. Surfaced so the storefront's
|
|
257
|
+
// order-detail page can render the markdown variant without composing
|
|
258
|
+
// a separate renderer.
|
|
259
|
+
function _esc(s) {
|
|
260
|
+
return _b().template.escapeHtml(s);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _renderMarkdown(src) {
|
|
264
|
+
if (typeof src !== "string") return "";
|
|
265
|
+
var lines = src.replace(/\r\n?/g, "\n").split("\n");
|
|
266
|
+
var out = [];
|
|
267
|
+
var i = 0;
|
|
268
|
+
while (i < lines.length) {
|
|
269
|
+
var line = lines[i];
|
|
270
|
+
if (!line.length) { i += 1; continue; }
|
|
271
|
+
var hm = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
272
|
+
if (hm) {
|
|
273
|
+
var level = hm[1].length;
|
|
274
|
+
out.push("<h" + level + ">" + _esc(hm[2]) + "</h" + level + ">");
|
|
275
|
+
i += 1;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (/^[-*]\s+/.test(line)) {
|
|
279
|
+
out.push("<ul>");
|
|
280
|
+
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
|
281
|
+
out.push("<li>" + _esc(lines[i].replace(/^[-*]\s+/, "")) + "</li>");
|
|
282
|
+
i += 1;
|
|
283
|
+
}
|
|
284
|
+
out.push("</ul>");
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (/^\d+\.\s+/.test(line)) {
|
|
288
|
+
out.push("<ol>");
|
|
289
|
+
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
|
290
|
+
out.push("<li>" + _esc(lines[i].replace(/^\d+\.\s+/, "")) + "</li>");
|
|
291
|
+
i += 1;
|
|
292
|
+
}
|
|
293
|
+
out.push("</ol>");
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
out.push("<p>" + _esc(line) + "</p>");
|
|
297
|
+
i += 1;
|
|
298
|
+
}
|
|
299
|
+
return out.join("\n");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---- factory ------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
function create(opts) {
|
|
305
|
+
opts = opts || {};
|
|
306
|
+
// `catalog` is held as an optional read-only marker - an operator
|
|
307
|
+
// that wants the primitive to refuse unknown SKUs at definition
|
|
308
|
+
// time can compose that at the caller, OR a future verb can
|
|
309
|
+
// consume this dep without changing the public factory shape.
|
|
310
|
+
// The primitive never reads from this dep today.
|
|
311
|
+
var catalog = opts.catalog || null;
|
|
312
|
+
if (catalog !== null && typeof catalog !== "object") {
|
|
313
|
+
throw new TypeError("assembly-instructions.create: opts.catalog must be an object or null");
|
|
314
|
+
}
|
|
315
|
+
void catalog;
|
|
316
|
+
var query = opts.query;
|
|
317
|
+
if (!query) {
|
|
318
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function _shapeInstruction(row) {
|
|
322
|
+
if (!row) return null;
|
|
323
|
+
return {
|
|
324
|
+
id: row.id,
|
|
325
|
+
sku: row.sku,
|
|
326
|
+
kind: row.kind,
|
|
327
|
+
content_url: row.content_url,
|
|
328
|
+
content_markdown: row.content_markdown,
|
|
329
|
+
locale: row.locale,
|
|
330
|
+
version: row.version,
|
|
331
|
+
published: row.published === 1 || row.published === true,
|
|
332
|
+
archived_at: row.archived_at,
|
|
333
|
+
created_at: row.created_at,
|
|
334
|
+
updated_at: row.updated_at,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function _shapeView(row) {
|
|
339
|
+
if (!row) return null;
|
|
340
|
+
return {
|
|
341
|
+
id: row.id,
|
|
342
|
+
instruction_id: row.instruction_id,
|
|
343
|
+
order_id: row.order_id,
|
|
344
|
+
customer_id: row.customer_id,
|
|
345
|
+
session_id_hash: row.session_id_hash,
|
|
346
|
+
occurred_at: row.occurred_at,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function _getInstructionById(id) {
|
|
351
|
+
var r = await query(
|
|
352
|
+
"SELECT * FROM product_instructions WHERE id = ?1 LIMIT 1",
|
|
353
|
+
[id],
|
|
354
|
+
);
|
|
355
|
+
return r.rows[0] || null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function _getInstructionByVersion(sku, locale, version) {
|
|
359
|
+
var r = await query(
|
|
360
|
+
"SELECT * FROM product_instructions " +
|
|
361
|
+
"WHERE sku = ?1 AND locale = ?2 AND version = ?3 LIMIT 1",
|
|
362
|
+
[sku, locale, version],
|
|
363
|
+
);
|
|
364
|
+
return r.rows[0] || null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Resolve the published row at the requested locale; fall back to
|
|
368
|
+
// the canonical English row if none exists. Archived rows are
|
|
369
|
+
// ignored at every step.
|
|
370
|
+
async function _resolvePublished(sku, locale) {
|
|
371
|
+
var primary = await query(
|
|
372
|
+
"SELECT * FROM product_instructions " +
|
|
373
|
+
"WHERE sku = ?1 AND locale = ?2 AND published = 1 AND archived_at IS NULL " +
|
|
374
|
+
"ORDER BY version DESC LIMIT 1",
|
|
375
|
+
[sku, locale],
|
|
376
|
+
);
|
|
377
|
+
if (primary.rows[0]) return { row: primary.rows[0], locale_used: locale };
|
|
378
|
+
if (locale === DEFAULT_LOCALE) return null;
|
|
379
|
+
var fallback = await query(
|
|
380
|
+
"SELECT * FROM product_instructions " +
|
|
381
|
+
"WHERE sku = ?1 AND locale = ?2 AND published = 1 AND archived_at IS NULL " +
|
|
382
|
+
"ORDER BY version DESC LIMIT 1",
|
|
383
|
+
[sku, DEFAULT_LOCALE],
|
|
384
|
+
);
|
|
385
|
+
if (fallback.rows[0]) return { row: fallback.rows[0], locale_used: DEFAULT_LOCALE };
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
|
|
391
|
+
KINDS: KINDS,
|
|
392
|
+
DEFAULT_LOCALE: DEFAULT_LOCALE,
|
|
393
|
+
|
|
394
|
+
// Append a new (sku, locale, version) row. The operator numbers
|
|
395
|
+
// versions - staging v2 alongside v1 lets the CMS preview the
|
|
396
|
+
// next revision before flipping `published`. Same version number
|
|
397
|
+
// refuses with a typed error so a sloppy CMS doesn't silently
|
|
398
|
+
// overwrite history.
|
|
399
|
+
defineInstruction: async function (input) {
|
|
400
|
+
if (!input || typeof input !== "object") {
|
|
401
|
+
throw new TypeError("assembly-instructions.defineInstruction: input object required");
|
|
402
|
+
}
|
|
403
|
+
var sku = _sku(input.sku);
|
|
404
|
+
var kind = _kind(input.kind);
|
|
405
|
+
var locale = _locale(input.locale);
|
|
406
|
+
var version = _version(input.version);
|
|
407
|
+
var published = input.published === true ? 1 : 0;
|
|
408
|
+
|
|
409
|
+
var contentUrl = null;
|
|
410
|
+
var contentMarkdown = null;
|
|
411
|
+
if (kind === "markdown") {
|
|
412
|
+
if (input.content_markdown == null) {
|
|
413
|
+
throw new TypeError("assembly-instructions.defineInstruction: kind 'markdown' requires content_markdown");
|
|
414
|
+
}
|
|
415
|
+
contentMarkdown = _markdown(input.content_markdown);
|
|
416
|
+
if (input.content_url != null) {
|
|
417
|
+
throw new TypeError("assembly-instructions.defineInstruction: kind 'markdown' refuses content_url - pick one shape");
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
if (input.content_url == null) {
|
|
421
|
+
throw new TypeError("assembly-instructions.defineInstruction: kind '" + kind + "' requires content_url");
|
|
422
|
+
}
|
|
423
|
+
contentUrl = _contentUrl(input.content_url);
|
|
424
|
+
if (input.content_markdown != null) {
|
|
425
|
+
throw new TypeError("assembly-instructions.defineInstruction: kind '" + kind + "' refuses content_markdown - pick one shape");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
var existing = await _getInstructionByVersion(sku, locale, version);
|
|
430
|
+
if (existing) {
|
|
431
|
+
throw new TypeError("assembly-instructions.defineInstruction: (sku=" +
|
|
432
|
+
JSON.stringify(sku) + ", locale=" + JSON.stringify(locale) +
|
|
433
|
+
", version=" + version + ") already exists - bump the version");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
var ts = _now();
|
|
437
|
+
var id = _b().uuid.v7({ now: ts });
|
|
438
|
+
await query(
|
|
439
|
+
"INSERT INTO product_instructions " +
|
|
440
|
+
"(id, sku, kind, content_url, content_markdown, locale, version, published, archived_at, created_at, updated_at) " +
|
|
441
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?9)",
|
|
442
|
+
[id, sku, kind, contentUrl, contentMarkdown, locale, version, published, ts],
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
if (published === 1) {
|
|
446
|
+
await query(
|
|
447
|
+
"UPDATE product_instructions SET published = 0, updated_at = ?1 " +
|
|
448
|
+
"WHERE sku = ?2 AND locale = ?3 AND id != ?4 AND published = 1",
|
|
449
|
+
[_now(), sku, locale, id],
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return _shapeInstruction(await _getInstructionById(id));
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
// Look up the published row at the requested locale; fall back to
|
|
456
|
+
// the canonical English row when no row exists in the requested
|
|
457
|
+
// locale. Returns `null` when no published row exists in any
|
|
458
|
+
// locale - the storefront renders a "no instructions yet" panel.
|
|
459
|
+
getInstruction: async function (input) {
|
|
460
|
+
if (!input || typeof input !== "object") {
|
|
461
|
+
throw new TypeError("assembly-instructions.getInstruction: input object required");
|
|
462
|
+
}
|
|
463
|
+
var sku = _sku(input.sku);
|
|
464
|
+
var locale = input.locale == null ? DEFAULT_LOCALE : _locale(input.locale);
|
|
465
|
+
var resolved = await _resolvePublished(sku, locale);
|
|
466
|
+
if (!resolved) return null;
|
|
467
|
+
var shaped = _shapeInstruction(resolved.row);
|
|
468
|
+
shaped.locale_used = resolved.locale_used;
|
|
469
|
+
return shaped;
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
// Every non-archived row for a SKU, newest version first. Used
|
|
473
|
+
// by the operator's CMS to render the version history table.
|
|
474
|
+
listForSku: async function (sku) {
|
|
475
|
+
var skuV = _sku(sku);
|
|
476
|
+
var r = await query(
|
|
477
|
+
"SELECT * FROM product_instructions " +
|
|
478
|
+
"WHERE sku = ?1 AND archived_at IS NULL " +
|
|
479
|
+
"ORDER BY locale ASC, version DESC, id ASC",
|
|
480
|
+
[skuV],
|
|
481
|
+
);
|
|
482
|
+
return r.rows.map(_shapeInstruction);
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
// Flip `archived_at` so future getInstruction / listForSku ignore
|
|
486
|
+
// the row. The view log is preserved so support metrics survive
|
|
487
|
+
// a SKU's retirement. Idempotent.
|
|
488
|
+
archiveInstruction: async function (id) {
|
|
489
|
+
var idV = _id(id, "id");
|
|
490
|
+
var existing = await _getInstructionById(idV);
|
|
491
|
+
if (!existing) {
|
|
492
|
+
throw new TypeError("assembly-instructions.archiveInstruction: instruction id " +
|
|
493
|
+
JSON.stringify(idV) + " not found");
|
|
494
|
+
}
|
|
495
|
+
if (existing.archived_at != null) {
|
|
496
|
+
return _shapeInstruction(existing);
|
|
497
|
+
}
|
|
498
|
+
var ts = _now();
|
|
499
|
+
await query(
|
|
500
|
+
"UPDATE product_instructions SET archived_at = ?1, updated_at = ?1, published = 0 WHERE id = ?2",
|
|
501
|
+
[ts, idV],
|
|
502
|
+
);
|
|
503
|
+
return _shapeInstruction(await _getInstructionById(idV));
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
// Flip `published = 1` on the supplied row and `published = 0` on
|
|
507
|
+
// every prior published row in the same (sku, locale). The
|
|
508
|
+
// operator's "promote v2 to live" verb. Refuses to publish an
|
|
509
|
+
// archived row - operators that want to re-launch an archived
|
|
510
|
+
// version define a new version instead.
|
|
511
|
+
publishVersion: async function (input) {
|
|
512
|
+
if (!input || typeof input !== "object") {
|
|
513
|
+
throw new TypeError("assembly-instructions.publishVersion: input object required");
|
|
514
|
+
}
|
|
515
|
+
var instructionId = _id(input.instruction_id, "instruction_id");
|
|
516
|
+
var existing = await _getInstructionById(instructionId);
|
|
517
|
+
if (!existing) {
|
|
518
|
+
throw new TypeError("assembly-instructions.publishVersion: instruction_id " +
|
|
519
|
+
JSON.stringify(instructionId) + " not found");
|
|
520
|
+
}
|
|
521
|
+
if (existing.archived_at != null) {
|
|
522
|
+
throw new TypeError("assembly-instructions.publishVersion: instruction_id " +
|
|
523
|
+
JSON.stringify(instructionId) + " is archived - define a new version instead");
|
|
524
|
+
}
|
|
525
|
+
var ts = _now();
|
|
526
|
+
await query(
|
|
527
|
+
"UPDATE product_instructions SET published = 0, updated_at = ?1 " +
|
|
528
|
+
"WHERE sku = ?2 AND locale = ?3 AND id != ?4 AND published = 1",
|
|
529
|
+
[ts, existing.sku, existing.locale, instructionId],
|
|
530
|
+
);
|
|
531
|
+
await query(
|
|
532
|
+
"UPDATE product_instructions SET published = 1, updated_at = ?1 WHERE id = ?2",
|
|
533
|
+
[_now(), instructionId],
|
|
534
|
+
);
|
|
535
|
+
return _shapeInstruction(await _getInstructionById(instructionId));
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
// Append a view row. At least one of order_id / customer_id /
|
|
539
|
+
// session_id must be present; the session id is namespace-hashed
|
|
540
|
+
// so the raw cookie value never lands in the audit log. Refuses
|
|
541
|
+
// to record a view against an archived instruction - the audit
|
|
542
|
+
// log's signal is "did the customer open the LIVE guide", not
|
|
543
|
+
// "did the customer open any historical version".
|
|
544
|
+
recordView: async function (input) {
|
|
545
|
+
if (!input || typeof input !== "object") {
|
|
546
|
+
throw new TypeError("assembly-instructions.recordView: input object required");
|
|
547
|
+
}
|
|
548
|
+
var instructionId = _id(input.instruction_id, "instruction_id");
|
|
549
|
+
var hasOrder = input.order_id != null;
|
|
550
|
+
var hasCustomer = input.customer_id != null;
|
|
551
|
+
var hasSession = input.session_id != null;
|
|
552
|
+
if (!hasOrder && !hasCustomer && !hasSession) {
|
|
553
|
+
throw new TypeError("assembly-instructions.recordView: at least one of order_id / customer_id / session_id required");
|
|
554
|
+
}
|
|
555
|
+
var existing = await _getInstructionById(instructionId);
|
|
556
|
+
if (!existing) {
|
|
557
|
+
throw new TypeError("assembly-instructions.recordView: instruction_id " +
|
|
558
|
+
JSON.stringify(instructionId) + " not found");
|
|
559
|
+
}
|
|
560
|
+
if (existing.archived_at != null) {
|
|
561
|
+
throw new TypeError("assembly-instructions.recordView: instruction_id " +
|
|
562
|
+
JSON.stringify(instructionId) + " is archived");
|
|
563
|
+
}
|
|
564
|
+
var orderId = hasOrder ? _uuid(input.order_id, "order_id") : null;
|
|
565
|
+
var customerId = hasCustomer ? _uuid(input.customer_id, "customer_id") : null;
|
|
566
|
+
var sessionHash = hasSession ? _hashSession(_sessionId(input.session_id)) : null;
|
|
567
|
+
var occurredAt = input.occurred_at == null ? _now() : _epochMs(input.occurred_at, "occurred_at");
|
|
568
|
+
var id = _b().uuid.v7({ now: _now() });
|
|
569
|
+
await query(
|
|
570
|
+
"INSERT INTO product_instruction_views " +
|
|
571
|
+
"(id, instruction_id, order_id, customer_id, session_id_hash, occurred_at) " +
|
|
572
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
573
|
+
[id, instructionId, orderId, customerId, sessionHash, occurredAt],
|
|
574
|
+
);
|
|
575
|
+
var r = await query(
|
|
576
|
+
"SELECT * FROM product_instruction_views WHERE id = ?1 LIMIT 1",
|
|
577
|
+
[id],
|
|
578
|
+
);
|
|
579
|
+
return _shapeView(r.rows[0]);
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
// Every view by a customer, newest-first. Powers the support
|
|
583
|
+
// agent's "did they open the guide?" lookup.
|
|
584
|
+
viewsForCustomer: async function (customerId, listOpts) {
|
|
585
|
+
var cid = _uuid(customerId, "customer_id");
|
|
586
|
+
listOpts = listOpts || {};
|
|
587
|
+
if (typeof listOpts !== "object") {
|
|
588
|
+
throw new TypeError("assembly-instructions.viewsForCustomer: opts must be an object");
|
|
589
|
+
}
|
|
590
|
+
var limit = _limit(listOpts.limit);
|
|
591
|
+
var r = await query(
|
|
592
|
+
"SELECT * FROM product_instruction_views " +
|
|
593
|
+
"WHERE customer_id = ?1 " +
|
|
594
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?2",
|
|
595
|
+
[cid, limit],
|
|
596
|
+
);
|
|
597
|
+
return r.rows.map(_shapeView);
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
// Top-N most-viewed instructions in a window. Powers the
|
|
601
|
+
// operator's "which guides are pulling traffic?" dashboard.
|
|
602
|
+
popularInstructions: async function (input) {
|
|
603
|
+
if (!input || typeof input !== "object") {
|
|
604
|
+
throw new TypeError("assembly-instructions.popularInstructions: input object required");
|
|
605
|
+
}
|
|
606
|
+
var from = _epochMs(input.from, "from");
|
|
607
|
+
var to = _epochMs(input.to, "to");
|
|
608
|
+
if (to < from) {
|
|
609
|
+
throw new TypeError("assembly-instructions.popularInstructions: to (" + to +
|
|
610
|
+
") must be >= from (" + from + ")");
|
|
611
|
+
}
|
|
612
|
+
var limit = _limit(input.limit);
|
|
613
|
+
var r = await query(
|
|
614
|
+
"SELECT v.instruction_id AS instruction_id, COUNT(*) AS view_count, " +
|
|
615
|
+
"i.sku AS sku, i.locale AS locale, i.kind AS kind " +
|
|
616
|
+
"FROM product_instruction_views v " +
|
|
617
|
+
"JOIN product_instructions i ON i.id = v.instruction_id " +
|
|
618
|
+
"WHERE v.occurred_at >= ?1 AND v.occurred_at <= ?2 " +
|
|
619
|
+
"GROUP BY v.instruction_id " +
|
|
620
|
+
"ORDER BY view_count DESC, v.instruction_id ASC LIMIT ?3",
|
|
621
|
+
[from, to, limit],
|
|
622
|
+
);
|
|
623
|
+
return r.rows.map(function (row) {
|
|
624
|
+
return {
|
|
625
|
+
instruction_id: row.instruction_id,
|
|
626
|
+
sku: row.sku,
|
|
627
|
+
locale: row.locale,
|
|
628
|
+
kind: row.kind,
|
|
629
|
+
view_count: Number(row.view_count) || 0,
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
// Orders whose line-item SKUs have published instructions the
|
|
635
|
+
// customer hasn't opened. Window is `days` days from now into the
|
|
636
|
+
// past - operators tune the window per the product class. Returns
|
|
637
|
+
// the order rows plus the list of (sku, instruction_id) pairs the
|
|
638
|
+
// customer hasn't viewed.
|
|
639
|
+
unviewedForCustomer: async function (input) {
|
|
640
|
+
if (!input || typeof input !== "object") {
|
|
641
|
+
throw new TypeError("assembly-instructions.unviewedForCustomer: input object required");
|
|
642
|
+
}
|
|
643
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
644
|
+
var days = _days(input.days);
|
|
645
|
+
var nowMs = input.now == null ? _now() : _epochMs(input.now, "now");
|
|
646
|
+
var cutoff = nowMs - days * DAY_MS;
|
|
647
|
+
|
|
648
|
+
var lines = await query(
|
|
649
|
+
"SELECT o.id AS order_id, o.created_at AS order_created_at, " +
|
|
650
|
+
"l.sku AS sku " +
|
|
651
|
+
"FROM orders o " +
|
|
652
|
+
"JOIN order_lines l ON l.order_id = o.id " +
|
|
653
|
+
"WHERE o.customer_id = ?1 AND o.created_at >= ?2 " +
|
|
654
|
+
"ORDER BY o.created_at DESC, o.id ASC, l.sku ASC",
|
|
655
|
+
[customerId, cutoff],
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
var byOrder = {};
|
|
659
|
+
var orderOrder = [];
|
|
660
|
+
var resolved = {};
|
|
661
|
+
for (var i = 0; i < lines.rows.length; i += 1) {
|
|
662
|
+
var ln = lines.rows[i];
|
|
663
|
+
var orderId = ln.order_id;
|
|
664
|
+
if (!byOrder[orderId]) {
|
|
665
|
+
byOrder[orderId] = {
|
|
666
|
+
order_id: orderId,
|
|
667
|
+
order_created_at: ln.order_created_at,
|
|
668
|
+
unviewed: [],
|
|
669
|
+
seenSkus: {},
|
|
670
|
+
};
|
|
671
|
+
orderOrder.push(orderId);
|
|
672
|
+
}
|
|
673
|
+
if (byOrder[orderId].seenSkus[ln.sku]) continue;
|
|
674
|
+
byOrder[orderId].seenSkus[ln.sku] = true;
|
|
675
|
+
|
|
676
|
+
var skuRes;
|
|
677
|
+
if (Object.prototype.hasOwnProperty.call(resolved, ln.sku)) {
|
|
678
|
+
skuRes = resolved[ln.sku];
|
|
679
|
+
} else {
|
|
680
|
+
skuRes = await _resolvePublished(ln.sku, DEFAULT_LOCALE);
|
|
681
|
+
resolved[ln.sku] = skuRes;
|
|
682
|
+
}
|
|
683
|
+
if (!skuRes) continue;
|
|
684
|
+
|
|
685
|
+
var v = await query(
|
|
686
|
+
"SELECT id FROM product_instruction_views " +
|
|
687
|
+
"WHERE instruction_id = ?1 AND customer_id = ?2 AND occurred_at >= ?3 LIMIT 1",
|
|
688
|
+
[skuRes.row.id, customerId, ln.order_created_at],
|
|
689
|
+
);
|
|
690
|
+
if (v.rows[0]) continue;
|
|
691
|
+
byOrder[orderId].unviewed.push({
|
|
692
|
+
sku: ln.sku,
|
|
693
|
+
instruction_id: skuRes.row.id,
|
|
694
|
+
locale: skuRes.row.locale,
|
|
695
|
+
kind: skuRes.row.kind,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
var out = [];
|
|
700
|
+
for (var j = 0; j < orderOrder.length; j += 1) {
|
|
701
|
+
var entry = byOrder[orderOrder[j]];
|
|
702
|
+
if (!entry.unviewed.length) continue;
|
|
703
|
+
out.push({
|
|
704
|
+
order_id: entry.order_id,
|
|
705
|
+
order_created_at: entry.order_created_at,
|
|
706
|
+
unviewed: entry.unviewed,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
return out;
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
// Render a markdown-kind instruction's body to escaped HTML.
|
|
713
|
+
// Surfaced so the storefront's order-detail page can render the
|
|
714
|
+
// markdown variant without composing a separate renderer. Every
|
|
715
|
+
// text run passes through `b.template.escapeHtml`.
|
|
716
|
+
renderMarkdown: function (markdown) {
|
|
717
|
+
return _renderMarkdown(markdown);
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Top-level `run()` for direct invocation - exercises the primitive's
|
|
723
|
+
// factory shape against an in-memory query stub so the smoke caller
|
|
724
|
+
// can confirm the module loads + the factory composes without touching
|
|
725
|
+
// a remote D1 or a migration file.
|
|
726
|
+
async function run() {
|
|
727
|
+
var rows = [];
|
|
728
|
+
var q = async function (sql, params) {
|
|
729
|
+
params = params || [];
|
|
730
|
+
var verb = sql.replace(/^\s+/, "").split(/\s+/)[0].toUpperCase();
|
|
731
|
+
if (verb === "INSERT" && /product_instructions/.test(sql)) {
|
|
732
|
+
rows.push({
|
|
733
|
+
id: params[0], sku: params[1], kind: params[2],
|
|
734
|
+
content_url: params[3], content_markdown: params[4],
|
|
735
|
+
locale: params[5], version: params[6], published: params[7],
|
|
736
|
+
archived_at: null, created_at: params[8], updated_at: params[8],
|
|
737
|
+
});
|
|
738
|
+
return { rows: [], rowCount: 1 };
|
|
739
|
+
}
|
|
740
|
+
if (verb === "SELECT" && /FROM product_instructions/.test(sql) && /id = \?1/.test(sql)) {
|
|
741
|
+
var id = params[0];
|
|
742
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
743
|
+
if (rows[i].id === id) return { rows: [rows[i]], rowCount: 1 };
|
|
744
|
+
}
|
|
745
|
+
return { rows: [], rowCount: 0 };
|
|
746
|
+
}
|
|
747
|
+
if (verb === "SELECT" && /FROM product_instructions/.test(sql) &&
|
|
748
|
+
/sku = \?1 AND locale = \?2 AND version = \?3/.test(sql)) {
|
|
749
|
+
for (var j = 0; j < rows.length; j += 1) {
|
|
750
|
+
if (rows[j].sku === params[0] && rows[j].locale === params[1] &&
|
|
751
|
+
rows[j].version === params[2]) {
|
|
752
|
+
return { rows: [rows[j]], rowCount: 1 };
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return { rows: [], rowCount: 0 };
|
|
756
|
+
}
|
|
757
|
+
if (verb === "UPDATE") return { rows: [], rowCount: 0 };
|
|
758
|
+
return { rows: [], rowCount: 0 };
|
|
759
|
+
};
|
|
760
|
+
var ai = create({ query: q });
|
|
761
|
+
await ai.defineInstruction({
|
|
762
|
+
sku: "DESK-OAK-72IN",
|
|
763
|
+
kind: "pdf",
|
|
764
|
+
content_url: "https://cdn.example.com/guides/desk-oak-72in-v1.pdf",
|
|
765
|
+
locale: "en",
|
|
766
|
+
version: 1,
|
|
767
|
+
published: true,
|
|
768
|
+
});
|
|
769
|
+
return { ok: true };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
module.exports = {
|
|
773
|
+
create: create,
|
|
774
|
+
run: run,
|
|
775
|
+
KINDS: KINDS,
|
|
776
|
+
DEFAULT_LOCALE: DEFAULT_LOCALE,
|
|
777
|
+
};
|