support_desk 0.1.0

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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +32 -0
  3. data/.simplecov +53 -0
  4. data/Appraisals +18 -0
  5. data/CHANGELOG.md +135 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +469 -0
  8. data/Rakefile +55 -0
  9. data/app/assets/stylesheets/support_desk.css +242 -0
  10. data/app/controllers/support_desk/application_controller.rb +92 -0
  11. data/app/controllers/support_desk/console/application_controller.rb +18 -0
  12. data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
  13. data/app/controllers/support_desk/tickets_controller.rb +119 -0
  14. data/app/helpers/support_desk/engine_helper.rb +196 -0
  15. data/app/views/chats/slots/_inbox_top.html.erb +31 -0
  16. data/app/views/chats/slots/_locked_composer.html.erb +18 -0
  17. data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
  18. data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
  19. data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
  20. data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
  21. data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
  22. data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
  23. data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
  24. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
  25. data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
  26. data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
  27. data/app/views/support_desk/console/tickets/index.html.erb +37 -0
  28. data/app/views/support_desk/console/tickets/show.html.erb +45 -0
  29. data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
  30. data/app/views/support_desk/tickets/_door.html.erb +12 -0
  31. data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
  32. data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
  33. data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
  34. data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
  35. data/app/views/support_desk/tickets/_write.html.erb +56 -0
  36. data/app/views/support_desk/tickets/index.html.erb +53 -0
  37. data/app/views/support_desk/tickets/new.html.erb +14 -0
  38. data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
  39. data/config/console_routes.rb +16 -0
  40. data/config/locales/support_desk.console.en.yml +93 -0
  41. data/config/locales/support_desk.console.es.yml +93 -0
  42. data/config/locales/support_desk.en.yml +79 -0
  43. data/config/locales/support_desk.es.yml +84 -0
  44. data/config/routes.rb +24 -0
  45. data/context7.json +4 -0
  46. data/gemfiles/rails_7.2.gemfile +34 -0
  47. data/gemfiles/rails_8.0.gemfile +34 -0
  48. data/gemfiles/rails_8.1.gemfile +34 -0
  49. data/lib/generators/support_desk/console_generator.rb +94 -0
  50. data/lib/generators/support_desk/install_generator.rb +86 -0
  51. data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
  52. data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
  53. data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
  54. data/lib/generators/support_desk/templates/initializer.rb +186 -0
  55. data/lib/generators/support_desk/views_generator.rb +50 -0
  56. data/lib/support_desk/configuration.rb +675 -0
  57. data/lib/support_desk/console.rb +487 -0
  58. data/lib/support_desk/console_engine.rb +63 -0
  59. data/lib/support_desk/console_routes.rb +107 -0
  60. data/lib/support_desk/context_card.rb +90 -0
  61. data/lib/support_desk/current.rb +26 -0
  62. data/lib/support_desk/doctor.rb +220 -0
  63. data/lib/support_desk/engine.rb +141 -0
  64. data/lib/support_desk/errors.rb +49 -0
  65. data/lib/support_desk/events.rb +122 -0
  66. data/lib/support_desk/macros.rb +73 -0
  67. data/lib/support_desk/models/application_record.rb +11 -0
  68. data/lib/support_desk/models/assignment.rb +83 -0
  69. data/lib/support_desk/models/concerns/agent.rb +79 -0
  70. data/lib/support_desk/models/concerns/requester.rb +71 -0
  71. data/lib/support_desk/models/concerns/supportable.rb +88 -0
  72. data/lib/support_desk/models/desk.rb +101 -0
  73. data/lib/support_desk/models/event.rb +72 -0
  74. data/lib/support_desk/models/ticket.rb +1124 -0
  75. data/lib/support_desk/queue.rb +171 -0
  76. data/lib/support_desk/summary.rb +70 -0
  77. data/lib/support_desk/test_helpers.rb +137 -0
  78. data/lib/support_desk/timeline.rb +104 -0
  79. data/lib/support_desk/topic.rb +290 -0
  80. data/lib/support_desk/topic_tree.rb +214 -0
  81. data/lib/support_desk/version.rb +5 -0
  82. data/lib/support_desk/wizard.rb +392 -0
  83. data/lib/support_desk.rb +288 -0
  84. metadata +229 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 953cadd4b7e15695dd5d17092647e38c5595e4b81bccfc2917b491c428f4d7d6
