@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/CHANGELOG.md +4 -0
- package/lib/admin.js +641 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/order-ratings.js +36 -0
- package/lib/storefront.js +253 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/lib/app.js +8 -0
- package/lib/vendor/blamejs/lib/mail.js +1 -0
- package/lib/vendor/blamejs/lib/network-dns.js +1 -0
- package/lib/vendor/blamejs/lib/network-nts.js +4 -0
- package/lib/vendor/blamejs/lib/ntp-check.js +8 -0
- package/lib/vendor/blamejs/lib/redis-client.js +8 -0
- package/lib/vendor/blamejs/lib/validate-opts.js +23 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.16.json +45 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +29 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/validate-opts-port.test.js +70 -0
- package/package.json +1 -1
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,
|