@blamejs/blamejs-shop 0.3.42 → 0.3.44

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/lib/admin.js CHANGED
@@ -503,6 +503,7 @@ function mount(router, deps) {
503
503
  var customerSegments = deps.customerSegments || null; // per-customer segment-membership panel disabled when absent
504
504
  var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
505
505
  var salesReports = deps.salesReports || null; // /admin/reports degrades to an unconfigured notice when absent
506
+ var orderExport = deps.orderExport || null; // /admin/exports (date-range CSV/NDJSON dump + scheduled-export queue) disabled when absent
506
507
  var printReceipts = deps.printReceipts || null; // order receipt document disabled when absent
507
508
  var packingSlips = deps.packingSlips || null; // order packing-slip document disabled when absent
508
509
  var pickLists = deps.pickLists || null; // warehouse pick-list console disabled when absent
@@ -511,6 +512,7 @@ function mount(router, deps) {
511
512
  var salesTaxFilings = deps.salesTaxFilings || null; // sales-tax-filing remittance console disabled when absent
512
513
  var supportTickets = deps.supportTickets || null; // support-ticket queue + thread console disabled when absent
513
514
  var orderExchanges = deps.orderExchanges || null; // exchange queue + FSM-action console disabled when absent
515
+ var orderRatings = deps.orderRatings || null; // per-order rating moderation queue (flag/clear + public reply) disabled when absent
514
516
  var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
515
517
 
516
518
  // Which optional console sections are wired — gates their nav links so a
@@ -519,7 +521,7 @@ function mount(router, deps) {
519
521
  // `reports` is always present in the nav (read-only sales summary needs no
520
522
  // extra dep); its route mounts unconditionally and renders an unconfigured
521
523
  // notice when the salesReports primitive isn't wired.
522
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, orderExchanges: !!orderExchanges };
524
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, orderExport: !!orderExport };
523
525
 
524
526
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
525
527
 
@@ -6429,6 +6431,422 @@ function mount(router, deps) {
6429
6431
  });
6430
6432
  }
6431
6433
 
