@blamejs/blamejs-shop 0.3.24 → 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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.24",
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.24",
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": {