@blamejs/blamejs-shop 0.4.21 → 0.4.23
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 -0
- package/lib/admin.js +45 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +70 -0
- 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/operator-accounts.js +52 -1
- package/lib/operator-audit-log.js +166 -6
- package/lib/order-export.js +14 -17
- package/lib/payment.js +178 -69
- package/lib/security-middleware.js +13 -5
- package/lib/storefront.js +128 -4
- package/lib/support-tickets.js +113 -53
- package/package.json +1 -1
package/lib/storefront.js
CHANGED
|
@@ -9085,13 +9085,70 @@ function _setSidCookie(req, res, sid) {
|
|
|
9085
9085
|
});
|
|
9086
9086
|
}
|
|
9087
9087
|
|
|
9088
|
+
// Store-free device-fingerprint binding for the sealed auth cookie. The
|
|
9089
|
+
// sealed envelope is tamper-proof but device-PORTABLE: a cookie lifted
|
|
9090
|
+
// off one device replays for the full 14-day life on any other. We bind
|
|
9091
|
+
// it softly to a SHAKE256 fingerprint of the device shape (User-Agent +
|
|
9092
|
+
// sorted Accept-Language / Accept-Encoding) carried INSIDE the sealed
|
|
9093
|
+
// envelope, recomputed + constant-time-compared on read. No external
|
|
9094
|
+
// store: `binding.fingerprint(req)` is a pure function of the request, so
|
|
9095
|
+
// the fingerprint lives in the cookie itself rather than a session table.
|
|
9096
|
+
//
|
|
9097
|
+
// The IP component is deliberately disabled (`ipPrefixBits {v4:0, v6:0}`):
|
|
9098
|
+
// a mobile or VPN visitor roams networks constantly, and signing them out
|
|
9099
|
+
// on a network hop is a worse outcome than the residual portability a
|
|
9100
|
+
// UA+Accept-only fingerprint leaves. Drift in the device shape is the
|
|
9101
|
+
// signal; a network change is not.
|
|
9102
|
+
//
|
|
9103
|
+
// `b.sessionDeviceBinding.create()` refuses to construct without either a
|
|
9104
|
+
// bindingStore or storeInSession — neither of which the store-free
|
|
9105
|
+
// `fingerprint()` path touches. Pass a b.cache-shaped no-op so the
|
|
9106
|
+
// constructor's opt-shape gate is satisfied; its methods are never called.
|
|
9107
|
+
var _NOOP_BINDING_STORE = {
|
|
9108
|
+
get: function () { return undefined; },
|
|
9109
|
+
set: function () { return undefined; },
|
|
9110
|
+
del: function () { return undefined; },
|
|
9111
|
+
};
|
|
9112
|
+
var _deviceBinding = null;
|
|
9113
|
+
function _deviceBindingInstance() {
|
|
9114
|
+
if (!_deviceBinding) {
|
|
9115
|
+
_deviceBinding = b.sessionDeviceBinding.create({
|
|
9116
|
+
bindingStore: _NOOP_BINDING_STORE,
|
|
9117
|
+
ipPrefixBits: { v4: 0, v6: 0 },
|
|
9118
|
+
});
|
|
9119
|
+
}
|
|
9120
|
+
return _deviceBinding;
|
|
9121
|
+
}
|
|
9122
|
+
|
|
9123
|
+
// The hex device fingerprint for THIS request, or null when it can't be
|
|
9124
|
+
// computed (a request shape the primitive refuses). A null fingerprint is
|
|
9125
|
+
// stored as "no binding" — the read side skips the check rather than
|
|
9126
|
+
// signing the visitor out over a missing signal.
|
|
9127
|
+
function _authDeviceFingerprint(req) {
|
|
9128
|
+
try {
|
|
9129
|
+
var fp = _deviceBindingInstance().fingerprint(req);
|
|
9130
|
+
return fp ? fp.toString("hex") : null;
|
|
9131
|
+
} catch (_e) {
|
|
9132
|
+
return null;
|
|
9133
|
+
}
|
|
9134
|
+
}
|
|
9135
|
+
|
|
9088
9136
|
// Auth + WebAuthn-challenge cookies carry a vault-sealed JSON envelope.
|
|
9089
9137
|
// writeSealed/readSealed handle the seal + the on-wire prefix; the
|
|
9090
9138
|
// caller works in plain objects.
|
|
9091
9139
|
function _setAuthCookie(req, res, env) {
|
|
9092
9140
|
var T = b.constants.TIME;
|
|
9093
9141
|
var secure = _secureForReq(req);
|
|
9094
|
-
|
|
9142
|
+
// Stash the device fingerprint inside the sealed envelope at mint time
|
|
9143
|
+
// so a later read can detect a cookie that has moved to a different
|
|
9144
|
+
// device shape. Additive: an env handed in WITHOUT `fp` (a caller that
|
|
9145
|
+
// doesn't know about binding) just gets it filled here.
|
|
9146
|
+
var sealed = env;
|
|
9147
|
+
if (env && env.fp == null) {
|
|
9148
|
+
var fp = _authDeviceFingerprint(req);
|
|
9149
|
+
if (fp) sealed = Object.assign({}, env, { fp: fp });
|
|
9150
|
+
}
|
|
9151
|
+
_cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(sealed), {
|
|
9095
9152
|
expires: new Date(Date.now() + T.days(14)),
|
|
9096
9153
|
secure: secure,
|
|
9097
9154
|
});
|
|
@@ -9570,6 +9627,14 @@ var LOGIN_ERROR_MESSAGES = {
|
|
|
9570
9627
|
link: "That sign-in link is invalid or has expired. Request a fresh one.",
|
|
9571
9628
|
};
|
|
9572
9629
|
|
|
9630
|
+
// Neutral (non-error) notices surfaced on the sign-in screen. The
|
|
9631
|
+
// device-binding soft sign-out lands here: a reassuring "sign in again"
|
|
9632
|
+
// message, never an alarming error, and it discloses nothing about WHY
|
|
9633
|
+
// (no "your session looked suspicious" — the visitor just signs in again).
|
|
9634
|
+
var LOGIN_NOTICE_MESSAGES = {
|
|
9635
|
+
device: "You've been signed out for your security. Please sign in again.",
|
|
9636
|
+
};
|
|
9637
|
+
|
|
9573
9638
|
function renderAccountLogin(opts) {
|
|
9574
9639
|
opts = opts || {};
|
|
9575
9640
|
var oauthButtons = "";
|
|
@@ -9588,6 +9653,12 @@ function renderAccountLogin(opts) {
|
|
|
9588
9653
|
var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
|
|
9589
9654
|
? "<p class=\"auth-form__message auth-form__message--err\">" + b.template.escapeHtml(LOGIN_ERROR_MESSAGES[opts.error]) + "</p>"
|
|
9590
9655
|
: "";
|
|
9656
|
+
// Neutral notice (e.g. the device-binding soft sign-out) renders in the
|
|
9657
|
+
// same slot with non-error styling. Error wins if both are somehow set.
|
|
9658
|
+
if (!errHtml && opts.notice && LOGIN_NOTICE_MESSAGES[opts.notice]) {
|
|
9659
|
+
errHtml = "<p class=\"auth-form__message\" role=\"status\">" +
|
|
9660
|
+
b.template.escapeHtml(LOGIN_NOTICE_MESSAGES[opts.notice]) + "</p>";
|
|
9661
|
+
}
|
|
9591
9662
|
// Render the email-link path INLINE (a working no-JS form), not as a link
|
|
9592
9663
|
// to a separate page, so both passwordless paths live on one screen.
|
|
9593
9664
|
var magicHtml = opts.magic_link_enabled ? LOGIN_MAGIC_INLINE : "";
|
|
@@ -11910,9 +11981,28 @@ function mount(router, deps) {
|
|
|
11910
11981
|
// block) and the account routes inside it, so there's one auth-cookie
|
|
11911
11982
|
// reader rather than a copy per call site. A missing / malformed /
|
|
11912
11983
|
// expired cookie returns null — never throws.
|
|
11984
|
+
//
|
|
11985
|
+
// Device-binding (soft): an envelope carrying a stashed `fp` is checked
|
|
11986
|
+
// against THIS request's recomputed fingerprint with a constant-time
|
|
11987
|
+
// compare. On drift the visitor reads as signed-out (return null) and a
|
|
11988
|
+
// one-shot `req._authDeviceDrift` flag is set so the page-level gates
|
|
11989
|
+
// clear the now-stale cookie and bounce to a neutral sign-in — never a
|
|
11990
|
+
// hard 401 mid-page. A pre-binding envelope (no `fp`, minted before this
|
|
11991
|
+
// shipped) passes through unchanged until its natural expiry, so a
|
|
11992
|
+
// deploy never mass-signs-out live sessions.
|
|
11913
11993
|
function _currentCustomerEnv(req) {
|
|
11914
11994
|
var env = _readAuthEnv(req);
|
|
11915
11995
|
if (!env || !env.customer_id || !env.exp || env.exp < Date.now()) return null;
|
|
11996
|
+
if (typeof env.fp === "string" && env.fp.length > 0) {
|
|
11997
|
+
var current = _authDeviceFingerprint(req);
|
|
11998
|
+
// A request whose fingerprint can't be recomputed (current === null)
|
|
11999
|
+
// is NOT treated as drift — absence of signal is not evidence of a
|
|
12000
|
+
// moved cookie. Only a present-but-mismatching fingerprint signs out.
|
|
12001
|
+
if (current !== null && !b.crypto.timingSafeEqual(env.fp, current)) {
|
|
12002
|
+
if (req) req._authDeviceDrift = true;
|
|
12003
|
+
return null;
|
|
12004
|
+
}
|
|
12005
|
+
}
|
|
11916
12006
|
return env;
|
|
11917
12007
|
}
|
|
11918
12008
|
|
|
@@ -14808,6 +14898,9 @@ function mount(router, deps) {
|
|
|
14808
14898
|
res.status(303); res.setHeader && res.setHeader("location", "/account");
|
|
14809
14899
|
return res.end ? res.end() : res.send("");
|
|
14810
14900
|
}
|
|
14901
|
+
// A drifted cookie that reached the sign-in screen directly still gets
|
|
14902
|
+
// cleared so the next request carries no stale envelope.
|
|
14903
|
+
if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
|
|
14811
14904
|
var cartCount = await _cartCountForReq(req);
|
|
14812
14905
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
14813
14906
|
// Login captcha is opt-in (CAPTCHA_GATE_LOGIN). The widget + the scoped
|
|
@@ -14827,6 +14920,7 @@ function mount(router, deps) {
|
|
|
14827
14920
|
apple_enabled: !!deps.oauthApple,
|
|
14828
14921
|
magic_link_enabled: !!(deps.customerPortal && deps.customerPortalEmail),
|
|
14829
14922
|
error: url && url.searchParams.get("error"),
|
|
14923
|
+
notice: url && url.searchParams.get("signed_out"),
|
|
14830
14924
|
captcha_kind: captchaLoginOn ? captchaKind : null,
|
|
14831
14925
|
captcha_public_key: captchaLoginOn ? captchaPubKey : null,
|
|
14832
14926
|
}));
|
|
@@ -15247,7 +15341,12 @@ function mount(router, deps) {
|
|
|
15247
15341
|
throw e;
|
|
15248
15342
|
}
|
|
15249
15343
|
if (!auth) {
|
|
15250
|
-
|
|
15344
|
+
// On device-binding drift, clear the stale cookie + surface a
|
|
15345
|
+
// neutral sign-in notice (matches _accountAuth's soft sign-out).
|
|
15346
|
+
if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
|
|
15347
|
+
res.status(303);
|
|
15348
|
+
res.setHeader && res.setHeader("location",
|
|
15349
|
+
(req && req._authDeviceDrift) ? "/account/login?signed_out=device" : "/account/login");
|
|
15251
15350
|
return res.end ? res.end() : res.send("");
|
|
15252
15351
|
}
|
|
15253
15352
|
var customer = await deps.customers.get(auth.customer_id);
|
|
@@ -15505,7 +15604,14 @@ function mount(router, deps) {
|
|
|
15505
15604
|
throw e;
|
|
15506
15605
|
}
|
|
15507
15606
|
if (!auth) {
|
|
15508
|
-
|
|
15607
|
+
// Device-binding drift: clear the now-stale cookie and bounce to a
|
|
15608
|
+
// neutral sign-in notice (never a hard 401 mid-page). Any other
|
|
15609
|
+
// not-signed-in case (no cookie, expired) bounces to the plain
|
|
15610
|
+
// login with no notice.
|
|
15611
|
+
if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
|
|
15612
|
+
res.status(303);
|
|
15613
|
+
res.setHeader && res.setHeader("location",
|
|
15614
|
+
(req && req._authDeviceDrift) ? "/account/login?signed_out=device" : "/account/login");
|
|
15509
15615
|
res.end ? res.end() : res.send("");
|
|
15510
15616
|
return null;
|
|
15511
15617
|
}
|
|
@@ -19993,7 +20099,22 @@ function mount(router, deps) {
|
|
|
19993
20099
|
|
|
19994
20100
|
router.post("/unsubscribe", async function (req, res) {
|
|
19995
20101
|
var body = req.body || {};
|
|
19996
|
-
|
|
20102
|
+
// RFC 8058 one-click: the mail client POSTs to the EXACT URL in the
|
|
20103
|
+
// List-Unsubscribe header — token in the `?token=` query string —
|
|
20104
|
+
// with a `List-Unsubscribe=One-Click` form body and NOTHING else
|
|
20105
|
+
// (no token of its own). So the URL token is authoritative; the
|
|
20106
|
+
// body `token` is only the fallback the on-page confirm form POSTs
|
|
20107
|
+
// from its hidden field. Reading body.token alone (the prior shape)
|
|
20108
|
+
// meant a native one-click POST carried no token -> "not-found" ->
|
|
20109
|
+
// the recipient was never unsubscribed. Parse the token off req.url
|
|
20110
|
+
// (the router only populates req.query when a route declares a query
|
|
20111
|
+
// validator), then fall back to the confirm-form body field.
|
|
20112
|
+
var urlToken = "";
|
|
20113
|
+
try {
|
|
20114
|
+
var u = req.url ? new URL(req.url, "http://localhost") : null;
|
|
20115
|
+
if (u) urlToken = u.searchParams.get("token") || "";
|
|
20116
|
+
} catch (_eUrl) { urlToken = ""; }
|
|
20117
|
+
var token = urlToken || (typeof body.token === "string" ? body.token : "");
|
|
19997
20118
|
var cartCount = 0;
|
|
19998
20119
|
try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
|
|
19999
20120
|
var outcome;
|
|
@@ -20001,6 +20122,9 @@ function mount(router, deps) {
|
|
|
20001
20122
|
// `consumeUnsubscribeToken` returns a structured result (it does
|
|
20002
20123
|
// not throw on a bad/missing token — it returns `{ ok:false,
|
|
20003
20124
|
// error:"not-found" }`). An empty token is handled the same way.
|
|
20125
|
+
// It is single-use, so the one-click POST and a later confirm-form
|
|
20126
|
+
// POST of the same token are idempotent (the second reads
|
|
20127
|
+
// "already" — still a success page, no error).
|
|
20004
20128
|
var result = await deps.newsletter.consumeUnsubscribeToken(token);
|
|
20005
20129
|
outcome = _unsubscribeOutcome(result);
|
|
20006
20130
|
} 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