@blamejs/blamejs-shop 0.3.25 → 0.3.26

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.26 (2026-05-31) — **Customers can raise support tickets and operators work them from a queue.** The support-ticket system had a complete backend — threaded messages, assignment, tags, and a status workflow — but no way in or out: a shopper could not open a ticket and an operator could not see one. Both sides are now wired. A logged-in customer can raise a ticket from their account (with an optional link to one of their own orders), see the thread, and reply while it is open. Operators get a Support queue in the console with an unassigned-triage view, the full thread for each ticket, and controls to reply (with an internal-note option that the customer never sees), assign, tag, and move the ticket through its status workflow. A customer only ever sees their own tickets, and replying to a closed ticket is refused. **Added:** *Customer support tickets* — A Support section in the account area lets a signed-in customer open a ticket (subject, message, category, and an optional reference to one of their own orders), list their tickets with status, read the full message thread, and reply while the ticket is open. Internal operator notes are never shown to the customer, a customer can only see and reply to their own tickets, and a reply to a closed ticket is refused. · *Support queue in the admin console* — A Support screen lists inbound tickets by status with an unassigned-triage view. Opening a ticket shows the full thread, the requester, the current status, the assignee, and tags. From there an operator can reply (optionally as an internal note), assign the ticket, tag it, and move it through its status workflow — in progress, waiting on the customer, resolved, closed, or reopened.
12
+
11
13
  - v0.3.25 (2026-05-30) — **Open an individual customer to see their orders and manage store credit, notes, and loyalty.** The customers list was view-only — there was no way to open a single customer. Each customer now has a detail screen showing their identity, recent orders (linked), store-credit balance with a grant/deduct control, loyalty balance and tier (with a link to adjust points), internal operator notes, and the segments they belong to. A store-credit grant or deduction requires a reason and is written to the store-credit ledger, an over-deduction is refused, and segment membership is shown read-only because it is computed from purchase behavior rather than set by hand. **Added:** *Customer detail screen* — Opening a customer from the roster now shows their record, recent orders, store-credit balance with a reason-gated grant/deduct (recorded in the store-credit ledger; an over-deduction is refused), their loyalty balance and tier with a link to adjust points, and an internal notes log you can add to. The segments a customer belongs to are listed read-only, since membership is derived from their behavior. Customer identity fields remain read-only by design.
12
14
 
13
15
  - v0.3.24 (2026-05-30) — **Manage the loyalty program from the admin console.** The loyalty program was visible to customers at /account/loyalty — their points balance, tier, the ways to earn, and the rewards — but there was no way to configure it from the console, so it could not actually be run. A new Loyalty admin screen now manages the whole program: create and edit the rules that award points (and archive ones that should no longer apply), build and edit the rewards catalog (a reward must be active to appear to customers), and grant or deduct a specific customer's points balance. A points adjustment requires a reason and is written to the loyalty ledger, so every manual change is recorded. **Added:** *Loyalty program administration* — A Loyalty screen in the admin console manages earn rules (the events that award points and how many), the rewards catalog (what points can be redeemed for, with per-kind values), and per-customer point adjustments. Only active, non-archived rules and rewards appear to customers. A manual grant or deduction requires a reason and is recorded in the loyalty ledger alongside automatic earn and redeem activity, and an over-deduction or invalid adjustment is rejected without changing the balance. This completes a program that previously could be shown to shoppers but not configured.
package/lib/admin.js CHANGED
@@ -508,6 +508,7 @@ function mount(router, deps) {
508
508
  var shippingLabels = deps.shippingLabels || null; // per-shipment carrier-label record disabled when absent
509
509
  var splitShipments = deps.splitShipments || null; // order split-shipment planner disabled when absent
510
510
  var salesTaxFilings = deps.salesTaxFilings || null; // sales-tax-filing remittance console disabled when absent
511
+ var supportTickets = deps.supportTickets || null; // support-ticket queue + thread console disabled when absent
511
512
 
512
513
  // Which optional console sections are wired — gates their nav links so a
513
514
  // signed-in admin is never sent to a route that wasn't mounted. Passed
@@ -515,7 +516,7 @@ function mount(router, deps) {
515
516
  // `reports` is always present in the nav (read-only sales summary needs no
516
517
  // extra dep); its route mounts unconditionally and renders an unconfigured
517
518
  // notice when the salesReports primitive isn't wired.
518
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, 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 };
519
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, 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 };
519
520
 
520
521
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
521
522
 
@@ -3578,6 +3579,220 @@ function mount(router, deps) {
3578
3579
  ));
3579
3580
  }
3580
3581
 
3582
+ // ---- support tickets ------------------------------------------------
3583
+ // The operator queue over the customer-service ticketing surface: a
3584
+ // status-filterable list (plus an unassigned-triage view via the
3585
+ // primitive's listUnassigned), a ticket detail with the full thread +
3586
+ // requester + assignee + tags, an operator reply, assign, tag, and the
3587
+ // FSM status transitions the module exposes (in_progress / waiting /
3588
+ // resolved / closed / reopened). Content-negotiated like the other
3589
+ // screens: bearer → JSON; signed-in browser → the HTML console.
3590
+ if (supportTickets) {
3591
+ var support = supportTickets;
3592
+
3593
+ // Resolve the ?status= chip into a list. The special "unassigned"
3594
+ // chip routes through listUnassigned; every real status routes through
3595
+ // the primitive's status-filtered list. A bad/absent chip falls back
3596
+ // to the whole board.
3597
+ async function _supportRows(filter) {
3598
+ if (filter === "unassigned") {
3599
+ return { rows: await support.listUnassigned({}), filter: "unassigned" };
3600
+ }
3601
+ if (filter && support.ALLOWED_STATUSES.indexOf(filter) !== -1) {
3602
+ return { rows: await support.list({ status_filter: filter, limit: support.MAX_LIST_LIMIT }), filter: filter };
3603
+ }
3604
+ return { rows: await support.list({ limit: support.MAX_LIST_LIMIT }), filter: "all" };
3605
+ }
3606
+
3607
+ router.get("/admin/support", _pageOrApi(true,
3608
+ R(async function (req, res) {
3609
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3610
+ var filter = (url && url.searchParams.get("status")) || null;
3611
+ var resolved = await _supportRows(filter);
3612
+ _json(res, 200, { rows: resolved.rows, status: resolved.filter });
3613
+ }),
3614
+ async function (req, res) {
3615
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3616
+ var filter = (url && url.searchParams.get("status")) || null;
3617
+ var resolved = await _supportRows(filter);
3618
+ _sendHtml(res, 200, renderAdminSupport({
3619
+ shop_name: deps.shop_name, nav_available: navAvailable,
3620
+ tickets: resolved.rows, status: resolved.filter,
3621
+ }));
3622
+ },
3623
+ ));
3624
+
3625
+ // Resolve a ticket by :id, surfacing a malformed / unknown id as a
3626
+ // 404 (the route is a defensive request-shape reader, never a 500).
3627
+ // Returns null after sending the 404 so the caller short-circuits.
3628
+ async function _supportTicketOr404(req, res, isJson) {
3629
+ var ticket;
3630
+ try { ticket = await support.get(req.params.id); }
3631
+ catch (e) {
3632
+ if (e instanceof TypeError) {
3633
+ if (isJson) { _problem(res, 404, "support-ticket-not-found"); return null; }
3634
+ _sendHtml(res, 404, renderAdminSupport({
3635
+ shop_name: deps.shop_name, nav_available: navAvailable, tickets: [], status: "all", notice: "Ticket not found.",
3636
+ }));
3637
+ return null;
3638
+ }
3639
+ throw e;
3640
+ }
3641
+ if (!ticket) {
3642
+ if (isJson) { _problem(res, 404, "support-ticket-not-found"); return null; }
3643
+ _sendHtml(res, 404, renderAdminSupport({
3644
+ shop_name: deps.shop_name, nav_available: navAvailable, tickets: [], status: "all", notice: "Ticket not found.",
3645
+ }));
3646
+ return null;
3647
+ }
3648
+ return ticket;
3649
+ }
3650
+
3651
+ router.get("/admin/support/:id", _pageOrApi(true,
3652
+ R(async function (req, res) {
3653
+ var ticket = await _supportTicketOr404(req, res, true); if (!ticket) return;
3654
+ var thread = await support.thread(ticket.id);
3655
+ _json(res, 200, thread);
3656
+ }),
3657
+ async function (req, res) {
3658
+ var ticket = await _supportTicketOr404(req, res, false); if (!ticket) return;
3659
+ var thread = await support.thread(ticket.id);
3660
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3661
+ _sendHtml(res, 200, renderAdminSupportTicket({
3662
+ shop_name: deps.shop_name,
3663
+ nav_available: navAvailable,
3664
+ ticket: thread.ticket,
3665
+ messages: thread.messages,
3666
+ transitions: support.ALLOWED_STATUSES,
3667
+ moved: url && url.searchParams.get("moved"),
3668
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this ticket." : null,
3669
+ }));
3670
+ },
3671
+ ));
3672
+
3673
+ // The browser side of a per-ticket action: run `opFn(id, body)`, then
3674
+ // PRG back to the detail. A bad shape (TypeError), an FSM refusal, a
3675
+ // closed-ticket reply, or a not-found row becomes an ?err=1 notice on
3676
+ // the detail, never a 500; anything else propagates to the wrapper.
3677
+ function _supportClientError(e) {
3678
+ if (!e) return false;
3679
+ if (e instanceof TypeError) return true;
3680
+ return e.code === "SUPPORT_TICKET_NOT_FOUND" ||
3681
+ e.code === "SUPPORT_TICKET_CLOSED" ||
3682
+ e.code === "SUPPORT_TICKET_TRANSITION_REFUSED";
3683
+ }
3684
+ function _supportAction(jsonHandler, auditEvent, opFn) {
3685
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
3686
+ var id = req.params.id;
3687
+ try { await opFn(id, req.body || {}); }
3688
+ catch (e) {
3689
+ if (_supportClientError(e)) {
3690
+ return _redirect(res, "/admin/support/" + encodeURIComponent(id) + "?err=1");
3691
+ }
3692
+ throw e;
3693
+ }
3694
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
3695
+ _redirect(res, "/admin/support/" + encodeURIComponent(id) + "?moved=1");
3696
+ });
3697
+ }
3698
+
3699
+ // Map a support primitive error to an API problem. A bad shape is a
3700
+ // 400, a not-found a 404, a closed-ticket reply / refused transition a
3701
+ // 409 — same classification the browser path uses, surfaced as
3702
+ // problem-details for the JSON contract.
3703
+ function _supportApiError(res, e) {
3704
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
3705
+ if (e && e.code === "SUPPORT_TICKET_NOT_FOUND") return _problem(res, 404, "support-ticket-not-found");
3706
+ if (e && e.code === "SUPPORT_TICKET_CLOSED") return _problem(res, 409, "conflict", e.message);
3707
+ if (e && e.code === "SUPPORT_TICKET_TRANSITION_REFUSED") return _problem(res, 409, "conflict", e.message);
3708
+ return null;
3709
+ }
3710
+
3711
+ // Operator reply — author "operator". An internal=1 note is supported
3712
+ // (operator-only; never shown to the customer) via the form's
3713
+ // checkbox.
3714
+ router.post("/admin/support/:id/reply", _supportAction(
3715
+ W("support.reply", async function (req, res) {
3716
+ var body = req.body || {};
3717
+ var ticket;
3718
+ try {
3719
+ ticket = await support.reply({
3720
+ ticket_id: req.params.id,
3721
+ author: "operator",
3722
+ body: body.body,
3723
+ internal: body.internal === "1" || body.internal === true,
3724
+ });
3725
+ } catch (e) {
3726
+ var mapped = _supportApiError(res, e); if (mapped !== null) return mapped;
3727
+ throw e;
3728
+ }
3729
+ _json(res, 200, ticket);
3730
+ return ticket;
3731
+ }),
3732
+ "support.reply",
3733
+ function (id, body) {
3734
+ return support.reply({
3735
+ ticket_id: id, author: "operator", body: body.body,
3736
+ internal: body.internal === "1" || body.internal === true,
3737
+ });
3738
+ },
3739
+ ));
3740
+
3741
+ // Assign the ticket to an operator (operator_id is a UUID — the
3742
+ // console seeds it from a hidden field carrying the signed-in
3743
+ // operator's id, or an explicit input).
3744
+ router.post("/admin/support/:id/assign", _supportAction(
3745
+ W("support.assign", async function (req, res) {
3746
+ var body = req.body || {};
3747
+ var ticket;
3748
+ try { ticket = await support.assign({ ticket_id: req.params.id, operator_id: body.operator_id }); }
3749
+ catch (e) { var mapped = _supportApiError(res, e); if (mapped !== null) return mapped; throw e; }
3750
+ _json(res, 200, ticket);
3751
+ return ticket;
3752
+ }),
3753
+ "support.assign",
3754
+ function (id, body) { return support.assign({ ticket_id: id, operator_id: body.operator_id }); },
3755
+ ));
3756
+
3757
+ // Add a free-form tag.
3758
+ router.post("/admin/support/:id/tag", _supportAction(
3759
+ W("support.tag", async function (req, res) {
3760
+ var body = req.body || {};
3761
+ var ticket;
3762
+ try { ticket = await support.addTag({ ticket_id: req.params.id, tag: body.tag }); }
3763
+ catch (e) { var mapped = _supportApiError(res, e); if (mapped !== null) return mapped; throw e; }
3764
+ _json(res, 200, ticket);
3765
+ return ticket;
3766
+ }),
3767
+ "support.tag",
3768
+ function (id, body) { return support.addTag({ ticket_id: id, tag: body.tag }); },
3769
+ ));
3770
+
3771
+ // FSM status transitions. Each posts to /admin/support/:id/<to_status>
3772
+ // for the edges the module's graph exposes; an illegal edge from the
3773
+ // current state is refused by the primitive (→ ?err=1 notice). The
3774
+ // `reopen` URL maps to the module's `reopened` status (the customer-
3775
+ // facing verb for a resolved ticket re-entering the workflow).
3776
+ function _supportTransition(verb, toStatus) {
3777
+ router.post("/admin/support/:id/" + verb, _supportAction(
3778
+ W("support." + verb, async function (req, res) {
3779
+ var ticket;
3780
+ try { ticket = await support.transition({ ticket_id: req.params.id, to_status: toStatus, reason: (req.body && req.body.reason) || undefined }); }
3781
+ catch (e) { var mapped = _supportApiError(res, e); if (mapped !== null) return mapped; throw e; }
3782
+ _json(res, 200, ticket);
3783
+ return ticket;
3784
+ }),
3785
+ "support." + verb,
3786
+ function (id, body) { return support.transition({ ticket_id: id, to_status: toStatus, reason: (body && body.reason) || undefined }); },
3787
+ ));
3788
+ }
3789
+ _supportTransition("in-progress", "in_progress");
3790
+ _supportTransition("waiting-customer", "waiting_customer");
3791
+ _supportTransition("resolved", "resolved");
3792
+ _supportTransition("closed", "closed");
3793
+ _supportTransition("reopen", "reopened");
3794
+ }
3795
+
3581
3796
  // ---- config ---------------------------------------------------------
