@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 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,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.43",
2
+ "version": "0.3.44",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.43",
3
+ "version": "0.3.44",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {