support_desk 0.1.2 → 0.1.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c258a259ca701d1c8850126370d587f1297eba3429ec0649912258596ac3789d
4
- data.tar.gz: 48684ab7f4b23e2d76e2c17839bf602900e4e22123ef41400595546caedcdcf4
3
+ metadata.gz: 587ddd67c318b1c6b4cbe7b496a65ec2bfb70b39818556a5d90ad81415db17c8
4
+ data.tar.gz: 3ab9972eca4fc07680e1b33d139400d7f1c6ef9116e3778967f070d05f5111e1
5
5
  SHA512:
6
- metadata.gz: 70aa54f8c81942b81f10e00b7259825ca003b71bafb127fac498cb8325562207abd1a5a76628d41163c78e04ee3ca5bd5833d1bdd40b4b8756bd7a136225fec2
7
- data.tar.gz: 65cddb6b49effbb1898a91c06cc44ce7944215c2139ce885580ec79faabd7c682693b171b589b756f7cab9fb1cb0f4b07df8532872e68b9ab3ddcfdeb2f9a9e2
6
+ metadata.gz: 27e60ca1419b9fd778e6d57ac35dea358b7f422829b92c36f00d123153b967a0171c8f7edfeeef8715e597b2e169fa48629325d4314aa2fdc2593f8c27662924
7
+ data.tar.gz: f04e5ba138fd6e847c0aad98ef5f42eeaf1018900eccd4f3b72af15a9ba82a00e9506bf2412e900145918a2eb97299b5b0fbb2d1b0bcabba0c23b890ae027fc3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to this project are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.1.3] - 2026-09-17
8
+
9
+ ### Fixed
10
+
11
+ - Opening a case and its first message is atomic; rejected uploads render validation errors with the draft preserved.
12
+ - Reopening historical cases preserves both conversations when a newer case already exists; new submissions still reuse an open case.
13
+ - Assignment authorizes its actor, and assignment, hand-off, release and reply policy check current state under the row lock.
14
+ - Delayed message registration derives whose turn it is from both clocks and cannot reopen a case closed after the message.
15
+ - Reading an authorized console transcript marks the desk's read horizon through the last displayed message.
16
+ - Topics resolve within their ticket's desk; wizard routing and response promises honor nondefault desks.
17
+ - The requester upload form uses multipart encoding, and its send budget is shared with chats.
18
+ - Requires chats >= 0.3.2, whose authors need no messaging capabilities of
19
+ their own, so staff sign a desk reply without becoming messagers.
20
+
7
21
  ## [0.1.2] - 2026-09-16
8
22
 
9
23
  ### Fixed
data/README.md CHANGED
@@ -429,13 +429,19 @@ end
429
429
 
430
430
  Pass `key:` from anywhere that runs more than once (a `to_prepare` block, an engine initializer) and re-registering replaces that subscriber instead of stacking a copy on every reload.
431
431
 
432
- Keep notification titles generic — `ticket.notification_title` is and put the detail in the body. A lock screen shouldn't spell out what somebody's support case is about.
432
+ Keep push titles and bodies generic — `ticket.notification_title` is safe for previews.
433
+ `ticket.notification_body` contains case details for an authenticated feed, not a lock screen.
433
434
 
434
435
  ## Compatibility
435
436
 