3582
3797
 
3583
3798
  var config = deps.config || null;
@@ -7881,6 +8096,7 @@ var ADMIN_NAV_ITEMS = [
7881
8096
  { key: "reports", href: "/admin/reports", label: "Reports" },
7882
8097
  { key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
7883
8098
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
8099
+ { key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
7884
8100
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
7885
8101
  { key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
7886
8102
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
@@ -9360,6 +9576,165 @@ function renderAdminReturn(opts) {
9360
9576
  return _renderAdminShell(opts.shop_name, "Return " + (r.rma_code || r.id.slice(0, 8)), body, "returns", opts.nav_available);
9361
9577
  }
9362
9578
 
9579
+ // The support-ticket queue chips: every FSM status the module exposes,
9580
+ // plus "all" (the whole board) and an "unassigned" triage view.
9581
+ var SUPPORT_STATUS_FILTERS = [
9582
+ "all", "unassigned", "new", "in_progress", "waiting_customer", "resolved", "closed", "reopened",
9583
+ ];
9584
+
9585
+ function _supportPillClass(status) {
9586
+ if (status === "resolved") return "paid";
9587
+ if (status === "closed") return "cancelled";
9588
+ if (status === "new" || status === "reopened") return "pending";
9589
+ return ""; // in_progress / waiting_customer use the default pill
9590
+ }
9591
+
9592
+ function _supportPriorityClass(priority) {
9593
+ return priority === "urgent" || priority === "high" ? "cancelled"
9594
+ : priority === "low" ? "" : "pending";
9595
+ }
9596
+
9597
+ // Operator queue. A status / unassigned chip set filters the board; each
9598
+ // row links to the ticket detail. Renders the requester (email is stored
9599
+ // hash-only, so the row shows the short hash), the status + priority
9600
+ // pills, the assignee state, and when it was opened.
9601
+ function renderAdminSupport(opts) {
9602
+ opts = opts || {};
9603
+ var tickets = opts.tickets || [];
9604
+ var active = opts.status || "all";
9605
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
9606
+
9607
+ var chips = "<div class=\"order-filters\">" +
9608
+ SUPPORT_STATUS_FILTERS.map(function (s) {
9609
+ return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/support?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
9610
+ }).join("") +
9611
+ "</div>";
9612
+
9613
+ var rows = tickets.map(function (t) {
9614
+ var assignee = t.assigned_operator_id ? _htmlEscape(String(t.assigned_operator_id).slice(0, 8)) : "<span class=\"meta\">unassigned</span>";
9615
+ return "<tr>" +
9616
+ "<td><a class=\"order-id\" href=\"/admin/support/" + _htmlEscape(t.id) + "\">" + _htmlEscape(t.subject) + "</a></td>" +
9617
+ "<td>" + _htmlEscape(t.category) + "</td>" +
9618
+ "<td><span class=\"status-pill " + _supportPillClass(t.status) + "\">" + _htmlEscape(t.status) + "</span></td>" +
9619
+ "<td><span class=\"status-pill " + _supportPriorityClass(t.priority) + "\">" + _htmlEscape(t.priority) + "</span></td>" +
9620
+ "<td>" + assignee + "</td>" +
9621
+ "<td>" + _htmlEscape(_fmtDate(t.opened_at)) + "</td>" +
9622
+ "</tr>";
9623
+ }).join("");
9624
+
9625
+ var table = tickets.length
9626
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Subject</th><th scope=\"col\">Category</th><th scope=\"col\">Status</th><th scope=\"col\">Priority</th><th scope=\"col\">Assignee</th><th scope=\"col\">Opened</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
9627
+ : "<p class=\"empty\">No “" + _htmlEscape(active) + "” tickets.</p>";
9628
+
9629
+ var body = "<section><h2>Support</h2>" + notice + chips + table + "</section>";
9630
+ return _renderAdminShell(opts.shop_name, "Support", body, "support", opts.nav_available);
9631
+ }
9632
+
9633
+ // Ticket detail — the full thread (customer + operator + system + internal
9634
+ // notes), the requester / status / assignee / tags, and the action forms:
9635
+ // operator reply (with an internal-note toggle), assign, tag, and the FSM
9636
+ // status transitions legal from the current state.
9637
+ function renderAdminSupportTicket(opts) {
9638
+ opts = opts || {};
9639
+ var t = opts.ticket;
9640
+ var messages = opts.messages || [];
9641
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Ticket updated.</div>" : "";
9642
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
9643
+
9644
+ var thread = messages.map(function (m) {
9645
+ var who = m.author === "operator" ? "Operator" : m.author === "system" ? "System" : "Customer";
9646
+ var internalTag = Number(m.internal) === 1 ? " <span class=\"status-pill pending\">internal</span>" : "";
9647
+ return "<li class=\"support-msg support-msg--" + _htmlEscape(String(m.author)) + "\">" +
9648
+ "<p class=\"meta\">" + _htmlEscape(who) + internalTag + " · " + _htmlEscape(_fmtDate(m.created_at)) + "</p>" +
9649
+ "<p class=\"support-msg__body\">" + _htmlEscape(String(m.body)).replace(/\n/g, "<br>") + "</p>" +
9650
+ "</li>";
9651
+ }).join("");
9652
+ var threadHtml = messages.length
9653
+ ? "<ul class=\"support-thread\">" + thread + "</ul>"
9654
+ : "<p class=\"empty\">No messages.</p>";
9655
+
9656
+ var tags = (t.tags || []).map(function (tag) {
9657
+ return "<span class=\"status-pill\">" + _htmlEscape(tag) + "</span>";
9658
+ }).join(" ") || "<span class=\"meta\">none</span>";
9659
+
9660
+ function _field(label, value) {
9661
+ return "<p><span class=\"meta\">" + _htmlEscape(label) + "</span><br>" + (value ? _htmlEscape(String(value)) : "<span class=\"meta\">—</span>") + "</p>";
9662
+ }
9663
+
9664
+ // Legal FSM edges from the current status drive which transition
9665
+ // buttons render. Mirrors the module's TRANSITIONS graph.
9666
+ var EDGES = {
9667
+ "new": ["in-progress", "closed"],
9668
+ "in_progress": ["waiting-customer", "resolved", "closed"],
9669
+ "waiting_customer": ["in-progress", "resolved", "closed"],
9670
+ "resolved": ["reopen", "closed"],
9671
+ "reopened": ["in-progress", "closed"],
9672
+ "closed": [],
9673
+ };
9674
+ var TRANSITION_LABELS = {
9675
+ "in-progress": "Mark in progress", "waiting-customer": "Wait on customer",
9676
+ "resolved": "Resolve", "closed": "Close", "reopen": "Reopen",
9677
+ };
9678
+ var transitionForms = (EDGES[t.status] || []).map(function (verb) {
9679
+ return "<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/" + verb + "\" class=\"form-inline\">" +
9680
+ "<button class=\"btn" + (verb === "closed" ? " btn--danger" : "") + "\" type=\"submit\">" + _htmlEscape(TRANSITION_LABELS[verb] || verb) + "</button>" +
9681
+ "</form>";
9682
+ }).join("");
9683
+ var transitions = transitionForms
9684
+ ? "<div class=\"order-actions\">" + transitionForms + "</div>"
9685
+ : "<span class=\"meta\">This ticket is closed — no further status changes.</span>";
9686
+
9687
+ var replyForm = t.status === "closed"
9688
+ ? "<span class=\"meta\">Closed tickets can't take a reply.</span>"
9689
+ : "<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/reply\" class=\"return-action\">" +
9690
+ "<h4>Reply</h4>" +
9691
+ "<label class=\"form-field\"><span>Message</span><textarea name=\"body\" rows=\"4\" maxlength=\"8000\" required></textarea></label>" +
9692
+ "<label class=\"form-field form-field--inline\"><input type=\"checkbox\" name=\"internal\" value=\"1\"> <span>Internal note (not shown to the customer)</span></label>" +
9693
+ "<button class=\"btn\" type=\"submit\">Send reply</button>" +
9694
+ "</form>";
9695
+
9696
+ var assignForm =
9697
+ "<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/assign\" class=\"return-action\">" +
9698
+ "<h4>Assign</h4>" +
9699
+ _setupField("Operator id (UUID)", "operator_id", t.assigned_operator_id || "", "text", "The operator to own this ticket.", " required") +
9700
+ "<button class=\"btn\" type=\"submit\">Assign</button>" +
9701
+ "</form>";
9702
+
9703
+ var tagForm =
9704
+ "<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/tag\" class=\"return-action\">" +
9705
+ "<h4>Add tag</h4>" +
9706
+ _setupField("Tag", "tag", "", "text", "", " maxlength=\"32\" required") +
9707
+ "<button class=\"btn\" type=\"submit\">Add tag</button>" +
9708
+ "</form>";
9709
+
9710
+ var body =
9711
+ "<section class=\"mw-48\">" +
9712
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/support\">&larr; Support</a></div>" +
9713
+ "<h2>" + _htmlEscape(t.subject) + " " +
9714
+ "<span class=\"status-pill " + _supportPillClass(t.status) + "\">" + _htmlEscape(t.status) + "</span></h2>" +
9715
+ "<p class=\"meta\">Opened " + _htmlEscape(_fmtDate(t.opened_at)) +
9716
+ " · priority " + _htmlEscape(t.priority) +
9717
+ (t.order_id ? " · order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(t.order_id) + "\">" + _htmlEscape(String(t.order_id).slice(0, 8)) + "</a>" : "") +
9718
+ "</p>" +
9719
+ moved + notice +
9720
+ "<div class=\"two-col\">" +
9721
+ "<div class=\"panel\"><h3 class=\"subhead\">Conversation</h3>" + threadHtml + "</div>" +
9722
+ "<div class=\"panel\"><h3 class=\"subhead\">Details</h3>" +
9723
+ _field("Category", t.category) +
9724
+ _field("Requester", t.customer_email_hash ? String(t.customer_email_hash).slice(0, 12) + "…" : null) +
9725
+ _field("Customer id", t.customer_id ? String(t.customer_id).slice(0, 8) : null) +
9726
+ _field("Assignee", t.assigned_operator_id ? String(t.assigned_operator_id).slice(0, 8) : null) +
9727
+ "<p><span class=\"meta\">Tags</span><br>" + tags + "</p>" +
9728
+ "</div>" +
9729
+ "</div>" +
9730
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Status</h3>" + transitions + "</div>" +
9731
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
9732
+ "<div class=\"return-actions\">" + replyForm + assignForm + tagForm + "</div>" +
9733
+ "</div>" +
9734
+ "</section>";
9735
+ return _renderAdminShell(opts.shop_name, "Ticket", body, "support", opts.nav_available);
9736
+ }
9737
+
9363
9738
  // The review states an operator can filter the moderation queue by.
9364
9739
  var REVIEW_STATUS_FILTERS = ["pending", "published", "rejected"];
9365
9740
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.25",
2
+ "version": "0.3.26",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/storefront.js CHANGED
@@ -3158,6 +3158,215 @@ function renderReturns(opts) {
3158
3158
  });
3159
3159
  }
3160
3160
 
3161
+ // Support ticket — the categories a shopper can pick when raising one,
3162
+ // and the human label for each. The values mirror the support-tickets
3163
+ // module's ALLOWED_CATEGORIES; the backend validates the submitted value
3164
+ // against its own allow-list, so a forged value is refused there.
3165
+ var SUPPORT_CATEGORIES = [
3166
+ ["pre_sale", "Pre-sale question"],
3167
+ ["order_issue", "Problem with an order"],
3168
+ ["shipping", "Shipping / delivery"],
3169
+ ["billing", "Billing"],
3170
+ ["refund", "Refund"],
3171
+ ["account", "My account"],
3172
+ ["complaint", "Complaint"],
3173
+ ["feature_request", "Feature request"],
3174
+ ["other", "Something else"],
3175
+ ];
3176
+
3177
+ // Customer-facing status word for a ticket. The module's status enum is
3178
+ // operator vocabulary (new / in_progress / waiting_customer / resolved /
3179
+ // closed / reopened); these are the shopper-facing phrasings.
3180
+ var SUPPORT_STATUS_LABELS = {
3181
+ "new": "Received",
3182
+ "in_progress": "In progress",
3183
+ "waiting_customer": "Awaiting your reply",
3184
+ "resolved": "Resolved",
3185
+ "closed": "Closed",
3186
+ "reopened": "Reopened",
3187
+ };
3188
+
3189
+ function _supportStatusBadge(status) {
3190
+ var esc = b.template.escapeHtml;
3191
+ var label = SUPPORT_STATUS_LABELS[status] || status;
3192
+ return "<span class=\"return-status return-status--" + esc(String(status)) + "\">" +
3193
+ esc(String(label)) + "</span>";
3194
+ }
3195
+
3196
+ function _supportCategoryLabel(value) {
3197
+ for (var i = 0; i < SUPPORT_CATEGORIES.length; i += 1) {
3198
+ if (SUPPORT_CATEGORIES[i][0] === value) return SUPPORT_CATEGORIES[i][1];
3199
+ }
3200
+ return value;
3201
+ }
3202
+
3203
+ // The signed-in customer's support-ticket list. `opts.tickets` is the
3204
+ // ownership-scoped set (already filtered to this customer); each links to
3205
+ // its thread view.
3206
+ function renderSupportList(opts) {
3207
+ var esc = b.template.escapeHtml;
3208
+ var tickets = opts.tickets || [];
3209
+ var rowsHtml = "";
3210
+ for (var i = 0; i < tickets.length; i += 1) {
3211
+ var t = tickets[i];
3212
+ var date = t.opened_at ? new Date(Number(t.opened_at)).toISOString().slice(0, 10) : "";
3213
+ rowsHtml +=
3214
+ "<li class=\"return-card\">" +
3215
+ "<div class=\"return-card__head\">" +
3216
+ "<a class=\"return-card__rma\" href=\"/account/support/" + esc(String(t.id)) + "\">" + esc(String(t.subject)) + "</a>" +
3217
+ _supportStatusBadge(t.status) +
3218
+ "</div>" +
3219
+ "<p class=\"return-card__meta\">" + esc(_supportCategoryLabel(t.category)) +
3220
+ (date ? " &middot; <time datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
3221
+ "</p>" +
3222
+ "</li>";
3223
+ }
3224
+ var inner = rowsHtml
3225
+ ? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
3226
+ : "<div class=\"account-empty\">" +
3227
+ "<p class=\"account-empty__lede\">No support tickets yet. Raise one and we'll get back to you.</p>" +
3228
+ "</div>";
3229
+ var notice = "";
3230
+ if (opts.created) {
3231
+ notice = "<p class=\"form-notice form-notice--ok\" role=\"status\">Your ticket has been raised — we'll reply here.</p>";
3232
+ }
3233
+ var body =
3234
+ "<section class=\"account-returns\">" +
3235
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3236
+ "<li><a href=\"/account\">Account</a></li>" +
3237
+ "<li aria-current=\"page\">Support</li>" +
3238
+ "</ol></nav>" +
3239
+ "<header class=\"account-recently-viewed__head\">" +
3240
+ "<h1 class=\"account-returns__title\">Support</h1>" +
3241
+ "<a class=\"btn-primary\" href=\"/account/support/new\">Raise a ticket</a>" +
3242
+ "</header>" +
3243
+ notice +
3244
+ inner +
3245
+ "</section>";
3246
+ return _wrap({
3247
+ title: "Support",
3248
+ shop_name: opts.shop_name || "blamejs.shop",
3249
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3250
+ theme_css: opts.theme_css,
3251
+ body: body,
3252
+ });
3253
+ }
3254
+
3255
+ // The new-ticket form. `opts.orders` is the signed-in customer's recent
3256
+ // orders so they can attach one (optional). `opts.values` re-fills the
3257
+ // form after a validation bounce; `opts.notice` is the error to show.
3258
+ function renderSupportNew(opts) {
3259
+ var esc = b.template.escapeHtml;
3260
+ var v = opts.values || {};
3261
+ var orders = opts.orders || [];
3262
+ var categoryOpts = SUPPORT_CATEGORIES.map(function (c) {
3263
+ var sel = v.category === c[0] ? " selected" : "";
3264
+ return "<option value=\"" + esc(c[0]) + "\"" + sel + ">" + esc(c[1]) + "</option>";
3265
+ }).join("");
3266
+ var orderOptions = "<option value=\"\">No specific order</option>" +
3267
+ orders.map(function (o) {
3268
+ var sel = v.order_id === o.id ? " selected" : "";
3269
+ var when = o.created_at ? new Date(Number(o.created_at)).toISOString().slice(0, 10) : "";
3270
+ return "<option value=\"" + esc(String(o.id)) + "\"" + sel + ">" +
3271
+ esc(String(o.id).slice(0, 8)) + (when ? " · " + esc(when) : "") + "</option>";
3272
+ }).join("");
3273
+ var orderField = orders.length
3274
+ ? "<label class=\"form-field\"><span class=\"form-field__label\">Related order (optional)</span>" +
3275
+ "<select name=\"order_id\">" + orderOptions + "</select></label>"
3276
+ : "";
3277
+ var notice = opts.notice
3278
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
3279
+ : "";
3280
+ var body =
3281
+ "<section class=\"return-form-page\">" +
3282
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3283
+ "<li><a href=\"/account\">Account</a></li>" +
3284
+ "<li><a href=\"/account/support\">Support</a></li>" +
3285
+ "<li aria-current=\"page\">Raise a ticket</li>" +
3286
+ "</ol></nav>" +
3287
+ "<h1 class=\"return-form-page__title\">Raise a support ticket</h1>" +
3288
+ notice +
3289
+ "<form class=\"return-form form-stack\" method=\"post\" action=\"/account/support\">" +
3290
+ "<label class=\"form-field\"><span class=\"form-field__label\">Email for our reply</span>" +
3291
+ "<input type=\"email\" name=\"customer_email\" value=\"" + esc(v.customer_email == null ? "" : String(v.customer_email)) + "\" required autocomplete=\"email\" maxlength=\"254\"></label>" +
3292
+ "<label class=\"form-field\"><span class=\"form-field__label\">Category</span>" +
3293
+ "<select name=\"category\" required>" + categoryOpts + "</select></label>" +
3294
+ orderField +
3295
+ "<label class=\"form-field\"><span class=\"form-field__label\">Subject</span>" +
3296
+ "<input type=\"text\" name=\"subject\" value=\"" + esc(v.subject == null ? "" : String(v.subject)) + "\" required maxlength=\"200\"></label>" +
3297
+ "<label class=\"form-field\"><span class=\"form-field__label\">How can we help?</span>" +
3298
+ "<textarea name=\"body\" required maxlength=\"8000\" rows=\"6\">" + esc(v.body == null ? "" : String(v.body)) + "</textarea></label>" +
3299
+ "<button type=\"submit\" class=\"btn-primary\">Send</button>" +
3300
+ "</form>" +
3301
+ "</section>";
3302
+ return _wrap({
3303
+ title: "Raise a ticket",
3304
+ shop_name: opts.shop_name || "blamejs.shop",
3305
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3306
+ theme_css: opts.theme_css,
3307
+ body: body,
3308
+ });
3309
+ }
3310
+
3311
+ // One ticket's thread view. `opts.ticket` is the row, `opts.messages` the
3312
+ // thread in created_at ASC. Internal operator notes (internal=1) are
3313
+ // filtered out by the route before rendering — they never reach the
3314
+ // customer. A non-closed ticket shows the reply box.
3315
+ function renderSupportTicket(opts) {
3316
+ var esc = b.template.escapeHtml;
3317
+ var t = opts.ticket;
3318
+ var messages = opts.messages || [];
3319
+ var msgHtml = "";
3320
+ for (var i = 0; i < messages.length; i += 1) {
3321
+ var m = messages[i];
3322
+ var who = m.author === "operator" ? "Support" : m.author === "system" ? "System" : "You";
3323
+ var when = m.created_at ? new Date(Number(m.created_at)).toISOString().slice(0, 16).replace("T", " ") : "";
3324
+ msgHtml +=
3325
+ "<li class=\"support-msg support-msg--" + esc(String(m.author)) + "\">" +
3326
+ "<p class=\"support-msg__who\">" + esc(who) +
3327
+ (when ? " <time class=\"support-msg__when\" datetime=\"" + esc(when) + "\">" + esc(when) + "</time>" : "") + "</p>" +
3328
+ "<p class=\"support-msg__body\">" + esc(String(m.body)).replace(/\n/g, "<br>") + "</p>" +
3329
+ "</li>";
3330
+ }
3331
+ var notice = opts.notice
3332
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
3333
+ : "";
3334
+ var replyOk = opts.replied
3335
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">Your reply has been added.</p>"
3336
+ : "";
3337
+ var closed = t.status === "closed";
3338
+ var replyForm = closed
3339
+ ? "<p class=\"meta\">This ticket is closed. Raise a new one if you still need help.</p>"
3340
+ : "<form class=\"return-form form-stack\" method=\"post\" action=\"/account/support/" + esc(String(t.id)) + "/reply\">" +
3341
+ "<label class=\"form-field\"><span class=\"form-field__label\">Add a reply</span>" +
3342
+ "<textarea name=\"body\" required maxlength=\"8000\" rows=\"5\"></textarea></label>" +
3343
+ "<button type=\"submit\" class=\"btn-primary\">Send reply</button>" +
3344
+ "</form>";
3345
+ var body =
3346
+ "<section class=\"account-returns\">" +
3347
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3348
+ "<li><a href=\"/account\">Account</a></li>" +
3349
+ "<li><a href=\"/account/support\">Support</a></li>" +
3350
+ "<li aria-current=\"page\">Ticket</li>" +
3351
+ "</ol></nav>" +
3352
+ "<header class=\"account-recently-viewed__head\">" +
3353
+ "<h1 class=\"account-returns__title\">" + esc(String(t.subject)) + "</h1>" +
3354
+ _supportStatusBadge(t.status) +
3355
+ "</header>" +
3356
+ "<p class=\"return-card__meta\">" + esc(_supportCategoryLabel(t.category)) + "</p>" +
3357
+ replyOk + notice +
3358
+ "<ul class=\"support-thread\">" + msgHtml + "</ul>" +
3359
+ replyForm +
3360
+ "</section>";
3361
+ return _wrap({
3362
+ title: String(t.subject),
3363
+ shop_name: opts.shop_name || "blamejs.shop",
3364
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3365
+ theme_css: opts.theme_css,
3366
+ body: body,
3367
+ });
3368
+ }
3369
+
3161
3370
  // Loyalty transaction-type pill — reuses the `pdp__badge` class the
3162
3371
  // theme already styles. The type is one of the ledger's closed enum
3163
3372
  // (earn / redeem / expire / adjust / tier-bonus).
@@ -5978,6 +6187,7 @@ var ACCOUNT_DASH_PAGE =
5978
6187
  " <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
5979
6188
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
5980
6189
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
6190
+ " <a class=\"btn-secondary\" href=\"/account/support\">Support</a>\n" +
5981
6191
  " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
5982
6192
  " <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
5983
6193
  " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
@@ -10531,6 +10741,212 @@ function mount(router, deps) {
10531
10741
  });
10532
10742
  }
10533
10743
 
10744
+ // Support tickets — the signed-in customer raises a ticket, lists
10745
+ // their own, reads a thread, and replies. EVERY route is login-gated
10746
+ // AND scoped to the session customer's id: the support primitive
10747
+ // stores `customer_id` on each ticket, so a ticket whose customer_id
10748
+ // doesn't match the signed-in shopper is a 404 (never a cross-customer
10749
+ // reveal — the IDOR defense). The raw email is never on disk, so the
10750
+ // intake form collects a reply-to address (the backend validates +
10751
+ // hashes it); the list / view / reply paths key on the session
10752
+ // customer_id alone, independent of any email.
10753
+ if (deps.supportTickets) {
10754
+ var support = deps.supportTickets;
10755
+
10756
+ function _supportAuth(req, res) {
10757
+ var auth;
10758
+ try { auth = _currentCustomer(req); }
10759
+ catch (e) {
10760
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
10761
+ throw e;
10762
+ }
10763
+ if (!auth) {
10764
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
10765
+ res.end ? res.end() : res.send("");
10766
+ return null;
10767
+ }
10768
+ return auth;
10769
+ }
10770
+
10771
+ // Load the ticket named in :id and confirm it belongs to the
10772
+ // signed-in customer. A malformed id (guardUuid TypeError from
10773
+ // support.get), a missing ticket, or a ticket owned by someone else
10774
+ // ALL return 404 — never a 500, never a leak of another customer's
10775
+ // ticket. This single helper is the ownership gate every per-ticket
10776
+ // route funnels through.
10777
+ async function _ownedTicket(req, res, auth) {
10778
+ var ticket;
10779
+ try { ticket = await support.get(req.params && req.params.id); }
10780
+ catch (e) {
10781
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
10782
+ throw e;
10783
+ }
10784
+ if (!ticket || ticket.customer_id !== auth.customer_id) {
10785
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
10786
+ return null;
10787
+ }
10788
+ return ticket;
10789
+ }
10790
+
10791
+ // The customer's own ticket list, scoped to their session customer_id.
10792
+ router.get("/account/support", async function (req, res) {
10793
+ var auth = _supportAuth(req, res); if (!auth) return;
10794
+ var tickets = await support.listByCustomerId(auth.customer_id, { limit: 100 });
10795
+ var cartCount = await _cartCountForReq(req);
10796
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
10797
+ _send(res, 200, renderSupportList({
10798
+ tickets: tickets,
10799
+ created: url && url.searchParams.get("ok") === "1",
10800
+ shop_name: shopName,
10801
+ cart_count: cartCount,
10802
+ }));
10803
+ });
10804
+
10805
+ // The new-ticket form. Offers the customer's recent orders as an
10806
+ // optional attachment.
10807
+ router.get("/account/support/new", async function (req, res) {
10808
+ var auth = _supportAuth(req, res); if (!auth) return;
10809
+ var orders = [];
10810
+ if (deps.order) {
10811
+ try {
10812
+ var page = await deps.order.listForCustomer(auth.customer_id, { limit: 20 });
10813
+ orders = page.rows;
10814
+ } catch (_e) { orders = []; }
10815
+ }
10816
+ var cartCount = await _cartCountForReq(req);
10817
+ _send(res, 200, renderSupportNew({
10818
+ orders: orders,
10819
+ shop_name: shopName,
10820
+ cart_count: cartCount,
10821
+ }));
10822
+ });
10823
+
10824
+ // Create the ticket as the session customer. customer_id is pinned
10825
+ // to the session — never read off the form — so a shopper can only
10826
+ // ever open a ticket against their own account. An optional order_id
10827
+ // is accepted only after confirming the order belongs to this
10828
+ // customer (so a forged id can't attach a stranger's order); a
10829
+ // non-owned / unknown order is dropped silently rather than failing
10830
+ // the ticket. The backend validates subject / body / category /
10831
+ // email shape and surfaces a TypeError on bad input as a clean
10832
+ // re-render, never a 500.
10833
+ router.post("/account/support", async function (req, res) {
10834
+ var auth = _supportAuth(req, res); if (!auth) return;
10835
+ var body = req.body || {};
10836
+ var cartCount = await _cartCountForReq(req);
10837
+
10838
+ async function _orders() {
10839
+ if (!deps.order) return [];
10840
+ try { return (await deps.order.listForCustomer(auth.customer_id, { limit: 20 })).rows; }
10841
+ catch (_e) { return []; }
10842
+ }
10843
+
10844
+ // Only attach an order the requesting customer actually owns.
10845
+ var orderId;
10846
+ if (body.order_id && deps.order) {
10847
+ try {
10848
+ var ord = await deps.order.get(body.order_id);
10849
+ if (ord && ord.customer_id === auth.customer_id) orderId = ord.id;
10850
+ } catch (_e) { /* malformed / unknown order id — attach nothing */ }
10851
+ }
10852
+
10853
+ var opened;
10854
+ try {
10855
+ opened = await support.open({
10856
+ customer_id: auth.customer_id,
10857
+ customer_email: body.customer_email,
10858
+ subject: body.subject,
10859
+ body: body.body,
10860
+ category: body.category,
10861
+ order_id: orderId,
10862
+ });
10863
+ } catch (e) {
10864
+ if (e instanceof TypeError) {
10865
+ return _send(res, 400, renderSupportNew({
10866
+ orders: await _orders(),
10867
+ values: body,
10868
+ notice: (e && e.message || "").replace(/^supportTickets[.:]\s*/, "") || "Please check your ticket and try again.",
10869
+ shop_name: shopName,
10870
+ cart_count: cartCount,
10871
+ }));
10872
+ }
10873
+ throw e;
10874
+ }
10875
+ res.status(303);
10876
+ res.setHeader && res.setHeader("location", "/account/support/" + encodeURIComponent(opened.id) + "?ok=1");
10877
+ return res.end ? res.end() : res.send("");
10878
+ });
10879
+
10880
+ // One ticket's thread. Ownership-scoped (foreign / unknown → 404).
10881
+ // Internal operator notes are filtered out before render — the
10882
+ // customer never sees an internal=1 message.
10883
+ router.get("/account/support/:id", async function (req, res) {
10884
+ var auth = _supportAuth(req, res); if (!auth) return;
10885
+ var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
10886
+ var thread = await support.thread(ticket.id);
10887
+ var visible = (thread.messages || []).filter(function (m) { return Number(m.internal) !== 1; });
10888
+ var cartCount = await _cartCountForReq(req);
10889
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
10890
+ _send(res, 200, renderSupportTicket({
10891
+ ticket: thread.ticket,
10892
+ messages: visible,
10893
+ replied: url && url.searchParams.get("ok") === "1",
10894
+ shop_name: shopName,
10895
+ cart_count: cartCount,
10896
+ }));
10897
+ });
10898
+
10899
+ // Append a customer reply. Ownership-scoped; refused on a closed
10900
+ // ticket (the backend rejects a closed-ticket reply with a typed
10901
+ // SUPPORT_TICKET_CLOSED error — surfaced as a 409 re-render, never a
10902
+ // 500). author is pinned to "customer".
10903
+ router.post("/account/support/:id/reply", async function (req, res) {
10904
+ var auth = _supportAuth(req, res); if (!auth) return;
10905
+ var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
10906
+ var body = req.body || {};
10907
+ var cartCount = await _cartCountForReq(req);
10908
+
10909
+ // A closed ticket can't take a reply — short-circuit to a
10910
+ // re-render with a notice rather than calling the backend (which
10911
+ // would also refuse, but this keeps the message customer-readable).
10912
+ if (ticket.status === "closed") {
10913
+ var closedThread = await support.thread(ticket.id);
10914
+ return _send(res, 409, renderSupportTicket({
10915
+ ticket: closedThread.ticket,
10916
+ messages: (closedThread.messages || []).filter(function (m) { return Number(m.internal) !== 1; }),
10917
+ notice: "This ticket is closed — raise a new one if you still need help.",
10918
+ shop_name: shopName,
10919
+ cart_count: cartCount,
10920
+ }));
10921
+ }
10922
+
10923
+ try {
10924
+ await support.reply({
10925
+ ticket_id: ticket.id,
10926
+ author: "customer",
10927
+ author_id: auth.customer_id,
10928
+ body: body.body,
10929
+ });
10930
+ } catch (e) {
10931
+ if (e instanceof TypeError || (e && e.code === "SUPPORT_TICKET_CLOSED")) {
10932
+ var againThread = await support.thread(ticket.id);
10933
+ var status = (e && e.code === "SUPPORT_TICKET_CLOSED") ? 409 : 400;
10934
+ return _send(res, status, renderSupportTicket({
10935
+ ticket: againThread.ticket,
10936
+ messages: (againThread.messages || []).filter(function (m) { return Number(m.internal) !== 1; }),
10937
+ notice: (e && e.message || "").replace(/^supportTickets[.:]\s*/, "") || "Please check your reply and try again.",
10938
+ shop_name: shopName,
10939
+ cart_count: cartCount,
10940
+ }));
10941
+ }
10942
+ throw e;
10943
+ }
10944
+ res.status(303);
10945
+ res.setHeader && res.setHeader("location", "/account/support/" + encodeURIComponent(ticket.id) + "?ok=1");
10946
+ return res.end ? res.end() : res.send("");
10947
+ });
10948
+ }
10949
+
10534
10950
  // Loyalty — the signed-in customer's points balance + tier, the
10535
10951
  // earn/redeem ledger, how points are earned, and (when a reward
10536
10952
  // catalog + redemption primitive are wired) a redeem-a-reward
@@ -755,6 +755,60 @@ function create(opts) {
755
755
  return r.rows.map(_hydrate);
756
756
  },
757
757
 
758
+ // Customer-account-scoped list — every ticket whose `customer_id`
759
+ // matches, newest-opened first, optionally narrowed to one status.
760
+ // Keyed on the row's `customer_id` (NOT the email hash) so the
761
+ // storefront, which holds only the session customer id (the raw
762
+ // email is never on disk), can list + ownership-gate a signed-in
763
+ // shopper's tickets without re-deriving the email hash. Composes the
764
+ // same id / status / limit validators as the rest of the surface.
765
+ listByCustomerId: async function (customerId, listOpts) {
766
+ customerId = _uuid(customerId, "customer_id");
767
+ listOpts = listOpts || {};
768
+ var limit = _limit(listOpts.limit);
769
+ var where = ["customer_id = ?1"];
770
+ var params = [customerId];
771
+ var idx = 2;
772
+ if (listOpts.status_filter != null) {
773
+ where.push("status = ?" + idx);
774
+ params.push(_status(listOpts.status_filter, "status_filter"));
775
+ idx += 1;
776
+ }
777
+ params.push(limit);
778
+ var sql = "SELECT * FROM support_tickets WHERE " + where.join(" AND ") +
779
+ " ORDER BY opened_at DESC, id DESC LIMIT ?" + idx;
780
+ var r = await query(sql, params);
781
+ return r.rows.map(_hydrate);
782
+ },
783
+
784
+ // Operator queue across every assignment state — newest-stale first
785
+ // (oldest last_action_at), optionally narrowed to one status. The
786
+ // status filter drives the admin console's status chips; absent a
787
+ // filter it surfaces the whole board (every status, including
788
+ // resolved / closed) so the operator can audit history, capped at
789
+ // `limit`.
790
+ list: async function (listOpts) {
791
+ listOpts = listOpts || {};
792
+ var limit = _limit(listOpts.limit);
793
+ var where = [];
794
+ var params = [];
795
+ var idx = 1;
796
+ if (listOpts.status_filter != null) {
797
+ where.push("status = ?" + idx);
798
+ params.push(_status(listOpts.status_filter, "status_filter"));
799
+ idx += 1;
800
+ }
801
+ params.push(limit);
802
+ var sql = "SELECT * FROM support_tickets" +
803
+ (where.length ? " WHERE " + where.join(" AND ") : "") +
804
+ " ORDER BY CASE priority " +
805
+ " WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 " +
806
+ " WHEN 'normal' THEN 2 WHEN 'low' THEN 3 ELSE 4 END, " +
807
+ "last_action_at ASC LIMIT ?" + idx;
808
+ var r = await query(sql, params);
809
+ return r.rows.map(_hydrate);
810
+ },
811
+
758
812
  // Full thread view — ticket + every message in created_at ASC
759
813
  // order so the caller can render the conversation top-to-bottom.
760
814
  thread: async function (ticketId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
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": {