@blamejs/blamejs-shop 0.0.66 → 0.0.72

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/click-and-collect.js +711 -0
  5. package/lib/clickstream.js +713 -0
  6. package/lib/customer-activity.js +862 -0
  7. package/lib/customer-notes.js +712 -0
  8. package/lib/customer-risk-profile.js +593 -0
  9. package/lib/customer-surveys.js +1012 -0
  10. package/lib/damage-photos.js +473 -0
  11. package/lib/dropship-forwarding.js +645 -0
  12. package/lib/email-templates.js +817 -0
  13. package/lib/index.js +36 -0
  14. package/lib/inventory-allocations.js +559 -0
  15. package/lib/inventory-writeoffs.js +636 -0
  16. package/lib/knowledge-base.js +1104 -0
  17. package/lib/locale-router.js +1077 -0
  18. package/lib/loyalty-earn-rules.js +786 -0
  19. package/lib/operator-roles.js +768 -0
  20. package/lib/order-escalation.js +951 -0
  21. package/lib/order-ratings.js +495 -0
  22. package/lib/order-tags.js +944 -0
  23. package/lib/packing-slips.js +810 -0
  24. package/lib/pixel-events.js +995 -0
  25. package/lib/print-queue.js +681 -0
  26. package/lib/product-qa.js +749 -0
  27. package/lib/promo-bundles.js +835 -0
  28. package/lib/push-notifications.js +937 -0
  29. package/lib/refund-automation.js +853 -0
  30. package/lib/reorder-reminders.js +798 -0
  31. package/lib/robots-config.js +753 -0
  32. package/lib/seller-signup.js +1052 -0
  33. package/lib/sitemap-generator.js +717 -0
  34. package/lib/split-shipments.js +7 -1
  35. package/lib/subscription-gifts.js +710 -0
  36. package/lib/tax-cert-renewals.js +632 -0
  37. package/lib/tier-benefits.js +776 -0
  38. package/lib/vendor/MANIFEST.json +2 -2
  39. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  40. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  41. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  42. package/lib/vendor/blamejs/package.json +1 -1
  43. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  44. package/lib/wishlist-alerts.js +842 -0
  45. package/lib/wishlist-sharing.js +718 -0
  46. package/package.json +1 -1
