@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.
- package/CHANGELOG.md +4 -0
- package/lib/admin.js +811 -5
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +416 -0
- package/lib/support-tickets.js +54 -0
- package/package.json +1 -1
package/lib/asset-manifest.json
CHANGED
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 ? " · <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
|
package/lib/support-tickets.js
CHANGED
|
@@ -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