@blamejs/blamejs-shop 0.4.0 → 0.4.2
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/README.md +1 -1
- package/SECURITY.md +11 -0
- package/lib/admin.js +318 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +148 -11
- package/lib/customer-activity.js +72 -23
- package/lib/order.js +87 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.2 (2026-06-05) — **Abandoned checkouts release their stock holds, and five more inventory and admin hardening fixes.** The inventory enforcement introduced in 0.4.0 placed a stock hold at checkout but had no path to free it when a buyer abandoned without paying or cancelling — each abandoned checkout permanently subtracted from sellable stock until an operator intervened. A scheduled reaper now cancels pending orders older than a configurable age (default two hours), cancelling the payment intent first so a late payment can never complete against a reaped order, and releasing the held stock through the existing cancellation path. Around it, five more fixes harden the same surface: settlement failures during payment confirmation no longer strand holds silently (each item settles independently and failures land in the operator error log with the exact item and quantity), a rollback path no longer releases holds belonging to an order that was successfully created, pre-order campaigns whose launch date has passed now enforce real stock limits instead of remaining exempt, the admin activity timeline rejects protocol-relative link targets, and activity reads are bounded instead of scanning a customer's full history per page view. **Fixed:** *Stock holds from abandoned checkouts are reclaimed* — A pending order that never completes payment now has its stock hold released automatically. The scheduled reaper cancels pending orders older than CHECKOUT_PENDING_TTL_MINUTES (default 120, minimum 5; invalid values refuse to boot). For card payments the payment intent is cancelled at the processor before the order is touched — if the processor reports the payment already succeeded, the order is left alone for the webhook to settle. Each sweep reports counts of reaped, skipped, and errored orders. · *Payment settlement is crash-safe per item* — When an order is marked paid, each line's stock decrement now settles independently: one item's database failure no longer blocks the others, no longer fails the payment webhook, and is captured to the operator error log naming the item, quantity, and order so the operator can reconcile stock from the existing adjustment screen. · *Checkout rollback no longer releases holds it does not own* — If checkout fails after the order record was created, the error path previously released all of the attempt's stock holds — which could free units belonging to the order itself or, on a shared item, a concurrent shopper's reservation. Holds are now released on failure only when no order was created; once an order exists it owns its holds, and cancellation or the reaper frees them. The same correction applies to the PayPal order-creation path. · *Pre-order campaigns enforce stock after their launch date* — An active pre-order campaign exempts its product from stock holds by design — pre-orders sell beyond the shelf. That exemption now ends when the campaign's launch date passes: a launched product sells from real inventory even if the campaign has not yet been moved out of its pre-order state in the console. · *Admin activity links and reads hardened* — The customer activity timeline's internal-link guard now rejects protocol-relative targets, and each activity source is read with a bound matching the requested page instead of scanning the customer's entire history. Pagination, ordering, and the summary counts are unchanged.
|
|
12
|
+
|
|
13
|
+
- v0.4.1 (2026-06-05) — **Order notes and a customer activity timeline in the admin console.** Two operator surfaces land in the admin console. Every order detail screen gains a customer-service notes thread: operators record internal notes or customer-visible ones, pin the important thread to the top, and mark issues resolved or reopen them — with note bodies length-bounded, control-character-rejected, and escaped at render. Every customer detail screen gains a read-only activity timeline aggregating what that customer has done across the store — orders placed and their lifecycle transitions, loyalty points earned, wishlist additions, reviews submitted, and support tickets opened — read directly from the tables those features already populate, newest first. Both panels appear only when their backing modules are wired, and every note mutation is ownership-scoped to its order and audited. **Added:** *Customer-service notes on order detail* — The admin order screen shows a notes thread with pinned notes floated first and the rest newest-first. Operators add notes as internal (the default) or customer-visible, pin and unpin them, and resolve or reopen them with a short resolution summary; resolving a customer-visible note is refused so customer-facing context is never silently closed out. Bodies are validated server-side (8000-character cap, control characters rejected) and HTML-escaped at render. Each mutation verifies the note belongs to the order in the URL before acting, returns clean errors for unknown or malformed identifiers, and emits an audit event. The same surface is available as JSON under the admin bearer token. · *Customer activity timeline on customer detail* — The admin customer screen shows an aggregated, newest-first activity feed: order placements and status transitions, loyalty point movements, wishlist additions, review submissions, and support tickets. The timeline is a read-only view over the tables those features already write — no new tracking or recording was added anywhere in the request path. The panel shows the most recent fifty events, links each event to its admin screen where one exists, and renders an explicit empty state for customers with no history. The same feed is available as JSON under the admin bearer token.
|
|
14
|
+
|
|
11
15
|
- v0.4.0 (2026-06-05) — **Inventory is enforced at the point of sale, completing the transactions, checkout, and analytics surface.** Stock levels were previously display-only: the product page showed honest availability, but nothing reserved inventory at checkout or debited it on a sale, so concurrent buyers could oversell a SKU and stock counts never moved. Checkout now places an atomic per-SKU hold before any charge — a sold-out line re-renders the form with a friendly message instead of charging — and the order lifecycle settles the hold: payment converts it to a real stock decrement (idempotent across webhook re-deliveries), cancellation releases it, and refunds deliberately leave restocking to the operator's judgment. Untracked SKUs remain unlimited, and pre-order campaigns keep their own reservation flow. The README previously described an oversell-prevention mechanism that was not actually wired; it now describes the real one. This minor release caps the transactions, checkout, and analytics arc: server-validated addresses and digital-cart checkout, discount codes with console authoring, gift cards, loyalty, store pickup, a dark-themed Stripe payment surface with express wallets and verified 3-D Secure, shipment tracking timelines, consent-gated funnel analytics with an admin dashboard, abandoned-cart visibility with honest recovery codes, operator error logs, and confirmation resends. Known, documented deferrals: customer receipt downloads (the confirmation page and signed email serve as the receipt), partial refunds from the browser console (the JSON API supports them), and a real-time new-order notification (the dashboard and outbound webhooks cover arrival today). **Added:** *Point-of-sale inventory enforcement* — Checkout reserves stock with an atomic conditional hold per shippable line before charging — insufficient stock re-renders the checkout with a clear message and charges nothing, and two concurrent buyers of the last unit resolve to exactly one sale. Payment converts holds into stock decrements, idempotently across webhook re-deliveries; cancelling a pending order releases its holds precisely, even when other shoppers hold the same SKU. Refunds do not auto-restock — returned goods re-enter stock through the operator's existing restock action, by judgment. SKUs without an inventory row remain available without limit, and pre-order campaigns are unaffected. The inventory primitive gains the hold and decrement operations its documentation previously described, and the README now reflects the actual oversell-prevention mechanism. **Changed:** *The 0.3 series rolls up* — This release follows nineteen 0.3.x patches that built out the commerce surface: server-side address validation with accessible per-field errors, digital-cart checkout without an address, delivery-date estimates, discount unlock codes with cart redemption and console authoring, full shipment-tracking timelines, consent-gated funnel analytics and an Analytics console screen, abandoned-cart visibility with single-use recovery codes, an operator-readable error log with a JSON API, order-confirmation resends, segment CSV exports, payment-processor TLS fixes verified by live payment, a dark-themed payment surface with express wallets, and a vendored-framework refresh. See the changelog for the full sequence.
|
|
12
16
|
|
|
13
17
|
## v0.3.x
|
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
97
97
|
| **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
|
|
98
98
|
| **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
|
|
99
99
|
| **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
|
|
100
|
-
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Carts** (`/admin/carts`) lists abandoned carts — active, has items, idle past a tunable window (24h default) — with line counts, value at risk, and guest/signed-in attribution; a per-cart action mints a single-use, code-gated discount the operator shares through their own channel (recovery email is impossible by design: buyer addresses are stored only as hashes, and the screen says so). **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Carts, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
|
|
100
|
+
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer, plus a customer-service notes thread per order (internal or customer-visible, pinnable, resolvable); **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); each customer opens to an aggregated activity timeline (orders, loyalty, wishlist, reviews, support) read from the tables those primitives already populate; customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Carts** (`/admin/carts`) lists abandoned carts — active, has items, idle past a tunable window (24h default) — with line counts, value at risk, and guest/signed-in attribution; a per-cart action mints a single-use, code-gated discount the operator shares through their own channel (recovery email is impossible by design: buyer addresses are stored only as hashes, and the screen says so). **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Carts, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
|
|
101
101
|
| **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
|
|
102
102
|
| **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
|
|
103
103
|
|
package/SECURITY.md
CHANGED
|
@@ -180,6 +180,17 @@ node -e "
|
|
|
180
180
|
Redemption decrements with an atomic `balance >= amount` SQL guard
|
|
181
181
|
keyed on the order id, so concurrent or replayed checkouts can never
|
|
182
182
|
overdraw a card or apply more than the remaining balance.
|
|
183
|
+
- **Abandoned checkouts don't strand stock forever.** Checkout reserves
|
|
184
|
+
stock with an atomic conditional hold before charging; an order whose
|
|
185
|
+
buyer abandons the payment sheet (or whose PaymentIntent expires) would
|
|
186
|
+
otherwise hold that stock indefinitely. A background tick cancels
|
|
187
|
+
pending orders older than `CHECKOUT_PENDING_TTL_MINUTES` (default 120,
|
|
188
|
+
minimum 5) so their holds release back to the shelf. For a Stripe order
|
|
189
|
+
the tick cancels the PaymentIntent FIRST — if Stripe reports the payment
|
|
190
|
+
already succeeded, the order is left pending for the webhook to settle,
|
|
191
|
+
so a reap can never cancel an order whose payment completed. Tune the
|
|
192
|
+
TTL to the longest payment flow you support (slow 3-D Secure, manual
|
|
193
|
+
bank pushes) so a legitimate in-progress checkout is never reaped.
|
|
183
194
|
- **Loyalty points are account-scoped money — earned and spent under
|
|
184
195
|
the same double-spend discipline.** The `/account/loyalty` page and
|
|
185
196
|
the redeem actions are login-gated and read the customer id from the
|
package/lib/admin.js
CHANGED
|
@@ -52,6 +52,12 @@ var MS_PER_HOUR = C.TIME.hours(1);
|
|
|
52
52
|
|
|
53
53
|
var AUDIT_NAMESPACE = "shop_admin";
|
|
54
54
|
|
|
55
|
+
// Newest-N bound on the order-detail customer-service notes panel + the
|
|
56
|
+
// customer-detail activity timeline. Both are bounded reads — the console
|
|
57
|
+
// renders the most recent slice rather than paginating an unbounded list.
|
|
58
|
+
var NOTES_PANEL_LIMIT = 50;
|
|
59
|
+
var ACTIVITY_PANEL_LIMIT = 50;
|
|
60
|
+
|
|
55
61
|
// Operator-readable error-log sink (lib/error-log.js), set by mount()
|
|
56
62
|
// when the deployment wires `deps.errorLog`. `_safeNotice` records the
|
|
57
63
|
// scrubbed message of a genuine 5xx here so the failure is reachable
|
|
@@ -547,6 +553,8 @@ function mount(router, deps) {
|
|
|
547
553
|
var storeCredit = deps.storeCredit || null; // per-customer store-credit panel + grant/deduct disabled when absent
|
|
548
554
|
var customerNotes = deps.customerNotes || null; // per-customer CRM notes panel disabled when absent
|
|
549
555
|
var customerSegments = deps.customerSegments || null; // per-customer segment-membership panel disabled when absent
|
|
556
|
+
var customerActivity = deps.customerActivity || null; // per-customer chronological activity-timeline panel disabled when absent
|
|
557
|
+
var orderNotes = deps.orderNotes || null; // per-order customer-service notes panel + add/lifecycle disabled when absent
|
|
550
558
|
var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
|
|
551
559
|
var salesReports = deps.salesReports || null; // /admin/reports degrades to an unconfigured notice when absent
|
|
552
560
|
var orderExport = deps.orderExport || null; // /admin/exports (date-range CSV/NDJSON dump + scheduled-export queue) disabled when absent
|
|
@@ -1777,6 +1785,16 @@ function mount(router, deps) {
|
|
|
1777
1785
|
try { splitPlans = await splitShipments.splitsForOrder(o.id); }
|
|
1778
1786
|
catch (_se) { splitPlans = []; }
|
|
1779
1787
|
}
|
|
1788
|
+
// Customer-service notes attached to the order — both visibility tiers
|
|
1789
|
+
// (internal + customer-visible), pinned-first then newest-first, bounded
|
|
1790
|
+
// to the most recent NOTES_PANEL_LIMIT. Best-effort: an unmigrated
|
|
1791
|
+
// order_notes table degrades to "no notes" rather than 500-ing the page.
|
|
1792
|
+
var notes = [];
|
|
1793
|
+
if (orderNotes) {
|
|
1794
|
+
try {
|
|
1795
|
+
notes = (await orderNotes.listForOrder({ order_id: o.id, limit: NOTES_PANEL_LIMIT })).rows || [];
|
|
1796
|
+
} catch (_ne) { notes = []; }
|
|
1797
|
+
}
|
|
1780
1798
|
_sendHtml(res, 200, renderAdminOrder({
|
|
1781
1799
|
shop_name: deps.shop_name,
|
|
1782
1800
|
nav_available: navAvailable,
|
|
@@ -1816,11 +1834,173 @@ function mount(router, deps) {
|
|
|
1816
1834
|
ship_done: url && url.searchParams.get("ship"),
|
|
1817
1835
|
label_done: url && url.searchParams.get("label"),
|
|
1818
1836
|
split_done: url && url.searchParams.get("split"),
|
|
1837
|
+
// Customer-service notes panel — renders only when the order-notes
|
|
1838
|
+
// primitive is wired (its routes mount only then). `note_done` flags a
|
|
1839
|
+
// successful add / lifecycle action (PRG); `note_err` surfaces a clean
|
|
1840
|
+
// operator-facing notice from a refused write.
|
|
1841
|
+
can_notes: !!orderNotes,
|
|
1842
|
+
notes: notes,
|
|
1843
|
+
note_done: url && url.searchParams.get("note"),
|
|
1844
|
+
note_err: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
|
|
1845
|
+
note_authors: orderNotes ? orderNotes.ALLOWED_AUTHORS : null,
|
|
1846
|
+
note_visibility: orderNotes ? orderNotes.ALLOWED_VISIBILITY : null,
|
|
1819
1847
|
notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this order." : null,
|
|
1820
1848
|
}));
|
|
1821
1849
|
},
|
|
1822
1850
|
));
|
|
1823
1851
|
|
|
1852
|
+
// ---- order notes (customer-service threaded notes) ------------------
|
|
1853
|
+
//
|
|
1854
|
+
// The operator's customer-service surface on the order-detail screen: a
|
|
1855
|
+
// list of notes attached to the order (both internal + customer-visible
|
|
1856
|
+
// tiers, pinned-first then newest-first) plus an add form and the lifecycle
|
|
1857
|
+
// the order-notes primitive offers — pin / unpin (float to the top) and
|
|
1858
|
+
// resolve / reopen (close out an internal thread with a short summary). The
|
|
1859
|
+
// note body is operator free text, escaped at render and length-capped by
|
|
1860
|
+
// the primitive's validator. Every mutation is audited; the panel renders
|
|
1861
|
+
// only when the order-notes primitive is wired.
|
|
1862
|
+
if (orderNotes) {
|
|
1863
|
+
// Resolve the :id to an order. A malformed id throws inside order.get's
|
|
1864
|
+
// defensive reader — treat that as "no such order" so the route refuses
|
|
1865
|
+
// cleanly (404) rather than 500-ing.
|
|
1866
|
+
async function _resolveOrder(id) {
|
|
1867
|
+
try { return await order.get(id); }
|
|
1868
|
+
catch (e) { if (e instanceof TypeError) return null; throw e; }
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// A note belongs to an order. The primitive's pin / resolve / reopen move
|
|
1872
|
+
// a row by note id alone — they carry no notion of which order the
|
|
1873
|
+
// operator is acting on — so every per-note write route under :id MUST
|
|
1874
|
+
// first assert the note belongs to THAT order before mutating it, or an
|
|
1875
|
+
// operator on order A's screen could pin / resolve order B's note by
|
|
1876
|
+
// guessing its id (an IDOR). Mirrors the customer-notes
|
|
1877
|
+
// _noteBelongsToCustomer scoping. A malformed note id throws inside get's
|
|
1878
|
+
// UUID guard -> a clean 400; a well-formed unknown id (or one on a
|
|
1879
|
+
// different order) returns null -> a clean 404, with nothing written.
|
|
1880
|
+
async function _orderNoteBelongs(noteId, orderId) {
|
|
1881
|
+
var note;
|
|
1882
|
+
try { note = await orderNotes.get(noteId); }
|
|
1883
|
+
catch (e) { if (e instanceof TypeError) throw e; return false; }
|
|
1884
|
+
return (note && note.order_id === orderId) ? note : false;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// ---- add a note ----------------------------------------------------
|
|
1888
|
+
// Scoped to the :id order. Defaults author "operator" + visibility
|
|
1889
|
+
// "internal" (the console never writes a system note; an operator can opt
|
|
1890
|
+
// a note customer-visible via the form's visibility select). Bad input
|
|
1891
|
+
// (empty / over-long body, bad author / visibility) is a clean 4xx with
|
|
1892
|
+
// nothing written.
|
|
1893
|
+
router.post("/admin/orders/:id/notes", _pageOrApi(false,
|
|
1894
|
+
W("order.note.add", async function (req, res) {
|
|
1895
|
+
var o = await _resolveOrder(req.params.id);
|
|
1896
|
+
if (!o) return _problem(res, 404, "order-not-found");
|
|
1897
|
+
var body = req.body || {};
|
|
1898
|
+
var note;
|
|
1899
|
+
try {
|
|
1900
|
+
note = await orderNotes.add({
|
|
1901
|
+
order_id: o.id,
|
|
1902
|
+
author: (body.author === "customer" || body.author === "system") ? body.author : "operator",
|
|
1903
|
+
visibility: body.visibility === "customer_visible" ? "customer_visible" : "internal",
|
|
1904
|
+
body: body.body,
|
|
1905
|
+
});
|
|
1906
|
+
} catch (e) {
|
|
1907
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
1908
|
+
throw e;
|
|
1909
|
+
}
|
|
1910
|
+
_json(res, 201, note);
|
|
1911
|
+
return { id: o.id };
|
|
1912
|
+
}),
|
|
1913
|
+
async function (req, res) {
|
|
1914
|
+
var o = await _resolveOrder(req.params.id);
|
|
1915
|
+
if (!o) return _sendHtml(res, 404, renderAdminOrders({
|
|
1916
|
+
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
1917
|
+
}));
|
|
1918
|
+
var body = req.body || {};
|
|
1919
|
+
var enc = encodeURIComponent(o.id);
|
|
1920
|
+
try {
|
|
1921
|
+
await orderNotes.add({
|
|
1922
|
+
order_id: o.id,
|
|
1923
|
+
author: (body.author === "customer" || body.author === "system") ? body.author : "operator",
|
|
1924
|
+
visibility: body.visibility === "customer_visible" ? "customer_visible" : "internal",
|
|
1925
|
+
body: body.body,
|
|
1926
|
+
});
|
|
1927
|
+
} catch (e) {
|
|
1928
|
+
var n = _safeNotice(e, "order.note.add");
|
|
1929
|
+
return _redirect(res, "/admin/orders/" + enc +
|
|
1930
|
+
"?note_err=" + encodeURIComponent(n.message.replace(/^(?:admin|orderNotes)[.:]\s*/, "")));
|
|
1931
|
+
}
|
|
1932
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.note.add", outcome: "success", metadata: { id: o.id } });
|
|
1933
|
+
_redirect(res, "/admin/orders/" + enc + "?note=1");
|
|
1934
|
+
},
|
|
1935
|
+
));
|
|
1936
|
+
|
|
1937
|
+
// ---- per-note lifecycle (pin / unpin / resolve / reopen) -----------
|
|
1938
|
+
// Each route asserts note-belongs-to-order BEFORE mutating, then runs the
|
|
1939
|
+
// matching primitive call. `mutate(note, body)` returns the updated note
|
|
1940
|
+
// (bearer JSON) or throws a TypeError on bad input (-> a clean 400 /
|
|
1941
|
+
// note_err redirect). A missing / cross-order note is a clean 404 (bearer)
|
|
1942
|
+
// / note_err redirect (browser); nothing is mutated on any refusal.
|
|
1943
|
+
function _orderNoteWriteRoute(routePath, audit, mutate) {
|
|
1944
|
+
router.post(routePath, _pageOrApi(false,
|
|
1945
|
+
W(audit, async function (req, res) {
|
|
1946
|
+
var o = await _resolveOrder(req.params.id);
|
|
1947
|
+
if (!o) return _problem(res, 404, "order-not-found");
|
|
1948
|
+
var owned;
|
|
1949
|
+
try { owned = await _orderNoteBelongs(req.params.noteId, o.id); }
|
|
1950
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
1951
|
+
if (!owned) return _problem(res, 404, "note-not-found");
|
|
1952
|
+
var updated;
|
|
1953
|
+
try { updated = await mutate(owned, req.body || {}); }
|
|
1954
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
1955
|
+
_json(res, 200, updated);
|
|
1956
|
+
return { id: o.id };
|
|
1957
|
+
}),
|
|
1958
|
+
async function (req, res) {
|
|
1959
|
+
var o = await _resolveOrder(req.params.id);
|
|
1960
|
+
if (!o) return _sendHtml(res, 404, renderAdminOrders({
|
|
1961
|
+
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
1962
|
+
}));
|
|
1963
|
+
var enc = encodeURIComponent(o.id);
|
|
1964
|
+
var owned;
|
|
1965
|
+
try { owned = await _orderNoteBelongs(req.params.noteId, o.id); }
|
|
1966
|
+
catch (e) {
|
|
1967
|
+
var nm = _safeNotice(e, audit);
|
|
1968
|
+
return _redirect(res, "/admin/orders/" + enc +
|
|
1969
|
+
"?note_err=" + encodeURIComponent(nm.message.replace(/^(?:admin|orderNotes)[.:]\s*/, "")));
|
|
1970
|
+
}
|
|
1971
|
+
if (!owned) {
|
|
1972
|
+
return _redirect(res, "/admin/orders/" + enc +
|
|
1973
|
+
"?note_err=" + encodeURIComponent("That note was not found on this order."));
|
|
1974
|
+
}
|
|
1975
|
+
try { await mutate(owned, req.body || {}); }
|
|
1976
|
+
catch (e) {
|
|
1977
|
+
var n = _safeNotice(e, audit);
|
|
1978
|
+
return _redirect(res, "/admin/orders/" + enc +
|
|
1979
|
+
"?note_err=" + encodeURIComponent(n.message.replace(/^(?:admin|orderNotes)[.:]\s*/, "")));
|
|
1980
|
+
}
|
|
1981
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + audit, outcome: "success", metadata: { id: o.id } });
|
|
1982
|
+
_redirect(res, "/admin/orders/" + enc + "?note=1");
|
|
1983
|
+
},
|
|
1984
|
+
));
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Pin / unpin — a pinned note floats to the top of the order's note list
|
|
1988
|
+
// (listForOrder orders pinned DESC). Idempotent at the primitive.
|
|
1989
|
+
_orderNoteWriteRoute("/admin/orders/:id/notes/:noteId/pin", "order.note.pin",
|
|
1990
|
+
function (owned) { return orderNotes.pin(owned.id); });
|
|
1991
|
+
_orderNoteWriteRoute("/admin/orders/:id/notes/:noteId/unpin", "order.note.unpin",
|
|
1992
|
+
function (owned) { return orderNotes.unpin(owned.id); });
|
|
1993
|
+
|
|
1994
|
+
// Resolve / reopen — close out an INTERNAL thread with a short (<=280-char)
|
|
1995
|
+
// operator summary, or reopen it. The primitive refuses to resolve a
|
|
1996
|
+
// customer-visible note (only internal threads carry a resolution) — that
|
|
1997
|
+
// refusal surfaces as a clean 4xx / note_err, never a 500.
|
|
1998
|
+
_orderNoteWriteRoute("/admin/orders/:id/notes/:noteId/resolve", "order.note.resolve",
|
|
1999
|
+
function (owned, body) { return orderNotes.resolve({ note_id: owned.id, resolution: body.resolution }); });
|
|
2000
|
+
_orderNoteWriteRoute("/admin/orders/:id/notes/:noteId/reopen", "order.note.reopen",
|
|
2001
|
+
function (owned) { return orderNotes.reopen(owned.id); });
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1824
2004
|
// ---- audit / activity log (read-only) -------------------------------
|
|
1825
2005
|
//
|
|
1826
2006
|
// A read-only window onto the framework's tamper-evident audit chain —
|
|
@@ -3049,6 +3229,21 @@ function mount(router, deps) {
|
|
|
3049
3229
|
catch (_e) { segments = []; }
|
|
3050
3230
|
}
|
|
3051
3231
|
|
|
3232
|
+
// Chronological activity timeline — the read-only per-customer feed the
|
|
3233
|
+
// aggregator flattens from the wired source primitives (order
|
|
3234
|
+
// transitions, wishlist saves, loyalty ledger, support tickets,
|
|
3235
|
+
// reviews), newest-first, bounded to the most recent N. Best-effort: an
|
|
3236
|
+
// unmigrated source / cache table degrades the panel to "no activity"
|
|
3237
|
+
// rather than 500-ing the page.
|
|
3238
|
+
var activity = [];
|
|
3239
|
+
if (customerActivity) {
|
|
3240
|
+
try {
|
|
3241
|
+
activity = (await customerActivity.forCustomer({
|
|
3242
|
+
customer_id: customer.id, limit: ACTIVITY_PANEL_LIMIT,
|
|
3243
|
+
})).events || [];
|
|
3244
|
+
} catch (_e) { activity = []; }
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3052
3247
|
return Object.assign({
|
|
3053
3248
|
shop_name: deps.shop_name,
|
|
3054
3249
|
nav_available: navAvailable,
|
|
@@ -3065,6 +3260,8 @@ function mount(router, deps) {
|
|
|
3065
3260
|
show_archived_notes: showArchivedNotes,
|
|
3066
3261
|
can_segments: !!customerSegments,
|
|
3067
3262
|
segments: segments,
|
|
3263
|
+
can_activity: !!customerActivity,
|
|
3264
|
+
activity: activity,
|
|
3068
3265
|
}, flags);
|
|
3069
3266
|
}
|
|
3070
3267
|
|
|
@@ -3096,6 +3293,7 @@ function mount(router, deps) {
|
|
|
3096
3293
|
loyalty: model.loyalty,
|
|
3097
3294
|
notes: model.notes,
|
|
3098
3295
|
segments: model.segments,
|
|
3296
|
+
activity: model.activity,
|
|
3099
3297
|
});
|
|
3100
3298
|
}),
|
|
3101
3299
|
async function (req, res) {
|
|
@@ -12664,6 +12862,18 @@ function renderAdminOrder(opts) {
|
|
|
12664
12862
|
splitPanel = _orderSplitPanel(o, opts.split_plans || []);
|
|
12665
12863
|
}
|
|
12666
12864
|
|
|
12865
|
+
// Customer-service notes panel — only when the order-notes primitive is
|
|
12866
|
+
// wired (its routes mount only then). The note body is operator free text,
|
|
12867
|
+
// escaped at render. `_orderNotesPanel` lists the notes newest-first with
|
|
12868
|
+
// their per-note lifecycle controls, then the add form.
|
|
12869
|
+
var notesPanel = "";
|
|
12870
|
+
if (opts.can_notes) {
|
|
12871
|
+
notesPanel = _orderNotesPanel(o.id, opts.notes || [],
|
|
12872
|
+
opts.note_done ? "<div class=\"banner banner--ok\">Note saved.</div>" : "",
|
|
12873
|
+
opts.note_err ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.note_err) + "</div>" : "",
|
|
12874
|
+
opts.note_authors || [], opts.note_visibility || []);
|
|
12875
|
+
}
|
|
12876
|
+
|
|
12667
12877
|
var body =
|
|
12668
12878
|
"<section class=\"mw-48\">" +
|
|
12669
12879
|
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">← Orders</a></div>" +
|
|
@@ -12684,12 +12894,85 @@ function renderAdminOrder(opts) {
|
|
|
12684
12894
|
"</div>" +
|
|
12685
12895
|
documentsPanel +
|
|
12686
12896
|
resendPanel +
|
|
12897
|
+
notesPanel +
|
|
12687
12898
|
splitPanel +
|
|
12688
12899
|
trackingPanel +
|
|
12689
12900
|
"</section>";
|
|
12690
12901
|
return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
|
|
12691
12902
|
}
|
|
12692
12903
|
|
|
12904
|
+
// The order-detail customer-service notes panel. Lists the order's notes
|
|
12905
|
+
// newest-first (pinned float to the top, as the primitive orders them) with
|
|
12906
|
+
// each note's tier (internal / customer-visible), author, timestamp, pins,
|
|
12907
|
+
// resolution summary, and the legal lifecycle controls — pin / unpin always,
|
|
12908
|
+
// resolve (on an active internal note) / reopen (on a resolved one). Then an
|
|
12909
|
+
// add form (body + author + visibility). The note body is operator free text,
|
|
12910
|
+
// escaped with _htmlEscape at every interpolation. Every form renders through
|
|
12911
|
+
// _renderAdminShell, which injects the _csrf field into each POST form, so no
|
|
12912
|
+
// per-form token handling is needed here.
|
|
12913
|
+
function _orderNotesPanel(orderId, notes, doneBanner, errBanner, authors, visibilities) {
|
|
12914
|
+
var enc = encodeURIComponent(orderId);
|
|
12915
|
+
var noteItems = notes.map(function (n) {
|
|
12916
|
+
var nenc = _htmlEscape(encodeURIComponent(n.id));
|
|
12917
|
+
var base = "/admin/orders/" + _htmlEscape(enc) + "/notes/" + nenc;
|
|
12918
|
+
var isPinned = Number(n.pinned) === 1;
|
|
12919
|
+
var isInternal = n.visibility === "internal";
|
|
12920
|
+
var isResolved = n.resolved_at != null;
|
|
12921
|
+
var pills =
|
|
12922
|
+
"<span class=\"status-pill " + (isInternal ? "pending" : "shipped") + "\">" +
|
|
12923
|
+
_htmlEscape(isInternal ? "Internal" : "Customer-visible") + "</span>" +
|
|
12924
|
+
(isPinned ? "<span class=\"status-pill paid\">Pinned</span>" : "") +
|
|
12925
|
+
(isResolved ? "<span class=\"status-pill refunded\">Resolved</span>" : "");
|
|
12926
|
+
var pinForm = "<form method=\"post\" action=\"" + base + (isPinned ? "/unpin" : "/pin") + "\" class=\"form-inline\">" +
|
|
12927
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">" + (isPinned ? "Unpin" : "Pin") + "</button></form>";
|
|
12928
|
+
// Resolve / reopen apply only to internal threads (the primitive refuses a
|
|
12929
|
+
// customer-visible resolve). An active internal note offers a resolve form
|
|
12930
|
+
// (with the required summary); a resolved one offers reopen.
|
|
12931
|
+
var resolveForm = "";
|
|
12932
|
+
if (isInternal) {
|
|
12933
|
+
resolveForm = isResolved
|
|
12934
|
+
? "<form method=\"post\" action=\"" + base + "/reopen\" class=\"form-inline\">" +
|
|
12935
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Reopen</button></form>"
|
|
12936
|
+
: "<form method=\"post\" action=\"" + base + "/resolve\" class=\"return-action\">" +
|
|
12937
|
+
"<input type=\"text\" name=\"resolution\" placeholder=\"Resolution summary\" maxlength=\"280\" aria-label=\"Resolution summary\" required>" +
|
|
12938
|
+
"<button class=\"btn\" type=\"submit\">Resolve</button></form>";
|
|
12939
|
+
}
|
|
12940
|
+
var resolutionLine = (isResolved && n.resolution)
|
|
12941
|
+
? "<div class=\"note-meta\">Resolution: " + _htmlEscape(String(n.resolution)) + "</div>"
|
|
12942
|
+
: "";
|
|
12943
|
+
return "<li class=\"note-item\">" +
|
|
12944
|
+
"<div class=\"note-meta\">" + _htmlEscape(String(n.author)) + " · " + _htmlEscape(_fmtDate(n.created_at)) +
|
|
12945
|
+
" " + pills + "</div>" +
|
|
12946
|
+
"<div class=\"note-body\">" + _htmlEscape(String(n.body)) + "</div>" +
|
|
12947
|
+
resolutionLine +
|
|
12948
|
+
"<div class=\"actions-row\">" + pinForm + resolveForm + "</div>" +
|
|
12949
|
+
"</li>";
|
|
12950
|
+
}).join("");
|
|
12951
|
+
var authorOpts = (authors || []).map(function (a) {
|
|
12952
|
+
return "<option value=\"" + _htmlEscape(a) + "\"" + (a === "operator" ? " selected" : "") + ">" + _htmlEscape(a) + "</option>";
|
|
12953
|
+
}).join("");
|
|
12954
|
+
var visibilityOpts = (visibilities || []).map(function (v) {
|
|
12955
|
+
return "<option value=\"" + _htmlEscape(v) + "\"" + (v === "internal" ? " selected" : "") + ">" +
|
|
12956
|
+
_htmlEscape(v === "customer_visible" ? "Customer-visible" : v) + "</option>";
|
|
12957
|
+
}).join("");
|
|
12958
|
+
return "<div class=\"panel mt\"><h3 class=\"subhead\">Customer-service notes</h3>" +
|
|
12959
|
+
doneBanner + errBanner +
|
|
12960
|
+
"<p class=\"meta\">Threaded notes attached to this order. Internal notes are operator-only; customer-visible notes surface on the customer's order page. Pin the notes every shift should see first; resolve an internal thread with a short summary once it's handled.</p>" +
|
|
12961
|
+
(notes.length
|
|
12962
|
+
? "<ul class=\"note-list\">" + noteItems + "</ul>"
|
|
12963
|
+
: "<p class=\"empty\">No notes on this order yet.</p>") +
|
|
12964
|
+
"<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(enc) + "/notes\">" +
|
|
12965
|
+
"<label class=\"form-field\"><span>Note</span>" +
|
|
12966
|
+
"<textarea name=\"body\" required maxlength=\"8000\" rows=\"3\" placeholder=\"e.g. Buyer asked to delay shipment until Monday\"></textarea></label>" +
|
|
12967
|
+
"<div class=\"two-col\">" +
|
|
12968
|
+
"<label class=\"form-field\"><span>Author</span><select name=\"author\">" + authorOpts + "</select></label>" +
|
|
12969
|
+
"<label class=\"form-field\"><span>Visibility</span><select name=\"visibility\">" + visibilityOpts + "</select></label>" +
|
|
12970
|
+
"</div>" +
|
|
12971
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add note</button></div>" +
|
|
12972
|
+
"</form>" +
|
|
12973
|
+
"</div>";
|
|
12974
|
+
}
|
|
12975
|
+
|
|
12693
12976
|
// Per-shipment carrier-label sub-panel for the order detail. Lists the
|
|
12694
12977
|
// recorded labels (tracking number, broker, cost, status pill) with a
|
|
12695
12978
|
// "Mark used" action on purchased ones, then a form to record a freshly-
|
|
@@ -13260,11 +13543,45 @@ function renderAdminCustomerDetail(opts) {
|
|
|
13260
13543
|
"</div>";
|
|
13261
13544
|
}
|
|
13262
13545
|
|
|
13546
|
+
// ---- activity timeline (read-only) ---------------------------------
|
|
13547
|
+
// The chronological per-customer feed the customerActivity aggregator
|
|
13548
|
+
// flattens from the wired source primitives, newest-first. Read-only — the
|
|
13549
|
+
// operator never writes an event here; every row originates in a source
|
|
13550
|
+
// primitive's own table. title / body / actor are escaped (title is a fixed
|
|
13551
|
+
// canonical string per kind, but body can carry source free text — a review
|
|
13552
|
+
// title, a ticket subject — so both pass through _htmlEscape).
|
|
13553
|
+
var activityPanel = "";
|
|
13554
|
+
if (opts.can_activity) {
|
|
13555
|
+
var activityRows = (opts.activity || []).map(function (e) {
|
|
13556
|
+
// Same-origin path only: a leading "/" that is NOT "//" — a
|
|
13557
|
+
// protocol-relative "//evil.example/x" also starts with "/" but
|
|
13558
|
+
// resolves to an off-site origin, and _htmlEscape leaves "/"
|
|
13559
|
+
// untouched, so it would survive into a working cross-origin href.
|
|
13560
|
+
var link = (typeof e.link === "string" && e.link.charAt(0) === "/" && e.link.charAt(1) !== "/") ? e.link : null;
|
|
13561
|
+
var titleCell = link
|
|
13562
|
+
? "<a href=\"" + _htmlEscape(link) + "\">" + _htmlEscape(String(e.title || e.kind)) + "</a>"
|
|
13563
|
+
: _htmlEscape(String(e.title || e.kind));
|
|
13564
|
+
return "<li class=\"note-item\">" +
|
|
13565
|
+
"<div class=\"note-meta\">" + _htmlEscape(_fmtDate(e.occurred_at)) +
|
|
13566
|
+
" · <code class=\"order-id\">" + _htmlEscape(String(e.kind)) + "</code>" +
|
|
13567
|
+
(e.actor ? " · " + _htmlEscape(String(e.actor)) : "") + "</div>" +
|
|
13568
|
+
"<div class=\"note-body\">" + titleCell +
|
|
13569
|
+
(e.body ? " <span class=\"meta\">" + _htmlEscape(String(e.body)) + "</span>" : "") + "</div>" +
|
|
13570
|
+
"</li>";
|
|
13571
|
+
}).join("");
|
|
13572
|
+
activityPanel = "<div class=\"panel\"><h3 class=\"subhead\">Activity</h3>" +
|
|
13573
|
+
"<p class=\"meta\">A read-only chronological feed of this customer's recent events — orders, wishlist saves, loyalty changes, support tickets, and reviews — newest first. Each event lives in its source record; nothing is edited here.</p>" +
|
|
13574
|
+
((opts.activity || []).length
|
|
13575
|
+
? "<ul class=\"note-list\">" + activityRows + "</ul>"
|
|
13576
|
+
: "<p class=\"empty\">No recorded activity yet.</p>") +
|
|
13577
|
+
"</div>";
|
|
13578
|
+
}
|
|
13579
|
+
|
|
13263
13580
|
var body = "<section>" +
|
|
13264
13581
|
"<div class=\"actions-row\"><h2>" + _htmlEscape(c.display_name) + "</h2>" +
|
|
13265
13582
|
"<a class=\"btn btn--ghost\" href=\"/admin/customers\"><span aria-hidden=\"true\">←</span> All customers</a></div>" +
|
|
13266
13583
|
saved +
|
|
13267
|
-
identity + ordersPanel + creditPanel + loyaltyPanel + notesPanel + segmentsPanel +
|
|
13584
|
+
identity + ordersPanel + creditPanel + loyaltyPanel + notesPanel + segmentsPanel + activityPanel +
|
|
13268
13585
|
"</section>";
|
|
13269
13586
|
return _renderAdminShell(opts.shop_name, c.display_name || "Customer", body, "customers", opts.nav_available);
|
|
13270
13587
|
}
|
package/lib/asset-manifest.json
CHANGED
package/lib/checkout.js
CHANGED
|
@@ -606,7 +606,24 @@ function create(deps) {
|
|
|
606
606
|
async function _holdExempt(sku) {
|
|
607
607
|
if (preorder && typeof preorder.openCampaignForSku === "function") {
|
|
608
608
|
try {
|
|
609
|
-
|
|
609
|
+
var campaign = await preorder.openCampaignForSku(sku);
|
|
610
|
+
// A pre-order line sells beyond the shelf only while the campaign
|
|
611
|
+
// is genuinely pre-launch: before `launch_at` the unit isn't
|
|
612
|
+
// released yet, so the hold is deliberately skipped. ONCE
|
|
613
|
+
// `launch_at` has passed, the SKU is selling against real stock —
|
|
614
|
+
// keep it hold-exempt and a past-launch campaign that the operator
|
|
615
|
+
// never manually flipped to `launched` would oversell real
|
|
616
|
+
// inventory without bound. So past-launch_at → NOT exempt (holds
|
|
617
|
+
// apply). openCampaignForSku's own semantics are unchanged (the
|
|
618
|
+
// storefront PDP CTA still keys off the active row); the launch_at
|
|
619
|
+
// gate lives here, on the buy path. A campaign with no launch_at
|
|
620
|
+
// is treated as still-pre-launch (exempt).
|
|
621
|
+
if (campaign) {
|
|
622
|
+
var launchAt = campaign.launch_at == null ? null : Number(campaign.launch_at);
|
|
623
|
+
if (launchAt == null || !Number.isFinite(launchAt) || launchAt > Date.now()) {
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
610
627
|
} catch (_e) { /* not exempt on lookup failure */ }
|
|
611
628
|
}
|
|
612
629
|
if (backorder && typeof backorder.availabilityFor === "function") {
|
|
@@ -660,10 +677,12 @@ function create(deps) {
|
|
|
660
677
|
|
|
661
678
|
// Best-effort release of a set of placed holds (the rollback path).
|
|
662
679
|
// Drop-silent per hold: a release failure must not mask the original
|
|
663
|
-
// error that triggered the rollback
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
680
|
+
// error that triggered the rollback. This path runs ONLY when no pending
|
|
681
|
+
// order was created (the conditional rollback in confirm) — once an order
|
|
682
|
+
// exists it owns its holds, and the only thing that frees them is the
|
|
683
|
+
// paid decrement, an explicit cancel, or the stale-pending-order reaper
|
|
684
|
+
// (`reapStalePending`), driven once a minute by the worker cron, which
|
|
685
|
+
// cancels pending orders older than the TTL so their holds release.
|
|
667
686
|
async function _releaseStockHolds(holds) {
|
|
668
687
|
if (!Array.isArray(holds)) return;
|
|
669
688
|
for (var i = 0; i < holds.length; i += 1) {
|
|
@@ -821,10 +840,19 @@ function create(deps) {
|
|
|
821
840
|
// before the order is committed, so a refused gift-card / payment
|
|
822
841
|
// error never strands held stock.
|
|
823
842
|
var stockHolds = await _placeStockHolds(quote.lines);
|
|
843
|
+
// Conditional rollback: the catch releases the placed holds ONLY when
|
|
844
|
+
// no pending order was created. Once createFromCart succeeds, the
|
|
845
|
+
// order row OWNS those holds — releasing them here would double-free
|
|
846
|
+
// (the paid decrement / cancel release, or the stale-pending reaper,
|
|
847
|
+
// settles them once the order exists), and a floored blanket release
|
|
848
|
+
// could even eat a CONCURRENT shopper's hold on the same SKU. The
|
|
849
|
+
// context object is mutated inside _confirmAfterHolds the instant the
|
|
850
|
+
// order is created.
|
|
851
|
+
var rollbackCtx = { orderCreated: false };
|
|
824
852
|
try {
|
|
825
|
-
return await this._confirmAfterHolds(input, quote, email, stockHolds);
|
|
853
|
+
return await this._confirmAfterHolds(input, quote, email, stockHolds, rollbackCtx);
|
|
826
854
|
} catch (e) {
|
|
827
|
-
await _releaseStockHolds(stockHolds);
|
|
855
|
+
if (!rollbackCtx.orderCreated) await _releaseStockHolds(stockHolds);
|
|
828
856
|
throw e;
|
|
829
857
|
}
|
|
830
858
|
},
|
|
@@ -835,8 +863,11 @@ function create(deps) {
|
|
|
835
863
|
// Stripe-intent) both live inside that guard. Internal — invoked only
|
|
836
864
|
// through confirm() above (hence the `_` prefix); `stockHolds` is the
|
|
837
865
|
// list of placed holds, which both terminal paths leave in place (the
|
|
838
|
-
// pending order owns them until paid / cancelled).
|
|
839
|
-
|
|
866
|
+
// pending order owns them until paid / cancelled). `rollbackCtx` is the
|
|
867
|
+
// mutable flag the catch reads: set `orderCreated` true the moment a
|
|
868
|
+
// pending order exists so a LATER throw doesn't release holds the order
|
|
869
|
+
// now owns.
|
|
870
|
+
_confirmAfterHolds: async function (input, quote, email, stockHolds, rollbackCtx) {
|
|
840
871
|
// Already validated in confirm() above; re-read here since the PI
|
|
841
872
|
// creation moved into this split-out body.
|
|
842
873
|
var idempotencyKey = input.idempotency_key;
|
|
@@ -906,6 +937,9 @@ function create(deps) {
|
|
|
906
937
|
customer_email_hash: emailHash,
|
|
907
938
|
lines: orderLines,
|
|
908
939
|
});
|
|
940
|
+
// The pending order now owns the holds — a throw from here on must
|
|
941
|
+
// NOT blanket-release them (see the conditional rollback in confirm).
|
|
942
|
+
if (rollbackCtx) rollbackCtx.orderCreated = true;
|
|
909
943
|
if (gc) await _redeemGiftCard(gc, paidOrder.id);
|
|
910
944
|
if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id);
|
|
911
945
|
// Best-effort: record the auto-discount redemptions against the
|
|
@@ -962,6 +996,11 @@ function create(deps) {
|
|
|
962
996
|
customer_email_hash: emailHash,
|
|
963
997
|
lines: orderLines,
|
|
964
998
|
});
|
|
999
|
+
// The pending order now owns the holds — a throw from here on (gift-card
|
|
1000
|
+
// burn, discount recording) must NOT blanket-release them (see the
|
|
1001
|
+
// conditional rollback in confirm). The reaper / cancel / paid edge
|
|
1002
|
+
// settles them now that the order exists.
|
|
1003
|
+
if (rollbackCtx) rollbackCtx.orderCreated = true;
|
|
965
1004
|
|
|
966
1005
|
// Burn the gift-card + loyalty credits against the created order.
|
|
967
1006
|
// Runs after the order row exists so a failed order never spends
|
|
@@ -995,6 +1034,93 @@ function create(deps) {
|
|
|
995
1034
|
};
|
|
996
1035
|
},
|
|
997
1036
|
|
|
1037
|
+
// Stale-pending-order reaper. A pending order whose buyer abandoned the
|
|
1038
|
+
// payment sheet (Stripe / PayPal) or whose PaymentIntent expired holds
|
|
1039
|
+
// its reserved stock forever — `confirm` places the holds and creates
|
|
1040
|
+
// the pending order, but the only hold-freeing FSM edges are
|
|
1041
|
+
// pending→paid (decrement) and pending→cancelled (release), and nothing
|
|
1042
|
+
// fires the cancel automatically. This reaper, driven once a minute by
|
|
1043
|
+
// the worker cron's `/_/stale-order-reap` tick, cancels orders older
|
|
1044
|
+
// than the TTL so their holds release.
|
|
1045
|
+
//
|
|
1046
|
+
// Per order, Stripe-paid orders: cancel the PaymentIntent FIRST so a
|
|
1047
|
+
// late authorization can't complete AFTER we cancel the order. If Stripe
|
|
1048
|
+
// reports the PI already succeeded / is not cancelable
|
|
1049
|
+
// (`payment_intent_unexpected_state`, or any non-success cancel), SKIP
|
|
1050
|
+
// the order — the webhook has settled or will settle it, and cancelling
|
|
1051
|
+
// an order whose payment may have completed would lose a real sale.
|
|
1052
|
+
// Only after a clean PI cancel (or for an order with no Stripe
|
|
1053
|
+
// PaymentIntent — a PayPal-created order, whose capture path guards on
|
|
1054
|
+
// local status so reaping first is safe, or a zero-amount leftover) do
|
|
1055
|
+
// we fire the cancel transition, which releases the holds via the
|
|
1056
|
+
// existing path.
|
|
1057
|
+
//
|
|
1058
|
+
// Per-order failures are drop-silent + continue (hot-path sink tier) but
|
|
1059
|
+
// COUNTED in the returned summary so the operator sees the reap's shape.
|
|
1060
|
+
// `ttlMinutes` is validated config-time by the caller (the tick handler
|
|
1061
|
+
// throws on garbage at boot); defended here too with a documented
|
|
1062
|
+
// default so a direct call is never unsafe.
|
|
1063
|
+
reapStalePending: async function (opts) {
|
|
1064
|
+
opts = opts || {};
|
|
1065
|
+
var ttlMinutes = opts.ttl_minutes;
|
|
1066
|
+
if (ttlMinutes == null) ttlMinutes = 120;
|
|
1067
|
+
if (typeof ttlMinutes !== "number" || !isFinite(ttlMinutes) || !Number.isInteger(ttlMinutes) || ttlMinutes < 5) {
|
|
1068
|
+
throw new TypeError("checkout.reapStalePending: ttl_minutes must be an integer >= 5");
|
|
1069
|
+
}
|
|
1070
|
+
var nowMs = typeof opts.now === "number" ? opts.now : Date.now();
|
|
1071
|
+
var cutoffTs = nowMs - b.constants.TIME.minutes(ttlMinutes);
|
|
1072
|
+
var batchLimit = opts.limit == null ? 100 : opts.limit;
|
|
1073
|
+
|
|
1074
|
+
var summary = {
|
|
1075
|
+
scanned: 0,
|
|
1076
|
+
reaped: 0, // cancelled (holds released)
|
|
1077
|
+
skipped_paid: 0, // PI already succeeded / not cancelable — left pending
|
|
1078
|
+
errored: 0, // a per-order failure, dropped + counted
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
var stale;
|
|
1082
|
+
try {
|
|
1083
|
+
stale = (await order.listStalePending(cutoffTs, batchLimit)).rows;
|
|
1084
|
+
} catch (e) {
|
|
1085
|
+
// The candidate read itself failed — surface it in the summary
|
|
1086
|
+
// rather than throwing out of the tick (which would 5xx the cron).
|
|
1087
|
+
return Object.assign({ ok: false, error: (e && e.message) || String(e) }, summary);
|
|
1088
|
+
}
|
|
1089
|
+
summary.scanned = stale.length;
|
|
1090
|
+
|
|
1091
|
+
for (var i = 0; i < stale.length; i += 1) {
|
|
1092
|
+
var row = stale[i];
|
|
1093
|
+
try {
|
|
1094
|
+
var piId = row.payment_intent_id;
|
|
1095
|
+
// A Stripe PaymentIntent id is `pi_…`; a PayPal order id is opaque
|
|
1096
|
+
// (no `pi_` prefix) and has no cancel API for an unapproved order
|
|
1097
|
+
// (its capture path guards on local status, so reaping first can
|
|
1098
|
+
// never double-charge). Only the Stripe case needs a pre-cancel.
|
|
1099
|
+
var isStripePi = typeof piId === "string" && piId.indexOf("pi_") === 0;
|
|
1100
|
+
if (isStripePi && payment && typeof payment.cancelPaymentIntent === "function") {
|
|
1101
|
+
try {
|
|
1102
|
+
await payment.cancelPaymentIntent(piId);
|
|
1103
|
+
} catch (_cancelErr) {
|
|
1104
|
+
// PI already succeeded / not cancelable → the webhook owns this
|
|
1105
|
+
// order's settlement. Leave it pending; never cancel an order
|
|
1106
|
+
// whose payment may have completed.
|
|
1107
|
+
summary.skipped_paid += 1;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
// PI cancelled cleanly (or no Stripe PI) — release the holds by
|
|
1112
|
+
// cancelling the order through the existing FSM edge.
|
|
1113
|
+
await order.transition(row.id, "cancel", { reason: "stale-pending-reap" });
|
|
1114
|
+
summary.reaped += 1;
|
|
1115
|
+
} catch (_e) {
|
|
1116
|
+
// drop-silent per order — by design (hot-path sink): one bad order
|
|
1117
|
+
// must not poison the sweep. Counted so the operator sees it.
|
|
1118
|
+
summary.errored += 1;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return Object.assign({ ok: true }, summary);
|
|
1122
|
+
},
|
|
1123
|
+
|
|
998
1124
|
// Verify a Stripe webhook payload and dispatch the order
|
|
999
1125
|
// transition. Returns { handled, order?, event_type }.
|
|
1000
1126
|
handleStripeEvent: async function (input) {
|
|
@@ -1091,6 +1217,14 @@ function create(deps) {
|
|
|
1091
1217
|
// here (no PayPal order created). Released on any throw before the
|
|
1092
1218
|
// local order row commits.
|
|
1093
1219
|
var ppHolds = await _placeStockHolds(quote.lines);
|
|
1220
|
+
// Conditional rollback, identical discipline to the Stripe confirm:
|
|
1221
|
+
// release the placed holds ONLY when no pending order was created.
|
|
1222
|
+
// Once createFromCart succeeds the order owns the holds; releasing
|
|
1223
|
+
// them here would double-free and could eat a concurrent shopper's
|
|
1224
|
+
// hold on the same SKU. A pending PayPal order that the buyer never
|
|
1225
|
+
// approves is reaped by the stale-pending reaper (no PayPal-side
|
|
1226
|
+
// cancel needed — the capture path guards on local status).
|
|
1227
|
+
var ppOrderCreated = false;
|
|
1094
1228
|
try {
|
|
1095
1229
|
// Resolve an optional gift-card credit before opening the PayPal
|
|
1096
1230
|
// order so a bad code fails without a remote round-trip.
|
|
@@ -1121,6 +1255,7 @@ function create(deps) {
|
|
|
1121
1255
|
customer_email_hash: emailHash,
|
|
1122
1256
|
lines: ppLines,
|
|
1123
1257
|
});
|
|
1258
|
+
ppOrderCreated = true;
|
|
1124
1259
|
await _redeemGiftCard(gc, paidOrder.id);
|
|
1125
1260
|
await cart.setStatus(quote.cart_id, "converted");
|
|
1126
1261
|
var settled = await order.transition(paidOrder.id, "mark_paid", { reason: "gift_card:full" });
|
|
@@ -1149,13 +1284,15 @@ function create(deps) {
|
|
|
1149
1284
|
customer_email_hash: emailHash,
|
|
1150
1285
|
lines: ppLines,
|
|
1151
1286
|
});
|
|
1287
|
+
ppOrderCreated = true;
|
|
1152
1288
|
if (gc) await _redeemGiftCard(gc, createdOrder.id);
|
|
1153
1289
|
await cart.setStatus(quote.cart_id, "converted");
|
|
1154
1290
|
return { order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status, gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null };
|
|
1155
1291
|
} catch (e) {
|
|
1156
|
-
//
|
|
1292
|
+
// A throw BEFORE the order row commits (PayPal open failure,
|
|
1157
1293
|
// gift-card error) releases the holds so PayPal can't strand stock.
|
|
1158
|
-
|
|
1294
|
+
// After the order commits it owns the holds — don't blanket-release.
|
|
1295
|
+
if (!ppOrderCreated) await _releaseStockHolds(ppHolds);
|
|
1159
1296
|
throw e;
|
|
1160
1297
|
}
|
|
1161
1298
|
},
|
package/lib/customer-activity.js
CHANGED
|
@@ -248,7 +248,11 @@ function create(opts) {
|
|
|
248
248
|
// the aggregator). Missing peers collapse to an empty list so the
|
|
249
249
|
// aggregator stays the same shape regardless of wiring.
|
|
250
250
|
|
|
251
|
-
|
|
251
|
+
// A read bound is "active" only for a positive integer; null / undefined
|
|
252
|
+
// (the summary path) means read everything, as before.
|
|
253
|
+
function _isBound(n) { return Number.isInteger(n) && n > 0; }
|
|
254
|
+
|
|
255
|
+
async function _collectOrderEvents(customerId, fromTs, toTs, boundLimit) {
|
|
252
256
|
if (!orderPeer) return [];
|
|
253
257
|
var sql = "SELECT ot.order_id, ot.from_state, ot.to_state, ot.on_event, " +
|
|
254
258
|
"ot.reason, ot.occurred_at " +
|
|
@@ -259,7 +263,11 @@ function create(opts) {
|
|
|
259
263
|
var idx = 2;
|
|
260
264
|
if (fromTs != null) { sql += " AND ot.occurred_at >= ?" + idx; params.push(fromTs); idx += 1; }
|
|
261
265
|
if (toTs != null) { sql += " AND ot.occurred_at <= ?" + idx; params.push(toTs); idx += 1; }
|
|
262
|
-
|
|
266
|
+
// Bounded read: newest-N (DESC + LIMIT) when the paginated caller supplies
|
|
267
|
+
// a bound; the aggregator re-sorts newest-first so the DESC here is just
|
|
268
|
+
// the read order. Unbounded (summary path) keeps the full ASC scan.
|
|
269
|
+
if (_isBound(boundLimit)) { sql += " ORDER BY ot.occurred_at DESC LIMIT ?" + idx; params.push(boundLimit); }
|
|
270
|
+
else { sql += " ORDER BY ot.occurred_at ASC"; }
|
|
263
271
|
var rows = (await query(sql, params)).rows;
|
|
264
272
|
var out = [];
|
|
265
273
|
for (var i = 0; i < rows.length; i += 1) {
|
|
@@ -278,7 +286,7 @@ function create(opts) {
|
|
|
278
286
|
return out;
|
|
279
287
|
}
|
|
280
288
|
|
|
281
|
-
async function _collectWishlistEvents(customerId, fromTs, toTs) {
|
|
289
|
+
async function _collectWishlistEvents(customerId, fromTs, toTs, boundLimit) {
|
|
282
290
|
if (!wishlistPeer) return [];
|
|
283
291
|
var sql = "SELECT id, product_id, variant_id, notes, created_at " +
|
|
284
292
|
"FROM wishlist_entries WHERE customer_id = ?1";
|
|
@@ -286,7 +294,8 @@ function create(opts) {
|
|
|
286
294
|
var idx = 2;
|
|
287
295
|
if (fromTs != null) { sql += " AND created_at >= ?" + idx; params.push(fromTs); idx += 1; }
|
|
288
296
|
if (toTs != null) { sql += " AND created_at <= ?" + idx; params.push(toTs); idx += 1; }
|
|
289
|
-
sql += " ORDER BY created_at
|
|
297
|
+
if (_isBound(boundLimit)) { sql += " ORDER BY created_at DESC LIMIT ?" + idx; params.push(boundLimit); }
|
|
298
|
+
else { sql += " ORDER BY created_at ASC"; }
|
|
290
299
|
var rows = (await query(sql, params)).rows;
|
|
291
300
|
var out = [];
|
|
292
301
|
for (var i = 0; i < rows.length; i += 1) {
|
|
@@ -303,7 +312,7 @@ function create(opts) {
|
|
|
303
312
|
return out;
|
|
304
313
|
}
|
|
305
314
|
|
|
306
|
-
async function _collectLoyaltyEvents(customerId, fromTs, toTs) {
|
|
315
|
+
async function _collectLoyaltyEvents(customerId, fromTs, toTs, boundLimit) {
|
|
307
316
|
if (!loyaltyPeer) return [];
|
|
308
317
|
var sql = "SELECT id, transaction_type, points, source, order_id, notes, " +
|
|
309
318
|
"occurred_at FROM loyalty_transactions WHERE customer_id = ?1";
|
|
@@ -311,7 +320,8 @@ function create(opts) {
|
|
|
311
320
|
var idx = 2;
|
|
312
321
|
if (fromTs != null) { sql += " AND occurred_at >= ?" + idx; params.push(fromTs); idx += 1; }
|
|
313
322
|
if (toTs != null) { sql += " AND occurred_at <= ?" + idx; params.push(toTs); idx += 1; }
|
|
314
|
-
sql += " ORDER BY occurred_at
|
|
323
|
+
if (_isBound(boundLimit)) { sql += " ORDER BY occurred_at DESC LIMIT ?" + idx; params.push(boundLimit); }
|
|
324
|
+
else { sql += " ORDER BY occurred_at ASC"; }
|
|
315
325
|
var rows = (await query(sql, params)).rows;
|
|
316
326
|
var out = [];
|
|
317
327
|
for (var i = 0; i < rows.length; i += 1) {
|
|
@@ -331,19 +341,27 @@ function create(opts) {
|
|
|
331
341
|
return out;
|
|
332
342
|
}
|
|
333
343
|
|
|
334
|
-
async function _collectSupportEvents(customerId, fromTs, toTs) {
|
|
344
|
+
async function _collectSupportEvents(customerId, fromTs, toTs, boundLimit) {
|
|
335
345
|
if (!supportPeer) return [];
|
|
336
346
|
// The support_tickets row contributes an "opened" event at
|
|
337
347
|
// opened_at and a "resolved" event at resolved_at when stamped.
|
|
338
348
|
// The bounded-window filter is applied per-event after
|
|
339
349
|
// splitting (a ticket opened inside the window but resolved
|
|
340
|
-
// outside still surfaces its opened event).
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
350
|
+
// outside still surfaces its opened event). Bounded read: order by
|
|
351
|
+
// the ticket's NEWEST event timestamp — MAX(opened_at, resolved_at)
|
|
352
|
+
// — not opened_at alone, or an old ticket resolved recently would
|
|
353
|
+
// be dropped and its resolved event would vanish from the page.
|
|
354
|
+
// Every event in the true newest-`boundLimit` set belongs to a
|
|
355
|
+
// ticket whose newest-event timestamp is at least that event's, so
|
|
356
|
+
// the newest `boundLimit` tickets under this ordering cover any
|
|
357
|
+
// single page of `boundLimit` events. Unbounded (summary path)
|
|
358
|
+
// keeps the full ASC scan for exact windowed counts.
|
|
359
|
+
var supSql = "SELECT id, subject, category, status, priority, opened_at, " +
|
|
360
|
+
"resolved_at, closed_at FROM support_tickets WHERE customer_id = ?1 ";
|
|
361
|
+
var supParams = [customerId];
|
|
362
|
+
if (_isBound(boundLimit)) { supSql += "ORDER BY MAX(opened_at, COALESCE(resolved_at, opened_at)) DESC LIMIT ?2"; supParams.push(boundLimit); }
|
|
363
|
+
else { supSql += "ORDER BY opened_at ASC"; }
|
|
364
|
+
var rows = (await query(supSql, supParams)).rows;
|
|
347
365
|
var out = [];
|
|
348
366
|
for (var i = 0; i < rows.length; i += 1) {
|
|
349
367
|
var t = rows[i];
|
|
@@ -375,7 +393,7 @@ function create(opts) {
|
|
|
375
393
|
return out;
|
|
376
394
|
}
|
|
377
395
|
|
|
378
|
-
async function _collectReviewEvents(customerId, fromTs, toTs) {
|
|
396
|
+
async function _collectReviewEvents(customerId, fromTs, toTs, boundLimit) {
|
|
379
397
|
if (!reviewsPeer) return [];
|
|
380
398
|
// Only authenticated-customer reviews carry a customer_id (the
|
|
381
399
|
// anonymous email-hash submissions land in the reviews table
|
|
@@ -387,7 +405,8 @@ function create(opts) {
|
|
|
387
405
|
var idx = 2;
|
|
388
406
|
if (fromTs != null) { sql += " AND created_at >= ?" + idx; params.push(fromTs); idx += 1; }
|
|
389
407
|
if (toTs != null) { sql += " AND created_at <= ?" + idx; params.push(toTs); idx += 1; }
|
|
390
|
-
sql += " ORDER BY created_at
|
|
408
|
+
if (_isBound(boundLimit)) { sql += " ORDER BY created_at DESC LIMIT ?" + idx; params.push(boundLimit); }
|
|
409
|
+
else { sql += " ORDER BY created_at ASC"; }
|
|
391
410
|
var rows = (await query(sql, params)).rows;
|
|
392
411
|
var out = [];
|
|
393
412
|
for (var i = 0; i < rows.length; i += 1) {
|
|
@@ -406,13 +425,18 @@ function create(opts) {
|
|
|
406
425
|
|
|
407
426
|
// ---- aggregation ------------------------------------------------------
|
|
408
427
|
|
|
409
|
-
|
|
428
|
+
// `boundLimit` (optional) caps each collector's read to the newest N rows
|
|
429
|
+
// — passed by the paginated `forCustomer` read where a page is at most
|
|
430
|
+
// `limit` events. The summary path passes null/undefined (unbounded) so
|
|
431
|
+
// the 30/90/365-day windowed kind-counts stay exact for a very active
|
|
432
|
+
// customer. A null bound preserves the original read-everything behavior.
|
|
433
|
+
async function _collectAll(customerId, fromTs, toTs, boundLimit) {
|
|
410
434
|
var batches = await Promise.all([
|
|
411
|
-
_collectOrderEvents(customerId, fromTs, toTs),
|
|
412
|
-
_collectWishlistEvents(customerId, fromTs, toTs),
|
|
413
|
-
_collectLoyaltyEvents(customerId, fromTs, toTs),
|
|
414
|
-
_collectSupportEvents(customerId, fromTs, toTs),
|
|
415
|
-
_collectReviewEvents(customerId, fromTs, toTs),
|
|
435
|
+
_collectOrderEvents(customerId, fromTs, toTs, boundLimit),
|
|
436
|
+
_collectWishlistEvents(customerId, fromTs, toTs, boundLimit),
|
|
437
|
+
_collectLoyaltyEvents(customerId, fromTs, toTs, boundLimit),
|
|
438
|
+
_collectSupportEvents(customerId, fromTs, toTs, boundLimit),
|
|
439
|
+
_collectReviewEvents(customerId, fromTs, toTs, boundLimit),
|
|
416
440
|
]);
|
|
417
441
|
var flat = [];
|
|
418
442
|
for (var b = 0; b < batches.length; b += 1) {
|
|
@@ -647,7 +671,32 @@ function create(opts) {
|
|
|
647
671
|
var limit = _limit(input.limit, MAX_LIMIT, DEFAULT_LIMIT);
|
|
648
672
|
var cursor = _decodeCursor(input.cursor, "forCustomer");
|
|
649
673
|
|
|
650
|
-
|
|
674
|
+
// Per-collector read bound: a single page draws at most `limit` events
|
|
675
|
+
// total across all sources, so the newest rows from EACH source (at or
|
|
676
|
+
// before the cursor's timestamp when paginating) are a sufficient
|
|
677
|
+
// superset — the merge + sort + cursor + slice below can never need an
|
|
678
|
+
// older row than the `limit`-th newest of any one source. A customer
|
|
679
|
+
// with thousands of events no longer reads them all per panel render.
|
|
680
|
+
//
|
|
681
|
+
// The cursor's tail timestamp tightens the upper edge so deep pages
|
|
682
|
+
// stay correct: collectors return the newest events at-or-before it,
|
|
683
|
+
// and _applyCursor then drops the equal-or-newer boundary tuple. Because
|
|
684
|
+
// that drop removes the single boundary event per source, the bound is
|
|
685
|
+
// `limit + 1` — enough that a full `limit` page (and a correct
|
|
686
|
+
// next_cursor) survives the drop and pagination doesn't stall early.
|
|
687
|
+
// A `kinds` filter narrows the OUTPUT after collection, so when one is
|
|
688
|
+
// active the bound widens to MAX_LIMIT to give the filter enough
|
|
689
|
+
// candidates per source.
|
|
690
|
+
var collectorBound = kinds ? MAX_LIMIT : (limit + 1);
|
|
691
|
+
var collectorToTs = toTs;
|
|
692
|
+
if (cursor && cursor.length) {
|
|
693
|
+
var cursorTs = Number(cursor[0]);
|
|
694
|
+
if (Number.isFinite(cursorTs)) {
|
|
695
|
+
collectorToTs = (collectorToTs == null) ? cursorTs : Math.min(collectorToTs, cursorTs);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
var events = await _collectAll(customerId, fromTs, collectorToTs, collectorBound);
|
|
651
700
|
events = _filterKinds(events, kinds);
|
|
652
701
|
_sortNewestFirst(events);
|
|
653
702
|
if (cursor) events = _applyCursor(events, cursor);
|
package/lib/order.js
CHANGED
|
@@ -209,6 +209,14 @@ function create(opts) {
|
|
|
209
209
|
// the physical return is inspected. Opt-in like the other handles so
|
|
210
210
|
// tests and an inventory-less deploy run unchanged.
|
|
211
211
|
var inventory = opts.inventory || null;
|
|
212
|
+
// Optional error-log handle — when present, an inventory settlement
|
|
213
|
+
// failure (a decrement / release throw on the paid / cancel edge) is
|
|
214
|
+
// captured to the operator's error feed (/admin/errors) with the exact
|
|
215
|
+
// sku / qty / order so the stranded hold is reconcilable by hand via the
|
|
216
|
+
// inventory-adjustment surface. Opt-in like the other handles; absent it,
|
|
217
|
+
// settlement failures still surface to the audit sink (b.audit.safeEmit
|
|
218
|
+
// below), just not the durable error feed.
|
|
219
|
+
var errorLog = opts.errorLog || null;
|
|
212
220
|
// Pagination cursors for listForCustomer are HMAC-tagged via
|
|
213
221
|
// b.pagination so an operator can't hand-craft one to skip past a
|
|
214
222
|
// hidden order or replay across deployments. The secret defaults
|
|
@@ -223,6 +231,48 @@ function create(opts) {
|
|
|
223
231
|
}
|
|
224
232
|
var cursorSecret = opts.cursorSecret;
|
|
225
233
|
|
|
234
|
+
// Settle one held SKU on a state edge — `inventory.decrement` on the
|
|
235
|
+
// paid edge, `inventory.release` on the cancel-from-pending edge.
|
|
236
|
+
//
|
|
237
|
+
// drop-silent-with-capture — by design: the order's payment has already
|
|
238
|
+
// succeeded (paid edge) or the cancel has already persisted (cancel
|
|
239
|
+
// edge), and the webhook driving this MUST return 2xx or Stripe retries
|
|
240
|
+
// forever. A throw here would 500 the webhook, the retry would hit the
|
|
241
|
+
// already-advanced guard and skip, and the hold would strand silently.
|
|
242
|
+
// So a per-SKU settlement failure is caught, NOT re-thrown, and surfaced
|
|
243
|
+
// LOUDLY instead: an `order.settlement.error` audit event plus, when the
|
|
244
|
+
// error-log handle is wired, a durable row in /admin/errors carrying the
|
|
245
|
+
// exact sku / qty / order so the operator reconciles the stranded hold
|
|
246
|
+
// via the inventory-adjustment surface. Returns true on success, false on
|
|
247
|
+
// a (captured) failure so the caller can count strandings.
|
|
248
|
+
async function _settleSku(verb, sku, qty, orderId) {
|
|
249
|
+
try {
|
|
250
|
+
await inventory[verb](sku, qty);
|
|
251
|
+
return true;
|
|
252
|
+
} catch (e) {
|
|
253
|
+
var message = "order.settlement." + verb + " failed — sku=" + sku +
|
|
254
|
+
" qty=" + qty + " order=" + orderId + ": " + (e && e.message || e);
|
|
255
|
+
try {
|
|
256
|
+
b.audit.safeEmit({
|
|
257
|
+
action: "order.settlement.error",
|
|
258
|
+
outcome: "failure",
|
|
259
|
+
metadata: { verb: verb, sku: sku, qty: qty, order_id: orderId, message: (e && e.message) || String(e) },
|
|
260
|
+
});
|
|
261
|
+
} catch (_auditErr) { /* drop-silent — the capture below is the durable record */ }
|
|
262
|
+
if (errorLog && typeof errorLog.captureServerError === "function") {
|
|
263
|
+
try {
|
|
264
|
+
await errorLog.captureServerError({
|
|
265
|
+
route: "/order/" + orderId + "/settlement",
|
|
266
|
+
method: "POST",
|
|
267
|
+
status: 500,
|
|
268
|
+
message: message,
|
|
269
|
+
});
|
|
270
|
+
} catch (_logErr) { /* drop-silent — never let the error-feed write mask the original failure */ }
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
226
276
|
return {
|
|
227
277
|
TERMINAL_STATES: TERMINAL_STATES,
|
|
228
278
|
|
|
@@ -376,6 +426,13 @@ function create(opts) {
|
|
|
376
426
|
var holdMap = _stockHoldMap(refreshed);
|
|
377
427
|
var holdSkus = Object.keys(holdMap);
|
|
378
428
|
if (holdSkus.length) {
|
|
429
|
+
// Each SKU settles in its own try/catch (_settleSku) so a single
|
|
430
|
+
// SKU's decrement / release throw on a transient DB error can't
|
|
431
|
+
// strand the OTHER SKUs' holds or 500 the webhook — the remaining
|
|
432
|
+
// SKUs in the loop still settle, and each failure surfaces loudly
|
|
433
|
+
// (audit + error-feed) for manual reconciliation. The transition
|
|
434
|
+
// has already persisted; settlement is post-commit best-effort
|
|
435
|
+
// with a durable failure trail, never a hard gate.
|
|
379
436
|
if (result.from === "pending" && result.to === "paid"
|
|
380
437
|
&& typeof inventory.decrement === "function") {
|
|
381
438
|
// Convert each held SKU's reservation into a real shelf debit,
|
|
@@ -383,7 +440,7 @@ function create(opts) {
|
|
|
383
440
|
// guard (stock_held >= qty) makes a re-delivered mark_paid a
|
|
384
441
|
// no-op, so this is idempotent across webhook re-deliveries.
|
|
385
442
|
for (var di = 0; di < holdSkus.length; di += 1) {
|
|
386
|
-
await
|
|
443
|
+
await _settleSku("decrement", holdSkus[di], holdMap[holdSkus[di]], orderId);
|
|
387
444
|
}
|
|
388
445
|
} else if (result.from === "pending" && result.to === "cancelled"
|
|
389
446
|
&& typeof inventory.release === "function") {
|
|
@@ -393,7 +450,7 @@ function create(opts) {
|
|
|
393
450
|
// another shopper holds on the same SKU. release floors at zero
|
|
394
451
|
// so a double-cancel can't underflow stock_held.
|
|
395
452
|
for (var ri = 0; ri < holdSkus.length; ri += 1) {
|
|
396
|
-
await
|
|
453
|
+
await _settleSku("release", holdSkus[ri], holdMap[holdSkus[ri]], orderId);
|
|
397
454
|
}
|
|
398
455
|
}
|
|
399
456
|
}
|
|
@@ -586,6 +643,34 @@ function create(opts) {
|
|
|
586
643
|
return { rows: rows };
|
|
587
644
|
},
|
|
588
645
|
|
|
646
|
+
// Pending orders whose `created_at` is at or before `olderThanTs` —
|
|
647
|
+
// the candidate set for the stale-pending-order reaper. A pending
|
|
648
|
+
// order that never advanced to paid (the buyer abandoned the Stripe /
|
|
649
|
+
// PayPal sheet, the PaymentIntent expired) holds its reserved stock
|
|
650
|
+
// forever with no FSM edge to free it; the reaper cancels these so the
|
|
651
|
+
// hold releases. Bounded (`limit`, default 100, capped at
|
|
652
|
+
// MAX_LIST_LIMIT) so one tick reaps a chunk and the next picks up the
|
|
653
|
+
// rest. Returns the raw row (id + payment_intent_id + created_at are
|
|
654
|
+
// what the reaper needs); the reaper drives the actual hold release
|
|
655
|
+
// through `transition(id, "cancel", …)`. Oldest-first so the longest-
|
|
656
|
+
// stranded holds free first.
|
|
657
|
+
listStalePending: async function (olderThanTs, limit) {
|
|
658
|
+
if (typeof olderThanTs !== "number" || !isFinite(olderThanTs) || olderThanTs < 0) {
|
|
659
|
+
throw new TypeError("order.listStalePending: olderThanTs must be a non-negative epoch-ms number");
|
|
660
|
+
}
|
|
661
|
+
var lim = limit == null ? 100 : limit;
|
|
662
|
+
if (!Number.isInteger(lim) || lim <= 0 || lim > MAX_LIST_LIMIT) {
|
|
663
|
+
throw new TypeError("order.listStalePending: limit must be 1..." + MAX_LIST_LIMIT);
|
|
664
|
+
}
|
|
665
|
+
var rows = (await query(
|
|
666
|
+
"SELECT id, payment_intent_id, created_at FROM orders " +
|
|
667
|
+
"WHERE status = 'pending' AND created_at <= ?1 " +
|
|
668
|
+
"ORDER BY created_at ASC, id ASC LIMIT ?2",
|
|
669
|
+
[olderThanTs, lim],
|
|
670
|
+
)).rows;
|
|
671
|
+
return { rows: rows };
|
|
672
|
+
},
|
|
673
|
+
|
|
589
674
|
// The actions available from a given status, as {on, to, label} —
|
|
590
675
|
// drives the transition buttons on the operator order-detail page.
|
|
591
676
|
// A terminal status returns []. Synchronous (pure lookup).
|
package/package.json
CHANGED