@blamejs/blamejs-shop 0.3.33 → 0.3.34

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.34 (2026-05-31) — **Edit, pin, and archive the internal notes on a customer.** Internal notes on a customer could only be added — a note with a typo or one that was no longer true could not be corrected or removed, which is a liability for notes like "do not ship to this address" or "allergic to X". The customer detail screen now lets you edit a note's text, pin important notes to the top, and archive notes that no longer apply (and restore them). Each action is limited to notes that belong to the customer being viewed. **Added:** *Edit, pin, and archive customer notes* — The notes panel on a customer's detail screen now supports the full lifecycle: edit a note's text to fix or update it, pin the notes that matter so they sort to the top, and archive notes that no longer apply, with a toggle to show archived notes and restore them. Previously notes were append-only, so a wrong or stale note could not be corrected or retired. Every edit, pin, and archive action is scoped to the customer being viewed, so a note can only be changed from the record it belongs to.
12
+
11
13
  - v0.3.33 (2026-05-31) — **Create and manage customer segments from the admin console.** Customer segments could be shown on a customer's profile but there was no way to define or manage them, so the feature could not actually be used. A new Segments screen in the admin console creates rule-based segments from recency, frequency, lifetime-order, spend, average-order-value, and refund-rate criteria, edits their rules, archives and restores them, and recomputes membership on demand — showing how many customers each segment now contains. Segment membership stays rule-derived: it is recalculated from order history, not assigned by hand. **Added:** *Customer segments administration* — A Segments screen in the admin console manages rule-based customer segments. Each segment is defined by RFM-style criteria — how recently and how often a customer orders, their lifetime order count and spend, average order value, and refund rate — and a list shows every segment with its rules, current member count, and status, filterable by active or archived. You can create and edit segments, archive and restore them, and recompute membership on demand, which re-evaluates the rules against order history and reports how many customers match. Membership is always derived from the rules, never assigned per customer.
12
14
 
13
15
  - v0.3.32 (2026-05-31) — **Collection pages now page through every product instead of stopping at 24.** A collection page showed only its first 24 products and provided no way to reach the rest, so any product past the 24th in a collection was silently unreachable. Collection pages now have previous/next pagination, matching the search results, so a collection of any size can be browsed in full. A shared link to a specific page keeps its position, while the page's canonical URL stays the plain collection address. **Fixed:** *Collection pages paginate* — A collection with more than 24 products was truncated at the first 24 with no way to see the others. Collection pages now carry previous/next pagination — the same control used on search results — so every product in a collection is reachable. The canonical URL remains the bare collection address on every page (so search engines see one collection page, not duplicates), while a shared link to a deeper page preserves its position.
package/lib/admin.js CHANGED
@@ -2674,8 +2674,13 @@ function mount(router, deps) {
2674
2674
  }
2675
2675
 
2676
2676
  var notes = [];
2677
+ var showArchivedNotes = flags.show_archived_notes === true;
2677
2678
  if (customerNotes) {
2678
- try { notes = (await customerNotes.notesForCustomer({ customer_id: customer.id, limit: 20 })).rows || []; }
2679
+ try {
2680
+ notes = (await customerNotes.notesForCustomer({
2681
+ customer_id: customer.id, limit: 50, include_archived: showArchivedNotes,
2682
+ })).rows || [];
2683
+ }
2679
2684
  catch (_e) { notes = []; }
2680
2685
  }
2681
2686
 
@@ -2698,6 +2703,7 @@ function mount(router, deps) {
2698
2703
  loyalty_link: !!deps.loyalty,
2699
2704
  can_notes: !!customerNotes,
2700
2705
  notes: notes,
2706
+ show_archived_notes: showArchivedNotes,
2701
2707
  can_segments: !!customerSegments,
2702
2708
  segments: segments,
2703
2709
  }, flags);
