@blamejs/blamejs-shop 0.3.43 → 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 +2 -0
- package/lib/admin.js +251 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/order-ratings.js +36 -0
- package/lib/storefront.js +253 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.44 (2026-05-31) — **Customers can rate a delivered order, and the admin console moderates and replies.** There was no way for a customer to give feedback on how an order was fulfilled, and no way for an operator to act on it. Customers can now rate one of their own delivered orders on shipping, packaging, and how likely they are to recommend you, with an optional comment. The admin console gets a Ratings screen with a moderation queue of every flagged comment plus the most recent ratings, where an operator can flag a comment or post one public reply that the customer then sees on their order page. A customer can only rate an order they own, and every comment and reply is HTML-escaped wherever it is shown, so feedback text can't inject markup into another shopper's or an operator's page. **Added:** *Order ratings* — A customer viewing one of their own orders can rate it on three axes — shipping, packaging, and likelihood to recommend (1 to 5) — and optionally leave a comment. Each order can be rated once. The rating, and any public reply from the store, appear on the customer's order page. A customer can only rate an order tied to their own account; the rating is always recorded against the signed-in customer, never a value taken from the request. · *Ratings moderation in the admin console* — A new Ratings screen lists recent ratings with their scores and comments, alongside a moderation queue that surfaces every flagged comment regardless of its score, so a flagged comment on an otherwise high rating is never hidden. An operator can flag a comment for moderation and post a single public reply to a rating, which is then shown to the customer. Comments and replies are HTML-escaped everywhere they are rendered.
|
|
12
|
+
|
|
11
13
|
- v0.3.43 (2026-05-31) — **Export orders for a date range as CSV or NDJSON, with scheduled exports.** There was no way to pull a bulk export of orders over a date range. A new Exports screen in the admin console previews how many orders and how much revenue a date range covers, then downloads them as a CSV or NDJSON file. The export can also be scheduled and managed as a queued job. Customer email addresses are exported only as a hash, never in the clear, and any field that could be interpreted as a spreadsheet formula is neutralized so an exported file can't run code when opened. This release also updates the vendored blamejs runtime to v0.14.16. **Added:** *Order export* — An Exports screen in the admin console exports orders over a chosen date range. It first shows a preview — the order count, revenue, and average order value for the range — then downloads the orders as a CSV or NDJSON file with a standard set of columns. Exports can also be queued as scheduled jobs and cancelled before they run. Customer email is exported as a hash rather than in the clear, and any cell that begins with a character a spreadsheet would treat as a formula is escaped, so an exported file can't execute anything when opened in Excel or Sheets. **Changed:** *Vendored blamejs runtime updated to v0.14.16* — The bundled blamejs runtime is updated to v0.14.16, which validates connection ports at configuration time — a malformed port (a non-integer or out-of-range value) is now rejected with a clear error at startup instead of silently falling back to a default. No operator action is required.
|
|
12
14
|
|
|
13
15
|
- v0.3.42 (2026-05-31) — **The blog now pages through every post, and the byline shows the shop name.** The blog index showed only the 12 most recent posts with no way to reach the rest, so older posts were a dead end for anyone browsing — even though they stayed in the sitemap. The index now has previous/next pagination, so every published post is reachable. The post byline and the article's structured data now show the shop name instead of an internal author identifier, and a missing blog post or product page no longer returns a body on a HEAD request. **Fixed:** *Blog index pagination* — The blog index listed only the 12 most recent posts and offered no way to reach older ones from /blog. It now has previous/next pagination — the same control used on collection and search pages — so every published post is reachable, with the next link appearing only when there is genuinely another page. · *Blog byline shows the shop name, not an internal id* — The blog post byline and the article's structured data displayed the raw internal author identifier. They now show the shop name, so the public byline and the author Google reads from the structured data are meaningful rather than an internal id. · *Missing pages don't return a body on HEAD* — A missing blog post or product page returned a full page body even for a HEAD request. Those 404 responses now omit the body on HEAD, matching the other pages and the HTTP spec.
|
package/lib/admin.js
CHANGED
|
@@ -512,6 +512,7 @@ function mount(router, deps) {
|
|
|
512
512
|
var salesTaxFilings = deps.salesTaxFilings || null; // sales-tax-filing remittance console disabled when absent
|
|
513
513
|
var supportTickets = deps.supportTickets || null; // support-ticket queue + thread console disabled when absent
|
|
514
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
|
|
515
516
|
var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
|
|
516
517
|
|
|
517
518
|
// Which optional console sections are wired — gates their nav links so a
|
|
@@ -520,7 +521,7 @@ function mount(router, deps) {
|
|
|
520
521
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
521
522
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
522
523
|
// notice when the salesReports primitive isn't wired.
|
|
523
|
-
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, orderExport: !!orderExport };
|
|
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 };
|
|
524
525
|
|
|
525
526
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
526
527
|
|
|
@@ -6710,6 +6711,142 @@ function mount(router, deps) {
|
|
|
6710
6711
|
));
|
|
6711
6712
|
}
|
|
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
|
+
|
|
6713
6850
|
// ---- analytics ------------------------------------------------------
|
|
6714
6851
|
|
|
6715
6852
|
var analytics = deps.analytics || null;
|
|
@@ -9564,6 +9701,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
9564
9701
|
{ key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
|
|
9565
9702
|
{ key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
|
|
9566
9703
|
{ key: "exchanges", href: "/admin/exchanges", label: "Exchanges", requires: "orderExchanges" },
|
|
9704
|
+
{ key: "ratings", href: "/admin/ratings", label: "Ratings", requires: "orderRatings" },
|
|
9567
9705
|
{ key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
|
|
9568
9706
|
{ key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
|
|
9569
9707
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
@@ -11637,6 +11775,117 @@ function renderAdminExchange(opts) {
|
|
|
11637
11775
|
return _renderAdminShell(opts.shop_name, "Exchange " + String(x.id).slice(0, 8), body, "exchanges", opts.nav_available);
|
|
11638
11776
|
}
|
|
11639
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
|
+
|
|
11640
11889
|
// The review states an operator can filter the moderation queue by.
|
|
11641
11890
|
var REVIEW_STATUS_FILTERS = ["pending", "published", "rejected"];
|
|
11642
11891
|
|
|
@@ -15311,6 +15560,7 @@ module.exports = {
|
|
|
15311
15560
|
renderAdminReturns: renderAdminReturns,
|
|
15312
15561
|
renderAdminReturn: renderAdminReturn,
|
|
15313
15562
|
renderAdminReviews: renderAdminReviews,
|
|
15563
|
+
renderAdminRatings: renderAdminRatings,
|
|
15314
15564
|
renderAdminQuestions: renderAdminQuestions,
|
|
15315
15565
|
renderAdminQuestion: renderAdminQuestion,
|
|
15316
15566
|
renderAdminCollections: renderAdminCollections,
|
package/lib/asset-manifest.json
CHANGED
package/lib/order-ratings.js
CHANGED
|
@@ -455,6 +455,41 @@ function create(opts) {
|
|
|
455
455
|
return _topRatings(input, "topNegativeRatings", "negative");
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
// ---- listFlagged -----------------------------------------------------
|
|
459
|
+
|
|
460
|
+
// The moderation queue: every rating whose comment an operator has
|
|
461
|
+
// flagged, most-recent-first, backed by the (comment_flagged,
|
|
462
|
+
// occurred_at) index. Unlike topNegativeRatings (score-ordered, capped
|
|
463
|
+
// at MAX_TOP_LIMIT), this surfaces ALL flagged comments regardless of
|
|
464
|
+
// their score — so a flagged comment on an otherwise high rating is
|
|
465
|
+
// never hidden behind the score cap. The from/to window is optional and
|
|
466
|
+
// applied only when supplied; the row count is capped by `limit`.
|
|
467
|
+
async function listFlagged(input) {
|
|
468
|
+
input = (input && typeof input === "object") ? input : {};
|
|
469
|
+
var limit = _limit(input.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, "limit");
|
|
470
|
+
|
|
471
|
+
var sql = "SELECT * FROM order_ratings WHERE comment_flagged = 1";
|
|
472
|
+
var params = [];
|
|
473
|
+
if (input.from != null || input.to != null) {
|
|
474
|
+
var from = _epoch(input.from, "from");
|
|
475
|
+
var to = _epoch(input.to, "to");
|
|
476
|
+
if (from > to) {
|
|
477
|
+
throw new TypeError("orderRatings.listFlagged: from must be <= to");
|
|
478
|
+
}
|
|
479
|
+
params.push(from);
|
|
480
|
+
sql += " AND occurred_at >= ?" + params.length;
|
|
481
|
+
params.push(to);
|
|
482
|
+
sql += " AND occurred_at <= ?" + params.length;
|
|
483
|
+
}
|
|
484
|
+
params.push(limit);
|
|
485
|
+
sql += " ORDER BY occurred_at DESC, id DESC LIMIT ?" + params.length;
|
|
486
|
+
|
|
487
|
+
var r = await query(sql, params);
|
|
488
|
+
var out = [];
|
|
489
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decode(r.rows[i]));
|
|
490
|
+
return out;
|
|
491
|
+
}
|
|
492
|
+
|
|
458
493
|
return {
|
|
459
494
|
RATING_AXES: RATING_AXES.slice(),
|
|
460
495
|
MIN_RATING: MIN_RATING,
|
|
@@ -471,6 +506,7 @@ function create(opts) {
|
|
|
471
506
|
responseToCustomer: responseToCustomer,
|
|
472
507
|
topPositiveRatings: topPositiveRatings,
|
|
473
508
|
topNegativeRatings: topNegativeRatings,
|
|
509
|
+
listFlagged: listFlagged,
|
|
474
510
|
};
|
|
475
511
|
}
|
|
476
512
|
|
package/lib/storefront.js
CHANGED
|
@@ -6128,6 +6128,7 @@ var ORDER_PAGE =
|
|
|
6128
6128
|
" </table>\n" +
|
|
6129
6129
|
" </div>\n" +
|
|
6130
6130
|
" RAW_ORDER_ACTIONS" +
|
|
6131
|
+
" RAW_ORDER_RATING" +
|
|
6131
6132
|
" </div>\n" +
|
|
6132
6133
|
" <aside class=\"order-page__totals\">\n" +
|
|
6133
6134
|
" RAW_ORDER_TIMELINE" +
|
|
@@ -6335,6 +6336,134 @@ function _orderActionsBlock(o) {
|
|
|
6335
6336
|
return "<div class=\"order-page__actions\">" + btns.join("") + "</div>";
|
|
6336
6337
|
}
|
|
6337
6338
|
|
|
6339
|
+
// A post-purchase rating is offered on the same window a return is: a paid
|
|
6340
|
+
// (or further-along) order, never a still-pending (unpaid) one nor the
|
|
6341
|
+
// terminal off-ramps (cancelled / refunded). The order FSM has no
|
|
6342
|
+
// `fulfilled` status — the post-payment states are paid / fulfilling /
|
|
6343
|
+
// shipped / delivered — so the rating window mirrors _orderEligibleForReturn
|
|
6344
|
+
// rather than gating on a status the primitive doesn't expose.
|
|
6345
|
+
function _orderEligibleForRating(status) {
|
|
6346
|
+
return status === "paid" || status === "fulfilling" ||
|
|
6347
|
+
status === "shipped" || status === "delivered";
|
|
6348
|
+
}
|
|
6349
|
+
|
|
6350
|
+
// The three rating axes the customer scores, in display order. Labels are
|
|
6351
|
+
// static framework copy; the axis key matches the submitRating field name.
|
|
6352
|
+
var ORDER_RATING_AXES = [
|
|
6353
|
+
{ key: "shipping", label: "Shipping", field: "shipping_rating" },
|
|
6354
|
+
{ key: "packaging", label: "Packaging", field: "packaging_rating" },
|
|
6355
|
+
{ key: "recommend", label: "Recommend us", field: "recommend_rating" },
|
|
6356
|
+
];
|
|
6357
|
+
|
|
6358
|
+
// 1–5 selector for one rating axis. Rendered as native radio buttons so the
|
|
6359
|
+
// form works with no JavaScript and a screen reader announces each option.
|
|
6360
|
+
// `field` is the submitRating field name; nothing here is operator/customer
|
|
6361
|
+
// input, but the labels are escaped at the sink for consistency.
|
|
6362
|
+
function _ratingAxisField(axis) {
|
|
6363
|
+
var esc = b.template.escapeHtml;
|
|
6364
|
+
var opts = "";
|
|
6365
|
+
for (var v = 1; v <= 5; v += 1) {
|
|
6366
|
+
opts +=
|
|
6367
|
+
"<label class=\"order-rating__option\">" +
|
|
6368
|
+
"<input type=\"radio\" name=\"" + esc(axis.field) + "\" value=\"" + v + "\" required>" +
|
|
6369
|
+
"<span>" + v + "</span>" +
|
|
6370
|
+
"</label>";
|
|
6371
|
+
}
|
|
6372
|
+
return "<fieldset class=\"order-rating__axis\">" +
|
|
6373
|
+
"<legend>" + esc(axis.label) + "</legend>" +
|
|
6374
|
+
"<div class=\"order-rating__scale\">" + opts + "</div>" +
|
|
6375
|
+
"</fieldset>";
|
|
6376
|
+
}
|
|
6377
|
+
|
|
6378
|
+
// The rating form (eligible, not-yet-rated order). Posts to
|
|
6379
|
+
// /orders/:id/rate; the container CSRF injection tokens it automatically
|
|
6380
|
+
// (the action is not an EDGE_POST_PATHS prefix). A correction notice from a
|
|
6381
|
+
// rejected submit (bad value / over-length comment / duplicate) renders
|
|
6382
|
+
// above the form.
|
|
6383
|
+
function _orderRatingForm(o, notice) {
|
|
6384
|
+
var esc = b.template.escapeHtml;
|
|
6385
|
+
var noticeHtml = notice
|
|
6386
|
+
? "<p class=\"form-notice form-notice--err\" role=\"alert\">" + esc(String(notice)) + "</p>"
|
|
6387
|
+
: "";
|
|
6388
|
+
var axes = ORDER_RATING_AXES.map(_ratingAxisField).join("");
|
|
6389
|
+
return "<section class=\"order-rating order-rating--form\">" +
|
|
6390
|
+
"<h2 class=\"pdp__variants-title\">Rate this order</h2>" +
|
|
6391
|
+
"<p class=\"order-rating__lede\">How did the delivery go? Your feedback helps us improve.</p>" +
|
|
6392
|
+
noticeHtml +
|
|
6393
|
+
"<form class=\"form-stack\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/rate\">" +
|
|
6394
|
+
axes +
|
|
6395
|
+
"<label class=\"form-field\"><span>Comment <small>(optional)</small></span>" +
|
|
6396
|
+
"<textarea name=\"comment\" rows=\"3\" maxlength=\"2000\" " +
|
|
6397
|
+
"placeholder=\"Tell us about your delivery experience\"></textarea></label>" +
|
|
6398
|
+
"<div class=\"order-page__actions\">" +
|
|
6399
|
+
"<button type=\"submit\" class=\"btn-primary\">Submit rating</button>" +
|
|
6400
|
+
"</div>" +
|
|
6401
|
+
"</form>" +
|
|
6402
|
+
"</section>";
|
|
6403
|
+
}
|
|
6404
|
+
|
|
6405
|
+
// The submitted rating (the three scores) plus, when present, the
|
|
6406
|
+
// customer's own comment and the operator's public reply. ESCAPE-BY-DEFAULT:
|
|
6407
|
+
// the comment and the reply are spliced from the primitive's pre-escaped
|
|
6408
|
+
// `comment_html` / `response_html` fields (escaped via b.template.escapeHtml
|
|
6409
|
+
// at the primitive's render layer), NEVER the raw `comment` / `response_text`
|
|
6410
|
+
// — so a `<script>`/`onerror` payload a customer typed is inert here. A
|
|
6411
|
+
// flagged comment is suppressed (operators moderate via the admin queue).
|
|
6412
|
+
function _orderRatingDisplay(rating) {
|
|
6413
|
+
var esc = b.template.escapeHtml;
|
|
6414
|
+
var scoreRows = ORDER_RATING_AXES.map(function (axis) {
|
|
6415
|
+
return "<div><dt>" + esc(axis.label) + "</dt>" +
|
|
6416
|
+
"<dd>" + esc(String(rating[axis.field])) + " / 5</dd></div>";
|
|
6417
|
+
}).join("");
|
|
6418
|
+
// comment_html is already escaped by the primitive — splice it, do not
|
|
6419
|
+
// re-escape (double-escaping would render visible entities) and never
|
|
6420
|
+
// reach for rating.comment (the raw, un-escaped string).
|
|
6421
|
+
var commentHtml = (rating.comment_html && !rating.comment_flagged)
|
|
6422
|
+
? "<div class=\"order-rating__comment\"><h3>Your comment</h3>" +
|
|
6423
|
+
"<p>" + rating.comment_html + "</p></div>"
|
|
6424
|
+
: "";
|
|
6425
|
+
// response_html is the operator's public reply, also pre-escaped — splice
|
|
6426
|
+
// it, never rating.response_text.
|
|
6427
|
+
var responseHtml = rating.response_html
|
|
6428
|
+
? "<div class=\"order-rating__response\"><h3>Our reply</h3>" +
|
|
6429
|
+
"<p>" + rating.response_html + "</p></div>"
|
|
6430
|
+
: "";
|
|
6431
|
+
return "<section class=\"order-rating order-rating--done\">" +
|
|
6432
|
+
"<h2 class=\"pdp__variants-title\">Your rating</h2>" +
|
|
6433
|
+
"<dl class=\"order-rating__scores\">" + scoreRows + "</dl>" +
|
|
6434
|
+
commentHtml +
|
|
6435
|
+
responseHtml +
|
|
6436
|
+
"</section>";
|
|
6437
|
+
}
|
|
6438
|
+
|
|
6439
|
+
// Resolve the rating panel for the order page: the submitted rating (read-
|
|
6440
|
+
// only display) when one exists, the submission form when the order is
|
|
6441
|
+
// eligible and unrated, and nothing otherwise (a pending / cancelled /
|
|
6442
|
+
// refunded order, or a renderer reached without the ratings primitive
|
|
6443
|
+
// wired). `opts.rating` is the getRating row (or null); `opts.rating_notice`
|
|
6444
|
+
// is a correction message echoed back onto a rejected submit.
|
|
6445
|
+
function _orderRatingBlock(opts) {
|
|
6446
|
+
var o = opts.order;
|
|
6447
|
+
if (opts.rating) return _orderRatingDisplay(opts.rating);
|
|
6448
|
+
if (opts.rating_eligible && _orderEligibleForRating(o.status)) {
|
|
6449
|
+
return _orderRatingForm(o, opts.rating_notice || null);
|
|
6450
|
+
}
|
|
6451
|
+
return "";
|
|
6452
|
+
}
|
|
6453
|
+
|
|
6454
|
+
// Map a ?rate_err= code (set by a rejected rating POST on its PRG redirect)
|
|
6455
|
+
// to a clean, operator-safe correction message rendered above the rating
|
|
6456
|
+
// form. Defensive request reader — an unknown / absent code yields no
|
|
6457
|
+
// notice, never a raw error or a leak. The duplicate / value / length codes
|
|
6458
|
+
// mirror the primitive's refusal classes without echoing its raw message.
|
|
6459
|
+
function _ratingNoticeFor(code) {
|
|
6460
|
+
if (code === "dupe") return "You've already rated this order.";
|
|
6461
|
+
if (code === "value") return "Please give each rating a score from 1 to 5.";
|
|
6462
|
+
if (code === "comment") return "Your comment couldn't be saved — please shorten it and try again.";
|
|
6463
|
+
if (code === "input") return "Please check your rating and try again.";
|
|
6464
|
+
return null;
|
|
6465
|
+
}
|
|
6466
|
+
|
|
6338
6467
|
function renderOrder(opts) {
|
|
6339
6468
|
if (!opts || !opts.order) throw new TypeError("storefront.renderOrder: opts.order required");
|
|
6340
6469
|
var o = opts.order;
|
|
@@ -6376,6 +6505,10 @@ function renderOrder(opts) {
|
|
|
6376
6505
|
var timelineHtml = _orderTimelineBlock(o.status);
|
|
6377
6506
|
var trackingHtml = _orderTrackingBlock(shipments);
|
|
6378
6507
|
var actionsHtml = _orderActionsBlock(o);
|
|
6508
|
+
// Post-purchase rating panel — container-only (session-gated). The route
|
|
6509
|
+
// passes the resolved getRating row (rating) + the eligibility/notice
|
|
6510
|
+
// flags; the edge render never does, so the panel is empty at the edge.
|
|
6511
|
+
var ratingHtml = _orderRatingBlock(opts);
|
|
6379
6512
|
if (opts.theme) {
|
|
6380
6513
|
return opts.theme.render("order", {
|
|
6381
6514
|
title: "Order " + o.id,
|
|
@@ -6393,6 +6526,7 @@ function renderOrder(opts) {
|
|
|
6393
6526
|
timeline_html: timelineHtml,
|
|
6394
6527
|
tracking_html: trackingHtml,
|
|
6395
6528
|
actions_html: actionsHtml,
|
|
6529
|
+
rating_html: ratingHtml,
|
|
6396
6530
|
can_return: _orderEligibleForReturn(o.status),
|
|
6397
6531
|
can_reorder: _orderEligibleForReorder(o.status),
|
|
6398
6532
|
can_cancel: _orderEligibleForCancel(o.status),
|
|
@@ -6444,6 +6578,11 @@ function renderOrder(opts) {
|
|
|
6444
6578
|
.replace("RAW_ORDER_TRACKING", trackingHtml)
|
|
6445
6579
|
.replace("RAW_ORDER_ACTIONS", actionsHtml)
|
|
6446
6580
|
.replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
|
|
6581
|
+
// The rating panel carries customer/operator free text (already escaped
|
|
6582
|
+
// into comment_html / response_html, but a `$` in that text would still
|
|
6583
|
+
// trip String.replace's dollar substitution) — splice it via the
|
|
6584
|
+
// replacer-function helper, never a replacement-string .replace.
|
|
6585
|
+
body = _spliceRaw(body, "RAW_ORDER_RATING", ratingHtml);
|
|
6447
6586
|
// Post-purchase cross-sell rail — reuses the catalog grid + product-card
|
|
6448
6587
|
// markup (so it inherits the storefront's card styling), rendered only
|
|
6449
6588
|
// when the picker returned something.
|
|
@@ -10738,12 +10877,35 @@ function mount(router, deps) {
|
|
|
10738
10877
|
}
|
|
10739
10878
|
} catch (_e) { shipments = []; }
|
|
10740
10879
|
}
|
|
10880
|
+
// Post-purchase fulfillment rating. Surfaced only on a signed-in
|
|
10881
|
+
// customer's OWN order (the IDOR gate above already proved
|
|
10882
|
+
// o.customer_id === orderAuth.customer_id when set) — a guest order
|
|
10883
|
+
// carries no owner, so the rating form/display is suppressed there.
|
|
10884
|
+
// Best-effort: an absent ratings primitive (or its table unmigrated)
|
|
10885
|
+
// degrades to "no rating panel" rather than 500-ing the order page.
|
|
10886
|
+
var ratingRow = null;
|
|
10887
|
+
var ratingEligible = false;
|
|
10888
|
+
if (deps.orderRatings && o.customer_id && orderAuth && o.customer_id === orderAuth.customer_id) {
|
|
10889
|
+
// Offer the rating surface only AFTER a successful read: an absent /
|
|
10890
|
+
// unmigrated ratings table degrades to "no rating panel" rather than
|
|
10891
|
+
// rendering a form whose POST would then 500 against the same missing
|
|
10892
|
+
// table. Eligibility also tracks the order-status window so the form
|
|
10893
|
+
// shows only when a submit would actually be accepted (the POST gates
|
|
10894
|
+
// on the same _orderEligibleForRating check).
|
|
10895
|
+
try {
|
|
10896
|
+
ratingRow = await deps.orderRatings.getRating({ order_id: o.id });
|
|
10897
|
+
ratingEligible = _orderEligibleForRating(o.status);
|
|
10898
|
+
} catch (_e) { ratingRow = null; ratingEligible = false; }
|
|
10899
|
+
}
|
|
10741
10900
|
var ordUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
10742
10901
|
_send(res, 200, renderOrder({
|
|
10743
10902
|
order: o,
|
|
10744
10903
|
product_lookup: productLookup,
|
|
10745
10904
|
recommendations: recommendations,
|
|
10746
10905
|
shipments: shipments,
|
|
10906
|
+
rating: ratingRow,
|
|
10907
|
+
rating_eligible: ratingEligible,
|
|
10908
|
+
rating_notice: ordUrl ? _ratingNoticeFor(ordUrl.searchParams.get("rate_err")) : null,
|
|
10747
10909
|
reordered: ordUrl ? ordUrl.searchParams.get("reordered") === "1" : false,
|
|
10748
10910
|
cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
|
|
10749
10911
|
shop_name: shopName,
|
|
@@ -14661,6 +14823,97 @@ function mount(router, deps) {
|
|
|
14661
14823
|
res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id) + "?cancelled=1");
|
|
14662
14824
|
return res.end ? res.end() : res.send("");
|
|
14663
14825
|
});
|
|
14826
|
+
|
|
14827
|
+
// POST /orders/:id/rate — a customer rates one of their OWN orders
|
|
14828
|
+
// (shipping / packaging / recommend, plus an optional comment). The
|
|
14829
|
+
// rating's customer_id is pinned to the SESSION customer — never a
|
|
14830
|
+
// form/query field — and the order_id is verified to belong to that
|
|
14831
|
+
// session before submitRating runs. Two refusals guard against IDOR: an
|
|
14832
|
+
// unauthenticated POST goes to login (never near an order), and a
|
|
14833
|
+
// foreign / guest-owned / unknown / malformed order is a clean 404
|
|
14834
|
+
// (never rated, never leaked). Bad input (rating out of [1,5], over-
|
|
14835
|
+
// length comment) and a duplicate submission map to a clean correction
|
|
14836
|
+
// redirect, never a 500 or a raw-error leak. Mounts only when the
|
|
14837
|
+
// ratings primitive is wired.
|
|
14838
|
+
if (deps.orderRatings) {
|
|
14839
|
+
router.post("/orders/:order_id/rate", async function (req, res) {
|
|
14840
|
+
var orderId = req.params && req.params.order_id;
|
|
14841
|
+
// Rating is a logged-in-customer action — resolve the session first
|
|
14842
|
+
// so an unauthenticated POST goes to login, never near the order.
|
|
14843
|
+
var rateAuth = _currentCustomerEnv(req);
|
|
14844
|
+
if (!rateAuth) {
|
|
14845
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
14846
|
+
return res.end ? res.end() : res.send("");
|
|
14847
|
+
}
|
|
14848
|
+
var o;
|
|
14849
|
+
try { o = orderId ? await deps.order.get(orderId) : null; }
|
|
14850
|
+
catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
|
|
14851
|
+
// Ownership gate against IDOR: a missing order, a malformed id, an
|
|
14852
|
+
// order owned by another customer, OR a guest order with no owner
|
|
14853
|
+
// all 404 — the rating never touches an order the caller doesn't own.
|
|
14854
|
+
if (!o || !o.customer_id || o.customer_id !== rateAuth.customer_id) {
|
|
14855
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
14856
|
+
}
|
|
14857
|
+
// Eligibility gate mirrors the rating window (paid → delivered). A
|
|
14858
|
+
// pending / cancelled / refunded order bounces back unchanged.
|
|
14859
|
+
if (!_orderEligibleForRating(o.status)) {
|
|
14860
|
+
res.status(303);
|
|
14861
|
+
res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
|
|
14862
|
+
return res.end ? res.end() : res.send("");
|
|
14863
|
+
}
|
|
14864
|
+
var body = req.body || {};
|
|
14865
|
+
// Defensive request-shape readers: the rating axes arrive as form
|
|
14866
|
+
// strings — coerce to integers so the primitive's strict [1,5]
|
|
14867
|
+
// integer validator sees a number (a blank / garbage field becomes
|
|
14868
|
+
// NaN, which the primitive refuses as a clean TypeError → "value").
|
|
14869
|
+
function _scoreField(raw) {
|
|
14870
|
+
// Whole-string integer only. parseInt would silently accept a
|
|
14871
|
+
// crafted "5abc" (→5) or "1.9" (→1) and persist a value the
|
|
14872
|
+
// shopper never chose; require the entire field to be digits so
|
|
14873
|
+
// anything else becomes NaN, which the primitive's strict [1,5]
|
|
14874
|
+
// validator refuses as a clean "value" correction.
|
|
14875
|
+
var s = String(raw == null ? "" : raw).trim();
|
|
14876
|
+
if (!/^-?\d+$/.test(s)) return NaN;
|
|
14877
|
+
var n = Number(s);
|
|
14878
|
+
return Number.isInteger(n) ? n : NaN;
|
|
14879
|
+
}
|
|
14880
|
+
function _rateRedirect(code) {
|
|
14881
|
+
res.status(303);
|
|
14882
|
+
res.setHeader && res.setHeader("location",
|
|
14883
|
+
"/orders/" + encodeURIComponent(o.id) + (code ? "?rate_err=" + code : ""));
|
|
14884
|
+
return res.end ? res.end() : res.send("");
|
|
14885
|
+
}
|
|
14886
|
+
var comment = typeof body.comment === "string" && body.comment.length ? body.comment : undefined;
|
|
14887
|
+
try {
|
|
14888
|
+
await deps.orderRatings.submitRating({
|
|
14889
|
+
order_id: o.id,
|
|
14890
|
+
customer_id: rateAuth.customer_id, // session-pinned, never from the form
|
|
14891
|
+
shipping_rating: _scoreField(body.shipping_rating),
|
|
14892
|
+
packaging_rating: _scoreField(body.packaging_rating),
|
|
14893
|
+
recommend_rating: _scoreField(body.recommend_rating),
|
|
14894
|
+
comment: comment,
|
|
14895
|
+
});
|
|
14896
|
+
} catch (e) {
|
|
14897
|
+
// Coded errors first (a plain Error with .code), then the
|
|
14898
|
+
// primitive's TypeErrors — so a duplicate surfaces its own notice
|
|
14899
|
+
// rather than the generic bad-input one. Any of these is a clean
|
|
14900
|
+
// correction redirect, never a 500.
|
|
14901
|
+
if (e && e.code === "ORDER_RATING_ALREADY_EXISTS") return _rateRedirect("dupe");
|
|
14902
|
+
if (e instanceof TypeError) {
|
|
14903
|
+
// The comment validator's message names "comment"; everything
|
|
14904
|
+
// else is a rating-value problem. Either way, no raw leak.
|
|
14905
|
+
var isComment = /comment/.test(String(e.message || ""));
|
|
14906
|
+
return _rateRedirect(isComment ? "comment" : "value");
|
|
14907
|
+
}
|
|
14908
|
+
throw e;
|
|
14909
|
+
}
|
|
14910
|
+
// PRG back to the order page — the submitted rating now renders in
|
|
14911
|
+
// place of the form (a refresh doesn't re-submit).
|
|
14912
|
+
res.status(303);
|
|
14913
|
+
res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
|
|
14914
|
+
return res.end ? res.end() : res.send("");
|
|
14915
|
+
});
|
|
14916
|
+
}
|
|
14664
14917
|
}
|
|
14665
14918
|
|
|
14666
14919
|
// POST /cart/lines — add a line. Reads variant_id + qty from the
|
package/package.json
CHANGED