@blamejs/blamejs-shop 0.0.53 → 0.0.56
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 +6 -0
- package/lib/addresses.js +430 -0
- package/lib/analytics.js +400 -0
- package/lib/cart-abandonment.js +664 -0
- package/lib/currency-display.js +432 -0
- package/lib/email-suppressions.js +579 -0
- package/lib/email.js +264 -0
- package/lib/index.js +14 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/loyalty.js +496 -0
- package/lib/newsletter.js +176 -12
- package/lib/notifications.js +474 -0
- package/lib/order-tracking.js +456 -0
- package/lib/payment.js +193 -13
- package/lib/referrals.js +649 -0
- package/lib/returns.js +627 -0
- package/lib/reviews.js +412 -0
- package/lib/search-suggestions.js +528 -0
- package/lib/tax-exempt.js +519 -0
- package/lib/tax.js +391 -3
- package/lib/vendor/MANIFEST.json +1 -1
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search suggestions — autocomplete dropdown data for the storefront
|
|
3
|
+
* search input. Visitors typing in the header search box expect three
|
|
4
|
+
* categories side-by-side:
|
|
5
|
+
*
|
|
6
|
+
* 1. Top product matches by title / description prefix (sourced from
|
|
7
|
+
* the catalog primitive's existing `products.search` shape).
|
|
8
|
+
* 2. Top recent popular queries other shoppers typed (aggregated
|
|
9
|
+
* from `search_query_log` over a 30-day window).
|
|
10
|
+
* 3. Operator-curated featured suggestions (e.g. typing "free"
|
|
11
|
+
* surfaces "Free shipping over $50") from
|
|
12
|
+
* `featured_search_suggestions`.
|
|
13
|
+
*
|
|
14
|
+
* The route handler that exposes `GET /search/suggestions?q=…` is
|
|
15
|
+
* wired by the storefront dispatcher separately — this primitive is
|
|
16
|
+
* pure data: input shape validation, persistence, aggregation.
|
|
17
|
+
*
|
|
18
|
+
* Composes:
|
|
19
|
+
* - `b.crypto.namespaceHash` — every `session_id` value is hashed
|
|
20
|
+
* under namespace `"search-suggestions-session"` before any DB
|
|
21
|
+
* write. The log table never holds a recoverable customer-side
|
|
22
|
+
* identifier. Query text is NOT hashed (it's user-typed search
|
|
23
|
+
* text, not PII — operators rely on plaintext to spot zero-
|
|
24
|
+
* result terms and stock-gap signals).
|
|
25
|
+
* - `b.uuid.v7` — row id for both featured suggestions and query
|
|
26
|
+
* log entries.
|
|
27
|
+
* - `b.safeSql.assertOneOf` / `quoteIdentifier` — column
|
|
28
|
+
* allowlisting on the partial-update path so a future refactor
|
|
29
|
+
* introducing dynamic column names can't widen the attack
|
|
30
|
+
* surface to identifier injection.
|
|
31
|
+
*
|
|
32
|
+
* Surface:
|
|
33
|
+
* - `recordQuery({ q, session_id, result_count })` — append a row
|
|
34
|
+
* to `search_query_log`. Normalises `q` (lowercase + trim), hashes
|
|
35
|
+
* `session_id`, refuses empty / oversized queries and bad
|
|
36
|
+
* `result_count`. Returns `{ id }`.
|
|
37
|
+
* - `suggest({ q, limit? })` — returns `{ products, queries,
|
|
38
|
+
* featured }`. `products` runs through the catalog primitive's
|
|
39
|
+
* `products.search` (title + description prefix), restricted to
|
|
40
|
+
* `status: "active"`. `queries` aggregates the popular-query
|
|
41
|
+
* window (last 30 days). `featured` returns matching curated
|
|
42
|
+
* rows with `status='active'` and within their `(starts_at,
|
|
43
|
+
* expires_at)` window. `limit` (default 5) bounds each category
|
|
44
|
+
* independently.
|
|
45
|
+
* - `addFeatured({ prefix, display_text, link_url, priority?,
|
|
46
|
+
* starts_at?, expires_at? })` — operator-only. Inserts a row.
|
|
47
|
+
* Returns the new row.
|
|
48
|
+
* - `updateFeatured(id, patch)` — partial update. Allows
|
|
49
|
+
* `display_text`, `link_url`, `priority`, `starts_at`,
|
|
50
|
+
* `expires_at`, `status`. Returns the updated row, or `null` if
|
|
51
|
+
* the id didn't exist.
|
|
52
|
+
* - `deleteFeatured(id)` — operator-only. Returns
|
|
53
|
+
* `{ removed: boolean }`.
|
|
54
|
+
* - `popularQueries({ from?, to?, limit? })` — operator dashboard
|
|
55
|
+
* aggregate. Returns `[ { query_normalized, count, last_seen,
|
|
56
|
+
* zero_result_share } ]` sorted by count desc.
|
|
57
|
+
* - `cleanupOldQueries(ts)` — retention sweep; deletes rows with
|
|
58
|
+
* `occurred_at < ts`. Returns `{ removed: number }`.
|
|
59
|
+
*
|
|
60
|
+
* Storage:
|
|
61
|
+
* - `featured_search_suggestions` (migration
|
|
62
|
+
* `0030_search_suggestions.sql`).
|
|
63
|
+
* - `search_query_log` (same migration).
|
|
64
|
+
*
|
|
65
|
+
* @primitive searchSuggestions
|
|
66
|
+
* @related b.crypto.namespaceHash, b.uuid, catalog.products.search
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
"use strict";
|
|
70
|
+
|
|
71
|
+
var SESSION_NAMESPACE = "search-suggestions-session";
|
|
72
|
+
var DEFAULT_LIMIT = 5;
|
|
73
|
+
var MAX_LIMIT = 50;
|
|
74
|
+
var MAX_QUERY_LEN = 200;
|
|
75
|
+
var MAX_PREFIX_LEN = 100;
|
|
76
|
+
var MAX_DISPLAY_TEXT_LEN = 200;
|
|
77
|
+
var MAX_LINK_URL_LEN = 2048;
|
|
78
|
+
var MAX_RESULT_COUNT = 1000000;
|
|
79
|
+
var POPULAR_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
80
|
+
var FEATURED_STATUSES = ["active", "expired", "draft"];
|
|
81
|
+
var ALLOWED_FEATURED_COLS = [
|
|
82
|
+
"prefix", "display_text", "link_url", "priority",
|
|
83
|
+
"starts_at", "expires_at", "status",
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// Lazy framework handle — same pattern as the rest of the shop
|
|
87
|
+
// primitives; avoids the require cycle that would arise from
|
|
88
|
+
// importing `./index` at module-eval time.
|
|
89
|
+
var bShop;
|
|
90
|
+
function _b() {
|
|
91
|
+
if (!bShop) bShop = require("./index");
|
|
92
|
+
return bShop.framework;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function _requireObject(input, fnLabel) {
|
|
96
|
+
if (!input || typeof input !== "object") {
|
|
97
|
+
throw new TypeError(fnLabel + ": input object required");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function _normalizeQuery(s, label) {
|
|
102
|
+
if (typeof s !== "string") {
|
|
103
|
+
throw new TypeError(label + ": q must be a string");
|
|
104
|
+
}
|
|
105
|
+
// Refuse control bytes so a malicious search term can't smuggle
|
|
106
|
+
// header-injection-class content into operator dashboards that
|
|
107
|
+
// render the term inline.
|
|
108
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
109
|
+
throw new TypeError(label + ": q must not contain control bytes");
|
|
110
|
+
}
|
|
111
|
+
var trimmed = s.trim().toLowerCase();
|
|
112
|
+
if (!trimmed.length) {
|
|
113
|
+
throw new TypeError(label + ": q must be a non-empty string");
|
|
114
|
+
}
|
|
115
|
+
if (trimmed.length > MAX_QUERY_LEN) {
|
|
116
|
+
throw new TypeError(label + ": q must be <= " + MAX_QUERY_LEN + " chars");
|
|
117
|
+
}
|
|
118
|
+
return trimmed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function _normalizePrefix(s, label) {
|
|
122
|
+
if (typeof s !== "string") {
|
|
123
|
+
throw new TypeError(label + ": prefix must be a string");
|
|
124
|
+
}
|
|
125
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
126
|
+
throw new TypeError(label + ": prefix must not contain control bytes");
|
|
127
|
+
}
|
|
128
|
+
var trimmed = s.trim().toLowerCase();
|
|
129
|
+
if (!trimmed.length) {
|
|
130
|
+
throw new TypeError(label + ": prefix must be a non-empty string");
|
|
131
|
+
}
|
|
132
|
+
if (trimmed.length > MAX_PREFIX_LEN) {
|
|
133
|
+
throw new TypeError(label + ": prefix must be <= " + MAX_PREFIX_LEN + " chars");
|
|
134
|
+
}
|
|
135
|
+
return trimmed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _displayText(s, label) {
|
|
139
|
+
if (typeof s !== "string" || !s.length) {
|
|
140
|
+
throw new TypeError(label + ": display_text must be a non-empty string");
|
|
141
|
+
}
|
|
142
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
143
|
+
throw new TypeError(label + ": display_text must not contain control bytes");
|
|
144
|
+
}
|
|
145
|
+
if (s.length > MAX_DISPLAY_TEXT_LEN) {
|
|
146
|
+
throw new TypeError(label + ": display_text must be <= " + MAX_DISPLAY_TEXT_LEN + " chars");
|
|
147
|
+
}
|
|
148
|
+
return s;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _linkUrl(s, label) {
|
|
152
|
+
if (typeof s !== "string" || !s.length) {
|
|
153
|
+
throw new TypeError(label + ": link_url must be a non-empty string");
|
|
154
|
+
}
|
|
155
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
156
|
+
throw new TypeError(label + ": link_url must not contain control bytes");
|
|
157
|
+
}
|
|
158
|
+
if (s.length > MAX_LINK_URL_LEN) {
|
|
159
|
+
throw new TypeError(label + ": link_url must be <= " + MAX_LINK_URL_LEN + " chars");
|
|
160
|
+
}
|
|
161
|
+
// Refuse `javascript:` / `data:` / `vbscript:` schemes — these
|
|
162
|
+
// would let a curated suggestion stage a script-in-href XSS the
|
|
163
|
+
// moment a visitor clicks the dropdown row. The operator-facing
|
|
164
|
+
// UI never legitimately needs these; the refusal is a hard wall,
|
|
165
|
+
// not a configurable preference.
|
|
166
|
+
if (/^\s*(javascript|data|vbscript):/i.test(s)) {
|
|
167
|
+
throw new TypeError(label + ": link_url scheme refused");
|
|
168
|
+
}
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _priority(n, label) {
|
|
173
|
+
if (n == null) return 0;
|
|
174
|
+
if (!Number.isInteger(n) || n < 0 || n > 1000000) {
|
|
175
|
+
throw new TypeError(label + ": priority must be a non-negative integer <= 1000000");
|
|
176
|
+
}
|
|
177
|
+
return n;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _epochMs(n, label, fnLabel) {
|
|
181
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
182
|
+
throw new TypeError(fnLabel + ": " + label + " must be a non-negative integer epoch-ms");
|
|
183
|
+
}
|
|
184
|
+
return n;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _optEpochMs(n, label, fnLabel) {
|
|
188
|
+
if (n == null) return null;
|
|
189
|
+
return _epochMs(n, label, fnLabel);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function _limit(n, fnLabel) {
|
|
193
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
194
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
|
|
195
|
+
throw new TypeError(fnLabel + ": limit must be 1..." + MAX_LIMIT);
|
|
196
|
+
}
|
|
197
|
+
return n;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _sessionId(s, fnLabel) {
|
|
201
|
+
if (typeof s !== "string" || !s.length) {
|
|
202
|
+
throw new TypeError(fnLabel + ": session_id must be a non-empty string");
|
|
203
|
+
}
|
|
204
|
+
if (s.length > 256) {
|
|
205
|
+
throw new TypeError(fnLabel + ": session_id must be <= 256 chars");
|
|
206
|
+
}
|
|
207
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
208
|
+
throw new TypeError(fnLabel + ": session_id must not contain control bytes");
|
|
209
|
+
}
|
|
210
|
+
return s;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function _resultCount(n, fnLabel) {
|
|
214
|
+
if (n == null) return 0;
|
|
215
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_RESULT_COUNT) {
|
|
216
|
+
throw new TypeError(fnLabel + ": result_count must be a non-negative integer <= " + MAX_RESULT_COUNT);
|
|
217
|
+
}
|
|
218
|
+
return n;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function _featuredStatus(s, fnLabel) {
|
|
222
|
+
if (FEATURED_STATUSES.indexOf(s) === -1) {
|
|
223
|
+
throw new TypeError(fnLabel + ": status must be one of " + FEATURED_STATUSES.join(", "));
|
|
224
|
+
}
|
|
225
|
+
return s;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function _id(id, fnLabel) {
|
|
229
|
+
if (typeof id !== "string" || !id.length) {
|
|
230
|
+
throw new TypeError(fnLabel + ": id must be a non-empty string");
|
|
231
|
+
}
|
|
232
|
+
return id;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function create(opts) {
|
|
236
|
+
opts = opts || {};
|
|
237
|
+
var query = opts.query;
|
|
238
|
+
if (!query) {
|
|
239
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
240
|
+
}
|
|
241
|
+
var catalog = opts.catalog || null;
|
|
242
|
+
|
|
243
|
+
function _featuredByPrefix(qLower, limit, now) {
|
|
244
|
+
// Featured rows are pinned to a specific lowercased prefix. The
|
|
245
|
+
// typical match is "is this prefix exactly what the customer
|
|
246
|
+
// typed so far?" — i.e. the row's prefix equals the leading
|
|
247
|
+
// chars of `q`. To honour both directions (customer typed
|
|
248
|
+
// `free` and the row prefix is `free`; customer typed `f` and
|
|
249
|
+
// the row prefix is `free` so the operator's promo surfaces
|
|
250
|
+
// early) we match either way:
|
|
251
|
+
// * `prefix = q` — exact prefix hit
|
|
252
|
+
// * `prefix LIKE q || '%'` — customer typed less than the
|
|
253
|
+
// full prefix; surface the row as soon as it's an
|
|
254
|
+
// unambiguous extension of what they've typed
|
|
255
|
+
// Active + non-expired only. Ordered by priority DESC then
|
|
256
|
+
// created_at DESC so an operator-pinned row wins ties.
|
|
257
|
+
var pattern = qLower.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_") + "%";
|
|
258
|
+
return query(
|
|
259
|
+
"SELECT id, prefix, display_text, link_url, priority, " +
|
|
260
|
+
"starts_at, expires_at, status, created_at, updated_at " +
|
|
261
|
+
"FROM featured_search_suggestions " +
|
|
262
|
+
"WHERE status = 'active' " +
|
|
263
|
+
"AND starts_at <= ?1 " +
|
|
264
|
+
"AND (expires_at IS NULL OR expires_at > ?1) " +
|
|
265
|
+
"AND (prefix = ?2 OR prefix LIKE ?3 ESCAPE '\\') " +
|
|
266
|
+
"ORDER BY priority DESC, created_at DESC, id DESC " +
|
|
267
|
+
"LIMIT ?4",
|
|
268
|
+
[now, qLower, pattern, limit],
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _popularByPrefix(qLower, limit, sinceTs) {
|
|
273
|
+
// Popular queries aggregate over the recent window. The
|
|
274
|
+
// `query_normalized` column is already lowercased + trimmed at
|
|
275
|
+
// the write site, so the LIKE pattern matches the column
|
|
276
|
+
// directly. Escape clause neutralises LIKE metachars so a
|
|
277
|
+
// search containing `%` or `_` matches the literal character.
|
|
278
|
+
var pattern = qLower.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_") + "%";
|
|
279
|
+
return query(
|
|
280
|
+
"SELECT query_normalized, COUNT(*) AS count, MAX(occurred_at) AS last_seen " +
|
|
281
|
+
"FROM search_query_log " +
|
|
282
|
+
"WHERE occurred_at >= ?1 " +
|
|
283
|
+
"AND query_normalized LIKE ?2 ESCAPE '\\' " +
|
|
284
|
+
"GROUP BY query_normalized " +
|
|
285
|
+
"ORDER BY count DESC, last_seen DESC " +
|
|
286
|
+
"LIMIT ?3",
|
|
287
|
+
[sinceTs, pattern, limit],
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
293
|
+
POPULAR_WINDOW_MS: POPULAR_WINDOW_MS,
|
|
294
|
+
|
|
295
|
+
recordQuery: async function (input) {
|
|
296
|
+
_requireObject(input, "searchSuggestions.recordQuery");
|
|
297
|
+
var qNorm = _normalizeQuery(input.q, "searchSuggestions.recordQuery");
|
|
298
|
+
var sessionId = _sessionId(input.session_id, "searchSuggestions.recordQuery");
|
|
299
|
+
var resultCount = _resultCount(input.result_count, "searchSuggestions.recordQuery");
|
|
300
|
+
var sessionHash = _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
|
|
301
|
+
var id = _b().uuid.v7();
|
|
302
|
+
var now = Date.now();
|
|
303
|
+
await query(
|
|
304
|
+
"INSERT INTO search_query_log " +
|
|
305
|
+
"(id, query_normalized, session_id_hash, result_count, occurred_at) " +
|
|
306
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
307
|
+
[id, qNorm, sessionHash, resultCount, now],
|
|
308
|
+
);
|
|
309
|
+
return { id: id };
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
suggest: async function (input) {
|
|
313
|
+
input = input || {};
|
|
314
|
+
var qNorm = _normalizeQuery(input.q, "searchSuggestions.suggest");
|
|
315
|
+
var limit = _limit(input.limit, "searchSuggestions.suggest");
|
|
316
|
+
var now = Date.now();
|
|
317
|
+
var sinceTs = now - POPULAR_WINDOW_MS;
|
|
318
|
+
|
|
319
|
+
// Featured + popular run as independent queries against this
|
|
320
|
+
// primitive's own tables. Product matches delegate to the
|
|
321
|
+
// catalog primitive's existing `products.search` shape so the
|
|
322
|
+
// dropdown stays consistent with what `/search?q=…` would
|
|
323
|
+
// return (case-insensitive title + description LIKE, status
|
|
324
|
+
// active only).
|
|
325
|
+
var featuredP = _featuredByPrefix(qNorm, limit, now);
|
|
326
|
+
var popularP = _popularByPrefix(qNorm, limit, sinceTs);
|
|
327
|
+
var productsP;
|
|
328
|
+
if (catalog && catalog.products && typeof catalog.products.search === "function") {
|
|
329
|
+
productsP = catalog.products.search({ q: qNorm, status: "active", limit: limit });
|
|
330
|
+
} else {
|
|
331
|
+
productsP = Promise.resolve({ rows: [], next_cursor: null });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
var resolved = await Promise.all([featuredP, popularP, productsP]);
|
|
335
|
+
var featuredR = resolved[0];
|
|
336
|
+
var popularR = resolved[1];
|
|
337
|
+
var productsR = resolved[2];
|
|
338
|
+
|
|
339
|
+
var queries = [];
|
|
340
|
+
for (var i = 0; i < popularR.rows.length; i += 1) {
|
|
341
|
+
var row = popularR.rows[i];
|
|
342
|
+
queries.push({
|
|
343
|
+
query_normalized: row.query_normalized,
|
|
344
|
+
count: Number(row.count),
|
|
345
|
+
last_seen: Number(row.last_seen),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
var featured = [];
|
|
349
|
+
for (var j = 0; j < featuredR.rows.length; j += 1) {
|
|
350
|
+
var f = featuredR.rows[j];
|
|
351
|
+
featured.push({
|
|
352
|
+
id: f.id,
|
|
353
|
+
prefix: f.prefix,
|
|
354
|
+
display_text: f.display_text,
|
|
355
|
+
link_url: f.link_url,
|
|
356
|
+
priority: Number(f.priority),
|
|
357
|
+
starts_at: Number(f.starts_at),
|
|
358
|
+
expires_at: f.expires_at == null ? null : Number(f.expires_at),
|
|
359
|
+
status: f.status,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
// The catalog primitive returns `{ rows, next_cursor }`; the
|
|
363
|
+
// dropdown only consumes the rows array. Pass through as-is
|
|
364
|
+
// so the route handler can render the same shape it would
|
|
365
|
+
// get from a `/search` request.
|
|
366
|
+
var products = Array.isArray(productsR && productsR.rows) ? productsR.rows : [];
|
|
367
|
+
return {
|
|
368
|
+
products: products,
|
|
369
|
+
queries: queries,
|
|
370
|
+
featured: featured,
|
|
371
|
+
};
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
addFeatured: async function (input) {
|
|
375
|
+
_requireObject(input, "searchSuggestions.addFeatured");
|
|
376
|
+
var prefix = _normalizePrefix(input.prefix, "searchSuggestions.addFeatured");
|
|
377
|
+
var displayText = _displayText(input.display_text, "searchSuggestions.addFeatured");
|
|
378
|
+
var linkUrl = _linkUrl(input.link_url, "searchSuggestions.addFeatured");
|
|
379
|
+
var priority = _priority(input.priority, "searchSuggestions.addFeatured");
|
|
380
|
+
var now = Date.now();
|
|
381
|
+
var startsAt = input.starts_at == null
|
|
382
|
+
? now
|
|
383
|
+
: _epochMs(input.starts_at, "starts_at", "searchSuggestions.addFeatured");
|
|
384
|
+
var expiresAt = _optEpochMs(input.expires_at, "expires_at", "searchSuggestions.addFeatured");
|
|
385
|
+
if (expiresAt != null && expiresAt <= startsAt) {
|
|
386
|
+
throw new TypeError("searchSuggestions.addFeatured: expires_at must be > starts_at");
|
|
387
|
+
}
|
|
388
|
+
var status = input.status == null ? "active" : _featuredStatus(input.status, "searchSuggestions.addFeatured");
|
|
389
|
+
var id = _b().uuid.v7();
|
|
390
|
+
await query(
|
|
391
|
+
"INSERT INTO featured_search_suggestions " +
|
|
392
|
+
"(id, prefix, display_text, link_url, priority, starts_at, expires_at, status, created_at, updated_at) " +
|
|
393
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
|
|
394
|
+
[id, prefix, displayText, linkUrl, priority, startsAt, expiresAt, status, now],
|
|
395
|
+
);
|
|
396
|
+
var row = (await query(
|
|
397
|
+
"SELECT * FROM featured_search_suggestions WHERE id = ?1 LIMIT 1",
|
|
398
|
+
[id],
|
|
399
|
+
)).rows[0];
|
|
400
|
+
return row || null;
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
updateFeatured: async function (id, patch) {
|
|
404
|
+
_id(id, "searchSuggestions.updateFeatured");
|
|
405
|
+
if (!patch || typeof patch !== "object") {
|
|
406
|
+
throw new TypeError("searchSuggestions.updateFeatured: patch object required");
|
|
407
|
+
}
|
|
408
|
+
var sets = [];
|
|
409
|
+
var params = [];
|
|
410
|
+
var i = 1;
|
|
411
|
+
function _addSet(col, val) {
|
|
412
|
+
// Defense in depth — the column literal here is hand-
|
|
413
|
+
// written, but route every dynamic identifier through the
|
|
414
|
+
// safeSql allowlist so a future refactor that widens the
|
|
415
|
+
// column set can't smuggle identifier injection past the
|
|
416
|
+
// gate.
|
|
417
|
+
_b().safeSql.assertOneOf(col, ALLOWED_FEATURED_COLS);
|
|
418
|
+
sets.push(_b().safeSql.quoteIdentifier(col, "sqlite") + " = ?" + (i++));
|
|
419
|
+
params.push(val);
|
|
420
|
+
}
|
|
421
|
+
if (patch.prefix !== undefined) {
|
|
422
|
+
_addSet("prefix", _normalizePrefix(patch.prefix, "searchSuggestions.updateFeatured"));
|
|
423
|
+
}
|
|
424
|
+
if (patch.display_text !== undefined) {
|
|
425
|
+
_addSet("display_text", _displayText(patch.display_text, "searchSuggestions.updateFeatured"));
|
|
426
|
+
}
|
|
427
|
+
if (patch.link_url !== undefined) {
|
|
428
|
+
_addSet("link_url", _linkUrl(patch.link_url, "searchSuggestions.updateFeatured"));
|
|
429
|
+
}
|
|
430
|
+
if (patch.priority !== undefined) {
|
|
431
|
+
_addSet("priority", _priority(patch.priority, "searchSuggestions.updateFeatured"));
|
|
432
|
+
}
|
|
433
|
+
if (patch.starts_at !== undefined) {
|
|
434
|
+
_addSet("starts_at", _epochMs(patch.starts_at, "starts_at", "searchSuggestions.updateFeatured"));
|
|
435
|
+
}
|
|
436
|
+
if (patch.expires_at !== undefined) {
|
|
437
|
+
_addSet("expires_at", patch.expires_at == null
|
|
438
|
+
? null
|
|
439
|
+
: _epochMs(patch.expires_at, "expires_at", "searchSuggestions.updateFeatured"));
|
|
440
|
+
}
|
|
441
|
+
if (patch.status !== undefined) {
|
|
442
|
+
_addSet("status", _featuredStatus(patch.status, "searchSuggestions.updateFeatured"));
|
|
443
|
+
}
|
|
444
|
+
if (sets.length === 0) {
|
|
445
|
+
// No-op update — callers shouldn't rely on a heartbeat
|
|
446
|
+
// updated_at flip from an empty patch; throw so the caller
|
|
447
|
+
// is explicit about intent.
|
|
448
|
+
throw new TypeError("searchSuggestions.updateFeatured: patch contained no updatable fields");
|
|
449
|
+
}
|
|
450
|
+
sets.push("updated_at = ?" + (i++));
|
|
451
|
+
params.push(Date.now());
|
|
452
|
+
params.push(id);
|
|
453
|
+
var r = await query(
|
|
454
|
+
"UPDATE featured_search_suggestions SET " + sets.join(", ") + " WHERE id = ?" + i,
|
|
455
|
+
params,
|
|
456
|
+
);
|
|
457
|
+
if (r.rowCount === 0) return null;
|
|
458
|
+
var row = (await query(
|
|
459
|
+
"SELECT * FROM featured_search_suggestions WHERE id = ?1 LIMIT 1",
|
|
460
|
+
[id],
|
|
461
|
+
)).rows[0];
|
|
462
|
+
return row || null;
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
deleteFeatured: async function (id) {
|
|
466
|
+
_id(id, "searchSuggestions.deleteFeatured");
|
|
467
|
+
var r = await query(
|
|
468
|
+
"DELETE FROM featured_search_suggestions WHERE id = ?1",
|
|
469
|
+
[id],
|
|
470
|
+
);
|
|
471
|
+
return { removed: Number(r.rowCount || 0) > 0 };
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
popularQueries: async function (popOpts) {
|
|
475
|
+
popOpts = popOpts || {};
|
|
476
|
+
var now = Date.now();
|
|
477
|
+
var from = popOpts.from == null
|
|
478
|
+
? now - POPULAR_WINDOW_MS
|
|
479
|
+
: _epochMs(popOpts.from, "from", "searchSuggestions.popularQueries");
|
|
480
|
+
var to = popOpts.to == null
|
|
481
|
+
? now
|
|
482
|
+
: _epochMs(popOpts.to, "to", "searchSuggestions.popularQueries");
|
|
483
|
+
if (to < from) {
|
|
484
|
+
throw new TypeError("searchSuggestions.popularQueries: to must be >= from");
|
|
485
|
+
}
|
|
486
|
+
var limit = _limit(popOpts.limit, "searchSuggestions.popularQueries");
|
|
487
|
+
var rows = (await query(
|
|
488
|
+
"SELECT query_normalized, " +
|
|
489
|
+
"COUNT(*) AS count, " +
|
|
490
|
+
"MAX(occurred_at) AS last_seen, " +
|
|
491
|
+
"SUM(CASE WHEN result_count = 0 THEN 1 ELSE 0 END) AS zero_count " +
|
|
492
|
+
"FROM search_query_log " +
|
|
493
|
+
"WHERE occurred_at >= ?1 AND occurred_at <= ?2 " +
|
|
494
|
+
"GROUP BY query_normalized " +
|
|
495
|
+
"ORDER BY count DESC, last_seen DESC " +
|
|
496
|
+
"LIMIT ?3",
|
|
497
|
+
[from, to, limit],
|
|
498
|
+
)).rows;
|
|
499
|
+
var out = [];
|
|
500
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
501
|
+
var count = Number(rows[i].count);
|
|
502
|
+
var zero = Number(rows[i].zero_count || 0);
|
|
503
|
+
out.push({
|
|
504
|
+
query_normalized: rows[i].query_normalized,
|
|
505
|
+
count: count,
|
|
506
|
+
last_seen: Number(rows[i].last_seen),
|
|
507
|
+
zero_result_share: count === 0 ? 0 : zero / count,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return out;
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
cleanupOldQueries: async function (ts) {
|
|
514
|
+
if (!Number.isInteger(ts) || ts < 0) {
|
|
515
|
+
throw new TypeError("searchSuggestions.cleanupOldQueries: ts must be a non-negative integer epoch-ms");
|
|
516
|
+
}
|
|
517
|
+
var r = await query(
|
|
518
|
+
"DELETE FROM search_query_log WHERE occurred_at < ?1",
|
|
519
|
+
[ts],
|
|
520
|
+
);
|
|
521
|
+
return { removed: Number(r.rowCount || 0) };
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
module.exports = {
|
|
527
|
+
create: create,
|
|
528
|
+
};
|