@@ -0,0 +1,681 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.printQueue
4
+ * @title Print queue — warehouse print job scheduler that decouples
5
+ * order-flow code from the physical printer matrix
6
+ *
7
+ * @intro
8
+ * The warehouse needs packing slips, shipping labels, return labels,
9
+ * and invoices printed at six workstations across two floors. Each
10
+ * workstation has a thermal label printer + a laser printer; some
11
+ * only print 4x6 thermal labels, others only handle Letter / A4
12
+ * paper. A naive "print this slip directly" verb couples the
13
+ * order-flow code to which printer is physically online right now,
14
+ * which paper is loaded, which workstation is unmanned at 3am.
15
+ *
16
+ * The fix is a queue: order-flow code calls `enqueueJob({...})` and
17
+ * walks away; workstations call `claimJob({ station_id, kinds? })`
18
+ * when their operator is ready to print, atomically marking the job
19
+ * in_progress; after the print succeeds the workstation calls
20
+ * `markComplete`. If the printer jams or runs out of paper,
21
+ * `markFailed({ retry: true })` puts the job back at the front of
22
+ * the queue for the next claimant with `retry_count + 1`.
23
+ *
24
+ * Lifecycle (five-state FSM):
25
+ *
26
+ * enqueueJob({ kind, payload_ref, priority, station_filter?,
27
+ * paper_size?, copies?, scheduled_at? })
28
+ * Creates a new `queued` row. `kind` is one of packing_slip /
29
+ * shipping_label / invoice / packing_label / return_label /
30
+ * pickup_slip. `payload_ref` is an opaque operator-supplied
31
+ * reference (typically an order_id, shipment_id, or pre-rendered
32
+ * PDF object key) — the queue itself doesn't interpret it; the
33
+ * claiming workstation resolves the payload via its own render
34
+ * pipeline. `priority` is 0..10 (higher wins). `paper_size`
35
+ * defaults to a kind-appropriate value (labels → thermal_4x6,
36
+ * slips/invoices → letter). `copies` defaults to 1.
37
+ * `station_filter` (optional) restricts the claim pool to a
38
+ * single station_id. `scheduled_at` (optional) defers the job —
39
+ * claims refuse to return rows whose scheduled_at exceeds the
40
+ * current clock. Returns the inserted row.
41
+ *
42
+ * claimJob({ station_id, kinds? })
43
+ * Atomically claims the highest-priority eligible queued row
44
+ * and flips it to `in_progress`. Eligible = `status='queued'`
45
+ * AND `scheduled_at <= now()` AND (`station_filter IS NULL` OR
46
+ * `station_filter = station_id`) AND (`kinds` is omitted OR
47
+ * `kind IN kinds`). Returns the claimed row or null when no job
48
+ * is eligible. The claim writes `claimed_by_station` +
49
+ * `claimed_at` so the audit trail answers "who was working on
50
+ * what when the network went down". Ordering: (priority DESC,
51
+ * scheduled_at ASC, created_at ASC) — highest priority wins,
52
+ * FIFO within a priority band.
53
+ *
54
+ * markComplete({ job_id, station_id })
55
+ * in_progress -> complete. Refuses when the job isn't claimed
56
+ * by the supplied station_id (a different workstation can't
57
+ * complete someone else's job). Stamps `completed_at` +
58
+ * `completed_by_station`.
59
+ *
60
+ * markFailed({ job_id, station_id, reason, retry? })
61
+ * in_progress -> failed. Refuses when the job isn't claimed by
62
+ * the supplied station_id. Stamps `failed_at` + `fail_reason`.
63
+ * When `retry: true`, inserts a fresh `queued` row with the
64
+ * same payload + `retry_count + 1`; the failed row stays on
65
+ * disk for the audit. Returns `{ failed, requeued? }` so the
66
+ * workstation knows whether to expect the same job back on the
67
+ * next poll.
68
+ *
69
+ * cancelJob({ job_id, reason })
70
+ * queued|in_progress -> cancelled. Used when the upstream
71
+ * trigger went away (order cancelled, shipment voided). Refuses
72
+ * cancelling a terminal job (complete / failed / cancelled).
73
+ * Persists `cancel_reason` + `failed_at`.
74
+ *
75
+ * Reads:
76
+ * getJob(job_id) — single row by id
77
+ * jobsForStation({ station_id, status?, limit? }) — claimed-by-this-
78
+ * station view,
79
+ * newest-first
80
+ * pendingByKind({ kind, priority_min? }) — operator dashboard
81
+ * "what's piling up
82
+ * in the queue"
83
+ * stationActivity({ station_id, from, to }) — per-station throughput
84
+ * breakdown
85
+ * dailyMetrics({ from, to }) — aggregate throughput
86
+ * across the window
87
+ *
88
+ * Maintenance:
89
+ * cleanupCompleted({ older_than_hours })
90
+ * Deletes complete + cancelled rows whose completed_at /
91
+ * failed_at is older than the cutoff. Failed-but-requeued rows
92
+ * (retried) are deleted too — the audit lives in the new queued
93
+ * row's `retry_count` lineage. Returns the deleted count.
94
+ *
95
+ * Composition:
96
+ * - b.uuid.v7 — job ids (sortable; the dashboard
97
+ * reads sort by id DESC to get
98
+ * newest-first without a second index).
99
+ * - b.guardUuid — strict UUID validation on every id
100
+ * input.
101
+ *
102
+ * Three-tier input validation: every public verb is either a
103
+ * config-time entry point (factory create) or a defensive
104
+ * request-shape reader. All throw on bad input — no drop-silent
105
+ * hot-path sinks.
106
+ *
107
+ * @primitive printQueue
108
+ * @related pickLists, orderTracking, splitShipments
109
+ */
110
+
111
+ var bShop;
112
+ function _b() {
113
+ if (!bShop) bShop = require("./index");
114
+ return bShop.framework;
115
+ }
116
+
117
+ // ---- constants ----------------------------------------------------------
118
+
119
+ var STATION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
120
+ var PAYLOAD_RE = /^[A-Za-z0-9][A-Za-z0-9._:/=+-]{0,255}$/;
121
+ var MAX_REASON = 280;
122
+ var MAX_KINDS = 16;
123
+ var MAX_LIMIT = 500;
124
+ var DEFAULT_LIMIT = 100;
125
+ var MAX_PRIORITY = 10;
126
+ var MAX_COPIES = 99;
127
+ var MAX_RETRY_COUNT = 64;
128
+
129
+ var KIND_ENUM = Object.freeze([
130
+ "packing_slip", "shipping_label", "invoice",
131
+ "packing_label", "return_label", "pickup_slip",
132
+ ]);
133
+
134
+ var PAPER_SIZE_ENUM = Object.freeze([
135
+ "letter", "a4", "thermal_4x6", "thermal_4x8",
136
+ ]);
137
+
138
+ var STATUS_ENUM = Object.freeze([
139
+ "queued", "in_progress", "complete", "failed", "cancelled",
140
+ ]);
141
+
142
+ // Default paper size keyed by job kind. Labels go to the thermal
143
+ // printer; slips + invoices go to the laser printer. Operators override
144
+ // per-job when their workflow doesn't match the default (e.g. printing
145
+ // a return label on letter stock because the thermal printer is down).
146
+ var DEFAULT_PAPER_BY_KIND = Object.freeze({
147
+ packing_slip: "letter",
148
+ shipping_label: "thermal_4x6",
149
+ invoice: "letter",
150
+ packing_label: "thermal_4x6",
151
+ return_label: "thermal_4x6",
152
+ pickup_slip: "letter",
153
+ });
154
+
155
+ // ---- validators ---------------------------------------------------------
156
+
157
+ function _id(s, label) {
158
+ try {
159
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
160
+ } catch (e) {
161
+ throw new TypeError("print-queue: " + label + " — " + (e && e.message || "invalid UUID"));
162
+ }
163
+ }
164
+ function _station(s, label) {
165
+ if (typeof s !== "string" || !STATION_RE.test(s)) {
166
+ throw new TypeError("print-queue: " + (label || "station_id") +
167
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
168
+ }
169
+ }
170
+ function _payloadRef(s) {
171
+ if (typeof s !== "string" || !PAYLOAD_RE.test(s)) {
172
+ throw new TypeError("print-queue: payload_ref must be a 1..256 char string of " +
173
+ "alnum + . _ : / = + - characters");
174
+ }
175
+ }
176
+ function _kind(s) {
177
+ if (KIND_ENUM.indexOf(s) === -1) {
178
+ throw new TypeError("print-queue: kind must be one of " + KIND_ENUM.join(", ") +
179
+ ", got " + JSON.stringify(s));
180
+ }
181
+ }
182
+ function _paperSize(s) {
183
+ if (PAPER_SIZE_ENUM.indexOf(s) === -1) {
184
+ throw new TypeError("print-queue: paper_size must be one of " + PAPER_SIZE_ENUM.join(", ") +
185
+ ", got " + JSON.stringify(s));
186
+ }
187
+ }
188
+ function _status(s) {
189
+ if (STATUS_ENUM.indexOf(s) === -1) {
190
+ throw new TypeError("print-queue: status must be one of " + STATUS_ENUM.join(", ") +
191
+ ", got " + JSON.stringify(s));
192
+ }
193
+ }
194
+ function _priority(n) {
195
+ if (!Number.isInteger(n) || n < 0 || n > MAX_PRIORITY) {
196
+ throw new TypeError("print-queue: priority must be an integer in 0..." + MAX_PRIORITY);
197
+ }
198
+ }
199
+ function _copies(n) {
200
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_COPIES) {
201
+ throw new TypeError("print-queue: copies must be an integer in 1..." + MAX_COPIES);
202
+ }
203
+ }
204
+ function _scheduledAt(n) {
205
+ if (!Number.isInteger(n) || n < 0) {
206
+ throw new TypeError("print-queue: scheduled_at must be a non-negative integer (epoch ms)");
207
+ }
208
+ }
209
+ function _reason(s, label) {
210
+ if (typeof s !== "string" || !s.length || s.length > MAX_REASON) {
211
+ throw new TypeError("print-queue: " + (label || "reason") +
212
+ " must be a non-empty string ≤ " + MAX_REASON + " chars");
213
+ }
214
+ return s;
215
+ }
216
+ function _limit(n) {
217
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
218
+ throw new TypeError("print-queue: limit must be an integer in 1..." + MAX_LIMIT);
219
+ }
220
+ }
221
+ function _hours(n, label) {
222
+ if (typeof n !== "number" || !isFinite(n) || n < 0) {
223
+ throw new TypeError("print-queue: " + label + " must be a non-negative finite number");
224
+ }
225
+ }
226
+ function _window(from, to) {
227
+ if (!Number.isInteger(from) || from < 0) {
228
+ throw new TypeError("print-queue: from must be a non-negative integer (epoch ms)");
229
+ }
230
+ if (!Number.isInteger(to) || to < 0) {
231
+ throw new TypeError("print-queue: to must be a non-negative integer (epoch ms)");
232
+ }
233
+ if (from > to) {
234
+ throw new TypeError("print-queue: from must be ≤ to");
235
+ }
236
+ }
237
+ function _kindsFilter(arr) {
238
+ if (!Array.isArray(arr) || arr.length === 0) {
239
+ throw new TypeError("print-queue: kinds filter must be a non-empty array when supplied");
240
+ }
241
+ if (arr.length > MAX_KINDS) {
242
+ throw new TypeError("print-queue: kinds filter accepts at most " + MAX_KINDS + " entries");
243
+ }
244
+ var seen = Object.create(null);
245
+ var out = [];
246
+ for (var i = 0; i < arr.length; i += 1) {
247
+ _kind(arr[i]);
248
+ if (seen[arr[i]]) {
249
+ throw new TypeError("print-queue: duplicate kind " + JSON.stringify(arr[i]) + " in filter");
250
+ }
251
+ seen[arr[i]] = true;
252
+ out.push(arr[i]);
253
+ }
254
+ return out;
255
+ }
256
+
257
+ // Monotonic clock — every write that stamps a timestamp uses _now()
258
+ // instead of Date.now() directly so an enqueueJob + immediate claimJob
259
+ // + markComplete sequence never collides on the millisecond boundary.
260
+ // Operators reading the timeline get a strict ordering even on hosts
261
+ // where Date.now() is coarse.
262
+ var _lastTs = 0;
263
+ function _now() {
264
+ var t = Date.now();
265
+ if (t <= _lastTs) t = _lastTs + 1;
266
+ _lastTs = t;
267
+ return t;
268
+ }
269
+
270
+ // Build a parameterized IN-clause from a fixed-enum list. The values
271
+ // themselves are validated against KIND_ENUM before reaching this
272
+ // function so no operator input lands in the SQL string verbatim; the
273
+ // param indices start at `startIndex` so callers can compose with
274
+ // other ?N placeholders.
275
+ function _inClause(values, startIndex) {
276
+ var placeholders = [];
277
+ for (var i = 0; i < values.length; i += 1) {
278
+ placeholders.push("?" + (startIndex + i));
279
+ }
280
+ return "(" + placeholders.join(", ") + ")";
281
+ }
282
+
283
+ // ---- factory ------------------------------------------------------------
284
+
285
+ function create(opts) {
286
+ opts = opts || {};
287
+ var query = opts.query;
288
+ if (!query) {
289
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
290
+ }
291
+
292
+ async function _getRow(id) {
293
+ var r = await query("SELECT * FROM print_jobs WHERE id = ?1", [id]);
294
+ return r.rows.length ? r.rows[0] : null;
295
+ }
296
+
297
+ return {
298
+ KIND_ENUM: KIND_ENUM,
299
+ PAPER_SIZE_ENUM: PAPER_SIZE_ENUM,
300
+ STATUS_ENUM: STATUS_ENUM,
301
+ MAX_PRIORITY: MAX_PRIORITY,
302
+ MAX_COPIES: MAX_COPIES,
303
+
304
+ // Enqueue a new print job. Validates every field, fills defaults
305
+ // (paper_size from DEFAULT_PAPER_BY_KIND[kind], copies = 1,
306
+ // scheduled_at = now), and inserts a `queued` row. Returns the
307
+ // inserted row.
308
+ enqueueJob: async function (input) {
309
+ if (!input || typeof input !== "object") {
310
+ throw new TypeError("print-queue.enqueueJob: input object required");
311
+ }
312
+ _kind(input.kind);
313
+ _payloadRef(input.payload_ref);
314
+ _priority(input.priority);
315
+ if (input.station_filter != null) _station(input.station_filter, "station_filter");
316
+ var paperSize = input.paper_size == null ? DEFAULT_PAPER_BY_KIND[input.kind] : input.paper_size;
317
+ _paperSize(paperSize);
318
+ var copies = input.copies == null ? 1 : input.copies;
319
+ _copies(copies);
320
+ var ts = _now();
321
+ var scheduledAt = input.scheduled_at == null ? ts : input.scheduled_at;
322
+ _scheduledAt(scheduledAt);
323
+
324
+ var id = _b().uuid.v7();
325
+ await query(
326
+ "INSERT INTO print_jobs (id, kind, payload_ref, priority, station_filter, " +
327
+ "paper_size, copies, scheduled_at, status, retry_count, created_at) " +
328
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'queued', 0, ?9)",
329
+ [id, input.kind, input.payload_ref, input.priority,
330
+ input.station_filter == null ? null : input.station_filter,
331
+ paperSize, copies, scheduledAt, ts],
332
+ );
333
+ return await _getRow(id);
334
+ },
335
+
336
+ // Atomically claim the highest-priority eligible queued job for
337
+ // the supplied station. Returns the claimed row (status flipped
338
+ // to `in_progress`) or null when no job is eligible. The
339
+ // SELECT + UPDATE pair is serialized inside the query
340
+ // implementation (sqlite is single-writer; D1 wraps in a
341
+ // transaction at the worker layer) so two workstations polling
342
+ // simultaneously can't both win the same row.
343
+ claimJob: async function (input) {
344
+ if (!input || typeof input !== "object") {
345
+ throw new TypeError("print-queue.claimJob: input object required");
346
+ }
347
+ _station(input.station_id);
348
+ var ts = _now();
349
+ var kindsFilter = null;
350
+ if (input.kinds != null) kindsFilter = _kindsFilter(input.kinds);
351
+
352
+ // Build the SELECT. station_filter must be null OR match the
353
+ // claiming station; scheduled_at must not exceed the current
354
+ // clock; kinds filter (when supplied) restricts to the
355
+ // workstation's declared capabilities. The ORDER BY matches
356
+ // the (status, priority DESC, scheduled_at) index exactly.
357
+ var sql = "SELECT id FROM print_jobs WHERE status = 'queued' " +
358
+ "AND scheduled_at <= ?1 " +
359
+ "AND (station_filter IS NULL OR station_filter = ?2) ";
360
+ var params = [ts, input.station_id];
361
+ if (kindsFilter) {
362
+ sql += "AND kind IN " + _inClause(kindsFilter, 3) + " ";
363
+ for (var i = 0; i < kindsFilter.length; i += 1) params.push(kindsFilter[i]);
364
+ }
365
+ sql += "ORDER BY priority DESC, scheduled_at ASC, created_at ASC, id ASC LIMIT 1";
366
+
367
+ var sel = await query(sql, params);
368
+ if (!sel.rows.length) return null;
369
+ var jobId = sel.rows[0].id;
370
+
371
+ // Compare-and-swap on status — guard against a racing claimer
372
+ // that already flipped the row in the same millisecond window.
373
+ // When the UPDATE affects zero rows, the other claimer won;
374
+ // return null and let the caller poll again.
375
+ var upd = await query(
376
+ "UPDATE print_jobs SET status = 'in_progress', claimed_by_station = ?1, " +
377
+ "claimed_at = ?2 WHERE id = ?3 AND status = 'queued'",
378
+ [input.station_id, ts, jobId],
379
+ );
380
+ if (upd.rowCount === 0) return null;
381
+ return await _getRow(jobId);
382
+ },
383
+
384
+ // in_progress -> complete. Refuses unless the job is claimed by
385
+ // the supplied station — a different workstation can't complete
386
+ // someone else's job (audit trail integrity).
387
+ markComplete: async function (input) {
388
+ if (!input || typeof input !== "object") {
389
+ throw new TypeError("print-queue.markComplete: input object required");
390
+ }
391
+ var jobId = _id(input.job_id, "job_id");
392
+ _station(input.station_id);
393
+ var row = await _getRow(jobId);
394
+ if (!row) {
395
+ throw new TypeError("print-queue.markComplete: job " + jobId + " not found");
396
+ }
397
+ if (row.status !== "in_progress") {
398
+ throw new TypeError("print-queue.markComplete: job is " + row.status +
399
+ ", only in_progress jobs can be completed");
400
+ }
401
+ if (row.claimed_by_station !== input.station_id) {
402
+ throw new TypeError("print-queue.markComplete: job claimed by " +
403
+ JSON.stringify(row.claimed_by_station) + ", not " +
404
+ JSON.stringify(input.station_id));
405
+ }
406
+ var ts = _now();
407
+ await query(
408
+ "UPDATE print_jobs SET status = 'complete', completed_at = ?1, " +
409
+ "completed_by_station = ?2 WHERE id = ?3",
410
+ [ts, input.station_id, jobId],
411
+ );
412
+ return await _getRow(jobId);
413
+ },
414
+
415
+ // in_progress -> failed. Same station-ownership check as
416
+ // markComplete. When `retry: true`, inserts a fresh `queued` row
417
+ // with the same payload + `retry_count + 1` so the job goes back
418
+ // to the head of the priority band. Returns `{ failed: row,
419
+ // requeued?: row }` so the caller can deep-link to the new
420
+ // queued row for monitoring.
421
+ markFailed: async function (input) {
422
+ if (!input || typeof input !== "object") {
423
+ throw new TypeError("print-queue.markFailed: input object required");
424
+ }
425
+ var jobId = _id(input.job_id, "job_id");
426
+ _station(input.station_id);
427
+ var reason = _reason(input.reason, "reason");
428
+ var retry = input.retry === true;
429
+ var row = await _getRow(jobId);
430
+ if (!row) {
431
+ throw new TypeError("print-queue.markFailed: job " + jobId + " not found");
432
+ }
433
+ if (row.status !== "in_progress") {
434
+ throw new TypeError("print-queue.markFailed: job is " + row.status +
435
+ ", only in_progress jobs can be marked failed");
436
+ }
437
+ if (row.claimed_by_station !== input.station_id) {
438
+ throw new TypeError("print-queue.markFailed: job claimed by " +
439
+ JSON.stringify(row.claimed_by_station) + ", not " +
440
+ JSON.stringify(input.station_id));
441
+ }
442
+ var ts = _now();
443
+ await query(
444
+ "UPDATE print_jobs SET status = 'failed', failed_at = ?1, fail_reason = ?2 " +
445
+ "WHERE id = ?3",
446
+ [ts, reason, jobId],
447
+ );
448
+ var failed = await _getRow(jobId);
449
+ var out = { failed: failed };
450
+
451
+ if (retry) {
452
+ // Refuse runaway retry loops. retry_count > MAX_RETRY_COUNT
453
+ // surfaces as a TypeError so the workstation operator can't
454
+ // accidentally hammer the queue when the underlying printer
455
+ // is wedged.
456
+ if (row.retry_count >= MAX_RETRY_COUNT) {
457
+ throw new TypeError("print-queue.markFailed: retry_count " + row.retry_count +
458
+ " has reached the cap (" + MAX_RETRY_COUNT + "); the operator " +
459
+ "must clear the underlying failure before requeueing");
460
+ }
461
+ var newId = _b().uuid.v7();
462
+ var ts2 = _now();
463
+ await query(
464
+ "INSERT INTO print_jobs (id, kind, payload_ref, priority, station_filter, " +
465
+ "paper_size, copies, scheduled_at, status, retry_count, created_at) " +
466
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'queued', ?9, ?10)",
467
+ [newId, row.kind, row.payload_ref, row.priority, row.station_filter,
468
+ row.paper_size, row.copies, ts2, row.retry_count + 1, ts2],
469
+ );
470
+ out.requeued = await _getRow(newId);
471
+ }
472
+ return out;
473
+ },
474
+
475
+ // queued|in_progress -> cancelled. Used when the upstream trigger
476
+ // went away (order cancelled, shipment voided). Refuses on
477
+ // terminal states so the audit trail can't be retroactively
478
+ // rewritten.
479
+ cancelJob: async function (input) {
480
+ if (!input || typeof input !== "object") {
481
+ throw new TypeError("print-queue.cancelJob: input object required");
482
+ }
483
+ var jobId = _id(input.job_id, "job_id");
484
+ var reason = _reason(input.reason, "reason");
485
+ var row = await _getRow(jobId);
486
+ if (!row) {
487
+ throw new TypeError("print-queue.cancelJob: job " + jobId + " not found");
488
+ }
489
+ if (row.status !== "queued" && row.status !== "in_progress") {
490
+ throw new TypeError("print-queue.cancelJob: job is " + row.status +
491
+ ", only queued or in_progress jobs can be cancelled");
492
+ }
493
+ var ts = _now();
494
+ await query(
495
+ "UPDATE print_jobs SET status = 'cancelled', cancel_reason = ?1, failed_at = ?2 " +
496
+ "WHERE id = ?3",
497
+ [reason, ts, jobId],
498
+ );
499
+ return await _getRow(jobId);
500
+ },
501
+
502
+ // Single-row read. Validates id shape so a typo surfaces as a
503
+ // clear refusal instead of a bare null.
504
+ getJob: async function (jobId) {
505
+ var id = _id(jobId, "job_id");
506
+ return await _getRow(id);
507
+ },
508
+
509
+ // Per-station view. Optional status filter; default limit 100,
510
+ // cap 500. Returns newest-first (claimed_at DESC NULLS LAST so
511
+ // unclaimed station_filter rows still surface).
512
+ jobsForStation: async function (input) {
513
+ if (!input || typeof input !== "object") {
514
+ throw new TypeError("print-queue.jobsForStation: input object required");
515
+ }
516
+ _station(input.station_id);
517
+ var hasStatus = input.status != null;
518
+ if (hasStatus) _status(input.status);
519
+ var limit = input.limit == null ? DEFAULT_LIMIT : input.limit;
520
+ _limit(limit);
521
+
522
+ // The query covers both claimed jobs (claimed_by_station =
523
+ // station_id) AND queued station-filtered jobs (station_filter
524
+ // = station_id AND status = 'queued') so the operator UI shows
525
+ // both "what I'm working on" and "what's pinned to my station".
526
+ var sql, params;
527
+ if (hasStatus) {
528
+ sql = "SELECT * FROM print_jobs WHERE " +
529
+ "(claimed_by_station = ?1 OR (station_filter = ?1 AND status = 'queued')) " +
530
+ "AND status = ?2 " +
531
+ "ORDER BY COALESCE(claimed_at, created_at) DESC, id DESC LIMIT ?3";
532
+ params = [input.station_id, input.status, limit];
533
+ } else {
534
+ sql = "SELECT * FROM print_jobs WHERE " +
535
+ "claimed_by_station = ?1 OR (station_filter = ?1 AND status = 'queued') " +
536
+ "ORDER BY COALESCE(claimed_at, created_at) DESC, id DESC LIMIT ?2";
537
+ params = [input.station_id, limit];
538
+ }
539
+ return (await query(sql, params)).rows;
540
+ },
541
+
542
+ // Operator dashboard "what's piling up in the queue". Filters
543
+ // queued + in_progress rows by kind; optional priority_min surfaces
544
+ // the high-priority backlog only. Newest-first within the
545
+ // priority band so the operator sees the most-stale piles at the
546
+ // bottom.
547
+ pendingByKind: async function (input) {
548
+ if (!input || typeof input !== "object") {
549
+ throw new TypeError("print-queue.pendingByKind: input object required");
550
+ }
551
+ _kind(input.kind);
552
+ var priorityMin = 0;
553
+ if (input.priority_min != null) {
554
+ _priority(input.priority_min);
555
+ priorityMin = input.priority_min;
556
+ }
557
+ var r = await query(
558
+ "SELECT * FROM print_jobs WHERE kind = ?1 AND priority >= ?2 " +
559
+ "AND status IN ('queued', 'in_progress') " +
560
+ "ORDER BY priority DESC, scheduled_at ASC, created_at ASC, id ASC",
561
+ [input.kind, priorityMin],
562
+ );
563
+ return r.rows;
564
+ },
565
+
566
+ // Maintenance sweep. Deletes complete + cancelled rows older
567
+ // than the cutoff (now - older_than_hours). Failed-and-requeued
568
+ // rows are deleted too — the audit lives in the new queued row's
569
+ // retry_count lineage. Returns `{ deleted: <count> }` so the
570
+ // operator can monitor the sweep.
571
+ cleanupCompleted: async function (input) {
572
+ if (!input || typeof input !== "object") {
573
+ throw new TypeError("print-queue.cleanupCompleted: input object required");
574
+ }
575
+ _hours(input.older_than_hours, "older_than_hours");
576
+ var cutoff = _now() - Math.floor(input.older_than_hours * 60 * 60 * 1000);
577
+ var r = await query(
578
+ "DELETE FROM print_jobs WHERE status IN ('complete', 'cancelled', 'failed') " +
579
+ "AND COALESCE(completed_at, failed_at, created_at) < ?1",
580
+ [cutoff],
581
+ );
582
+ return { deleted: r.rowCount };
583
+ },
584
+
585
+ // Per-station throughput breakdown across the window. Counts
586
+ // claims (claimed_at in window), completions (completed_at in
587
+ // window), and failures (failed_at in window, excluding
588
+ // cancellations — those aren't "the workstation tried and
589
+ // failed"). The window is half-open: [from, to).
590
+ stationActivity: async function (input) {
591
+ if (!input || typeof input !== "object") {
592
+ throw new TypeError("print-queue.stationActivity: input object required");
593
+ }
594
+ _station(input.station_id);
595
+ _window(input.from, input.to);
596
+ var rClaims = await query(
597
+ "SELECT COUNT(*) AS n FROM print_jobs " +
598
+ "WHERE claimed_by_station = ?1 AND claimed_at >= ?2 AND claimed_at < ?3",
599
+ [input.station_id, input.from, input.to],
600
+ );
601
+ var rComplete = await query(
602
+ "SELECT COUNT(*) AS n FROM print_jobs " +
603
+ "WHERE completed_by_station = ?1 AND completed_at >= ?2 AND completed_at < ?3",
604
+ [input.station_id, input.from, input.to],
605
+ );
606
+ var rFailed = await query(
607
+ "SELECT COUNT(*) AS n FROM print_jobs " +
608
+ "WHERE claimed_by_station = ?1 AND status = 'failed' " +
609
+ "AND failed_at >= ?2 AND failed_at < ?3",
610
+ [input.station_id, input.from, input.to],
611
+ );
612
+ return {
613
+ station_id: input.station_id,
614
+ from: input.from,
615
+ to: input.to,
616
+ claims: Number(rClaims.rows[0].n),
617
+ completed: Number(rComplete.rows[0].n),
618
+ failed: Number(rFailed.rows[0].n),
619
+ };
620
+ },
621
+
622
+ // Aggregate throughput across the window. Returns totals plus
623
+ // per-kind breakdown so the operator can spot e.g. "we printed
624
+ // 4,000 shipping labels but only 12 invoices today". Window is
625
+ // half-open: [from, to).
626
+ dailyMetrics: async function (input) {
627
+ if (!input || typeof input !== "object") {
628
+ throw new TypeError("print-queue.dailyMetrics: input object required");
629
+ }
630
+ _window(input.from, input.to);
631
+ var rEnqueued = await query(
632
+ "SELECT COUNT(*) AS n FROM print_jobs WHERE created_at >= ?1 AND created_at < ?2",
633
+ [input.from, input.to],
634
+ );
635
+ var rCompleted = await query(
636
+ "SELECT COUNT(*) AS n FROM print_jobs " +
637
+ "WHERE status = 'complete' AND completed_at >= ?1 AND completed_at < ?2",
638
+ [input.from, input.to],
639
+ );
640
+ var rFailed = await query(
641
+ "SELECT COUNT(*) AS n FROM print_jobs " +
642
+ "WHERE status = 'failed' AND failed_at >= ?1 AND failed_at < ?2",
643
+ [input.from, input.to],
644
+ );
645
+ var rCancelled = await query(
646
+ "SELECT COUNT(*) AS n FROM print_jobs " +
647
+ "WHERE status = 'cancelled' AND failed_at >= ?1 AND failed_at < ?2",
648
+ [input.from, input.to],
649
+ );
650
+ var rByKind = await query(
651
+ "SELECT kind, COUNT(*) AS n FROM print_jobs " +
652
+ "WHERE status = 'complete' AND completed_at >= ?1 AND completed_at < ?2 " +
653
+ "GROUP BY kind ORDER BY kind ASC",
654
+ [input.from, input.to],
655
+ );
656
+ var byKind = Object.create(null);
657
+ for (var i = 0; i < KIND_ENUM.length; i += 1) byKind[KIND_ENUM[i]] = 0;
658
+ for (var j = 0; j < rByKind.rows.length; j += 1) {
659
+ byKind[rByKind.rows[j].kind] = Number(rByKind.rows[j].n);
660
+ }
661
+ return {
662
+ from: input.from,
663
+ to: input.to,
664
+ enqueued: Number(rEnqueued.rows[0].n),
665
+ completed: Number(rCompleted.rows[0].n),
666
+ failed: Number(rFailed.rows[0].n),
667
+ cancelled: Number(rCancelled.rows[0].n),
668
+ completed_by_kind: byKind,
669
+ };
670
+ },
671
+ };
672
+ }
673
+
674
+ module.exports = {
675
+ create: create,
676
+ KIND_ENUM: KIND_ENUM,
677
+ PAPER_SIZE_ENUM: PAPER_SIZE_ENUM,
678
+ STATUS_ENUM: STATUS_ENUM,
679
+ MAX_PRIORITY: MAX_PRIORITY,
680
+ MAX_COPIES: MAX_COPIES,
681
+ };