436
437
  Rails 7.2, 8.0 and 8.1; Ruby >= 3.2; PostgreSQL, SQLite and MySQL; bigint or UUID primary keys (the migration follows your app's `primary_key_type`).
437
438
 
438
- PostgreSQL and SQLite additionally hold the two cardinality rules — one open ticket about one thing, one open assignment per ticket — as **partial unique indexes**, so a race loses at the database and not merely at the model. MySQL has no partial indexes, so there those two rules are model-only; that is why the suite's own matrix is SQLite and PostgreSQL.
439
+ PostgreSQL and SQLite enforce unique new-case creation and one open assignment per ticket
440
+ with **partial unique indexes**. New submissions reuse an existing open case. Reopening
441
+ historical cases is deliberately exempt from new-case deduplication: if a newer case
442
+ already exists, both histories remain open and support is notified of the reply. No
443
+ conversation is silently merged, closed or discarded. MySQL has no partial indexes,
444
+ so these guarantees are model-only there; the suite's matrix is SQLite and PostgreSQL.
439
445
 
440
446
  ## Testing
441
447
 
@@ -14,6 +14,7 @@ module SupportDesk
14
14
  CLOSED_TICKETS_SHOWN = 20
15
15
 
16
16
  before_action :set_wizard, only: %i[new create]
17
+ include Chats::SendRateLimited
17
18
 
18
19
  # Their cases: open ones as rows, closed ones folded away, and the door
19
20
  # to open another.
@@ -45,6 +46,9 @@ module SupportDesk
45
46
 
46
47
  ticket = @wizard.open!(message, files: files)
47
48
  redirect_to conversation_path_for(ticket)
49
+ rescue ActiveRecord::RecordInvalid => e
50
+ @error = e.record.errors.full_messages.to_sentence
51
+ render :new, status: :unprocessable_entity
48
52
  rescue SupportDesk::RateLimited, SupportDesk::TooManyOpenTickets => e
49
53
  render_limit_wall(e)
50
54
  rescue SupportDesk::InvalidTransition
@@ -66,6 +70,8 @@ module SupportDesk
66
70
 
67
71
  private
68
72
 
73
+ def chat_rate_limit_messager = current_requester
74
+
69
75
  def set_wizard
70
76
  @wizard = SupportDesk::Wizard.new(current_requester, params)
71
77
  # A token that is forged, expired, or points at somebody else's record
@@ -20,6 +20,7 @@
20
20
  <%= render "support_desk/tickets/context_card", subject: wizard.subject if wizard.subject %>
21
21
 
22
22
  <%= form_with url: support_desk_routes.tickets_path,
23
+ multipart: true,
23
24
  method: :post,
24
25
  class: "support-desk-form",
25
26
  data: { turbo_frame: "_top" } do %>
@@ -102,6 +102,7 @@ module SupportDesk
102
102
  before_action :set_support_ticket, only: MEMBER_ACTIONS
103
103
  before_action :authorize_support_console!
104
104
  before_action :require_offered_action!, only: TRANSITIONS
105
+ after_action :mark_support_transcript_read, only: :show
105
106
 
106
107
  helper_method :current_agent, :support_desk_record, :support_queue, :support_transcript,
107
108
  :console_ticket_path, :console_tickets_path, :console_file_path
@@ -218,7 +219,10 @@ module SupportDesk
218
219
  # Override to paginate a long one.
219
220
  def support_transcript(ticket = @ticket)
220
221
  scope = ticket.messages.visible.oldest_first.includes(:sender, :author)
221
- scope.respond_to?(:with_attached_files) ? scope.with_attached_files : scope
222
+ scope = scope.with_attached_files if scope.respond_to?(:with_attached_files)
223
+ scope.load
224
+ @support_read_through = scope.last&.created_at if ticket == @ticket
225
+ scope
222
226
  end
223
227
 
224
228
  # The default queue tab and the tickets behind it, for hosts that want
@@ -276,6 +280,12 @@ module SupportDesk
276
280
 
277
281
  private
278
282
 
283
+ def mark_support_transcript_read
284
+ return unless response.successful? && @support_read_through
285
+
286
+ @ticket.conversation.participant_for(@ticket.desk)&.read!(at: @support_read_through)
287
+ end
288
+
279
289
  # --- Who is asking ------------------------------------------------------------
280
290
 
281
291
  # The person answering. Define `current_agent` in your controller, or
@@ -55,11 +55,18 @@ module SupportDesk
55
55
  has_many :events, class_name: "SupportDesk::Event", inverse_of: :ticket, dependent: :delete_all
56
56
  has_many :messages, through: :conversation, source: :messages
57
57
 
58
- # `ticket.topic` is a Topic, never a String the tree is the thing that
59
- # carries behaviour, so the column casts both ways.
58
+ # Persist only the path. Resolving behavior needs this ticket's desk;
59
+ # an attribute caster has no record context and cannot choose the tree.
60
60
  attribute :topic, Topic::Type.new
61
61
  attribute :metadata, default: -> { {} }
62
62
 
63
+ def topic
64
+ path = self[:topic]
65
+ return if path.nil?
66
+
67
+ desk_config.topics.find(path) || Topic::Unknown.new(path)
68
+ end
69
+
63
70
  validates :status, inclusion: { in: STATUSES }
64
71
  validates :awaiting, inclusion: { in: AWAITING_STATES }
65
72
  validates :opened_via, inclusion: { in: CHANNELS }
@@ -166,24 +173,22 @@ module SupportDesk
166
173
  node = resolve_topic!(topic, about, desk, requester, about)
167
174
 
168
175
  cardinality = cardinality_key_for(requester: requester, subject: about, topic: node)
169
- existing = open_ticket_for(requester: requester, desk: desk, cardinality_key: cardinality)
176
+ existing = existing_for(requester: requester, desk: desk, subject: about, topic: node)
170
177
  return post_opening_message(existing, message, files) if existing
171
178
 
172
179
  enforce_rate_limit!(requester, desk)
173
180
  enforce_open_ticket_cap!(requester, desk)
174
181
 
175
- ticket, inserted = insert_ticket!(
176
- requester: requester, desk: desk, node: node, about: about, via: via,
177
- requester_role: requester_role, title: title, metadata: metadata, cardinality_key: cardinality
178
- )
179
- ticket = post_opening_message(ticket, message, files)
180
-
181
- # ONLY the request that actually inserted announces the ticket. Under
182
- # the insert race the loser is holding somebody else's ticket, and a
183
- # subscriber that pages every agent, or writes a row into a
184
- # hash-chained audit log, must not do it twice for one case.
185
- SupportDesk.emit_after_commit(:ticket_opened, ticket) if inserted
186
- ticket
182
+ ticket = transaction do
183
+ created, inserted = insert_ticket!(
184
+ requester: requester, desk: desk, node: node, about: about, via: via,
185
+ requester_role: requester_role, title: title, metadata: metadata, cardinality_key: cardinality
186
+ )
187
+ post_opening_message(created, message, files)
188
+ SupportDesk.emit_after_commit(:ticket_opened, created) if inserted
189
+ created
190
+ end
191
+ ticket.reload
187
192
  end
188
193
 
189
194
  # A human-friendly, unguessable-enough reference: "T-AB12CD".
@@ -218,6 +223,16 @@ module SupportDesk
218
223
  end
219
224
  end
220
225
 
226
+ # New submissions reuse an open case, including a reopened history.
227
+ # Reopening itself never merges or discards a different conversation.
228
+ def existing_for(requester:, desk:, subject:, topic:)
229
+ return if subject && !subject.class.one_open_support_ticket?
230
+
231
+ scope = not_closed.where(requester: requester, desk: desk, subject: subject)
232
+ scope = scope.where(topic: topic.to_s) unless subject
233
+ scope.newest_first.first
234
+ end
235
+
221
236
  private
222
237
 
223
238
  # "on this desk, and waiting longer than its own threshold" — with an
@@ -325,7 +340,9 @@ module SupportDesk
325
340
  attempts = 0
326
341
  begin
327
342
  attempts += 1
328
- ticket, opened = transaction do
343
+ # A unique-index loser must roll back a savepoint before querying
344
+ # the winner; PostgreSQL forbids reads in an aborted transaction.
345
+ ticket, opened = transaction(requires_new: true) do
329
346
  ticket = create!(
330
347
  desk: desk, requester: requester, requester_role: requester_role, subject: about,
331
348
  topic: node, title: title.presence, reference: unique_reference, status: "open",
@@ -491,14 +508,14 @@ module SupportDesk
491
508
  def reply!(body = nil, by: nil, files: [], request: nil)
492
509
  actor = resolve_actor(by)
493
510
  ensure_agent!(actor)
494
- ensure_writable!
495
511
 
496
512
  # One transaction, because taking the ticket and announcing it are
497
513
  # part of answering: a reply that raises (an empty body, a locked
498
514
  # conversation, a rate limit) must not leave the agent holding a case
499
515
  # they never answered, or "Lucía se ocupa de tu consulta" sitting in
500
516
  # the requester's thread with no reply under it.
501
- transaction do
517
+ with_lock do
518
+ ensure_writable!
502
519
  apply_reply_policy!(actor, request: request)
503
520
  desk.message!(conversation, body, files: files, author: actor)
504
521
  end
@@ -521,13 +538,14 @@ module SupportDesk
521
538
  # handed it. Repeating an assignment to the current holder does nothing.
522
539
  def assign!(to:, by: nil, reason: nil, note: nil, request: nil)
523
540
  actor = resolve_actor(by)
541
+ ensure_agent!(actor)
524
542
  ensure_assignable!(to)
525
- raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
526
543
 
527
544
  reason ||= to == actor ? :taken : :assigned
528
545
  assignment = nil
529
546
 
530
547
  event = write_transition!(:assigned, actor: actor, request: request) do
548
+ raise InvalidTransition, "can't assign a closed ticket — reopen it first" if closed?
531
549
  next false if assigned_to?(to)
532
550
 
533
551
  assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: reason, note: note)
@@ -545,18 +563,19 @@ module SupportDesk
545
563
  # whoever picks it up. Hand-off notes are always internal.
546
564
  def hand_off!(to:, note: nil, by: nil, request: nil)
547
565
  actor = resolve_actor(by)
548
- unless assigned_to?(actor)
549
- raise NotTheAssignee, "#{describe_actor(actor)} doesn't hold ticket #{reference} " \
550
- "(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
551
- end
566
+ ensure_agent!(actor)
552
567
  ensure_assignable!(to)
553
- raise InvalidTransition, "can't hand off a closed ticket" if closed?
554
-
555
- from = assignee
568
+ from = nil
556
569
  assignment = nil
557
570
  event = write_transition!(:handed_off, actor: actor, request: request) do
571
+ unless assigned_to?(actor)
572
+ raise NotTheAssignee, "#{describe_actor(actor)} doesn't hold ticket #{reference} " \
573
+ "(#{assignee ? describe_actor(assignee) : "nobody"} does) — use assign! to override"
574
+ end
575
+ raise InvalidTransition, "can't hand off a closed ticket" if closed?
558
576
  next false if assigned_to?(to)
559
577
 
578
+ from = assignee
560
579
  assignment = Assignment.open!(ticket: self, agent: to, by: actor, reason: :handed_off, note: note,
561
580
  release_reason: :handed_off)
562
581
  update!(assignee: to)
@@ -573,12 +592,12 @@ module SupportDesk
573
592
  def release!(by: nil, reason: :released, request: nil)
574
593
  actor = resolve_actor(by)
575
594
  ensure_agent!(actor)
576
- raise InvalidTransition, "can't release a closed ticket" if closed?
577
-
578
- from = assignee
595
+ from = nil
579
596
  event = write_transition!(:released, actor: actor, request: request) do
597
+ raise InvalidTransition, "can't release a closed ticket" if closed?
580
598
  next false if unassigned?
581
599
 
600
+ from = assignee
582
601
  assignments.open.each { |assignment| assignment.release!(reason: reason) }
583
602
  update!(assignee: nil)
584
603
  { "from" => SupportDesk.actor_key(from), "reason" => reason.to_s }
@@ -625,7 +644,8 @@ module SupportDesk
625
644
  # one update! hash reads the closed ticket's "none" and stores nil —
626
645
  # a reopened case that no SLA scope can see.
627
646
  assign_attributes(status: "open", closed_at: nil, closed_by: nil,
628
- reopen_count: reopen_count.to_i + 1, awaiting: awaiting_from_clocks)
647
+ reopen_count: reopen_count.to_i + 1, awaiting: awaiting_from_clocks,
648
+ cardinality_key: "reopened:#{id}")
629
649
  self.waiting_since = waiting_since_from_clocks
