@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.
@@ -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. Operator replies flip
439
- // `new -> in_progress` and stamp `first_response_at` if unset.
440
- // Operator replies also bump `last_action_at`; customer replies
441
- // do NOT advance the SLA clock (the clock measures operator
442
- // responsiveness, not customer chatter).
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
- // Operator reply flips `new -> in_progress` automatically.
483
- // Other states keep their status; SLA timer + first-response
484
- // stamp still advance.
485
- var newStatus = ticket.status;
486
- if (ticket.status === "new") {
487
- newStatus = "in_progress";
488
- await _writeStatusHistory(ticketId, ticket.status, newStatus, "operator-reply", ts);
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 that
497
- // would mask SLA breach. They DO move the ticket out of
498
- // `waiting_customer` back into `in_progress` because the
499
- // operator now owes the next move.
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 ticket = await _getRaw(ticketId);
606
- if (!ticket) {
607
- var err = new Error("supportTickets.addTag: ticket " + ticketId + " not found");
608
- err.code = "SUPPORT_TICKET_NOT_FOUND";
609
- throw err;
610
- }
611
- var tags;
612
- try { tags = JSON.parse(ticket.tags_json || "[]"); }
613
- catch (_e) { tags = []; }
614
- if (tags.indexOf(tag) === -1) {
615
- if (tags.length >= MAX_TAG_COUNT) {
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
- tags.push(tag);
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 ticket = await _getRaw(ticketId);
634
- if (!ticket) {
635
- var err = new Error("supportTickets.removeTag: ticket " + ticketId + " not found");
636
- err.code = "SUPPORT_TICKET_NOT_FOUND";
637
- throw err;
638
- }
639
- var tags;
640
- try { tags = JSON.parse(ticket.tags_json || "[]"); }
641
- catch (_e) { tags = []; }
642
- var idx = tags.indexOf(tag);
643
- if (idx !== -1) {
644
- tags.splice(idx, 1);
645
- await query(
646
- "UPDATE support_tickets SET tags_json = ?1 WHERE id = ?2",
647
- [JSON.stringify(tags), ticketId],
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.20",
3
+ "version": "0.4.22",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {