@blamejs/blamejs-shop 0.0.56 → 0.0.57
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 +2 -0
- package/lib/backorder.js +452 -0
- package/lib/bundles.js +587 -0
- package/lib/fraud-screen.js +808 -0
- package/lib/index.js +10 -0
- package/lib/inventory-locations.js +774 -0
- package/lib/order-export.js +724 -0
- package/lib/order-notes.js +563 -0
- package/lib/payment-methods.js +522 -0
- package/lib/print-on-demand.js +709 -0
- package/lib/save-for-later.js +667 -0
- package/lib/variants.js +726 -0
- package/package.json +1 -1
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.orderNotes
|
|
4
|
+
* @title Order notes — threaded customer-service notes attached to an order
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Customer-service conversation surface attached to an order.
|
|
8
|
+
* Each note is either a fresh thread starter or a reply to an
|
|
9
|
+
* earlier note; visibility is either `internal` (operator-only —
|
|
10
|
+
* fraud markers, fulfillment hints, hand-offs) or
|
|
11
|
+
* `customer_visible` (rendered on the customer's order page and
|
|
12
|
+
* emailed).
|
|
13
|
+
*
|
|
14
|
+
* Composes:
|
|
15
|
+
* - `b.guardUuid` — UUID-shape validation for ids
|
|
16
|
+
* - `b.uuid.v7` — row ids
|
|
17
|
+
* - `b.pagination` — HMAC-tagged tuple cursors for listForOrder
|
|
18
|
+
*
|
|
19
|
+
* Surface:
|
|
20
|
+
* add({ order_id, author, body, visibility, parent_note_id?,
|
|
21
|
+
* author_id?, tags? })
|
|
22
|
+
* get(note_id)
|
|
23
|
+
* listForOrder({ order_id, visibility_filter?, cursor?, limit? })
|
|
24
|
+
* thread({ order_id, root_note_id })
|
|
25
|
+
* markRead({ note_id, by_actor })
|
|
26
|
+
* pin(note_id) / unpin(note_id)
|
|
27
|
+
* customerVisibleForOrder(order_id)
|
|
28
|
+
* internalForOrder(order_id, { resolved? })
|
|
29
|
+
* resolve({ note_id, resolution }) / reopen(note_id)
|
|
30
|
+
*
|
|
31
|
+
* Storage:
|
|
32
|
+
* - `order_notes` (migration `0037_order_notes.sql`)
|
|
33
|
+
* - `order_note_reads` (same migration)
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var MAX_BODY_LEN = 8000;
|
|
37
|
+
var MAX_TAG_COUNT = 16;
|
|
38
|
+
var MAX_TAG_LEN = 32;
|
|
39
|
+
var MAX_RESOLUTION_LEN = 280;
|
|
40
|
+
var MAX_LIST_LIMIT = 100;
|
|
41
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
42
|
+
|
|
43
|
+
var ALLOWED_AUTHORS = ["customer", "operator", "system"];
|
|
44
|
+
var ALLOWED_VISIBILITY = ["internal", "customer_visible"];
|
|
45
|
+
|
|
46
|
+
// Refuse C0 control bytes + DEL + the LRM/RLM/ZWJ/ZWNJ family of
|
|
47
|
+
// zero-width / direction-override characters. Newlines (LF, CR) +
|
|
48
|
+
// tab are tolerated because operator notes legitimately wrap.
|
|
49
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
50
|
+
// Zero-width + direction-override family: ZWSP/ZWNJ/ZWJ (U+200B-200D),
|
|
51
|
+
// LRM/RLM (U+200E/U+200F), the bidi-formatting block
|
|
52
|
+
// (U+202A-U+202E), the invisible-math block (U+2060-U+2064), the
|
|
53
|
+
// LRI/RLI/FSI/PDI block (U+2066-U+2069), the BOM (U+FEFF), and the
|
|
54
|
+
// Arabic letter mark (U+061C). Spelled with \u-escapes so the
|
|
55
|
+
// detector pattern stays grep-able and ESLint's
|
|
56
|
+
// no-irregular-whitespace stays happy.
|
|
57
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
58
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// (pinned DESC, created_at DESC, id DESC) — pinned notes float to
|
|
62
|
+
// the top; the id tie-break keeps cursor pagination deterministic
|
|
63
|
+
// when two notes land in the same millisecond.
|
|
64
|
+
var LIST_ORDER_KEY = ["pinned:desc", "created_at:desc", "id:desc"];
|
|
65
|
+
|
|
66
|
+
// Lazy framework handle — matches the pattern used by the rest of
|
|
67
|
+
// the shop primitives; avoids the require cycle that would arise
|
|
68
|
+
// from importing `./index` at module-eval time.
|
|
69
|
+
var bShop;
|
|
70
|
+
function _b() {
|
|
71
|
+
if (!bShop) bShop = require("./index");
|
|
72
|
+
return bShop.framework;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- validators ---------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function _uuid(s, label) {
|
|
78
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
79
|
+
catch (e) { throw new TypeError("orderNotes: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _author(s) {
|
|
83
|
+
if (typeof s !== "string" || ALLOWED_AUTHORS.indexOf(s) === -1) {
|
|
84
|
+
throw new TypeError("orderNotes: author must be one of " + ALLOWED_AUTHORS.join(", "));
|
|
85
|
+
}
|
|
86
|
+
return s;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function _visibility(s) {
|
|
90
|
+
if (typeof s !== "string" || ALLOWED_VISIBILITY.indexOf(s) === -1) {
|
|
91
|
+
throw new TypeError("orderNotes: visibility must be one of " + ALLOWED_VISIBILITY.join(", "));
|
|
92
|
+
}
|
|
93
|
+
return s;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _body(s) {
|
|
97
|
+
if (typeof s !== "string") {
|
|
98
|
+
throw new TypeError("orderNotes: body must be a string");
|
|
99
|
+
}
|
|
100
|
+
var trimmed = s.trim();
|
|
101
|
+
if (!trimmed.length) {
|
|
102
|
+
throw new TypeError("orderNotes: body must be non-empty after trim");
|
|
103
|
+
}
|
|
104
|
+
if (s.length > MAX_BODY_LEN) {
|
|
105
|
+
throw new TypeError("orderNotes: body must be <= " + MAX_BODY_LEN + " characters");
|
|
106
|
+
}
|
|
107
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
108
|
+
throw new TypeError("orderNotes: body contains control bytes");
|
|
109
|
+
}
|
|
110
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
111
|
+
throw new TypeError("orderNotes: body contains zero-width / direction-override characters");
|
|
112
|
+
}
|
|
113
|
+
return s;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function _tags(input) {
|
|
117
|
+
if (input == null) return [];
|
|
118
|
+
if (!Array.isArray(input)) {
|
|
119
|
+
throw new TypeError("orderNotes: tags must be an array of strings");
|
|
120
|
+
}
|
|
121
|
+
if (input.length > MAX_TAG_COUNT) {
|
|
122
|
+
throw new TypeError("orderNotes: tags array must contain <= " + MAX_TAG_COUNT + " entries");
|
|
123
|
+
}
|
|
124
|
+
var out = [];
|
|
125
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
126
|
+
var t = input[i];
|
|
127
|
+
if (typeof t !== "string" || !t.length) {
|
|
128
|
+
throw new TypeError("orderNotes: tags[" + i + "] must be a non-empty string");
|
|
129
|
+
}
|
|
130
|
+
if (t.length > MAX_TAG_LEN) {
|
|
131
|
+
throw new TypeError("orderNotes: tags[" + i + "] must be <= " + MAX_TAG_LEN + " characters");
|
|
132
|
+
}
|
|
133
|
+
if (CONTROL_BYTE_RE.test(t) || ZERO_WIDTH_RE.test(t)) {
|
|
134
|
+
throw new TypeError("orderNotes: tags[" + i + "] contains control / zero-width bytes");
|
|
135
|
+
}
|
|
136
|
+
out.push(t);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _resolution(s) {
|
|
142
|
+
if (typeof s !== "string" || !s.length) {
|
|
143
|
+
throw new TypeError("orderNotes: resolution must be a non-empty string");
|
|
144
|
+
}
|
|
145
|
+
if (s.length > MAX_RESOLUTION_LEN) {
|
|
146
|
+
throw new TypeError("orderNotes: resolution must be <= " + MAX_RESOLUTION_LEN + " characters");
|
|
147
|
+
}
|
|
148
|
+
if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
149
|
+
throw new TypeError("orderNotes: resolution contains control / zero-width bytes");
|
|
150
|
+
}
|
|
151
|
+
return s;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _limit(n) {
|
|
155
|
+
if (n == null) return DEFAULT_LIST_LIMIT;
|
|
156
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
157
|
+
throw new TypeError("orderNotes: limit must be an integer 1..." + MAX_LIST_LIMIT);
|
|
158
|
+
}
|
|
159
|
+
return n;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _visibilityFilter(s) {
|
|
163
|
+
if (s == null) return undefined;
|
|
164
|
+
if (typeof s !== "string" || ALLOWED_VISIBILITY.indexOf(s) === -1) {
|
|
165
|
+
throw new TypeError("orderNotes: visibility_filter must be one of " + ALLOWED_VISIBILITY.join(", "));
|
|
166
|
+
}
|
|
167
|
+
return s;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
var _lastTs = 0;
|
|
171
|
+
function _now() {
|
|
172
|
+
var t = Date.now();
|
|
173
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
174
|
+
_lastTs = t;
|
|
175
|
+
return t;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _hydrate(row) {
|
|
179
|
+
if (!row) return row;
|
|
180
|
+
try { row.tags = JSON.parse(row.tags_json || "[]"); }
|
|
181
|
+
catch (_e) { row.tags = []; }
|
|
182
|
+
return row;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- factory ------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
function create(opts) {
|
|
188
|
+
opts = opts || {};
|
|
189
|
+
var query = opts.query;
|
|
190
|
+
if (!query) {
|
|
191
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
192
|
+
}
|
|
193
|
+
// Pagination cursors are HMAC-tagged via b.pagination so an
|
|
194
|
+
// operator can't hand-craft one to skip past a hidden note or
|
|
195
|
+
// replay across deployments. The secret defaults to a dev-only
|
|
196
|
+
// placeholder so the primitive boots in tests; production
|
|
197
|
+
// deployments must supply a derived value (typically
|
|
198
|
+
// b.crypto.namespaceHash("order-notes-cursor", D1_BRIDGE_SECRET)).
|
|
199
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
200
|
+
if (process.env.NODE_ENV === "production") {
|
|
201
|
+
throw new Error("orderNotes.create: opts.cursorSecret is required in production");
|
|
202
|
+
}
|
|
203
|
+
opts.cursorSecret = "order-notes-cursor-secret-dev-only";
|
|
204
|
+
}
|
|
205
|
+
var cursorSecret = opts.cursorSecret;
|
|
206
|
+
|
|
207
|
+
function _decodeCursor(cursor, label) {
|
|
208
|
+
if (cursor == null) return null;
|
|
209
|
+
if (typeof cursor !== "string") {
|
|
210
|
+
throw new TypeError("orderNotes." + label + ": cursor must be an opaque string or null");
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
var state = _b().pagination.decodeCursor(cursor, cursorSecret);
|
|
214
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
|
|
215
|
+
throw new TypeError("orderNotes." + label + ": cursor orderKey mismatch");
|
|
216
|
+
}
|
|
217
|
+
return state.vals;
|
|
218
|
+
} catch (e) {
|
|
219
|
+
if (e instanceof TypeError) throw e;
|
|
220
|
+
throw new TypeError("orderNotes." + label + ": cursor — " + (e && e.message || "malformed"));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _encodeNext(rows, limit) {
|
|
225
|
+
var last = rows[rows.length - 1];
|
|
226
|
+
if (!last || rows.length < limit) return null;
|
|
227
|
+
return _b().pagination.encodeCursor({
|
|
228
|
+
orderKey: LIST_ORDER_KEY,
|
|
229
|
+
vals: [last.pinned, last.created_at, last.id],
|
|
230
|
+
forward: true,
|
|
231
|
+
}, cursorSecret);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Internal helper — fetch a note by id without UUID re-validation
|
|
235
|
+
// and without hydrating tags. Used by mutation methods that need
|
|
236
|
+
// the raw row to confirm existence before writing.
|
|
237
|
+
async function _getRaw(id) {
|
|
238
|
+
var r = await query("SELECT * FROM order_notes WHERE id = ?1", [id]);
|
|
239
|
+
return r.rows[0] || null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
244
|
+
MAX_TAG_COUNT: MAX_TAG_COUNT,
|
|
245
|
+
MAX_TAG_LEN: MAX_TAG_LEN,
|
|
246
|
+
MAX_RESOLUTION_LEN: MAX_RESOLUTION_LEN,
|
|
247
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
248
|
+
ALLOWED_AUTHORS: ALLOWED_AUTHORS.slice(),
|
|
249
|
+
ALLOWED_VISIBILITY: ALLOWED_VISIBILITY.slice(),
|
|
250
|
+
|
|
251
|
+
add: async function (input) {
|
|
252
|
+
if (!input || typeof input !== "object") {
|
|
253
|
+
throw new TypeError("orderNotes.add: input object required");
|
|
254
|
+
}
|
|
255
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
256
|
+
var author = _author(input.author);
|
|
257
|
+
var visibility = _visibility(input.visibility);
|
|
258
|
+
var body = _body(input.body);
|
|
259
|
+
var tags = _tags(input.tags);
|
|
260
|
+
|
|
261
|
+
var parentId = null;
|
|
262
|
+
if (input.parent_note_id != null) {
|
|
263
|
+
parentId = _uuid(input.parent_note_id, "parent_note_id");
|
|
264
|
+
var parent = await _getRaw(parentId);
|
|
265
|
+
if (!parent) {
|
|
266
|
+
var pErr = new Error("orderNotes.add: parent_note_id " + parentId + " not found");
|
|
267
|
+
pErr.code = "ORDER_NOTE_PARENT_NOT_FOUND";
|
|
268
|
+
throw pErr;
|
|
269
|
+
}
|
|
270
|
+
if (parent.order_id !== orderId) {
|
|
271
|
+
throw new TypeError("orderNotes.add: parent_note_id belongs to a different order");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
var authorId = null;
|
|
276
|
+
if (input.author_id != null) {
|
|
277
|
+
authorId = _uuid(input.author_id, "author_id");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
var id = _b().uuid.v7();
|
|
281
|
+
var ts = _now();
|
|
282
|
+
await query(
|
|
283
|
+
"INSERT INTO order_notes " +
|
|
284
|
+
"(id, order_id, parent_note_id, author, author_id, visibility, body, tags_json, pinned, created_at, updated_at) " +
|
|
285
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, ?9, ?9)",
|
|
286
|
+
[id, orderId, parentId, author, authorId, visibility, body, JSON.stringify(tags), ts],
|
|
287
|
+
);
|
|
288
|
+
return await this.get(id);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
get: async function (id) {
|
|
292
|
+
_uuid(id, "note id");
|
|
293
|
+
var row = await _getRaw(id);
|
|
294
|
+
return _hydrate(row);
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
// Paginated note list for an order, ordered (pinned DESC,
|
|
298
|
+
// created_at DESC, id DESC). Optional `visibility_filter`
|
|
299
|
+
// restricts to one of the two visibility tiers — the storefront
|
|
300
|
+
// calls this with `customer_visible`, the operator console with
|
|
301
|
+
// either filter or none.
|
|
302
|
+
listForOrder: async function (listOpts) {
|
|
303
|
+
if (!listOpts || typeof listOpts !== "object") {
|
|
304
|
+
throw new TypeError("orderNotes.listForOrder: input object required");
|
|
305
|
+
}
|
|
306
|
+
var orderId = _uuid(listOpts.order_id, "order_id");
|
|
307
|
+
var visibility = _visibilityFilter(listOpts.visibility_filter);
|
|
308
|
+
var limit = _limit(listOpts.limit);
|
|
309
|
+
var cursorVals = _decodeCursor(listOpts.cursor, "listForOrder");
|
|
310
|
+
|
|
311
|
+
// Compose the WHERE + cursor-keyset clauses. The cursor
|
|
312
|
+
// keyset is `(pinned, created_at, id) < (cv0, cv1, cv2)` in
|
|
313
|
+
// lexicographic order — descending across all three columns.
|
|
314
|
+
var where = ["order_id = ?1"];
|
|
315
|
+
var params = [orderId];
|
|
316
|
+
var idx = 2;
|
|
317
|
+
if (visibility !== undefined) {
|
|
318
|
+
where.push("visibility = ?" + idx);
|
|
319
|
+
params.push(visibility);
|
|
320
|
+
idx += 1;
|
|
321
|
+
}
|
|
322
|
+
if (cursorVals) {
|
|
323
|
+
var a = idx; // pinned
|
|
324
|
+
var b = idx + 1; // created_at
|
|
325
|
+
var c = idx + 2; // id
|
|
326
|
+
where.push(
|
|
327
|
+
"(pinned < ?" + a + " OR " +
|
|
328
|
+
"(pinned = ?" + a + " AND created_at < ?" + b + ") OR " +
|
|
329
|
+
"(pinned = ?" + a + " AND created_at = ?" + b + " AND id < ?" + c + "))"
|
|
330
|
+
);
|
|
331
|
+
params.push(cursorVals[0], cursorVals[1], cursorVals[2]);
|
|
332
|
+
idx += 3;
|
|
333
|
+
}
|
|
334
|
+
params.push(limit);
|
|
335
|
+
var sql = "SELECT * FROM order_notes WHERE " + where.join(" AND ") +
|
|
336
|
+
" ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ?" + idx;
|
|
337
|
+
var r = await query(sql, params);
|
|
338
|
+
var rows = r.rows.map(_hydrate);
|
|
339
|
+
return { rows: rows, next_cursor: _encodeNext(rows, limit) };
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
// Tree-shaped thread starting from `root_note_id`. Returns the
|
|
343
|
+
// root + every descendant (any depth) as a nested
|
|
344
|
+
// `{ note, replies: [ ... ] }` structure. The caller is the
|
|
345
|
+
// operator console / customer order page; both want the full
|
|
346
|
+
// tree to render the conversation in order.
|
|
347
|
+
thread: async function (input) {
|
|
348
|
+
if (!input || typeof input !== "object") {
|
|
349
|
+
throw new TypeError("orderNotes.thread: input object required");
|
|
350
|
+
}
|
|
351
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
352
|
+
var rootId = _uuid(input.root_note_id, "root_note_id");
|
|
353
|
+
|
|
354
|
+
var rootRow = await _getRaw(rootId);
|
|
355
|
+
if (!rootRow) {
|
|
356
|
+
var rErr = new Error("orderNotes.thread: root_note_id " + rootId + " not found");
|
|
357
|
+
rErr.code = "ORDER_NOTE_NOT_FOUND";
|
|
358
|
+
throw rErr;
|
|
359
|
+
}
|
|
360
|
+
if (rootRow.order_id !== orderId) {
|
|
361
|
+
throw new TypeError("orderNotes.thread: root_note_id belongs to a different order");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Pull every note attached to the order in a single query,
|
|
365
|
+
// then bucket-by-parent in memory. Cheaper than a recursive
|
|
366
|
+
// CTE for the modest depths this primitive sees in practice,
|
|
367
|
+
// and portable across the in-memory test DB + D1.
|
|
368
|
+
var all = (await query(
|
|
369
|
+
"SELECT * FROM order_notes WHERE order_id = ?1 ORDER BY created_at ASC, id ASC",
|
|
370
|
+
[orderId],
|
|
371
|
+
)).rows;
|
|
372
|
+
var byParent = {};
|
|
373
|
+
for (var i = 0; i < all.length; i += 1) {
|
|
374
|
+
var n = _hydrate(all[i]);
|
|
375
|
+
var pk = n.parent_note_id || "__root__";
|
|
376
|
+
if (!byParent[pk]) byParent[pk] = [];
|
|
377
|
+
byParent[pk].push(n);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function _buildTree(noteId) {
|
|
381
|
+
var children = byParent[noteId] || [];
|
|
382
|
+
var built = [];
|
|
383
|
+
for (var j = 0; j < children.length; j += 1) {
|
|
384
|
+
var c = children[j];
|
|
385
|
+
built.push({ note: c, replies: _buildTree(c.id) });
|
|
386
|
+
}
|
|
387
|
+
return built;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
var root = _hydrate(rootRow);
|
|
391
|
+
return { note: root, replies: _buildTree(root.id) };
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
// Write a read receipt for a note. The (note_id, by_actor.role,
|
|
395
|
+
// by_actor.id) tuple is idempotent — re-emitting a receipt for
|
|
396
|
+
// the same reader is a no-op so the storefront can call this
|
|
397
|
+
// from the render path without worrying about row growth.
|
|
398
|
+
markRead: async function (input) {
|
|
399
|
+
if (!input || typeof input !== "object") {
|
|
400
|
+
throw new TypeError("orderNotes.markRead: input object required");
|
|
401
|
+
}
|
|
402
|
+
var noteId = _uuid(input.note_id, "note_id");
|
|
403
|
+
var by = input.by_actor;
|
|
404
|
+
if (!by || typeof by !== "object") {
|
|
405
|
+
throw new TypeError("orderNotes.markRead: by_actor object required");
|
|
406
|
+
}
|
|
407
|
+
var role = _author(by.role);
|
|
408
|
+
var readerId = null;
|
|
409
|
+
if (by.id != null) readerId = _uuid(by.id, "by_actor.id");
|
|
410
|
+
|
|
411
|
+
var existing = (await query(
|
|
412
|
+
"SELECT id FROM order_note_reads WHERE note_id = ?1 AND reader_actor = ?2 " +
|
|
413
|
+
"AND ((reader_id IS NULL AND ?3 IS NULL) OR reader_id = ?3) LIMIT 1",
|
|
414
|
+
[noteId, role, readerId],
|
|
415
|
+
)).rows[0];
|
|
416
|
+
if (existing) return { id: existing.id, idempotent: true };
|
|
417
|
+
|
|
418
|
+
// Confirm the note exists before writing the read row — the
|
|
419
|
+
// FK would catch it but the explicit refusal carries a better
|
|
420
|
+
// error code for the caller.
|
|
421
|
+
var note = await _getRaw(noteId);
|
|
422
|
+
if (!note) {
|
|
423
|
+
var nErr = new Error("orderNotes.markRead: note " + noteId + " not found");
|
|
424
|
+
nErr.code = "ORDER_NOTE_NOT_FOUND";
|
|
425
|
+
throw nErr;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
var id = _b().uuid.v7();
|
|
429
|
+
var ts = _now();
|
|
430
|
+
await query(
|
|
431
|
+
"INSERT INTO order_note_reads (id, note_id, reader_actor, reader_id, read_at) " +
|
|
432
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
433
|
+
[id, noteId, role, readerId, ts],
|
|
434
|
+
);
|
|
435
|
+
return { id: id, idempotent: false };
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
pin: async function (id) {
|
|
439
|
+
_uuid(id, "note id");
|
|
440
|
+
var ts = _now();
|
|
441
|
+
var r = await query(
|
|
442
|
+
"UPDATE order_notes SET pinned = 1, updated_at = ?1 WHERE id = ?2",
|
|
443
|
+
[ts, id],
|
|
444
|
+
);
|
|
445
|
+
if (r.rowCount === 0) {
|
|
446
|
+
var err = new Error("orderNotes.pin: note " + id + " not found");
|
|
447
|
+
err.code = "ORDER_NOTE_NOT_FOUND";
|
|
448
|
+
throw err;
|
|
449
|
+
}
|
|
450
|
+
return await this.get(id);
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
unpin: async function (id) {
|
|
454
|
+
_uuid(id, "note id");
|
|
455
|
+
var ts = _now();
|
|
456
|
+
var r = await query(
|
|
457
|
+
"UPDATE order_notes SET pinned = 0, updated_at = ?1 WHERE id = ?2",
|
|
458
|
+
[ts, id],
|
|
459
|
+
);
|
|
460
|
+
if (r.rowCount === 0) {
|
|
461
|
+
var err = new Error("orderNotes.unpin: note " + id + " not found");
|
|
462
|
+
err.code = "ORDER_NOTE_NOT_FOUND";
|
|
463
|
+
throw err;
|
|
464
|
+
}
|
|
465
|
+
return await this.get(id);
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
// Storefront convenience — only the notes the customer sees
|
|
469
|
+
// for this order, pinned-first, newest-first. No cursor: the
|
|
470
|
+
// storefront draws every visible note inline and the typical
|
|
471
|
+
// count is small (a handful).
|
|
472
|
+
customerVisibleForOrder: async function (orderId) {
|
|
473
|
+
orderId = _uuid(orderId, "order_id");
|
|
474
|
+
var r = await query(
|
|
475
|
+
"SELECT * FROM order_notes WHERE order_id = ?1 AND visibility = 'customer_visible' " +
|
|
476
|
+
"ORDER BY pinned DESC, created_at DESC, id DESC",
|
|
477
|
+
[orderId],
|
|
478
|
+
);
|
|
479
|
+
return r.rows.map(_hydrate);
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
// Operator-console filter. `resolved` toggle splits the active
|
|
483
|
+
// queue (resolved_at IS NULL) from the archive
|
|
484
|
+
// (resolved_at IS NOT NULL); leaving it undefined returns both.
|
|
485
|
+
internalForOrder: async function (orderId, filterOpts) {
|
|
486
|
+
orderId = _uuid(orderId, "order_id");
|
|
487
|
+
filterOpts = filterOpts || {};
|
|
488
|
+
var sql, params;
|
|
489
|
+
if (filterOpts.resolved === true) {
|
|
490
|
+
sql = "SELECT * FROM order_notes WHERE order_id = ?1 AND visibility = 'internal' " +
|
|
491
|
+
"AND resolved_at IS NOT NULL " +
|
|
492
|
+
"ORDER BY pinned DESC, created_at DESC, id DESC";
|
|
493
|
+
params = [orderId];
|
|
494
|
+
} else if (filterOpts.resolved === false) {
|
|
495
|
+
sql = "SELECT * FROM order_notes WHERE order_id = ?1 AND visibility = 'internal' " +
|
|
496
|
+
"AND resolved_at IS NULL " +
|
|
497
|
+
"ORDER BY pinned DESC, created_at DESC, id DESC";
|
|
498
|
+
params = [orderId];
|
|
499
|
+
} else if (filterOpts.resolved == null) {
|
|
500
|
+
sql = "SELECT * FROM order_notes WHERE order_id = ?1 AND visibility = 'internal' " +
|
|
501
|
+
"ORDER BY pinned DESC, created_at DESC, id DESC";
|
|
502
|
+
params = [orderId];
|
|
503
|
+
} else {
|
|
504
|
+
throw new TypeError("orderNotes.internalForOrder: resolved must be boolean or undefined");
|
|
505
|
+
}
|
|
506
|
+
var r = await query(sql, params);
|
|
507
|
+
return r.rows.map(_hydrate);
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
// Internal-thread workflow — close an internal note with a
|
|
511
|
+
// 280-char operator summary. The note's `resolved_at` +
|
|
512
|
+
// `resolution` are stamped; subsequent `reopen` clears both.
|
|
513
|
+
resolve: async function (input) {
|
|
514
|
+
if (!input || typeof input !== "object") {
|
|
515
|
+
throw new TypeError("orderNotes.resolve: input object required");
|
|
516
|
+
}
|
|
517
|
+
var noteId = _uuid(input.note_id, "note_id");
|
|
518
|
+
var resolution = _resolution(input.resolution);
|
|
519
|
+
var existing = await _getRaw(noteId);
|
|
520
|
+
if (!existing) {
|
|
521
|
+
var nErr = new Error("orderNotes.resolve: note " + noteId + " not found");
|
|
522
|
+
nErr.code = "ORDER_NOTE_NOT_FOUND";
|
|
523
|
+
throw nErr;
|
|
524
|
+
}
|
|
525
|
+
if (existing.visibility !== "internal") {
|
|
526
|
+
throw new TypeError("orderNotes.resolve: only internal notes can be resolved");
|
|
527
|
+
}
|
|
528
|
+
var ts = _now();
|
|
529
|
+
await query(
|
|
530
|
+
"UPDATE order_notes SET resolved_at = ?1, resolution = ?2, updated_at = ?1 WHERE id = ?3",
|
|
531
|
+
[ts, resolution, noteId],
|
|
532
|
+
);
|
|
533
|
+
return await this.get(noteId);
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
reopen: async function (id) {
|
|
537
|
+
_uuid(id, "note id");
|
|
538
|
+
var existing = await _getRaw(id);
|
|
539
|
+
if (!existing) {
|
|
540
|
+
var nErr = new Error("orderNotes.reopen: note " + id + " not found");
|
|
541
|
+
nErr.code = "ORDER_NOTE_NOT_FOUND";
|
|
542
|
+
throw nErr;
|
|
543
|
+
}
|
|
544
|
+
var ts = _now();
|
|
545
|
+
await query(
|
|
546
|
+
"UPDATE order_notes SET resolved_at = NULL, resolution = NULL, updated_at = ?1 WHERE id = ?2",
|
|
547
|
+
[ts, id],
|
|
548
|
+
);
|
|
549
|
+
return await this.get(id);
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
module.exports = {
|
|
555
|
+
create: create,
|
|
556
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
557
|
+
MAX_TAG_COUNT: MAX_TAG_COUNT,
|
|
558
|
+
MAX_TAG_LEN: MAX_TAG_LEN,
|
|
559
|
+
MAX_RESOLUTION_LEN: MAX_RESOLUTION_LEN,
|
|
560
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
561
|
+
ALLOWED_AUTHORS: ALLOWED_AUTHORS.slice(),
|
|
562
|
+
ALLOWED_VISIBILITY: ALLOWED_VISIBILITY.slice(),
|
|
563
|
+
};
|