630
650
  save!
631
651
  restore_assignment!(by: actor)
@@ -711,11 +731,10 @@ module SupportDesk
711
731
  attributes = { last_registered_message_id: message.id }
712
732
  case role
713
733
  when :requester
714
- attributes[:awaiting] = "agent"
715
734
  attributes[:last_requester_message_at] = message.created_at
716
- if closed? && desk_config.closed_tickets == :reopen_on_reply
735
+ if closed? && message.created_at > closed_at && desk_config.closed_tickets == :reopen_on_reply
717
736
  attributes.merge!(status: "open", closed_at: nil, closed_by: nil,
718
- reopen_count: reopen_count.to_i + 1)
737
+ reopen_count: reopen_count.to_i + 1, cardinality_key: "reopened:#{id}")
719
738
  reopened = true
720
739
  end
721
740
  when :agent
@@ -724,10 +743,10 @@ module SupportDesk
724
743
  # A closed case owes nobody anything. An agent adding one last
725
744
  # word keeps the clocks honest without putting the case back in a
726
745
  # queue that `close!` just took it out of.
727
- attributes[:awaiting] = "requester" unless closed?
728
746
  end
729
747
 
730
748
  assign_attributes(attributes)
749
+ self.awaiting = closed? ? "none" : awaiting_from_clocks
731
750
  self.waiting_since = waiting_since_from_clocks