@@ -2716,8 +2722,13 @@ function mount(router, deps) {
2716
2722
  R(async function (req, res) {
2717
2723
  var c = await _resolveCustomer(req.params.id);
2718
2724
  if (!c) return _problem(res, 404, "customer-not-found");
2719
- // Bearer JSON: the customer record + its satellites (no HTML).
2720
- var model = await _customerDetailModel(c, {});
2725
+ // Bearer JSON: the customer record + its satellites (no HTML). A
2726
+ // tooling client can opt into the archived notes via
2727
+ // ?notes_archived=1, matching the browser detail-screen toggle.
2728
+ var apiUrl = req.url ? new URL(req.url, "http://localhost") : null;
2729
+ var model = await _customerDetailModel(c, {
2730
+ show_archived_notes: !!(apiUrl && apiUrl.searchParams.get("notes_archived") === "1"),
2731
+ });
2721
2732
  _json(res, 200, {
2722
2733
  customer: c,
2723
2734
  recent_orders: model.recent_orders,
@@ -2738,6 +2749,7 @@ function mount(router, deps) {
2738
2749
  saved: url && url.searchParams.get("saved"),
2739
2750
  credit_notice: url && url.searchParams.get("credit_err") ? url.searchParams.get("credit_err") : null,
2740
2751
  note_notice: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
2752
+ show_archived_notes: !!(url && url.searchParams.get("notes_archived") === "1"),
2741
2753
  };
2742
2754
  _sendHtml(res, 200, renderAdminCustomerDetail(await _customerDetailModel(c, flags)));
2743
2755
  },
@@ -2870,6 +2882,104 @@ function mount(router, deps) {
2870
2882
  _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) + "?saved=1");
2871
2883
  },
2872
2884
  ));
2885
+
2886
+ // ---- customer notes (edit / pin / archive lifecycle) ---------------
2887
+ // A note belongs to a customer. customerNotes' update / pin / archive
2888
+ // methods move a row by note id alone — they carry no notion of which
2889
+ // customer the operator is acting on — so every per-note write route
2890
+ // under :id MUST first assert the note belongs to THAT customer before
2891
+ // mutating it, or an operator on customer A's screen could edit / pin /
2892
+ // retire customer B's note by guessing its id (an IDOR). This mirrors
2893
+ // the media setPrimary (_mediaBelongsToProduct) + support thread
2894
+ // (_ownedTicket) scoping: load the note, refuse it unless its
2895
+ // customer_id matches the path id. A malformed note id throws a
2896
+ // TypeError inside getNote's UUID guard → a clean 400; a well-formed
2897
+ // unknown id (or one owned by a different customer) returns null /
2898
+ // false → a clean 404, with nothing written either way.
2899
+ async function _noteBelongsToCustomer(noteId, customerId) {
2900
+ var note;
2901
+ try { note = await customerNotes.getNote(noteId); }
2902
+ catch (e) { if (e instanceof TypeError) throw e; return false; }
2903
+ return (note && note.customer_id === customerId) ? note : false;
2904
+ }
2905
+
2906
+ // Map a per-note write to a content-negotiated outcome. `mutate(note)`
2907
+ // performs the customerNotes call (already ownership-checked); on the
2908
+ // bearer path it returns the updated note as JSON, on the browser path
2909
+ // it PRGs back to the detail screen with a saved / note_err banner.
2910
+ // A malformed note id is a clean 400 (bearer) / note_err redirect
2911
+ // (browser); a missing / cross-customer note is a clean 404 (bearer) /
2912
+ // note_err redirect (browser). Nothing is mutated on any refusal.
2913
+ function _noteWriteRoute(routePath, audit, mutate) {
2914
+ router.post(routePath, _pageOrApi(false,
2915
+ W(audit, async function (req, res) {
2916
+ var c = await _resolveCustomer(req.params.id);
2917
+ if (!c) return _problem(res, 404, "customer-not-found");
2918
+ var owned;
2919
+ try { owned = await _noteBelongsToCustomer(req.params.noteId, c.id); }
2920
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
2921
+ if (!owned) return _problem(res, 404, "note-not-found");
2922
+ var updated;
2923
+ try { updated = await mutate(owned, req.body || {}); }
2924
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
2925
+ _json(res, 200, updated);
2926
+ return { id: c.id };
2927
+ }),
2928
+ async function (req, res) {
2929
+ var c = await _resolveCustomer(req.params.id);
2930
+ if (!c) return _sendHtml(res, 404, renderAdminCustomers({
2931
+ shop_name: deps.shop_name, nav_available: navAvailable, customers: [], notice: "Customer not found.",
2932
+ }));
2933
+ var enc = encodeURIComponent(c.id);
2934
+ var owned;
2935
+ try { owned = await _noteBelongsToCustomer(req.params.noteId, c.id); }
2936
+ catch (e) {
2937
+ var nm = _safeNotice(e, audit);
2938
+ return _redirect(res, "/admin/customers/" + enc +
2939
+ "?note_err=" + encodeURIComponent(nm.message.replace(/^(?:admin|customerNotes)[.:]\s*/, "")));
2940
+ }
2941
+ if (!owned) {
2942
+ return _redirect(res, "/admin/customers/" + enc +
2943
+ "?note_err=" + encodeURIComponent("That note was not found on this customer."));
2944
+ }
2945
+ try { await mutate(owned, req.body || {}); }
2946
+ catch (e) {
2947
+ var n = _safeNotice(e, audit);
2948
+ return _redirect(res, "/admin/customers/" + enc +
2949
+ "?note_err=" + encodeURIComponent(n.message.replace(/^(?:admin|customerNotes)[.:]\s*/, "")));
2950
+ }
2951
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + audit, outcome: "success", metadata: { id: c.id } });
2952
+ _redirect(res, "/admin/customers/" + enc + "?saved=1");
2953
+ },
2954
+ ));
2955
+ }
2956
+
2957
+ // Edit the note body / kind. The new text is required + length-capped by
2958
+ // the primitive's validator (empty / over-long body, bad kind → a clean
2959
+ // 4xx with nothing written); the kind is left unchanged when the form
2960
+ // omits it.
2961
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/edit", "customer.note.edit",
2962
+ function (owned, body) {
2963
+ var patch = { body: body.body };
2964
+ if (body.kind != null && body.kind !== "") patch.kind = body.kind;
2965
+ return customerNotes.updateNote(owned.id, patch);
2966
+ });
2967
+
2968
+ // Pin / unpin — a pinned note floats to the top of the customer's note
2969
+ // list (notesForCustomer orders pinned DESC). Idempotent at the
2970
+ // primitive.
2971
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/pin", "customer.note.pin",
2972
+ function (owned) { return customerNotes.pinNote(owned.id); });
2973
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/unpin", "customer.note.unpin",
2974
+ function (owned) { return customerNotes.unpinNote(owned.id); });
2975
+
2976
+ // Archive / unarchive — a soft-retire that drops the note from the
2977
+ // default (active) listing; unarchive brings it back. Both idempotent at
2978
+ // the primitive.
2979
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/archive", "customer.note.archive",
2980
+ function (owned) { return customerNotes.archiveNote(owned.id); });
2981
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/unarchive", "customer.note.unarchive",
2982
+ function (owned) { return customerNotes.unarchiveNote(owned.id); });
2873
2983
  }
