@blamejs/blamejs-shop 0.4.20 → 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 +4 -0
- package/README.md +1 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/collections.js +282 -14
- 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/gift-card-ledger.js +37 -8
- package/lib/gift-registry.js +43 -5
- package/lib/loyalty-earn-rules.js +106 -0
- package/lib/loyalty.js +63 -30
- package/lib/order-export.js +14 -17
- package/lib/order.js +24 -0
- package/lib/search-ranking.js +58 -2
- package/lib/security-middleware.js +13 -5
- package/lib/store-credit.js +31 -19
- package/lib/storefront.js +129 -20
- package/lib/subscription-controls.js +113 -0
- package/lib/support-tickets.js +113 -53
- package/package.json +1 -1
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