@blamejs/blamejs-shop 0.4.21 → 0.4.22
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 +2 -0
- package/README.md +1 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/compliance-export.js +61 -4
- package/lib/customer-portal.js +23 -0
- package/lib/customer-segments.js +8 -11
- package/lib/customers.js +72 -0
- package/lib/email-campaigns.js +8 -1
- package/lib/order-export.js +14 -17
- package/lib/security-middleware.js +13 -5
- package/lib/storefront.js +19 -1
- package/lib/support-tickets.js +113 -53
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.22 (2026-06-06) — **Privacy export covers every table, erasure revokes the login, one-click unsubscribe actually works, and bounces feed the suppression list.** A privacy and email-integrity release. The data-subject export now includes reviews, consent history, wishlist, surveys, and recently-viewed data — with a manifest that makes any absent section visible. Erasing a customer also deletes their passkeys and social-login links and revokes live sessions, so a deleted account can no longer sign in. The one-click unsubscribe that mail clients invoke from their native button now works (it pointed at a route that didn't exist), a new intake endpoint feeds provider bounce and complaint webhooks into the marketing suppression list, support-ticket edge cases are fixed, and the CSV exports share one complete injection-neutralizer. **Added:** *Bounce and complaint webhook intake* — A new `POST /api/webhooks/mail-bounce` endpoint accepts bounce and complaint webhooks from Postmark, SES, or Resend (selectable per request). Spam complaints and permanently-dead addresses land on the marketing suppression list — transactional mail still flows — and campaign metrics gain the bounce events they were built to read. The endpoint is armed only when `MAIL_BOUNCE_SECRET` is set and the provider presents it in the `x-mail-bounce-secret` header; unconfigured, it answers 503 and accepts nothing. **Fixed:** *The data-subject export includes every table that holds the customer* — The export bundle silently omitted reviews, consent history, wishlist items, survey responses, and recently-viewed data. All five are now included, and the bundle carries a completeness manifest listing every section as exported, empty, or absent — an omission is visible rather than silent. · *Erasure revokes the customer's ability to sign in* — Deleting a customer scrubbed their profile but left passkeys, Google/Apple login links, and live sessions intact — the "deleted" account could sign right back in. Erasure now deletes the passkeys and social-login links, revokes live portal sessions, and tombstones the email lookup hash irreversibly, while the anonymized record survives for order-history integrity. · *One-click unsubscribe works from the mail client's native button* — The unsubscribe headers stamped on every campaign pointed at a route that didn't exist, and the unsubscribe endpoint read its token from the form body — which a mail client's native one-click POST never carries. The header now points at the real endpoint and the token rides the URL, so the RFC 8058 one-click flow a mail client fires actually unsubscribes. The browser confirmation page is unchanged. · *Support tickets: reopen on customer reply, atomic tags, honest first-response time* — A customer reply to a resolved ticket silently disappeared from every operator queue — it now reopens the ticket with a fresh activity timestamp. Concurrent tag edits no longer overwrite each other (tag changes are single-statement updates). And an internal-only operator note no longer counts as the first response — only a reply the customer can actually see stamps the first-response time. · *CSV exports share one complete injection neutralizer* — The order-export and segment-members CSV exports each carried their own formula-injection defense, and both missed the tab, carriage-return, newline, and pipe vectors. Both now compose the framework's CSV cell guard, which covers the full vector set — including signed numerics, which the previous neutralizers deliberately exempted.
|
|
12
|
+
|
|
11
13
|
- v0.4.21 (2026-06-06) — **Subscription changes reach the payment processor, smart collections match real products, refunds claw back earned points, and the remaining balance races are closed.** A correctness release across the commerce surfaces. Changing a subscription's quantity now updates the processor before the local record — what the customer sees is what they're billed — and a cadence change on a processor-backed plan is honestly refused rather than silently ignored. Smart collections, which matched zero products in production because their rules read fields the catalog rows never carried, now evaluate against the real product data. Loyalty points earned on a purchase are reversed when the order is refunded or cancelled. And the loyalty, store-credit, gift-registry, and gift-card-ledger write paths that could lose updates or oversell under concurrency now use atomic guards. **Fixed:** *Subscription quantity changes reach the payment processor* — Changing a subscription's quantity updated the local record and showed a success message while the processor kept billing the original amount. The change is now pushed to the processor first — a processor failure leaves the local record untouched and surfaces the error, so the customer-visible state and the billed state can no longer diverge. Changing delivery frequency on a processor-backed subscription is refused with honest guidance (the billing cadence is bound to the plan's price and cannot be re-cadenced in place); self-managed local subscriptions keep their frequency controls. Self-manage controls also now respect the processor's status — a subscription the processor reports as cancelled or expired shows its state instead of live controls. · *Smart collections match real catalog products* — Smart-collection rules read fields like tags, price, and stock from each product row — fields the real catalog listing never carried, so every smart collection matched zero products in production even though admin previews built on richer mock rows looked right. Rule evaluation now joins the real tag, category, vendor, price, and inventory data onto each product page, and the admin preview routes through the same path the storefront uses. Smart-collection pages also stop re-walking the entire catalog on every paginated request — the matched set is briefly cached and a rule edit invalidates it. · *Refunds and cancellations reverse the loyalty points the order earned* — Points awarded when an order was paid survived a refund or cancellation — buying, earning, and refunding farmed points indefinitely. The earn record is now claimed atomically on the order's refund or cancel transition and the awarded points are clawed back from the balance, floored at zero when some were already spent. The reversal is idempotent (a re-delivered payment webhook reverses exactly once) and lifetime points — which drive tier — are deliberately untouched. · *Balance and inventory-adjacent races closed across loyalty, store credit, gift registry, and gift-card ledger* — Loyalty earn and adjust used read-then-write absolute updates that could lose concurrent updates; the gift-registry purchase check could oversell a registry item under two simultaneous purchases; and the gift-card ledger's overdraft check could let two concurrent debits both pass. All of these now use single-statement conditional writes that refuse cleanly when the guard fails. Store-credit expiry sweeps also stop under-expiring when operator-initiated deductions exist — the sweep now keys on its own prior output rather than netting all expiry rows together. · *Search-ranking click-through metrics are bounded and attributed* — The ranking metrics screen could show click-through rates above 100%, and clicks were attributed from the query string alone — trivially spoofable. A click now only counts when the same session recorded a real impression for that query, and displayed rates are bounded at 100%.
|
|
12
14
|
|
|
13
15
|
- v0.4.20 (2026-06-06) — **Payment integrity: refunds, gift-card balances, and checkout submission are race-proof, and abandoned checkouts return gift-card funds.** A money-path hardening release. Concurrent refund clicks on the same return can no longer reach the payment provider twice. A gift card that part-paid a checkout gets its balance back when the order is cancelled, fails payment, or is reaped as stale. Double-submitting checkout can no longer create two charges and two orders. Return requests are refused server-side on orders that aren't in a returnable state, the refund confirmation screen shows the currency the provider will actually refund, and a buy-online-pickup-in-store order now reaches its delivered state when picked up. **Fixed:** *A return can only be refunded once, even under concurrent requests* — Two simultaneous refund requests for the same approved return could both pass the status check and both reach the payment provider, each with a fresh idempotency key — a double refund. The refund now claims the return atomically before the provider is called (the second request answers 409), the provider call uses a key derived from the return itself so even a retry collapses into the same refund, and a provider failure releases the claim so the refund can be retried. · *Gift-card funds come back when a part-paid checkout dies* — When a gift card partially covered a checkout and the order was then cancelled, failed payment, or sat abandoned until reaped, the card's debit was never returned — the balance was permanently gone. Redemptions are now reversed on those order transitions: the card balance is restored (a fully drained card is reactivated), the reversal is idempotent, and it rides the same order lifecycle that releases inventory holds. · *Double-submitting checkout creates one charge and one order* — Two concurrent checkout submissions could both pass the cart-status read and each create a payment intent and an order. The cart is now claimed atomically as the single gate — the second submission is redirected back to the cart — and the payment-provider idempotency key is derived from the cart, so even a duplicate that slipped through would collapse into one charge on the provider's side. A failure before the order exists releases the claim so the customer can retry. · *Return requests are validated server-side* — The return-request form and its submission are now refused on orders that are not in a returnable state — a refunded, cancelled, or unpaid order answers with a clear message instead of opening a return that could feed a second refund downstream. Previously only the button's visibility enforced this. · *A failed gift-card settlement no longer strands a completed payment* — If recording a gift card's redemption failed after the payment had already succeeded and the order existed, the checkout aborted mid-way — leaving a paid order with a cart that never converted. The settlement step now completes the order regardless and surfaces the failure for follow-up instead of stranding the purchase. · *The refund confirmation shows the currency the provider refunds* — The confirmation screen displayed the return's approved currency, but the provider refunds in the original charge's currency. The screen now reads the same source the refund uses. · *Pickup orders reach their delivered state* — Marking a pickup order as picked up drove an order transition that was only legal for shipped orders, and the failure was silently swallowed — the pickup schedule said picked up while the order stayed in its paid state. The order lifecycle now has a pickup-completion transition, so a picked-up order genuinely lands in delivered and downstream reporting sees it.
|
package/README.md
CHANGED
|
@@ -204,6 +204,7 @@ variables. A signed-in operator can see the live on/off status of each at
|
|
|
204
204
|
| **PayPal checkout** | A native PayPal button on `/checkout` (PayPal Orders v2 — create / approve / capture), distinct from PayPal-through-Stripe. | `PAYPAL_CLIENT_ID`, `PAYPAL_SECRET` (a PayPal REST app), `PAYPAL_WEBHOOK_ID`, `PAYPAL_ENV` (`sandbox`\|`live`); Stripe checkout must also be live | The shop exchanges the OAuth2 token and creates / captures orders server-side; the button drives `/checkout/paypal/create` + `/checkout/paypal/capture`. Point a PayPal webhook at `/api/webhooks/paypal` (verified through PayPal's API). Allow `www.paypal.com` in your CSP `script-src` / `frame-src` (as you would `js.stripe.com`). |
|
|
205
205
|
| **Transactional email (SMTP)** | Order/ship/refund mail, abandoned-cart recovery, back-in-stock alerts, **wishlist sale + restock alerts and the periodic wishlist digest** (opt-in per customer on `/account/wishlist`), and **email magic-link sign-in** (a *Email me a sign-in link* option on `/account/login` for shoppers without a passkey or social login). | `SMTP_HOST`, `MAIL_FROM` (plus optional `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS`) | Without a mailer these surfaces stay inert — the wishlist crons scan nothing, the magic-link page reports email sign-in unavailable, and passkey / social login are unchanged. **Wishlist alerts + digests are additionally gated on an email-address resolver:** the customer store keeps only a salted email *hash* (never the plaintext), so out of the box there is no deliverable address and the crons send nothing even with SMTP set. They begin sending once you supply a resolver that maps a customer id to a deliverable address from your own plaintext-address store — the same hook abandoned-cart recovery uses. |
|
|
206
206
|
|
|
207
|
+
| **Email bounce / complaint intake** | Hard bounces and spam complaints from your email provider land on the marketing suppression list automatically (and backfill campaign metrics), so broadcasts stop mailing dead or complaining addresses. | `MAIL_BOUNCE_SECRET` (the value your provider sends in an `x-mail-bounce-secret` header), optional `MAIL_BOUNCE_VENDOR` (`postmark`\|`ses`\|`resend`, default `postmark`); point the provider's bounce/complaint webhook at `POST /api/webhooks/mail-bounce` (a `?vendor=` query overrides per request) | Without the secret the endpoint answers 503 — it never accepts an unauthenticated bounce. Complaints and hard bounces suppress at marketing scope (transactional mail still flows); soft bounces only backfill metrics. |
|
|
207
208
|
**Planned / not available:**
|
|
208
209
|
|
|
209
210
|
- **Shop Pay / "Sign in with Shop"** — **not available** to a self-hosted,
|
package/lib/asset-manifest.json
CHANGED
package/lib/compliance-export.js
CHANGED
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* me" (export) or "erase everything you hold on me" (deletion).
|
|
11
11
|
* The primitive owns the request lifecycle + composes per-domain
|
|
12
12
|
* readers (customers, order, order-notes, subscriptions,
|
|
13
|
-
* addresses, payment-methods, support-tickets, loyalty
|
|
14
|
-
*
|
|
13
|
+
* addresses, payment-methods, support-tickets, loyalty, reviews,
|
|
14
|
+
* consent ledger, wishlist, surveys, recently-viewed) to assemble
|
|
15
|
+
* the bundle — every table that keys a row by the customer, so the
|
|
16
|
+
* export holds the whole record, not just the order/identity core.
|
|
17
|
+
* Delivery (email / signed URL / secure
|
|
15
18
|
* download portal) is the operator worker's concern — this
|
|
16
19
|
* primitive returns the bundle as structured JSON and stamps
|
|
17
20
|
* the lifecycle row when the worker confirms dispatch.
|
|
@@ -76,7 +79,11 @@
|
|
|
76
79
|
*
|
|
77
80
|
* Scope semantics on export:
|
|
78
81
|
*
|
|
79
|
-
* - `full` — every injected reader contributes
|
|
82
|
+
* - `full` — every injected reader contributes (identity,
|
|
83
|
+
* orders, subscriptions, addresses, payment
|
|
84
|
+
* methods, support tickets, loyalty, reviews,
|
|
85
|
+
* consent ledger, wishlist, surveys,
|
|
86
|
+
* recently-viewed).
|
|
80
87
|
* - `orders_only` — only `order` + `orderNotes` contribute;
|
|
81
88
|
* identity / loyalty / subscriptions /
|
|
82
89
|
* addresses / payment methods / support
|
|
@@ -92,7 +99,9 @@
|
|
|
92
99
|
*
|
|
93
100
|
* @primitive complianceExport
|
|
94
101
|
* @related customers, order, orderNotes, subscriptions, addresses,
|
|
95
|
-
* paymentMethods, supportTickets, loyalty,
|
|
102
|
+
* paymentMethods, supportTickets, loyalty, reviews,
|
|
103
|
+
* consentLedger, wishlist, customerSurveys, recentlyViewed,
|
|
104
|
+
* orderExport
|
|
96
105
|
*/
|
|
97
106
|
|
|
98
107
|
var b = require("./vendor/blamejs");
|
|
@@ -124,10 +133,19 @@ var ZERO_WIDTH_RE = new RegExp(
|
|
|
124
133
|
|
|
125
134
|
// Scope -> which injected readers contribute on export. Keeps the
|
|
126
135
|
// fulfillRequest logic a single lookup instead of nested if-else.
|
|
136
|
+
//
|
|
137
|
+
// `full` enumerates every domain that keys a row by the customer
|
|
138
|
+
// (directly via customer_id, or via a per-customer hash) so a
|
|
139
|
+
// subject-access export holds everything the controller stores about
|
|
140
|
+
// the person — not just the order / identity core. A domain that
|
|
141
|
+
// isn't wired (or whose reader is absent) is reported in
|
|
142
|
+
// `sections_absent`, so an unexported table is always visible in the
|
|
143
|
+
// bundle manifest rather than silently dropped.
|
|
127
144
|
var SCOPE_SECTIONS = Object.freeze({
|
|
128
145
|
full: Object.freeze([
|
|
129
146
|
"customers", "addresses", "order", "orderNotes",
|
|
130
147
|
"subscriptions", "paymentMethods", "supportTickets", "loyalty",
|
|
148
|
+
"reviews", "consentLedger", "wishlist", "surveys", "recentlyViewed",
|
|
131
149
|
]),
|
|
132
150
|
orders_only: Object.freeze(["order", "orderNotes"]),
|
|
133
151
|
identity_only: Object.freeze(["customers", "addresses"]),
|
|
@@ -220,6 +238,28 @@ function _limit(n) {
|
|
|
220
238
|
|
|
221
239
|
function _now() { return Date.now(); }
|
|
222
240
|
|
|
241
|
+
// Classify a reader's returned section for the completeness manifest.
|
|
242
|
+
// A section is "empty" when the reader is wired but holds nothing for
|
|
243
|
+
// this customer: null, an empty array, or an object whose own values
|
|
244
|
+
// are all themselves empty (e.g. `{ balance: 0, history: [] }`). Any
|
|
245
|
+
// other shape (a non-empty array, an object carrying a row, a scalar)
|
|
246
|
+
// counts as "exported". The distinction lets the auditor tell "we hold
|
|
247
|
+
// nothing here" apart from "this is real PII we exported".
|
|
248
|
+
function _isEmptySection(section) {
|
|
249
|
+
if (section == null) return true;
|
|
250
|
+
if (Array.isArray(section)) return section.length === 0;
|
|
251
|
+
if (typeof section === "object") {
|
|
252
|
+
var keys = Object.keys(section);
|
|
253
|
+
if (keys.length === 0) return true;
|
|
254
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
255
|
+
if (!_isEmptySection(section[keys[i]])) return false;
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
// A scalar (string / number / boolean) is real exported data.
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
223
263
|
// ---- row hydration ------------------------------------------------------
|
|
224
264
|
|
|
225
265
|
function _hydrate(r) {
|
|
@@ -267,6 +307,11 @@ function create(opts) {
|
|
|
267
307
|
paymentMethods: opts.paymentMethods || null,
|
|
268
308
|
supportTickets: opts.supportTickets || null,
|
|
269
309
|
loyalty: opts.loyalty || null,
|
|
310
|
+
reviews: opts.reviews || null,
|
|
311
|
+
consentLedger: opts.consentLedger || null,
|
|
312
|
+
wishlist: opts.wishlist || null,
|
|
313
|
+
surveys: opts.surveys || null,
|
|
314
|
+
recentlyViewed: opts.recentlyViewed || null,
|
|
270
315
|
};
|
|
271
316
|
|
|
272
317
|
// ---- requestExport -------------------------------------------------
|
|
@@ -391,12 +436,21 @@ function create(opts) {
|
|
|
391
436
|
var bundle = {};
|
|
392
437
|
var sectionsPresent = [];
|
|
393
438
|
var sectionsAbsent = [];
|
|
439
|
+
// Per-section completeness manifest. Every scope section lands here
|
|
440
|
+
// with an explicit status — "exported" (reader wired + returned
|
|
441
|
+
// data), "empty" (reader wired but the customer has no rows here),
|
|
442
|
+
// or "absent" (no reader wired at fulfillment time). An auditor (or
|
|
443
|
+
// the data subject) reads this to confirm the bundle isn't
|
|
444
|
+
// surreptitiously incomplete: an unexported table is visible as
|
|
445
|
+
// `absent`, never silently dropped.
|
|
446
|
+
var manifest = [];
|
|
394
447
|
|
|
395
448
|
for (var s = 0; s < sections.length; s += 1) {
|
|
396
449
|
var sectionName = sections[s];
|
|
397
450
|
var reader = injectedReaders[sectionName];
|
|
398
451
|
if (!reader || typeof reader.forCustomerExport !== "function") {
|
|
399
452
|
sectionsAbsent.push(sectionName);
|
|
453
|
+
manifest.push({ section: sectionName, status: "absent" });
|
|
400
454
|
continue;
|
|
401
455
|
}
|
|
402
456
|
// The reader returns whatever shape it owns (array of rows,
|
|
@@ -406,6 +460,7 @@ function create(opts) {
|
|
|
406
460
|
var section = await reader.forCustomerExport(row.customer_id);
|
|
407
461
|
bundle[sectionName] = section == null ? null : section;
|
|
408
462
|
sectionsPresent.push(sectionName);
|
|
463
|
+
manifest.push({ section: sectionName, status: _isEmptySection(section) ? "empty" : "exported" });
|
|
409
464
|
}
|
|
410
465
|
|
|
411
466
|
var fulfilledAt = _now();
|
|
@@ -422,6 +477,7 @@ function create(opts) {
|
|
|
422
477
|
fulfilled_at: fulfilledAt,
|
|
423
478
|
sections_present: sectionsPresent,
|
|
424
479
|
sections_absent: sectionsAbsent,
|
|
480
|
+
manifest: manifest,
|
|
425
481
|
data: bundle,
|
|
426
482
|
};
|
|
427
483
|
}
|
|
@@ -504,6 +560,7 @@ function create(opts) {
|
|
|
504
560
|
}
|
|
505
561
|
|
|
506
562
|
var domainOrder = [
|
|
563
|
+
"recentlyViewed", "wishlist", "surveys", "reviews", "consentLedger",
|
|
507
564
|
"supportTickets", "orderNotes", "order", "subscriptions",
|
|
508
565
|
"paymentMethods", "loyalty", "addresses", "customers",
|
|
509
566
|
];
|
package/lib/customer-portal.js
CHANGED
|
@@ -287,6 +287,29 @@ function create(opts) {
|
|
|
287
287
|
return { revoked: Number(r.rowCount || 0) > 0 };
|
|
288
288
|
},
|
|
289
289
|
|
|
290
|
+
// Bulk eject every LIVE (issued) session for one customer in a
|
|
291
|
+
// single statement. The right-to-erasure / password-reset hammer:
|
|
292
|
+
// where `revokeSession` kills one id, this kills the whole live set
|
|
293
|
+
// so a deleted (or compromised) customer's outstanding portal links
|
|
294
|
+
// all stop working at once. Idempotent — a customer with no issued
|
|
295
|
+
// session flips zero rows. Only `issued` rows move (consumed /
|
|
296
|
+
// expired / already-revoked stay terminal). Returns the count.
|
|
297
|
+
revokeAllForCustomer: async function (customerId, reason) {
|
|
298
|
+
var cid = _uuid(customerId, "customer_id");
|
|
299
|
+
var clean = _optShortString(reason, "reason", MAX_REASON_LEN);
|
|
300
|
+
if (clean == null) {
|
|
301
|
+
throw new TypeError("customer-portal.revokeAllForCustomer: reason required (non-empty string ≤ " + MAX_REASON_LEN + " chars)");
|
|
302
|
+
}
|
|
303
|
+
var now = _now();
|
|
304
|
+
var r = await query(
|
|
305
|
+
"UPDATE customer_portal_sessions " +
|
|
306
|
+
"SET status = 'revoked', revoked_at = ?1, revoke_reason = ?2 " +
|
|
307
|
+
"WHERE customer_id = ?3 AND status = 'issued'",
|
|
308
|
+
[now, clean, cid],
|
|
309
|
+
);
|
|
310
|
+
return { revoked: Number(r.rowCount || 0) };
|
|
311
|
+
},
|
|
312
|
+
|
|
290
313
|
// Audit / debugging entry point. Returns every session ever
|
|
291
314
|
// minted for a customer, newest-first by created_at (id as
|
|
292
315
|
// tiebreak — both are uuid.v7-derived so the ordering is
|
package/lib/customer-segments.js
CHANGED
|
@@ -332,10 +332,13 @@ function _now() { return Date.now(); }
|
|
|
332
332
|
// ---- CSV helpers (members export) ---------------------------------------
|
|
333
333
|
//
|
|
334
334
|
// Same RFC-4180 quoting + spreadsheet-formula-injection neutralization
|
|
335
|
-
// the order-export primitive uses
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
335
|
+
// the order-export primitive uses, composing the SAME shared vendored
|
|
336
|
+
// neutralizer (b.guardCsv.escapeCell) so the two CSV surfaces can't drift:
|
|
337
|
+
// every cell is quoted, embedded `"` doubled, and a cell beginning with any
|
|
338
|
+
// formula-trigger char — `= + - @` plus the tab / CR / LF / pipe / full-
|
|
339
|
+
// width variants an `= + - @`-only check misses — gets a leading TAB so a
|
|
340
|
+
// spreadsheet treats it as text (a customer-controlled display_name is the
|
|
341
|
+
// injection vector here).
|
|
339
342
|
|
|
340
343
|
function _coerceCell(value) {
|
|
341
344
|
if (value == null) return "";
|
|
@@ -344,14 +347,8 @@ function _coerceCell(value) {
|
|
|
344
347
|
return JSON.stringify(value);
|
|
345
348
|
}
|
|
346
349
|
|
|
347
|
-
var _NUMERIC_SIGN_RE = /^[+-](?:\d+(?:\.\d+)?|\.\d+)$/;
|
|
348
|
-
|
|
349
350
|
function _neutralizeInjection(s) {
|
|
350
|
-
|
|
351
|
-
var first = s.charAt(0);
|
|
352
|
-
if (first !== "=" && first !== "+" && first !== "-" && first !== "@") return s;
|
|
353
|
-
if ((first === "+" || first === "-") && _NUMERIC_SIGN_RE.test(s)) return s;
|
|
354
|
-
return "'" + s;
|
|
351
|
+
return b.guardCsv.escapeCell(s);
|
|
355
352
|
}
|
|
356
353
|
|
|
357
354
|
function _csvCell(value) {
|
package/lib/customers.js
CHANGED
|
@@ -535,6 +535,78 @@ function create(opts) {
|
|
|
535
535
|
return true;
|
|
536
536
|
},
|
|
537
537
|
|
|
538
|
+
// Right-to-erasure auth revocation. A subject-access deletion must
|
|
539
|
+
// leave the customer UNABLE TO SIGN BACK IN — anonymizing the profile
|
|
540
|
+
// row alone is not erasure if every login credential still resolves.
|
|
541
|
+
// This deletes the THREE durable sign-in credentials keyed to the
|
|
542
|
+
// customer and severs the magic-link lookup key in ONE call:
|
|
543
|
+
//
|
|
544
|
+
// * customer_passkeys — every enrolled WebAuthn authenticator
|
|
545
|
+
// (passkey sign-in resolves a credential
|
|
546
|
+
// to this id; gone, the assertion misses).
|
|
547
|
+
// * customer_oauth_identities — every federated link (Google / Apple
|
|
548
|
+
// OIDC resolves byOAuthIdentity to this
|
|
549
|
+
// id; gone, the federated sign-in creates
|
|
550
|
+
// a fresh unrelated account instead).
|
|
551
|
+
// * email_hash -> tombstone — the magic-link / guest-order-claim path
|
|
552
|
+
// resolves byEmailHash; overwriting the
|
|
553
|
+
// hash with a per-id, non-reversible
|
|
554
|
+
// tombstone means no future link for the
|
|
555
|
+
// erased address can ever resolve this row
|
|
556
|
+
// (the address itself is never stored, so
|
|
557
|
+
// there is no plaintext to scrub — only
|
|
558
|
+
// the lookup key to break).
|
|
559
|
+
//
|
|
560
|
+
// The sealed 14-day auth cookie is stateless (no server session row to
|
|
561
|
+
// kill), but with no credential left to re-mint it and no lookup key to
|
|
562
|
+
// re-issue a magic link, the account cannot be re-entered after the
|
|
563
|
+
// cookie expires; the live customer-portal sessions are revoked
|
|
564
|
+
// separately by the caller (customerPortal.revokeAllForCustomer). Each
|
|
565
|
+
// step is idempotent — re-running on an already-erased row deletes
|
|
566
|
+
// nothing more and returns zero counts. dry_run reports the counts the
|
|
567
|
+
// wet run WOULD remove without mutating, so the operator preview shows
|
|
568
|
+
// the blast radius. Returns `{ passkeys, oauth_identities, email_hash_cleared }`.
|
|
569
|
+
eraseAuthForCustomer: async function (id, opts) {
|
|
570
|
+
_uuid(id, "customer id");
|
|
571
|
+
var dryRun = !!(opts && opts.dry_run);
|
|
572
|
+
var pkRows = (await query(
|
|
573
|
+
"SELECT COUNT(*) AS n FROM customer_passkeys WHERE customer_id = ?1", [id],
|
|
574
|
+
)).rows[0];
|
|
575
|
+
var oaRows = (await query(
|
|
576
|
+
"SELECT COUNT(*) AS n FROM customer_oauth_identities WHERE customer_id = ?1", [id],
|
|
577
|
+
)).rows[0];
|
|
578
|
+
var existing = (await query("SELECT email_hash FROM customers WHERE id = ?1", [id])).rows[0];
|
|
579
|
+
var emailHashSet = !!(existing && existing.email_hash && String(existing.email_hash).indexOf("erased:") !== 0);
|
|
580
|
+
if (dryRun) {
|
|
581
|
+
return {
|
|
582
|
+
passkeys: pkRows ? Number(pkRows.n) : 0,
|
|
583
|
+
oauth_identities: oaRows ? Number(oaRows.n) : 0,
|
|
584
|
+
email_hash_cleared: emailHashSet ? 1 : 0,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
var ts = _now();
|
|
588
|
+
var pkDel = await query("DELETE FROM customer_passkeys WHERE customer_id = ?1", [id]);
|
|
589
|
+
var oaDel = await query("DELETE FROM customer_oauth_identities WHERE customer_id = ?1", [id]);
|
|
590
|
+
// Tombstone the lookup key with a per-id, non-reversible value that
|
|
591
|
+
// can never collide with a real namespaceHash digest (those are hex)
|
|
592
|
+
// and can never be re-derived from any email address. Only rewrite a
|
|
593
|
+
// live (non-tombstoned) hash so a re-run is a no-op.
|
|
594
|
+
var emailHashCleared = 0;
|
|
595
|
+
if (emailHashSet) {
|
|
596
|
+
var tombstone = "erased:" + b.crypto.namespaceHash("customer-erased-email", id);
|
|
597
|
+
var upd = await query(
|
|
598
|
+
"UPDATE customers SET email_hash = ?1, updated_at = ?2 WHERE id = ?3 AND email_hash = ?4",
|
|
599
|
+
[tombstone, ts, id, existing.email_hash],
|
|
600
|
+
);
|
|
601
|
+
emailHashCleared = Number((upd && upd.rowCount) || 0);
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
passkeys: Number((pkDel && pkDel.rowCount) || 0),
|
|
605
|
+
oauth_identities: Number((oaDel && oaDel.rowCount) || 0),
|
|
606
|
+
email_hash_cleared: emailHashCleared,
|
|
607
|
+
};
|
|
608
|
+
},
|
|
609
|
+
|
|
538
610
|
// Mutate a customer's editable profile fields. v1 covers display_name
|
|
539
611
|
// only — the one field a customer can safely change without a
|
|
540
612
|
// verification round trip.
|
package/lib/email-campaigns.js
CHANGED
|
@@ -783,7 +783,14 @@ function create(opts) {
|
|
|
783
783
|
var signup = await newsletter.byEmailHash(emailHash);
|
|
784
784
|
if (!signup || !signup.id) return null;
|
|
785
785
|
var issued = await newsletter.issueUnsubscribeToken(signup.id);
|
|
786
|
-
|
|
786
|
+
// The storefront mounts the one-click unsubscribe at GET/POST
|
|
787
|
+
// /unsubscribe (lib/storefront.js + EDGE_POST_PATHS). The token rides
|
|
788
|
+
// in the URL — a mail client's RFC 8058 one-click POST fires at this
|
|
789
|
+
// exact URL with a `List-Unsubscribe=One-Click` body and no token of
|
|
790
|
+
// its own, so the route MUST be the real one and MUST read the token
|
|
791
|
+
// from here. (An earlier `/newsletter/unsubscribe` target had no route
|
|
792
|
+
// behind it — every native one-click POST 404'd and never unsubscribed.)
|
|
793
|
+
var url = unsubscribeBaseUrl + "/unsubscribe?token=" + encodeURIComponent(issued.token);
|
|
787
794
|
// Validate the header SHAPE through the vendored RFC 2369 + RFC 8058
|
|
788
795
|
// guard so a malformed link (non-https, control byte) never reaches
|
|
789
796
|
// the wire — Gmail / Yahoo refuse mail that carries a broken pair.
|
package/lib/order-export.js
CHANGED
|
@@ -212,7 +212,8 @@ function _limit(n) {
|
|
|
212
212
|
// RFC-4180 quoting: every cell wrapped in `"`, embedded `"` doubled.
|
|
213
213
|
// We quote unconditionally — the cost is a few extra bytes per cell;
|
|
214
214
|
// the win is that a downstream parser never has to track quote-vs-
|
|
215
|
-
// bare-cell state for a column with mixed shapes.
|
|
215
|
+
// bare-cell state for a column with mixed shapes. The injection
|
|
216
|
+
// neutralization runs first via the shared vendored primitive.
|
|
216
217
|
function _csvCell(value) {
|
|
217
218
|
var s = _coerceCell(value);
|
|
218
219
|
s = _neutralizeInjection(s);
|
|
@@ -227,23 +228,19 @@ function _coerceCell(value) {
|
|
|
227
228
|
return JSON.stringify(value);
|
|
228
229
|
}
|
|
229
230
|
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
231
|
+
// CSV injection neutralization — composes the vendored b.guardCsv.escapeCell
|
|
232
|
+
// (OWASP "CSV Injection" defense). The vendored primitive is the single
|
|
233
|
+
// shared neutralizer across every CSV export surface (order-export +
|
|
234
|
+
// customer-segments). It prefixes a leading TAB when a cell starts with ANY
|
|
235
|
+
// formula-trigger char — `= + - @` AND the tab / CR / LF / pipe / full-width
|
|
236
|
+
// variants a hand-rolled `= + - @`-only check misses. A leading tab renders
|
|
237
|
+
// as invisible whitespace in a spreadsheet, so the cell reads as text and
|
|
238
|
+
// never evaluates as a formula. The earlier in-tree check exempted signed
|
|
239
|
+
// numerics (`+15.00`); the shared primitive prefixes those too (the safe
|
|
240
|
+
// OWASP posture — `-2+3+cmd|…` is a real injection that begins like an
|
|
241
|
+
// amount), which is the more complete behavior this consolidation buys.
|
|
241
242
|
function _neutralizeInjection(s) {
|
|
242
|
-
|
|
243
|
-
var first = s.charAt(0);
|
|
244
|
-
if (first !== "=" && first !== "+" && first !== "-" && first !== "@") return s;
|
|
245
|
-
if ((first === "+" || first === "-") && _NUMERIC_SIGN_RE.test(s)) return s;
|
|
246
|
-
return "'" + s;
|
|
243
|
+
return b.guardCsv.escapeCell(s);
|
|
247
244
|
}
|
|
248
245
|
|
|
249
246
|
function _csvRow(cells) {
|
|
@@ -58,11 +58,19 @@ var _vendoredSecurityHeaders = require("./vendor/blamejs/lib/middleware/security
|
|
|
58
58
|
|
|
59
59
|
var C = b.constants;
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
61
|
+
// Server-to-server webhooks: cross-site by nature, unthrottleable by a
|
|
62
|
+
// per-IP human budget, and each authenticated by its own gate the handler
|
|
63
|
+
// verifies first thing — an HMAC signature (Stripe / PayPal) or a per-
|
|
64
|
+
// endpoint signing secret (the ESP bounce / complaint intake). They are
|
|
65
|
+
// exempt from the rate limiters, fetch-metadata, and the double-submit CSRF
|
|
66
|
+
// token (a third-party POST carries no session cookie or token). The
|
|
67
|
+
// `/api/` prefix also lands them in the vendored bot-guard's onlyForHtml
|
|
68
|
+
// skip, so the secret / signature gate is the deciding check.
|
|
69
|
+
var WEBHOOK_PATHS = [
|
|
70
|
+
"/api/webhooks/stripe",
|
|
71
|
+
"/api/webhooks/paypal",
|
|
72
|
+
"/api/webhooks/mail-bounce",
|
|
73
|
+
];
|
|
66
74
|
|
|
67
75
|
// Liveness / readiness probe — the container's Docker HEALTHCHECK hits
|
|
68
76
|
// this on a fixed cadence; never rate-limit it or a slow cold start
|
package/lib/storefront.js
CHANGED
|
@@ -19993,7 +19993,22 @@ function mount(router, deps) {
|
|
|
19993
19993
|
|
|
19994
19994
|
router.post("/unsubscribe", async function (req, res) {
|
|
19995
19995
|
var body = req.body || {};
|
|
19996
|
-
|
|
19996
|
+
// RFC 8058 one-click: the mail client POSTs to the EXACT URL in the
|
|
19997
|
+
// List-Unsubscribe header — token in the `?token=` query string —
|
|
19998
|
+
// with a `List-Unsubscribe=One-Click` form body and NOTHING else
|
|
19999
|
+
// (no token of its own). So the URL token is authoritative; the
|
|
20000
|
+
// body `token` is only the fallback the on-page confirm form POSTs
|
|
20001
|
+
// from its hidden field. Reading body.token alone (the prior shape)
|
|
20002
|
+
// meant a native one-click POST carried no token -> "not-found" ->
|
|
20003
|
+
// the recipient was never unsubscribed. Parse the token off req.url
|
|
20004
|
+
// (the router only populates req.query when a route declares a query
|
|
20005
|
+
// validator), then fall back to the confirm-form body field.
|
|
20006
|
+
var urlToken = "";
|
|
20007
|
+
try {
|
|
20008
|
+
var u = req.url ? new URL(req.url, "http://localhost") : null;
|
|
20009
|
+
if (u) urlToken = u.searchParams.get("token") || "";
|
|
20010
|
+
} catch (_eUrl) { urlToken = ""; }
|
|
20011
|
+
var token = urlToken || (typeof body.token === "string" ? body.token : "");
|
|
19997
20012
|
var cartCount = 0;
|
|
19998
20013
|
try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
|
|
19999
20014
|
var outcome;
|
|
@@ -20001,6 +20016,9 @@ function mount(router, deps) {
|
|
|
20001
20016
|
// `consumeUnsubscribeToken` returns a structured result (it does
|
|
20002
20017
|
// not throw on a bad/missing token — it returns `{ ok:false,
|
|
20003
20018
|
// error:"not-found" }`). An empty token is handled the same way.
|
|
20019
|
+
// It is single-use, so the one-click POST and a later confirm-form
|
|
20020
|
+
// POST of the same token are idempotent (the second reads
|
|
20021
|
+
// "already" — still a success page, no error).
|
|
20004
20022
|
var result = await deps.newsletter.consumeUnsubscribeToken(token);
|
|
20005
20023
|
outcome = _unsubscribeOutcome(result);
|
|
20006
20024
|
} catch (e) {
|
package/lib/support-tickets.js
CHANGED
|
@@ -435,11 +435,18 @@ function create(opts) {
|
|
|
435
435
|
return await _refresh(id);
|
|
436
436
|
},
|
|
437
437
|
|
|
438
|
-
// Append a message to the ticket thread.
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
438
|
+
// Append a message to the ticket thread.
|
|
439
|
+
// * A CUSTOMER-VISIBLE operator reply (internal=false) flips
|
|
440
|
+
// `new -> in_progress`, stamps `first_response_at` if unset, and
|
|
441
|
+
// bumps `last_action_at`. An INTERNAL operator note (internal=true)
|
|
442
|
+
// is operator-to-operator: append-only, no status flip, no
|
|
443
|
+
// first-response stamp (the customer never sees it, so it can't
|
|
444
|
+
// satisfy the response SLA).
|
|
445
|
+
// * A customer reply doesn't advance `last_action_at` on the
|
|
446
|
+
// operator's-clock states, but DOES requeue the ticket: a reply to
|
|
447
|
+
// `waiting_customer` returns it to `in_progress`, and a reply to a
|
|
448
|
+
// `resolved` ticket reopens it (`resolved -> reopened`, with a
|
|
449
|
+
// fresh SLA clock) so the operator's pushback never goes unseen.
|
|
443
450
|
reply: async function (input) {
|
|
444
451
|
if (!input || typeof input !== "object") {
|
|
445
452
|
throw new TypeError("supportTickets.reply: input object required");
|
|
@@ -479,30 +486,57 @@ function create(opts) {
|
|
|
479
486
|
);
|
|
480
487
|
|
|
481
488
|
if (author === "operator") {
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
+
// Only a CUSTOMER-VISIBLE operator reply counts as a first
|
|
490
|
+
// response or advances the workflow. An INTERNAL note (internal=1)
|
|
491
|
+
// is operator-to-operator — the customer never sees it, so
|
|
492
|
+
// stamping first_response_at off it would satisfy the SLA with
|
|
493
|
+
// content that never reached the person waiting. An internal note
|
|
494
|
+
// is append-only here: no status flip, no first_response_at, no
|
|
495
|
+
// last_action_at bump (it isn't operator responsiveness TO the
|
|
496
|
+
// customer). The message row was already inserted above.
|
|
497
|
+
if (!internal) {
|
|
498
|
+
// A customer-visible operator reply flips `new -> in_progress`
|
|
499
|
+
// automatically; other states keep their status. The
|
|
500
|
+
// first-response stamp + SLA timer advance only on this path.
|
|
501
|
+
var newStatus = ticket.status;
|
|
502
|
+
if (ticket.status === "new") {
|
|
503
|
+
newStatus = "in_progress";
|
|
504
|
+
await _writeStatusHistory(ticketId, ticket.status, newStatus, "operator-reply", ts);
|
|
505
|
+
}
|
|
506
|
+
var firstResp = ticket.first_response_at == null ? ts : ticket.first_response_at;
|
|
507
|
+
await query(
|
|
508
|
+
"UPDATE support_tickets SET status = ?1, first_response_at = ?2, last_action_at = ?3 WHERE id = ?4",
|
|
509
|
+
[newStatus, firstResp, ts, ticketId],
|
|
510
|
+
);
|
|
489
511
|
}
|
|
490
|
-
var firstResp = ticket.first_response_at == null ? ts : ticket.first_response_at;
|
|
491
|
-
await query(
|
|
492
|
-
"UPDATE support_tickets SET status = ?1, first_response_at = ?2, last_action_at = ?3 WHERE id = ?4",
|
|
493
|
-
[newStatus, firstResp, ts, ticketId],
|
|
494
|
-
);
|
|
495
512
|
} else if (author === "customer") {
|
|
496
|
-
// Customer replies don't advance last_action_at
|
|
497
|
-
// would mask SLA breach. They DO
|
|
498
|
-
//
|
|
499
|
-
//
|
|
513
|
+
// Customer replies don't advance last_action_at on the
|
|
514
|
+
// operator's-clock states — that would mask an SLA breach. They DO
|
|
515
|
+
// move the ticket back into a queue the operator owes the next
|
|
516
|
+
// move on:
|
|
517
|
+
// * waiting_customer -> in_progress (the customer answered)
|
|
518
|
+
// * resolved -> reopened (the customer pushed back; an
|
|
519
|
+
// FSM-legal edge, resolved ->
|
|
520
|
+
// reopened). Without this, a
|
|
521
|
+
// reply to a resolved ticket
|
|
522
|
+
// was silently dropped from
|
|
523
|
+
// every operator queue.
|
|
524
|
+
// last_action_at bumps ONLY on the resolved->reopened move so the
|
|
525
|
+
// reopened ticket surfaces with a fresh SLA clock (the operator now
|
|
526
|
+
// owes a response); the waiting_customer->in_progress move keeps the
|
|
527
|
+
// existing clock (the operator's responsiveness window never paused).
|
|
500
528
|
if (ticket.status === "waiting_customer") {
|
|
501
529
|
await _writeStatusHistory(ticketId, ticket.status, "in_progress", "customer-reply", ts);
|
|
502
530
|
await query(
|
|
503
531
|
"UPDATE support_tickets SET status = 'in_progress' WHERE id = ?1",
|
|
504
532
|
[ticketId],
|
|
505
533
|
);
|
|
534
|
+
} else if (ticket.status === "resolved") {
|
|
535
|
+
await _writeStatusHistory(ticketId, ticket.status, "reopened", "customer-reply", ts);
|
|
536
|
+
await query(
|
|
537
|
+
"UPDATE support_tickets SET status = 'reopened', last_action_at = ?1 WHERE id = ?2",
|
|
538
|
+
[ts, ticketId],
|
|
539
|
+
);
|
|
506
540
|
}
|
|
507
541
|
}
|
|
508
542
|
// system author: append-only; no state mutation.
|
|
@@ -596,56 +630,82 @@ function create(opts) {
|
|
|
596
630
|
return await _refresh(ticketId);
|
|
597
631
|
},
|
|
598
632
|
|
|
633
|
+
// Add a tag. The mutation is a SINGLE atomic JSON1 statement —
|
|
634
|
+
// `json_insert(..., '$[#]', ?)` appends only when the tag isn't
|
|
635
|
+
// already present (the json_each NOT-EXISTS guard) AND the ticket is
|
|
636
|
+
// under the cap (json_array_length guard). A prior read-modify-write
|
|
637
|
+
// (decode -> push in JS -> write the whole array back) lost one of two
|
|
638
|
+
// concurrent addTag writes: both read the same array, both appended
|
|
639
|
+
// their own tag, the last write clobbered the other. Doing the append
|
|
640
|
+
// inside SQLite removes the read-then-write window entirely. The read
|
|
641
|
+
// that remains exists ONLY to classify a zero-row update (idempotent
|
|
642
|
+
// dup vs cap-exceeded error) — it never feeds the write.
|
|
599
643
|
addTag: async function (input) {
|
|
600
644
|
if (!input || typeof input !== "object") {
|
|
601
645
|
throw new TypeError("supportTickets.addTag: input object required");
|
|
602
646
|
}
|
|
603
647
|
var ticketId = _uuid(input.ticket_id, "ticket_id");
|
|
604
648
|
var tag = _singleTag(input.tag);
|
|
605
|
-
var
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
649
|
+
var res = await query(
|
|
650
|
+
"UPDATE support_tickets " +
|
|
651
|
+
"SET tags_json = json_insert(COALESCE(tags_json, '[]'), '$[#]', ?1) " +
|
|
652
|
+
"WHERE id = ?2 " +
|
|
653
|
+
" AND (SELECT COUNT(*) FROM json_each(COALESCE(tags_json, '[]')) WHERE value = ?1) = 0 " +
|
|
654
|
+
" AND json_array_length(COALESCE(tags_json, '[]')) < ?3",
|
|
655
|
+
[tag, ticketId, MAX_TAG_COUNT],
|
|
656
|
+
);
|
|
657
|
+
if (Number((res && res.rowCount) || 0) === 0) {
|
|
658
|
+
// The atomic UPDATE matched no row. Read once to classify: a
|
|
659
|
+
// missing ticket is a hard error; an already-present tag is an
|
|
660
|
+
// idempotent no-op; otherwise the ticket is at the tag cap.
|
|
661
|
+
var ticket = await _getRaw(ticketId);
|
|
662
|
+
if (!ticket) {
|
|
663
|
+
var err = new Error("supportTickets.addTag: ticket " + ticketId + " not found");
|
|
664
|
+
err.code = "SUPPORT_TICKET_NOT_FOUND";
|
|
665
|
+
throw err;
|
|
666
|
+
}
|
|
667
|
+
var tags;
|
|
668
|
+
try { tags = JSON.parse(ticket.tags_json || "[]"); }
|
|
669
|
+
catch (_e) { tags = []; }
|
|
670
|
+
if (tags.indexOf(tag) === -1 && tags.length >= MAX_TAG_COUNT) {
|
|
616
671
|
throw new TypeError("supportTickets.addTag: ticket already has " + MAX_TAG_COUNT + " tags");
|
|
617
672
|
}
|
|
618
|
-
|
|
619
|
-
await query(
|
|
620
|
-
"UPDATE support_tickets SET tags_json = ?1 WHERE id = ?2",
|
|
621
|
-
[JSON.stringify(tags), ticketId],
|
|
622
|
-
);
|
|
673
|
+
// else: tag already present — idempotent success, nothing to do.
|
|
623
674
|
}
|
|
624
675
|
return await _refresh(ticketId);
|
|
625
676
|
},
|
|
626
677
|
|
|
678
|
+
// Remove a tag. Single atomic JSON1 statement — rebuild the array
|
|
679
|
+
// from `json_each` minus the target value. Same lost-update hazard as
|
|
680
|
+
// addTag if done read-modify-write; doing it in SQLite removes the
|
|
681
|
+
// window. Idempotent: a tag that isn't present matches no row in the
|
|
682
|
+
// EXISTS guard and the update is a no-op. A missing ticket is read
|
|
683
|
+
// back only to raise the not-found error (the update wrote nothing).
|
|
627
684
|
removeTag: async function (input) {
|
|
628
685
|
if (!input || typeof input !== "object") {
|
|
629
686
|
throw new TypeError("supportTickets.removeTag: input object required");
|
|
630
687
|
}
|
|
631
688
|
var ticketId = _uuid(input.ticket_id, "ticket_id");
|
|
632
689
|
var tag = _singleTag(input.tag);
|
|
633
|
-
var
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
690
|
+
var res = await query(
|
|
691
|
+
"UPDATE support_tickets " +
|
|
692
|
+
"SET tags_json = (" +
|
|
693
|
+
" SELECT COALESCE(json_group_array(value), '[]') " +
|
|
694
|
+
" FROM json_each(COALESCE(tags_json, '[]')) WHERE value <> ?1" +
|
|
695
|
+
") " +
|
|
696
|
+
"WHERE id = ?2 " +
|
|
697
|
+
" AND EXISTS (SELECT 1 FROM json_each(COALESCE(tags_json, '[]')) WHERE value = ?1)",
|
|
698
|
+
[tag, ticketId],
|
|
699
|
+
);
|
|
700
|
+
if (Number((res && res.rowCount) || 0) === 0) {
|
|
701
|
+
// No row changed — either the ticket is missing (hard error) or
|
|
702
|
+
// the tag simply wasn't present (idempotent no-op).
|
|
703
|
+
var ticket = await _getRaw(ticketId);
|
|
704
|
+
if (!ticket) {
|
|
705
|
+
var err = new Error("supportTickets.removeTag: ticket " + ticketId + " not found");
|
|
706
|
+
err.code = "SUPPORT_TICKET_NOT_FOUND";
|
|
707
|
+
throw err;
|
|
708
|
+
}
|
|
649
709
|
}
|
|
650
710
|
return await _refresh(ticketId);
|
|
651
711
|
},
|
package/package.json
CHANGED