6434
+ // ---- order export ---------------------------------------------------
6435
+ //
6436
+ // Bulk, date-range dump of the orders table to CSV / NDJSON, plus the
6437
+ // queue of operator-filed scheduled-export jobs a background worker
6438
+ // drains. Distinct from the per-order receipt CSV columns and the
6439
+ // /admin/reports day-series ?format=csv (an aggregate, not the rows):
6440
+ // this is the full row-level order set for offline analysis,
6441
+ // accounting reconciliation, or a 3PL handoff.
6442
+ //
6443
+ // Surfaces (mounted only when the orderExport primitive is wired):
6444
+ // GET /admin/exports — screen: range form +
6445
+ // summary preview (count + total) + the scheduled-export list +
6446
+ // a schedule-a-new-export form (bearer JSON returns the summary +
6447
+ // the export rows).
6448
+ // GET /admin/exports/download?from=&to=&format=
6449
+ // — file response: streams
6450
+ // csvForRange / ndjsonForRange with the right content-type +
6451
+ // content-disposition. The primitive owns the CSV-injection
6452
+ // defense (a leading = / + / - / @ cell is prefixed with `'`),
6453
+ // so the route composes its output and never hand-rolls
6454
+ // serialization from raw order fields.
6455
+ // POST /admin/exports/schedule — file a scheduled job.
6456
+ // POST /admin/exports/:id/cancel — cancel a queued job.
6457
+ //
6458
+ // Range: from/to are calendar-date (YYYY-MM-DD) inputs from the browser,
6459
+ // or raw epoch-ms for the JSON API. The window is half-open [from, to):
6460
+ // the inclusive end day advances to the next UTC midnight so the chosen
6461
+ // day is fully covered. An empty or inverted range is a config/entry-tier
6462
+ // 400 (the operator corrects the typo), surfaced via _safeNotice.
6463
+ if (orderExport) {
6464
+ var EXPORT_FORMATS = orderExport.FORMATS || ["csv", "ndjson"];
6465
+
6466
+ // Resolve the export window from the query string into epoch-ms
6467
+ // [from, to). Reuses the report screen's date helpers: the epoch-ms
6468
+ // `from`/`to` pair wins for machine clients; the calendar-date
6469
+ // `from-date`/`to-date` pair is what the browser date inputs submit
6470
+ // (the `to-date` end day is inclusive, so step to the next UTC midnight).
6471
+ // Both bounds are required — there is no implicit "last 30 days" default
6472
+ // for a bulk dump, so a missing bound is a clean 400 rather than a
6473
+ // surprise full-table export. Throws TypeError on a malformed/empty/
6474
+ // inverted range; the orderExport primitive re-validates (non-negative
6475
+ // integers, to >= from).
6476
+ function _exportWindow(url) {
6477
+ var from = _parseEpochMs(url && url.searchParams.get("from"), "from");
6478
+ var to = _parseEpochMs(url && url.searchParams.get("to"), "to");
6479
+ if (from == null) from = _parseDateParam(url && url.searchParams.get("from-date"), "from");
6480
+ if (to == null) {
6481
+ var toDate = _parseDateParam(url && url.searchParams.get("to-date"), "to");
6482
+ if (toDate != null) to = toDate + b.constants.TIME.days(1);
6483
+ }
6484
+ if (from == null || to == null) {
6485
+ throw new TypeError("admin: a from and a to date are both required for an export");
6486
+ }
6487
+ if (to <= from) {
6488
+ throw new TypeError("admin: the to date must be after the from date");
6489
+ }
6490
+ return { from: from, to: to };
6491
+ }
6492
+
6493
+ function _exportFormat(url) {
6494
+ var f = (url && url.searchParams.get("format")) || "csv";
6495
+ if (EXPORT_FORMATS.indexOf(f) === -1) {
6496
+ throw new TypeError("admin: format must be one of " + EXPORT_FORMATS.join(", "));
6497
+ }
6498
+ return f;
6499
+ }
6500
+
6501
+ // Stream the chosen format's async-iterable into the response body.
6502
+ // csvForRange / ndjsonForRange yield the header (CSV) then one chunk
6503
+ // per database batch; the route writes each batch to the socket as it
6504
+ // is produced, so the process holds at most one batch in memory no
6505
+ // matter how many orders the range covers (the resumable streamCursor
6506
+ // / scheduled-export queue remains the path for an out-of-band dump).
6507
+ // The primitive RFC-4180-quotes every CSV cell and neutralizes the
6508
+ // spreadsheet-formula injection vector, so the route never touches the
6509
+ // cell bytes.
6510
+ async function _streamExport(res, win, format) {
6511
+ var iter = format === "ndjson"
6512
+ ? orderExport.ndjsonForRange({ from: win.from, to: win.to })
6513
+ : orderExport.csvForRange({ from: win.from, to: win.to });
6514
+ var contentType = format === "ndjson"
6515
+ ? "application/x-ndjson; charset=utf-8"
6516
+ : "text/csv; charset=utf-8";
6517
+ var ext = format === "ndjson" ? "ndjson" : "csv";
6518
+ var fromDay = _dateInputValue(win.from);
6519
+ var toDay = _dateInputValue(win.to - b.constants.TIME.days(1));
6520
+ var filename = "orders-" + (fromDay || "start") + "-to-" + (toDay || "end") + "." + ext;
6521
+ res.status(200);
6522
+ if (res.setHeader) {
6523
+ res.setHeader("content-type", contentType);
6524
+ // The filename is built from validated epoch-ms date strings
6525
+ // (digits + hyphens only), so it carries no quote/CRLF that could
6526
+ // break out of the header — but keep it ASCII-safe regardless.
6527
+ res.setHeader("content-disposition", "attachment; filename=\"" + filename.replace(/[^A-Za-z0-9._-]/g, "") + "\"");
6528
+ res.setHeader("x-content-type-options", "nosniff");
6529
+ }
6530
+ // Write the header first, then one chunk per batch as the primitive
6531
+ // yields it — bounded memory regardless of the order count. A
6532
+ // response without an incremental write() (a JSON test stub) falls
6533
+ // back to buffering the date-bounded window.
6534
+ if (typeof res.write === "function" && typeof res.end === "function") {
6535
+ for await (var chunk of iter) {
6536
+ if (chunk) res.write(chunk);
6537
+ }
6538
+ res.end();
6539
+ } else {
6540
+ var body = "";
6541
+ for await (var c2 of iter) body += c2;
6542
+ if (res.end) res.end(body); else res.send(body);
6543
+ }
6544
+ }
6545
+
6546
+ router.get("/admin/exports", _pageOrApi(true,
6547
+ R(async function (req, res) {
6548
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6549
+ // A download link carries ?download=1 (+ from/to/format); the bearer
6550
+ // API serves it as a file too so tooling can pull the dump directly.
6551
+ if (url && url.searchParams.get("download")) {
6552
+ var winD, fmtD;
6553
+ try { winD = _exportWindow(url); fmtD = _exportFormat(url); }
6554
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6555
+ return _streamExport(res, winD, fmtD);
6556
+ }
6557
+ // Summary preview for a chosen range (omitted when no range given).
6558
+ var summary = null, win = null;
6559
+ if ((url && url.searchParams.get("from")) || (url && url.searchParams.get("from-date"))) {
6560
+ try { win = _exportWindow(url); }
6561
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6562
+ summary = await orderExport.summaryForRange({ from: win.from, to: win.to });
6563
+ }
6564
+ var listing = await orderExport.listExports({ limit: 50 });
6565
+ _json(res, 200, { summary: summary, range: win, exports: listing.rows || [] });
6566
+ }),
6567
+ async function (req, res) {
6568
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6569
+ // Download from the browser surface too (a link, not a fetch).
6570
+ if (url && url.searchParams.get("download")) {
6571
+ var win0, fmt0;
6572
+ try { win0 = _exportWindow(url); fmt0 = _exportFormat(url); }
6573
+ catch (e) {
6574
+ var nd = _safeNotice(e, "export.download");
6575
+ var rowsD = (await orderExport.listExports({ limit: 50 })).rows || [];
6576
+ return _sendHtml(res, nd.status, renderAdminExports({
6577
+ shop_name: deps.shop_name, nav_available: navAvailable, exports: rowsD,
6578
+ formats: EXPORT_FORMATS, notice: nd.message.replace(/^admin:\s*/, ""),
6579
+ }));
6580
+ }
6581
+ return _streamExport(res, win0, fmt0);
6582
+ }
6583
+ // Optional summary preview for a chosen range. A malformed/empty/
6584
+ // inverted range re-renders the screen with a correction notice
6585
+ // (config/entry tier) rather than 500-ing.
6586
+ var summary = null, win = null, format = "csv", notice = null;
6587
+ var hasRange = (url && url.searchParams.get("from-date")) || (url && url.searchParams.get("from"));
6588
+ if (hasRange) {
6589
+ try {
6590
+ win = _exportWindow(url);
6591
+ format = _exportFormat(url);
6592
+ summary = await orderExport.summaryForRange({ from: win.from, to: win.to });
6593
+ } catch (e) {
6594
+ if (!(e instanceof TypeError)) throw e;
6595
+ notice = _safeNotice(e, "export.summary").message.replace(/^admin:\s*/, "");
6596
+ win = null; summary = null;
6597
+ }
6598
+ }
6599
+ var rows = (await orderExport.listExports({ limit: 50 })).rows || [];
6600
+ _sendHtml(res, 200, renderAdminExports({
6601
+ shop_name: deps.shop_name, nav_available: navAvailable, exports: rows,
6602
+ formats: EXPORT_FORMATS, summary: summary, range: win, format: format,
6603
+ scheduled: url && url.searchParams.get("scheduled"),
6604
+ cancelled: url && url.searchParams.get("cancelled"),
6605
+ notice: notice,
6606
+ }));
6607
+ },
6608
+ ));
6609
+
6610
+ // The dedicated download route — a plain link target (and a bearer GET
6611
+ // for tooling). Same file response on both surfaces.
6612
+ router.get("/admin/exports/download", _pageOrApi(true,
6613
+ R(async function (req, res) {
6614
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6615
+ var win, format;
6616
+ try { win = _exportWindow(url); format = _exportFormat(url); }
6617
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6618
+ return _streamExport(res, win, format);
6619
+ }),
6620
+ async function (req, res) {
6621
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6622
+ var win, format;
6623
+ try { win = _exportWindow(url); format = _exportFormat(url); }
6624
+ catch (e) {
6625
+ if (!(e instanceof TypeError)) throw e;
6626
+ var n = _safeNotice(e, "export.download");
6627
+ var rows = (await orderExport.listExports({ limit: 50 })).rows || [];
6628
+ return _sendHtml(res, n.status, renderAdminExports({
6629
+ shop_name: deps.shop_name, nav_available: navAvailable, exports: rows,
6630
+ formats: EXPORT_FORMATS, notice: n.message.replace(/^admin:\s*/, ""),
6631
+ }));
6632
+ }
6633
+ return _streamExport(res, win, format);
6634
+ },
6635
+ ));
6636
+
6637
+ // File a scheduled export job. Range + format are validated here (the
6638
+ // same _exportWindow / _exportFormat shape the download uses); the
6639
+ // primitive re-validates and persists a `queued` row a worker drains.
6640
+ router.post("/admin/exports/schedule", _pageOrApi(false,
6641
+ W("export.schedule", async function (req, res) {
6642
+ var body = req.body || {};
6643
+ var job = await orderExport.scheduleExport({
6644
+ format: body.format,
6645
+ from: _strictNonNegIntField(body.from, "from"),
6646
+ to: _strictNonNegIntField(body.to, "to"),
6647
+ });
6648
+ _json(res, 201, job);
6649
+ return { id: job.id };
6650
+ }),
6651
+ async function (req, res) {
6652
+ var body = req.body || {};
6653
+ // The browser form submits calendar-date inputs; coerce to the
6654
+ // epoch-ms [from, to) window the primitive expects.
6655
+ try {
6656
+ var win = _exportWindow(new URL("http://localhost/?" +
6657
+ "from-date=" + encodeURIComponent(body["from-date"] || "") +
6658
+ "&to-date=" + encodeURIComponent(body["to-date"] || "")));
6659
+ var format = (body.format && EXPORT_FORMATS.indexOf(body.format) !== -1) ? body.format : null;
6660
+ if (!format) throw new TypeError("admin: format must be one of " + EXPORT_FORMATS.join(", "));
6661
+ await orderExport.scheduleExport({ format: format, from: win.from, to: win.to });
6662
+ } catch (e) {
6663
+ var n = _safeNotice(e, "export.schedule");
6664
+ var rows = (await orderExport.listExports({ limit: 50 })).rows || [];
6665
+ return _sendHtml(res, n.status, renderAdminExports({
6666
+ shop_name: deps.shop_name, nav_available: navAvailable, exports: rows,
6667
+ formats: EXPORT_FORMATS, notice: n.message.replace(/^admin:\s*/, ""),
6668
+ }));
6669
+ }
6670
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".export.schedule", outcome: "success" });
6671
+ _redirect(res, "/admin/exports?scheduled=1");
6672
+ },
6673
+ ));
6674
+
6675
+ // Cancel a queued scheduled export. A malformed id is a 400; an unknown
6676
+ // id (or a job past `queued`, which the FSM refuses) is surfaced as a
6677
+ // correction notice on the browser path / a 4xx on the bearer path.
6678
+ router.post("/admin/exports/:id/cancel", _pageOrApi(false,
6679
+ W("export.cancel", async function (req, res) {
6680
+ var row;
6681
+ try { row = await orderExport.cancelExport(req.params.id); }
6682
+ catch (e) {
6683
+ // The FSM refuses a cancel once the job is running/terminal — a
6684
+ // coded conflict (409), checked first. It is a plain Error with a
6685
+ // .code, never a TypeError; the message is operator-safe (it names
6686
+ // the refused transition, never storage internals).
6687
+ if (e && e.code) return _problem(res, 409, "conflict", "That export can no longer be cancelled.");
6688
+ // A syntactically valid id that names no job is a not-found
6689
+ // TypeError — distinct from a malformed id — so map it to 404 (a
6690
+ // malformed id stays a 400) and clients can tell the two apart.
6691
+ if (e instanceof TypeError && / not found$/.test(String(e.message || ""))) {
6692
+ return _problem(res, 404, "export-not-found");
6693
+ }
6694
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
6695
+ throw e;
6696
+ }
6697
+ // cancelExport throws on a missing row, so a falsy return is purely
6698
+ // defensive — keep the 404 in case the primitive ever returns null.
6699
+ if (!row) return _problem(res, 404, "export-not-found");
6700
+ _json(res, 200, row);
6701
+ return { id: row.id };
6702
+ }),
6703
+ async function (req, res) {
6704
+ var ok = false;
6705
+ try { ok = !!(await orderExport.cancelExport(req.params.id)); }
6706
+ catch (_e) { ok = false; }
6707
+ if (!ok) return _redirect(res, "/admin/exports?err=1");
6708
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".export.cancel", outcome: "success", metadata: { id: req.params.id } });
6709
+ _redirect(res, "/admin/exports?cancelled=1");
6710
+ },
6711
+ ));
6712
+ }
6713
+
6714
+ // ---- order ratings (moderation) -------------------------------------
6715
+ //
6716
+ // Surfaces (mounted only when the orderRatings primitive is wired):
6717
+ // GET /admin/ratings[?flagged=1] — screen: the flagged-comment
6718
+ // moderation queue first, then a list of recent ratings with
6719
+ // their scores + (escaped) comment. Bearer JSON returns both lists.
6720
+ // POST /admin/ratings/:id/flag — flag a comment for moderation.
6721
+ // POST /admin/ratings/:id/respond — post the operator's one public reply.
6722
+ //
6723
+ // Coded-error mapping mirrors the exchanges console: the primitive throws
6724
+ // plain Errors carrying `.code` (ORDER_RATING_NOT_FOUND / _ALREADY_FLAGGED
6725
+ // / _ALREADY_RESPONDED / _NO_COMMENT) AND TypeErrors for bad input, so the
6726
+ // `.code` checks MUST run before the generic `instanceof TypeError → 400`,
6727
+ // else a not-found / already-acted error would wrongly return 400.
6728
+ if (orderRatings) {
6729
+ // The operator-facing window for the recent-ratings list + the flagged
6730
+ // queue. A wide span (the primitive's read paths are date-windowed); the
6731
+ // list methods cap their own rows so this never streams unbounded.
6732
+ var RATINGS_WINDOW_MS = b.constants.TIME.days(365);
6733
+ function _ratingsWindow() {
6734
+ var to = Date.now();
6735
+ return { from: to - RATINGS_WINDOW_MS, to: to };
6736
+ }
6737
+
6738
+ // Map a thrown rating error to a problem-details status for the bearer
6739
+ // JSON path: missing row → 404, already-flagged / already-responded /
6740
+ // no-comment-to-flag → 409, bad shape → 400. The coded checks run BEFORE
6741
+ // the `instanceof TypeError` branch (the primitive's _NOT_FOUND etc. are
6742
+ // plain Errors with a .code; a malformed UUID is a TypeError).
6743
+ function _ratingApiError(res, e) {
6744
+ if (!e) return null;
6745
+ if (e.code === "ORDER_RATING_NOT_FOUND") return _problem(res, 404, "rating-not-found");
6746
+ if (e.code === "ORDER_RATING_ALREADY_FLAGGED" ||
6747
+ e.code === "ORDER_RATING_ALREADY_RESPONDED" ||
6748
+ e.code === "ORDER_RATING_NO_COMMENT") {
6749
+ return _problem(res, 409, "conflict", e.message);
6750
+ }
6751
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
6752
+ return null;
6753
+ }
6754
+ // The same classes for the browser path: a coded refusal / bad shape is a
6755
+ // correction notice (?err redirect), never a 500. Anything else propagates.
6756
+ function _ratingClientError(e) {
6757
+ if (!e) return false;
6758
+ if (e instanceof TypeError) return true;
6759
+ return e.code === "ORDER_RATING_NOT_FOUND" ||
6760
+ e.code === "ORDER_RATING_ALREADY_FLAGGED" ||
6761
+ e.code === "ORDER_RATING_ALREADY_RESPONDED" ||
6762
+ e.code === "ORDER_RATING_NO_COMMENT";
6763
+ }
6764
+
6765
+ // Resolve the flagged queue + the recent-ratings list for the screen /
6766
+ // the JSON API. The flagged queue comes from listFlagged (every flagged
6767
+ // comment in the window, most-recent-first, regardless of score) so a
6768
+ // flagged comment on an otherwise high rating is never hidden behind the
6769
+ // recent list's score cap; the recent list is the lowest-scored ratings
6770
+ // in the window (the moderation-priority view). Each read is best-effort
6771
+ // against an unmigrated table (degrades to an empty list, never a 500).
6772
+ async function _ratingsLists() {
6773
+ var win = _ratingsWindow();
6774
+ var recent = [], flagged = [];
6775
+ try { recent = await orderRatings.topNegativeRatings({ from: win.from, to: win.to, limit: 100 }); }
6776
+ catch (_e) { recent = []; }
6777
+ try { flagged = await orderRatings.listFlagged({ from: win.from, to: win.to }); }
6778
+ catch (_e2) { flagged = []; }
6779
+ return { recent: recent, flagged: flagged };
6780
+ }
6781
+
6782
+ router.get("/admin/ratings", _pageOrApi(true,
6783
+ R(async function (req, res) {
6784
+ var lists = await _ratingsLists();
6785
+ _json(res, 200, { flagged: lists.flagged, ratings: lists.recent });
6786
+ }),
6787
+ async function (req, res) {
6788
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6789
+ var onlyFlagged = !!(url && url.searchParams.get("flagged"));
6790
+ var lists = await _ratingsLists();
6791
+ _sendHtml(res, 200, renderAdminRatings({
6792
+ shop_name: deps.shop_name,
6793
+ nav_available: navAvailable,
6794
+ ratings: lists.recent,
6795
+ flagged: lists.flagged,
6796
+ only_flagged: onlyFlagged,
6797
+ moved: url && url.searchParams.get("moved"),
6798
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this rating." : null,
6799
+ }));
6800
+ },
6801
+ ));
6802
+
6803
+ // The browser side of a rating action: run opFn(id, body), then PRG back
6804
+ // to the queue. A bad shape / coded refusal / not-found becomes an ?err
6805
+ // notice, never a 500; anything else propagates.
6806
+ function _ratingAction(jsonHandler, auditEvent, opFn) {
6807
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
6808
+ var id = req.params.id;
6809
+ try { await opFn(id, req.body || {}); }
6810
+ catch (e) {
6811
+ if (_ratingClientError(e)) return _redirect(res, "/admin/ratings?err=1");
6812
+ throw e;
6813
+ }
6814
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
6815
+ _redirect(res, "/admin/ratings?moved=1");
6816
+ });
6817
+ }
6818
+
6819
+ // Flag a comment for moderation. The operator supplies a reason + their
6820
+ // own UUID (the single-bearer admin model has no per-operator session).
6821
+ router.post("/admin/ratings/:id/flag", _ratingAction(
6822
+ W("rating.flag", async function (req, res) {
6823
+ var body = req.body || {};
6824
+ var row;
6825
+ try { row = await orderRatings.flagComment({ rating_id: req.params.id, reason: body.reason, flagged_by: body.flagged_by }); }
6826
+ catch (e) { var mapped = _ratingApiError(res, e); if (mapped !== null) return mapped; throw e; }
6827
+ _json(res, 200, row);
6828
+ return { id: row.id };
6829
+ }),
6830
+ "rating.flag",
6831
+ function (id, body) { return orderRatings.flagComment({ rating_id: id, reason: body.reason, flagged_by: body.flagged_by }); },
6832
+ ));
6833
+
6834
+ // Post the operator's one public reply to a rating. The primitive
6835
+ // refuses a second reply (409 ORDER_RATING_ALREADY_RESPONDED).
6836
+ router.post("/admin/ratings/:id/respond", _ratingAction(
6837
+ W("rating.respond", async function (req, res) {
6838
+ var body = req.body || {};
6839
+ var row;
6840
+ try { row = await orderRatings.responseToCustomer({ rating_id: req.params.id, response: body.response, responded_by: body.responded_by }); }
6841
+ catch (e) { var mapped = _ratingApiError(res, e); if (mapped !== null) return mapped; throw e; }
6842
+ _json(res, 200, row);
6843
+ return { id: row.id };
6844
+ }),
6845
+ "rating.respond",
6846
+ function (id, body) { return orderRatings.responseToCustomer({ rating_id: id, response: body.response, responded_by: body.responded_by }); },
6847
+ ));
6848
+ }
6849
+
6432
6850
  // ---- analytics ------------------------------------------------------