732
751
  save!
733
752
 
@@ -825,7 +844,7 @@ module SupportDesk
825
844
  I18n.t("support_desk.notifications.title", desk: desk.name)
826
845
  end
827
846
 
828
- # The detail, for the body visible after unlock.
847
+ # Case detail for an authenticated feed. Do not use for push previews.
829
848
  def notification_body = label
830
849
 
831
850
  # A GDPR-friendly dump of the case as the requester experienced it:
@@ -192,9 +192,12 @@ module SupportDesk
192
192
 
193
193
  # The desk key tickets under this topic belong to.
194
194
  def desk_key
195
- (inherited_or_own(:desk) || :default).to_sym
195
+ desk_override || :default
196
196
  end
197
197
 
198
+ # nil means inherit the wizard's desk, not route to the default desk.
199
+ def desk_override = inherited_or_own(:desk)&.to_sym
200
+
198
201
  # Hidden from the wizard; the label still resolves for historic tickets.
199
202
  def retired? = !!inherited_or_own(:retired)
200
203
 
@@ -257,34 +260,10 @@ module SupportDesk
257
260
  def inspect = "#<SupportDesk::Topic::Unknown #{path}>"
258
261
  end
259
262
 
260
- # The ActiveModel attribute type behind `attribute :topic`: casts
261
- # Symbol | String | Topic on the way in, hands back a Topic on the way
262
- # out, and serializes to the path — so `where(topic: :ride)` works and a
263
- # view never sees a bare string.
264
- class Type < ActiveModel::Type::Value
265
- def type = :string
266
-
267
- def cast(value)
268
- case value
269
- when nil then nil
270
- when Topic then value
271
- else SupportDesk.find_topic(value.to_s)
272
- end
273
- end
274
-
275
- def serialize(value)
276
- case value
277
- when nil then nil
278
- when Topic then value.path
279
- else value.to_s
280
- end
281
- end
282
-
283
- def deserialize(value) = cast(value)
284
-
285
- def changed_in_place?(raw_old_value, new_value)
286
- raw_old_value != serialize(new_value)
287
- end
263
+ # Casting must not consult a global topic tree: the same path may mean
264
+ # different things on two desks. Ticket#topic resolves the stored path.
265
+ class Type < ActiveModel::Type::String
266
+ def cast(value) = value&.to_s
288
267
  end