2874
2984
  }
2875
2985
 
@@ -9925,25 +10035,56 @@ function renderAdminCustomerDetail(opts) {
9925
10035
  "<p class=\"empty\">Customer notes aren't wired in this deployment.</p></div>";
9926
10036
  } else {
9927
10037
  var noteNotice = opts.note_notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.note_notice) + "</div>" : "";
10038
+ var showArchived = opts.show_archived_notes === true;
10039
+ // A <select name="kind"> with the active kind preselected. Reused by the
10040
+ // add form and each note's inline edit form.
10041
+ function _noteKindSelect(active) {
10042
+ return ["general", "preference", "escalation", "warning", "billing"].map(function (k) {
10043
+ return "<option value=\"" + k + "\"" + (k === active ? " selected" : "") + ">" + k + "</option>";
10044
+ }).join("");
10045
+ }
9928
10046
  var noteRows = (opts.notes || []).map(function (n) {
9929
- return "<li><div class=\"note-meta\">" + _htmlEscape(n.kind) + " · " + _htmlEscape(_fmtDate(n.created_at)) + "</div>" +
9930
- "<div class=\"note-body\">" + _htmlEscape(n.body) + "</div></li>";
10047
+ var nenc = _htmlEscape(encodeURIComponent(n.id));
10048
+ var base = "/admin/customers/" + _htmlEscape(enc) + "/notes/" + nenc;
10049
+ var isPinned = Number(n.pinned) === 1;
10050
+ var isArchived = n.archived_at != null;
10051
+ var pills =
10052
+ (isPinned ? "<span class=\"status-pill paid\">Pinned</span>" : "") +
10053
+ (isArchived ? "<span class=\"status-pill cancelled\">Archived</span>" : "");
10054
+ // Inline edit form — kind + body, prefilled with the note's current
10055
+ // values; the primitive re-validates an empty / over-long body.
10056
+ var editForm = "<details class=\"note-edit\"><summary class=\"btn btn--ghost\">Edit</summary>" +
10057
+ "<form method=\"post\" action=\"" + base + "/edit\">" +
10058
+ "<label class=\"form-field\"><span>Kind</span>" +
10059
+ "<select name=\"kind\">" + _noteKindSelect(n.kind) + "</select></label>" +
10060
+ "<label class=\"form-field\"><span>Note</span>" +
10061
+ "<textarea name=\"body\" required maxlength=\"8000\" rows=\"3\">" + _htmlEscape(n.body) + "</textarea></label>" +
10062
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save note</button></div>" +
10063
+ "</form></details>";
10064
+ var pinForm = "<form method=\"post\" action=\"" + base + (isPinned ? "/unpin" : "/pin") + "\">" +
10065
+ "<button class=\"btn btn--ghost\" type=\"submit\">" + (isPinned ? "Unpin" : "Pin") + "</button></form>";
10066
+ var archiveForm = "<form method=\"post\" action=\"" + base + (isArchived ? "/unarchive" : "/archive") + "\">" +
10067
+ "<button class=\"btn btn--ghost\" type=\"submit\">" + (isArchived ? "Unarchive" : "Archive") + "</button></form>";
10068
+ return "<li class=\"note-item\">" +
10069
+ "<div class=\"note-meta\">" + _htmlEscape(n.kind) + " · " + _htmlEscape(_fmtDate(n.created_at)) +
10070
+ (pills ? " " + pills : "") + "</div>" +
10071
+ "<div class=\"note-body\">" + _htmlEscape(n.body) + "</div>" +
10072
+ "<div class=\"actions-row\">" + editForm + pinForm + archiveForm + "</div>" +
10073
+ "</li>";
9931
10074
  }).join("");