6433
6851
 
6434
6852
  var analytics = deps.analytics || null;
@@ -9277,11 +9695,13 @@ var ADMIN_NAV_ITEMS = [
9277
9695
  { key: "inventory", href: "/admin/inventory", label: "Inventory" },
9278
9696
  { key: "orders", href: "/admin/orders", label: "Orders" },
9279
9697
  { key: "reports", href: "/admin/reports", label: "Reports" },
9698
+ { key: "exports", href: "/admin/exports", label: "Exports", requires: "orderExport" },
9280
9699
  { key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
9281
9700
  { key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
9282
9701
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
9283
9702
  { key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
9284
9703
  { key: "exchanges", href: "/admin/exchanges", label: "Exchanges", requires: "orderExchanges" },
9704
+ { key: "ratings", href: "/admin/ratings", label: "Ratings", requires: "orderRatings" },
9285
9705
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
9286
9706
  { key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
9287
9707
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
@@ -9702,6 +10122,114 @@ function renderAdminReports(opts) {
9702
10122
  return _renderAdminShell(opts.shop_name, "Reports", body, "reports", opts.nav_available);
9703
10123
  }
9704
10124
 
10125
+ // Order-export screen. A date-range + format picker drives a summary
10126
+ // preview (order count + total for the chosen window) and the two download
10127
+ // links (CSV / NDJSON); below it the scheduled-export queue lists every
10128
+ // operator-filed job with a per-job cancel, and a schedule-a-new-export
10129
+ // form files one. `opts.summary` is null until the operator picks a range;
10130
+ // `opts.exports` is the scheduled-export rows.
10131
+ var EXPORT_STATUS_PILL = {
10132
+ queued: "pending",
10133
+ running: "fulfilling",
10134
+ complete: "paid",
10135
+ failed: "refunded",
10136
+ cancelled: "cancelled",
10137
+ };
10138
+ function renderAdminExports(opts) {
10139
+ opts = opts || {};
10140
+ var exportsRows = opts.exports || [];
10141
+ var formats = opts.formats || ["csv", "ndjson"];
10142
+ var summary = opts.summary || null;
10143
+ var range = opts.range || null;
10144
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
10145
+ var scheduled = opts.scheduled ? "<div class=\"banner banner--ok\">Export scheduled.</div>" : "";
10146
+ var cancelled = opts.cancelled ? "<div class=\"banner banner--ok\">Export cancelled.</div>" : "";
10147
+
10148
+ // The range form prefills with the chosen window (or blank). It GETs back
10149
+ // to /admin/exports so the window lives in the URL (bookmarkable) and the
10150
+ // summary preview re-renders; the two download links carry the same window
10151
+ // + the format as a ?download=1 query.
10152
+ var fromVal = range ? _dateInputValue(range.from) : "";
10153
+ var toVal = range ? _dateInputValue(range.to - b.constants.TIME.days(1)) : "";
10154
+
10155
+ var rangeForm =
10156
+ "<form method=\"get\" action=\"/admin/exports\" class=\"order-filters\">" +
10157
+ "<label class=\"form-field\"><span>From</span><input type=\"date\" name=\"from-date\" value=\"" + _htmlEscape(fromVal) + "\" required></label>" +
10158
+ "<label class=\"form-field\"><span>To</span><input type=\"date\" name=\"to-date\" value=\"" + _htmlEscape(toVal) + "\" required></label>" +
10159
+ "<button class=\"btn\" type=\"submit\">Preview range</button>" +
10160
+ "</form>";
10161
+
10162
+ // Summary preview + download links (only once a valid range is chosen).
10163
+ var previewBlock = "";
10164
+ if (summary && range) {
10165
+ // Pick a headline currency from the by_currency map (the busiest
10166
+ // bucket); fall back to USD when the window is empty.
10167
+ var curBuckets = Object.keys(summary.by_currency || {});
10168
+ var headlineCur = curBuckets.length ? curBuckets[0] : "USD";
10169
+ var qWin = "from=" + encodeURIComponent(String(range.from)) + "&to=" + encodeURIComponent(String(range.to));
10170
+ var dlLinks = formats.map(function (f) {
10171
+ return "<a class=\"btn btn--ghost\" href=\"/admin/exports/download?" + qWin + "&format=" + encodeURIComponent(f) + "\">Download " + _htmlEscape(f.toUpperCase()) + "</a>";
10172
+ }).join("");
10173
+ previewBlock =
10174
+ "<section><h2>Range preview</h2>" +
10175
+ "<p class=\"meta\">Window: " + _htmlEscape(_fmtDate(range.from)) + " → " + _htmlEscape(_fmtDate(range.to - b.constants.TIME.days(1))) + " (inclusive)</p>" +
10176
+ "<div class=\"stat-grid\">" +
10177
+ _statCard("Orders in range", String(summary.order_count)) +
10178
+ _statCard("Revenue", pricing.format(summary.revenue_minor, headlineCur)) +
10179
+ _statCard("Avg order value", pricing.format(summary.average_order_minor, headlineCur)) +
10180
+ "</div>" +
10181
+ "<div class=\"actions-row mt\">" + dlLinks + "</div>" +
10182
+ "</section>";
10183
+ }
10184
+
10185
+ // Scheduled-export queue table.
10186
+ var jobRows = exportsRows.map(function (j) {
10187
+ var pill = EXPORT_STATUS_PILL[j.status] || "pending";
10188
+ var from = j.from_ts != null ? _fmtDate(j.from_ts) : "—";
10189
+ var to = j.to_ts != null ? _fmtDate(j.to_ts) : "—";
10190
+ var rowCount = (j.row_count != null) ? String(j.row_count) : "—";
10191
+ var enc = _htmlEscape(encodeURIComponent(j.id));
10192
+ var cancelCell = j.status === "queued"
10193
+ ? "<form method=\"post\" action=\"/admin/exports/" + enc + "/cancel\" class=\"form-inline\">" +
10194
+ "<button class=\"btn btn--danger\" type=\"submit\">Cancel</button></form>"
10195
+ : "<span class=\"meta\">—</span>";
10196
+ return "<tr>" +
10197
+ "<td><code class=\"order-id\">" + _htmlEscape(String(j.format || "")) + "</code></td>" +
10198
+ "<td>" + _htmlEscape(from) + " → " + _htmlEscape(to) + "</td>" +
10199
+ "<td><span class=\"status-pill " + pill + "\">" + _htmlEscape(String(j.status || "")) + "</span></td>" +
10200
+ "<td class=\"num\">" + _htmlEscape(rowCount) + "</td>" +
10201
+ "<td>" + cancelCell + "</td>" +
10202
+ "</tr>";
10203
+ }).join("");
10204
+ var jobTable = exportsRows.length
10205
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Format</th><th scope=\"col\">Window</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Rows</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + jobRows + "</tbody></table></div>"
10206
+ : "<p class=\"empty\">No scheduled exports yet.</p>";
10207
+
10208
+ var scheduleFmtOptions = formats.map(function (f) {
10209
+ return "<option value=\"" + _htmlEscape(f) + "\">" + _htmlEscape(f.toUpperCase()) + "</option>";
10210
+ }).join("");
10211
+ var scheduleForm =
10212
+ "<div class=\"panel mt mw-42\">" +
10213
+ "<h3 class=\"subhead\">Schedule an export</h3>" +
10214
+ "<p class=\"meta\">Queue a date-range dump for a background worker to produce. The job appears below as “queued” and can be cancelled until the worker claims it.</p>" +
10215
+ "<form method=\"post\" action=\"/admin/exports/schedule\">" +
10216
+ "<label class=\"form-field\"><span>From</span><input type=\"date\" name=\"from-date\" required></label>" +
10217
+ "<label class=\"form-field\"><span>To</span><input type=\"date\" name=\"to-date\" required></label>" +
10218
+ "<label class=\"form-field\"><span>Format</span><select name=\"format\">" + scheduleFmtOptions + "</select></label>" +
10219
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Schedule export</button></div>" +
10220
+ "</form>" +
10221
+ "</div>";
10222
+
10223
+ var body =
10224
+ "<section><h2>Exports</h2>" + notice + scheduled + cancelled +
10225
+ "<p class=\"meta\">Dump the full order set for a date range to CSV or NDJSON — for accounting, analysis, or a fulfilment-partner handoff. Customer email is pseudonymised in the output.</p>" +
10226
+ rangeForm +
10227
+ "</section>" +
10228
+ previewBlock +
10229
+ "<section><h2>Scheduled exports</h2>" + jobTable + scheduleForm + "</section>";
10230
+ return _renderAdminShell(opts.shop_name, "Exports", body, "exports", opts.nav_available);
10231
+ }
10232
+
9705
10233
  // A single shipping-label row for the standalone cross-order tables. Shows
9706
10234
  // carrier + service, tracking number, cost (via pricing.format from the
9707
10235
  // label's own minor units + currency), the broker it came from, its status
@@ -11247,6 +11775,117 @@ function renderAdminExchange(opts) {
11247
11775
  return _renderAdminShell(opts.shop_name, "Exchange " + String(x.id).slice(0, 8), body, "exchanges", opts.nav_available);
11248
11776
  }
11249
11777
 
11778
+ // One rating card for the moderation queue / recent list. Shows the three
11779
+ // scores, the (escaped) comment, the flag state + reason, and the operator
11780
+ // reply — plus the flag / reply action forms.
11781
+ //
11782
+ // ESCAPE-BY-DEFAULT: the customer comment is rendered from the primitive's
11783
+ // pre-escaped `comment_html`, and the operator reply from `response_html`
11784
+ // — both already run through b.template.escapeHtml by the primitive's
11785
+ // render layer. They are spliced as-is, NEVER re-built from the raw
11786
+ // `comment` / `response_text`, so a `<script>`/`onerror` payload a customer
11787
+ // typed is inert on this screen. (`_htmlEscape` is NOT applied to the _html
11788
+ // fields — that would double-escape; it IS applied to every other field.)
11789
+ function _ratingCard(rt) {
11790
+ var shortId = _htmlEscape(String(rt.id).slice(0, 8));
11791
+ var shortOrd = _htmlEscape(String(rt.order_id).slice(0, 8));
11792
+ var scores =
11793
+ "<span class=\"meta\">Shipping " + _htmlEscape(String(rt.shipping_rating)) + "/5</span> · " +
11794
+ "<span class=\"meta\">Packaging " + _htmlEscape(String(rt.packaging_rating)) + "/5</span> · " +
11795
+ "<span class=\"meta\">Recommend " + _htmlEscape(String(rt.recommend_rating)) + "/5</span>";
11796
+ // comment_html / response_html are already escaped by the primitive —
11797
+ // splice them, never rt.comment / rt.response_text.
11798
+ var commentBlock = rt.comment_html
11799
+ ? "<p class=\"review-card__body\">" + rt.comment_html + "</p>"
11800
+ : "<p class=\"meta\">No comment.</p>";
11801
+ var flagState = rt.comment_flagged
11802
+ ? "<span class=\"status-pill cancelled\">Flagged</span>" +
11803
+ (rt.flag_reason ? " <span class=\"meta\">" + _htmlEscape(rt.flag_reason) + "</span>" : "")
11804
+ : "";
11805
+ var replyBlock = rt.response_html
11806
+ ? "<div class=\"panel\"><h4 class=\"subhead\">Operator reply</h4>" +
11807
+ "<p class=\"review-card__body\">" + rt.response_html + "</p></div>"
11808
+ : "";
11809
+
11810
+ // Flag form — only when there is a comment AND it isn't already flagged
11811
+ // (the primitive refuses both no-comment and already-flagged with a 409;
11812
+ // gating the form keeps the console from offering a doomed action).
11813
+ var flagForm = (rt.comment_html && !rt.comment_flagged)
11814
+ ? "<form method=\"post\" action=\"/admin/ratings/" + _htmlEscape(rt.id) + "/flag\" class=\"return-action\">" +
11815
+ "<h4>Flag comment</h4>" +
11816
+ _setupField("Reason", "reason", "", "text", "Why this comment is being flagged (shown in the audit log).", " maxlength=\"500\" required") +
11817
+ _setupField("Operator id (UUID)", "flagged_by", "", "text", "The operator placing the flag.", " required") +
11818
+ "<button class=\"btn btn--danger\" type=\"submit\">Flag</button>" +
11819
+ "</form>"
11820
+ : "";
11821
+ // Reply form — only when no reply exists yet (one reply per rating; the
11822
+ // primitive refuses a second with a 409).
11823
+ var replyForm = !rt.response_html
11824
+ ? "<form method=\"post\" action=\"/admin/ratings/" + _htmlEscape(rt.id) + "/respond\" class=\"return-action\">" +
11825
+ "<h4>Public reply</h4>" +
11826
+ _setupField("Reply", "response", "", "text", "Shown to the customer on their order page.", " maxlength=\"2000\" required") +
11827
+ _setupField("Operator id (UUID)", "responded_by", "", "text", "The operator authoring the reply.", " required") +
11828
+ "<button class=\"btn\" type=\"submit\">Post reply</button>" +
11829
+ "</form>"
11830
+ : "";
11831
+ var actions = (flagForm || replyForm)
11832
+ ? "<div class=\"return-actions\">" + flagForm + replyForm + "</div>"
11833
+ : "";
11834
+
11835
+ return "<div class=\"panel review-card\">" +
11836
+ "<div class=\"review-card__head\">" +
11837
+ "<code class=\"order-id\">" + shortId + "</code> " +
11838
+ "<span class=\"meta\">order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(rt.order_id) + "\">" + shortOrd + "</a></span> " +
11839
+ flagState +
11840
+ "</div>" +
11841
+ "<p>" + scores + "</p>" +
11842
+ commentBlock +
11843
+ replyBlock +
11844
+ actions +
11845
+ "</div>";
11846
+ }
11847
+
11848
+ // Order-ratings moderation console: the flagged-comment queue first, then a
11849
+ // list of recent ratings (lowest-scored first — the ones most likely to
11850
+ // need a reply). Each card carries the flag + public-reply forms.
11851
+ function renderAdminRatings(opts) {
11852
+ opts = opts || {};
11853
+ var recent = opts.ratings || [];
11854
+ var flagged = opts.flagged || [];
11855
+ var onlyFlagged = !!opts.only_flagged;
11856
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Rating updated.</div>" : "";
11857
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
11858
+
11859
+ var chips = "<div class=\"order-filters\">" +
11860
+ "<a class=\"chip" + (!onlyFlagged ? " chip--on" : "") + "\" href=\"/admin/ratings\">all recent</a>" +
11861
+ "<a class=\"chip" + (onlyFlagged ? " chip--on" : "") + "\" href=\"/admin/ratings?flagged=1\">flagged</a>" +
11862
+ "</div>";
11863
+
11864
+ var flaggedCards = flagged.map(_ratingCard).join("");
11865
+ var flaggedSection =
11866
+ "<section><h2>Flagged comments</h2>" +
11867
+ (flagged.length ? flaggedCards : "<p class=\"empty\">No flagged comments.</p>") +
11868
+ "</section>";
11869
+
11870
+ // The recent list excludes the ones already in the flagged queue above so
11871
+ // a flagged card isn't rendered twice on the default (unfiltered) view.
11872
+ var recentList = recent.filter(function (r) { return !r.comment_flagged; });
11873
+ var recentCards = recentList.map(_ratingCard).join("");
11874
+ var recentSection = onlyFlagged ? "" :
11875
+ "<section><h2>Recent ratings</h2>" +
11876
+ (recentList.length ? recentCards : "<p class=\"empty\">No ratings yet.</p>") +
11877
+ "</section>";
11878
+
11879
+ var body =
11880
+ "<section><h2>Ratings</h2>" + moved + notice +
11881
+ "<p class=\"meta\">Per-order shipping / packaging / recommend feedback. Flag a comment to suppress it on the storefront, or post one public reply the customer sees on their order page.</p>" +
11882
+ chips +
11883
+ "</section>" +
11884
+ flaggedSection +
11885
+ recentSection;
11886
+ return _renderAdminShell(opts.shop_name, "Ratings", body, "ratings", opts.nav_available);
11887
+ }
11888
+
11250
11889
  // The review states an operator can filter the moderation queue by.
11251
11890
  var REVIEW_STATUS_FILTERS = ["pending", "published", "rejected"];
11252
11891
 
@@ -14921,6 +15560,7 @@ module.exports = {
14921
15560
  renderAdminReturns: renderAdminReturns,
14922
15561
  renderAdminReturn: renderAdminReturn,
14923
15562
  renderAdminReviews: renderAdminReviews,
15563
+ renderAdminRatings: renderAdminRatings,
14924
15564
  renderAdminQuestions: renderAdminQuestions,
14925
15565
  renderAdminQuestion: renderAdminQuestion,
14926
15566
  renderAdminCollections: renderAdminCollections,