289
268
  end
290
269
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
@@ -84,7 +84,7 @@ module SupportDesk
84
84
  # requester's desk, and a #desk that depended on the topic would ask the
85
85
  # tree for the topic to find out which tree to ask.
86
86
  def target_desk
87
- key = topic&.desk_key
87
+ key = topic&.desk_override
88
88
  return desk if key.nil? || key == desk.key
89
89
 
90
90
  SupportDesk.desk(key) || desk
@@ -186,7 +186,7 @@ module SupportDesk
186
186
  end
187
187
 
188
188
  # The promise as a Duration, for hosts that want to phrase it themselves.
189
- def promise_within = desk.config.reply_within
189
+ def promise_within = target_desk.config.reply_within
190
190
 
191
191
  # "1 day", "4 horas" — through ActionView's date helper so it speaks the
192
192
  # requester's language, falling back to Duration#inspect in the (rare)
@@ -207,10 +207,7 @@ module SupportDesk
207
207
  def existing_ticket
208
208
  return nil if topic.nil? || topic.branch?
209
209
 
210
- key = Ticket.cardinality_key_for(requester: requester, subject: subject, topic: topic)
211
- return nil if key.start_with?("free:")
212
-
213
- Ticket.not_closed.find_by(requester: requester, desk: target_desk, cardinality_key: key)
210
+ Ticket.existing_for(requester: requester, desk: target_desk, subject: subject, topic: topic)
214
211
  end
215
212
 
216
213
  # Which records in +choices+ the requester already has an open case
@@ -270,15 +267,9 @@ module SupportDesk
270
267
  raise InvalidTransition, "the wizard is still on the #{step} step — pick one before submitting"
271
268
  end
272
269
 
273
- # `ask_support!` files under the REQUESTER's desk, which is right
274
- # until a topic says otherwise; then the redirect is spelled out.
275
- if target_desk == desk
276
- requester.ask_support!(message, about: subject, topic: topic.path, files: files)
277
- else
278
- Ticket.open!(requester: requester, message: message, about: subject, topic: topic.path,
279
- files: files, desk: target_desk,
280
- requester_role: requester.class.support_desk_requester_options[:as])
281
- end
270
+ Ticket.open!(requester: requester, message: message, about: subject, topic: topic.path,
271
+ files: files, desk: target_desk,
272
+ requester_role: requester.class.support_desk_requester_options[:as])
282
273
  end
283
274
 
284
275
  # A signed token for the currently chosen subject, to round-trip through
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: support_desk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-16 00:00:00.000000000 Z
10
+ date: 2026-09-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -56,6 +56,9 @@ dependencies:
56
56
  - - "~>"
57
57
  - !ruby/object:Gem::Version
58
58
  version: '0.3'
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 0.3.2
59
62
  type: :runtime
60
63
  prerelease: false
61
64
  version_requirements: !ruby/object:Gem::Requirement
@@ -63,6 +66,9 @@ dependencies:
63
66
  - - "~>"
64
67
  - !ruby/object:Gem::Version
65
68
  version: '0.3'
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 0.3.2
66
72
  - !ruby/object:Gem::Dependency
67
73
  name: globalid
68
74
  requirement: !ruby/object:Gem::Requirement