9932
- notesPanel = "<div class=\"panel\"><h3 class=\"subhead\">Notes</h3>" +
10075
+ var archivedToggle = showArchived
10076
+ ? "<a class=\"btn btn--ghost\" href=\"/admin/customers/" + _htmlEscape(enc) + "\">Hide archived</a>"
10077
+ : "<a class=\"btn btn--ghost\" href=\"/admin/customers/" + _htmlEscape(enc) + "?notes_archived=1\">Show archived</a>";
10078
+ notesPanel = "<div class=\"panel\"><div class=\"actions-row\"><h3 class=\"subhead\">Notes</h3>" + archivedToggle + "</div>" +
9933
10079
  noteNotice +
9934
- "<p class=\"meta\">Operator-only annotations on this customer. Never shown to the customer.</p>" +
10080
+ "<p class=\"meta\">Operator-only annotations on this customer. Never shown to the customer. " +
10081
+ "Pin the notes every shift should see first; archive a stale or wrong note to retire it from the active list.</p>" +
9935
10082
  ((opts.notes || []).length
9936
10083
  ? "<ul class=\"note-list\">" + noteRows + "</ul>"
9937
- : "<p class=\"empty\">No notes yet.</p>") +
10084
+ : "<p class=\"empty\">" + (showArchived ? "No notes (including archived)." : "No notes yet.") + "</p>") +
9938
10085
  "<form method=\"post\" action=\"/admin/customers/" + _htmlEscape(enc) + "/notes\">" +
9939
10086
  "<label class=\"form-field\"><span>Kind</span>" +
9940
- "<select name=\"kind\">" +
9941
- "<option value=\"general\">general</option>" +
9942
- "<option value=\"preference\">preference</option>" +
9943
- "<option value=\"escalation\">escalation</option>" +
9944
- "<option value=\"warning\">warning</option>" +
9945
- "<option value=\"billing\">billing</option>" +
9946
- "</select></label>" +
10087
+ "<select name=\"kind\">" + _noteKindSelect("general") + "</select></label>" +
9947
10088
  "<label class=\"form-field\"><span>Note</span>" +
9948
10089
  "<textarea name=\"body\" required maxlength=\"8000\" rows=\"3\" placeholder=\"e.g. VIP — comp shipping where possible\"></textarea></label>" +
9949
10090
  "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add note</button></div>" +
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.33",
2
+ "version": "0.3.34",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.33",
3
+ "version": "0.3.34",
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": {