4
+ data.tar.gz: 077ca8e707b0789140bd484414645ad1217b210d92d02557d7174fd32617b90b
5
+ SHA512:
6
+ metadata.gz: f442d5f55ab69f61941fcd0b401e0be726d7ca1b228d68d683b52cc14e795d1f136fadf1915faf0153dca9d33a5f3720f764cacd06f3bab3567af20a36393ba6
7
+ data.tar.gz: 83cc58116727f3f2aebf62f8b61f4a2617d443b58f7cee8179620e5889532e2f281a6515b6c058f7baed4512a259f06592ff6d8b420b2afcbd12e2ddc1241ad6
data/.rubocop.yml ADDED
@@ -0,0 +1,32 @@
1
+ # rubocop-rails-omakase is the house style across the gem ecosystem: Rails'
2
+ # own defaults, which means generated code (migrations, initializers) stays
3
+ # clean in a stock Rails app without us reformatting it.
4
+ inherit_gem:
5
+ rubocop-rails-omakase: rubocop.yml
6
+
7
+ # MERGE our excludes with rubocop's defaults (vendor/, node_modules/, …) —
8
+ # a bare Exclude key would REPLACE them.
9
+ inherit_mode:
10
+ merge:
11
+ - Exclude
12
+
13
+ AllCops:
14
+ TargetRubyVersion: 3.2
15
+ Exclude:
16
+ # The dummy host app mirrors GENERATED code (its migration is a copy of
17
+ # the install template) — don't lint it against the gem's own style.
18
+ - test/dummy/db/**/*
19
+ # Generator scratch output. `rake ci` runs the tests before rubocop, so
20
+ # leaving this in would make the set of linted files depend on whether a
21
+ # generator test had run — a gate that inspects 109 files on a clean
22
+ # checkout and 111 after a suite is a gate nobody can reproduce. The
23
+ # generated console is still linted for real: test/dummy's controller is
24
+ # a verbatim rendering of the same template, and a test keeps them equal.
25
+ - test/tmp/**/*
26
+ # Appraisal-generated Gemfiles.
27
+ - gemfiles/*
28
+
29
+ Layout/LineLength:
30
+ Max: 120
31
+ Exclude:
32
+ - support_desk.gemspec # the long-form rubygems description is one line by design
data/.simplecov ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SimpleCov configuration (auto-loaded by `require "simplecov"`); the suite
4
+ # calls SimpleCov.start from test_helper.rb, so this file stays
5
+ # configuration-only. Coherent with the rest of the gem ecosystem (chats,
6
+ # wallets, moderate, usage_credits, …).
7
+ SimpleCov.configure do
8
+ # SimpleFormatter for terminal-only output (no HTML generation)
9
+ formatter SimpleCov::Formatter::SimpleFormatter
10
+
11
+ # Don't count code that ISN'T unit-testable by this suite and would only
12
+ # distort the numbers:
13
+ # - the test suite itself;
14
+ # - generators + their templates: they run via `rails generate
15
+ # support_desk:install` in a real host. The generator classes ARE
16
+ # exercised by Rails::Generators::TestCase, but the migration template
17
+ # itself is never loaded as Ruby here (the dummy migrates a copy of it);
18
+ # - version.rb: a single constant, nothing to cover.
19
+ if respond_to?(:skip)
20
+ # SimpleCov 1.x vocabulary.
21
+ skip "/test/"
22
+ skip "/lib/generators/"
23
+ skip "/lib/support_desk/version.rb"
24
+ cover "lib/**/*.rb"
25
+ else
26
+ # Fallback vocabulary for SimpleCov 0.22.
27
+ add_filter "/test/"
28
+ add_filter "/lib/generators/"
29
+ add_filter "/lib/support_desk/version.rb"
30
+ track_files "lib/**/*.rb"
31
+ end
32
+
33
+ enable_coverage :branch
34
+
35
+ # Thresholds sit just under the current floor so the gate catches a real
36
+ # regression without failing on the existing baseline; raise as it grows.
37
+ minimum_coverage line: 94, branch: 74
38
+
39
+ # Disambiguate parallel test runs
40
+ command_name "Job #{ENV["TEST_ENV_NUMBER"]}" if ENV["TEST_ENV_NUMBER"]
41
+ end
42
+
43
+ # Print coverage summary to terminal after tests complete
44
+ SimpleCov.at_exit do
45
+ SimpleCov.result.format!
46
+ puts "\n#{"=" * 60}"
47
+ puts "COVERAGE SUMMARY"
48
+ puts "=" * 60
49
+ puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
50
+ branch_coverage = SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || "N/A"
51
+ puts "Branch Coverage: #{branch_coverage}%"
52
+ puts "=" * 60
53
+ end
data/Appraisals ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Test the minimum supported Rails version (matches the gemspec floor).
4
+ # `ActiveRecord.after_all_transactions_commit` — how every event this gem
5
+ # emits gets out only after the transition it describes is durable — is a
6
+ # Rails 7.2 API, which is what sets the floor.
7
+ appraise "rails-7.2" do
8
+ gem "rails", "~> 7.2.0"
9
+ end
10
+
11
+ appraise "rails-8.0" do
12
+ gem "rails", "~> 8.0.0"
13
+ end
14
+
15
+ # Test the latest Rails version — this is the default/main Gemfile anyway.
16
+ appraise "rails-8.1" do
17
+ gem "rails", "~> 8.1.0"
18
+ end
data/CHANGELOG.md ADDED
@@ -0,0 +1,135 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-09-16
8
+
9
+ First release: the whole core of a support desk, on top of `chats` 0.2.
10
+
11
+ ### Added
12
+
13
+ - **Tickets that are conversations.** `SupportDesk::Ticket` is a case with
14
+ exactly one `Chats::Conversation` behind it. Transitions (`reply!`,
15
+ `note!`, `assign!`, `hand_off!`, `release!`, `close!`, `reopen!`,
16
+ `change_topic!`, `attach_subject!`) each take `by:`, run under the
17
+ ticket's row lock, write one append-only event row, and emit after the
18
+ transaction commits. Repeating one that already happened returns `self`
19
+ and writes nothing; one that can't happen from here raises
20
+ `SupportDesk::InvalidTransition`.
21
+ - **Three macros.** `has_support_tickets` adds exactly four methods to
22
+ whoever asks for help; `supportable topic: :order` makes a domain record
23
+ something to ask about, with a working default for every method;
24
+ `acts_as_support_agent if: :admin?` makes someone able to answer, and
25
+ adds no verbs — the ticket is the subject of every sentence.
26
+ - **Topics as a tree of value objects.** Defined in code, frozen at boot,
27
+ stored on the ticket as a stable path (`"billing/invoice"`).
28
+ `ticket.topic` is a `SupportDesk::Topic`, not a string, and a path no tree
29
+ knows reads as a null object that still renders.
30
+ - **Assignment as a history.** Who held the case, when, why they stopped,
31
+ with `ticket.assignee` as the denormalised pointer to the one open row.
32
+ Reopening gives the case back to whoever handled it.
33
+ - **`awaiting` and the SLA clocks**, maintained by a single subscriber to
34
+ chats' `:message_created` and idempotent on the message id — so a message
35
+ typed in the app, mirrored in by email or posted by a bot all move the
36
+ same clock, and a redelivered event never double-counts.
37
+ - **Reply policies**: `:anyone` (the first agent to answer an unheld ticket
38
+ takes it; a drop-in on someone else's is recorded), `:take_over`,
39
+ `:assignee_only`.
40
+ - **`SupportDesk::Wizard`** — the three-step "what do you need help with?"
41
+ machine as a PORO, so the views are replaceable and a native app or an API
42
+ can drive the same steps. Subjects travel as signed GlobalIDs and are
43
+ re-checked against `supportable_by?` anyway.
44
+ - **A requester-facing engine you just mount.** `mount SupportDesk::Engine
45
+ => "/support"` and the user side is done: their list of cases (open ones
46
+ as chats rows, closed ones folded away), the three wizard frames at one
47
+ URL, and a stable `/support/tickets/:id` that redirects into the
48
+ conversation. Every step is a real URL, so the back gesture, a bookmark
49
+ and a cold-boot deep link all work; `data-turbo-action="advance"` is what
50
+ lets them also re-render in place. Limits (`max_open_tickets`,
51
+ `open_rate_limit`) render a wall with the cases they already have listed
52
+ on it, never a 500.
53
+ - **`link_to_support(about:, text:, **html)` and `support_unread_badge`.**
54
+ The door renders nothing for a record that is not supportable or not
55
+ theirs, so it is safe in shared partials, and leads to the conversation
56
+ they already have rather than opening a second one.
57
+ - **Two rows on chats’ own screens**, through its view slots: the
58
+ "¿Necesitas ayuda? Escríbenos" door above the inbox for somebody who has
59
+ never written (`config.inbox_entry`), and a way out of a case closed on a
60
+ desk configured `closed_tickets: :locked`.
61
+ - **`SupportDesk.native_path_rules(mount:, title:)`** — the Hotwire Native
62
+ path-configuration rules for both surfaces, as pushed screens.
63
+ - **`config.authenticate_method`** so the engine runs the host’s own
64
+ authentication filter, and `rails g support_desk:views` to eject every
65
+ requester-facing template.
66
+ - **Queues and presenters**
67
+ — `Queue#counts` in one grouped query, a badge
68
+ cached 30s per agent, `ContextCard`, `Summary`, `Timeline` and
69
+ `actions_for(agent)`, all with no view dependency.
70
+ - **The agent console, in three layers** — the query objects above, then a
71
+ routing concern and a controller concern, then a generator. The generated
72
+ madmin console and the turnkey `SupportDesk::ConsoleEngine` use nothing
73
+ the concerns don't expose, which is what makes "bring your own UI" a
74
+ promise rather than a hope.
75
+ - `concerns: :support_console` in any route set draws member `reply`,
76
+ `take`, `assign`, `hand_off`, `release`, `close`, `reopen`, `note` and
77
+ `change_topic`, plus collection `next`.
78
+ - `SupportDesk::Console` scopes everything it reaches through
79
+ `config.visible_desks_for` — the ticket, the queue, the tab counts, the
80
+ badge and `next`, with `?desk=` able to name only a desk already on that
81
+ list. A case on a desk you may not work is a **404**; no desks at all is
82
+ a **403**. It asks `config.authorize_console` before every action (a
83
+ hook that raises denies, and is reported through `Rails.error`), checks
84
+ `actions_for` so it never accepts a verb it wouldn't have offered, sets
85
+ `SupportDesk::Current.actor`, and turns every domain refusal into a
86
+ translated flash — a policy never 500s.
87
+ `SupportDesk::Console::Index` is the optional `@queue` / `@scope` /
88
+ `@tickets`, preloading everything a row renders including the subject.
89
+ - `rails g support_desk:console madmin` writes a host-owned controller, a
90
+ madmin resource, and the view set: queue tabs, waiting chips coloured by
91
+ `at_risk_after` / `reply_within`, context card, transcript, timeline,
92
+ a "Reply"/"Internal note" composer, the hand-off picker, and a nav badge.
93
+ Idempotent, `--force` to take new defaults.
94
+ - `mount SupportDesk::ConsoleEngine => "/admin/support"` for hosts with no
95
+ admin framework, rendering those same views through
96
+ `config.console_parent_controller`.
97
+ - **Events out, policy in.** `SupportDesk.on(:ticket_opened) { … }`,
98
+ multi-subscriber, isolated with `Rails.error.report`, mirrored on
99
+ `ActiveSupport::Notifications`.
100
+ - **Multi-desk configuration** where every desk inherits what it doesn't
101
+ state, with setters that validate on assignment and boot-time
102
+ `ConfigurationError`s that carry the fix. `config.visible_desks_for` and
103
+ `config.authorize_console` are the console's two hooks into it.
104
+ - **`SupportDesk.doctor`** — configuration, the chats seams, and the data
105
+ invariants, with an `ok?` for CI.
106
+ - **`SupportDesk::TestHelpers`** for host suites.
107
+ - Install generator (migration + annotated initializer), Spanish and
108
+ English locales, and a mountable engine.
109
+
110
+ ### Notes
111
+
112
+ - **The desk registers itself with chats at boot.** `acts_as_messager`
113
+ registers a class when it loads, and `Chats::Inbox` folds support threads
114
+ into one row only for registered grouped messagers, so under lazy
115
+ autoloading the inbox showed every support conversation as its own row
116
+ until something happened to reference `SupportDesk::Desk`. The engine's
117
+ `to_prepare` now touches the desk the way it already touches the helper
118
+ (#2). Eager-loading production hosts never saw it; development did.
119
+ - **`jsonb` on PostGIS.** The install migration decides jsonb-or-json by
120
+ adapter name, and activerecord-postgis-adapter answers `"PostGIS"`, not
121
+ `"PostgreSQL"` — the first cut matched the full word and silently gave
122
+ PostGIS hosts plain `json` columns. Caught while integrating with a PostGIS host, before
123
+ the gem shipped; the template now matches the prefix (`/\Apostg/i`).
124
+ - `SupportDesk::OffDuty` is NOT part of 0.1.0. Duty is a seam this release
125
+ only asks about (`agent.on_duty?` decides who is notified and what
126
+ `actions_for` offers); nothing in it assigns work, so nothing can
127
+ honestly refuse on those grounds yet. It arrives with the duty table in
128
+ 0.3. `SupportDesk::Locked` IS raised — writing into a closed ticket on a
129
+ desk configured `closed_tickets: :locked` — and subclasses
130
+ `InvalidTransition`, so either name catches it.
131
+
132
+ ### Credits
133
+
134
+ The wizard copy, the console screens and the Spanish strings began life in
135
+ carhey#309, and were lifted into the gem with the author's blessing.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Javi R
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.