bug_reports_client 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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +281 -0
  5. data/app/assets/tailwind/bug_reports_client/engine.css +7 -0
  6. data/app/controllers/bug_reports_client/application_controller.rb +35 -0
  7. data/app/controllers/bug_reports_client/bug_reports_controller.rb +308 -0
  8. data/app/controllers/bug_reports_client/webhooks_controller.rb +94 -0
  9. data/app/helpers/bug_reports_client/bug_reports_helper.rb +61 -0
  10. data/app/javascript/bug_reports_client/controllers/error_summary_controller.js +14 -0
  11. data/app/javascript/bug_reports_client/controllers/file_limit_controller.js +31 -0
  12. data/app/javascript/bug_reports_client/controllers/report_type_controller.js +54 -0
  13. data/app/javascript/bug_reports_client/controllers/screenshot_dropzone_controller.js +151 -0
  14. data/app/jobs/bug_reports_client/report_error_job.rb +44 -0
  15. data/app/models/bug_reports_client/application_record.rb +5 -0
  16. data/app/models/bug_reports_client/bug_report.rb +74 -0
  17. data/app/models/bug_reports_client/error_event.rb +46 -0
  18. data/app/models/concerns/bug_reports_client/reporter.rb +15 -0
  19. data/app/services/bug_reports_client/api_client.rb +174 -0
  20. data/app/services/bug_reports_client/description_builder.rb +91 -0
  21. data/app/views/bug_reports_client/bug_reports/_field_checkbox.html.erb +18 -0
  22. data/app/views/bug_reports_client/bug_reports/_field_select.html.erb +15 -0
  23. data/app/views/bug_reports_client/bug_reports/_field_text.html.erb +13 -0
  24. data/app/views/bug_reports_client/bug_reports/_field_textarea.html.erb +13 -0
  25. data/app/views/bug_reports_client/bug_reports/_filters.html.erb +31 -0
  26. data/app/views/bug_reports_client/bug_reports/_form.html.erb +185 -0
  27. data/app/views/bug_reports_client/bug_reports/_pagination.html.erb +21 -0
  28. data/app/views/bug_reports_client/bug_reports/_rating_badge.html.erb +11 -0
  29. data/app/views/bug_reports_client/bug_reports/_related_error.html.erb +20 -0
  30. data/app/views/bug_reports_client/bug_reports/_status_badge.html.erb +12 -0
  31. data/app/views/bug_reports_client/bug_reports/_type_badge.html.erb +6 -0
  32. data/app/views/bug_reports_client/bug_reports/all.html.erb +66 -0
  33. data/app/views/bug_reports_client/bug_reports/edit.html.erb +40 -0
  34. data/app/views/bug_reports_client/bug_reports/index.html.erb +104 -0
  35. data/app/views/bug_reports_client/bug_reports/new.html.erb +33 -0
  36. data/app/views/bug_reports_client/shared/_after_dismiss.turbo_stream.erb +4 -0
  37. data/app/views/bug_reports_client/shared/_alerts.html.erb +22 -0
  38. data/config/form_schema.yml +114 -0
  39. data/config/importmap.rb +6 -0
  40. data/config/locales/en.yml +213 -0
  41. data/config/routes.rb +14 -0
  42. data/db/migrate/20260721000001_create_bug_reports.rb +32 -0
  43. data/db/migrate/20260723000002_create_bug_report_error_events.rb +20 -0
  44. data/db/migrate/20260723000003_add_activity_to_bug_report_error_events.rb +10 -0
  45. data/lib/bug_reports_client/configuration.rb +121 -0
  46. data/lib/bug_reports_client/engine.rb +49 -0
  47. data/lib/bug_reports_client/error_context.rb +31 -0
  48. data/lib/bug_reports_client/error_reporter.rb +116 -0
  49. data/lib/bug_reports_client/form_schema.rb +204 -0
  50. data/lib/bug_reports_client/main_app_routes.rb +68 -0
  51. data/lib/bug_reports_client/version.rb +3 -0
  52. data/lib/bug_reports_client.rb +39 -0
  53. data/lib/generators/bug_reports_client/install/install_generator.rb +39 -0
  54. data/lib/generators/bug_reports_client/install/templates/AFTER_INSTALL +28 -0
  55. data/lib/generators/bug_reports_client/install/templates/bug_report_issue.md.example +23 -0
  56. data/lib/generators/bug_reports_client/install/templates/initializer.rb +31 -0
  57. data/lib/generators/bug_reports_client/views/views_generator.rb +19 -0
  58. metadata +134 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ddf7fc7f7b28ffe23e4a4e2965343975fc2d353887f7215e3c7de627259caa34
4
+ data.tar.gz: f5d7058b59536805b5c3554c7a1b34fc367a4db509fd804489bba639b153fed9
5
+ SHA512:
6
+ metadata.gz: cc3d27ecbfb780ff73c4d13171d03ce10994c780303b4e50ead54a498b4c51725fe6b38cfb2dcd2d4f818baa875a3ac7286b0618ef082fd9d18bddb2865ef2aa
7
+ data.tar.gz: 46f0ce019b199b6a86901d4b932905666e28689ba7221e0e5d534a5a98b1a15567bb9353d2978a5f652b4accfdb2de4506391ea1b9a50b80239e14abf966a239
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (23 July 2026)
4
+
5
+ - Initial release: mountable engine with a schema-driven report form
6
+ (YAML-defined fields, report type cards, drag-and-drop screenshots with
7
+ previews), my-reports and all-reports views, submission to the companion
8
+ bug-reports API, signed closure webhooks (timestamped, replay-protected)
9
+ and dismissable resolved-report alerts.
10
+ - Automatic error capture: unhandled 500s become deduplicated GitHub issues,
11
+ attributed to the user who hit them so the report form can offer
12
+ "did it relate to this error?".
13
+ - Install and views generators, i18n-based wording, optional markdown issue
14
+ template with placeholder substitution.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 PSA Squash Tour
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,281 @@
1
+ # bug_reports_client
2
+
3
+ A mountable Rails engine that adds a complete bug-and-feature reporting flow
4
+ to your app, backed by a central [bug-reports API](../README.md) that files
5
+ GitHub issues and calls back when they are closed.
6
+
7
+ What your users get:
8
+
9
+ - A polished **report form** with a bug/feature toggle, driven entirely by a
10
+ YAML schema you can customise per app - fields, types, required flags,
11
+ sections and wording.
12
+ - **Screenshot uploads** (Active Storage, optional).
13
+ - A **"My Reports"** page tracking the status of everything they've filed,
14
+ plus an admin-only "All Reports" view.
15
+ - **Resolved alerts**: when the GitHub issue is closed, a signed webhook flips
16
+ the local record and the reporter sees a dismissable "your report has been
17
+ resolved" banner.
18
+
19
+ Requirements: Rails >= 8.0, Turbo/Stimulus via importmap-rails, Tailwind CSS
20
+ (for the default views - or copy the views and style them however you like).
21
+
22
+ ## Installation
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem "bug_reports_client", github: "Professional-Squash-Association/bug-reports", glob: "client/*.gemspec"
27
+ ```
28
+
29
+ ```bash
30
+ bundle install
31
+ bin/rails g bug_reports_client:install
32
+ bin/rails bug_reports_client:install:migrations && bin/rails db:migrate
33
+ ```
34
+
35
+ The install generator creates `config/initializers/bug_reports_client.rb`,
36
+ mounts the engine at `/bug_reports`, and copies the default form schema to
37
+ `config/bug_report_form.yml` ready to customise. It prints the remaining
38
+ checklist:
39
+
40
+ ```ruby
41
+ # app/models/user.rb
42
+ include BugReportsClient::Reporter
43
+ ```
44
+
45
+ ```erb
46
+ <%# somewhere in your layout %>
47
+ <%= link_to "Report a bug", bug_reports_client.new_bug_report_path %>
48
+ <%= bug_report_alerts %>
49
+ ```
50
+
51
+ Environment variables (or set the equivalents in the initializer):
52
+
53
+ | Variable | Purpose |
54
+ |---|---|
55
+ | `BUG_REPORT_API_URL` | Base URL of the bug-reports API, e.g. `https://bugs.example.com/api` |
56
+ | `BUG_REPORT_API_KEY` | This app's API token (Bearer auth) |
57
+ | `BUG_REPORT_WEBHOOK_SECRET` | This app's webhook secret (HMAC verification of closure callbacks) |
58
+ | `APP_HOST` | Public HTTPS origin of this app (callback URL + screenshot links) |
59
+
60
+ On the API side, the app needs an `ApiKey` record and an entry in
61
+ `config/repo_mapping.yml` mapping its `source` name to a GitHub repository.
62
+
63
+ ## Configuration
64
+
65
+ Everything lives in the initializer. Only `source` is required; the rest have
66
+ sensible defaults:
67
+
68
+ ```ruby
69
+ BugReportsClient.configure do |config|
70
+ config.source = "myapp" # must match the API key name
71
+
72
+ # Host integration (defaults shown)
73
+ config.parent_controller = "::ApplicationController"
74
+ config.current_user_method = :current_user
75
+ config.authenticate_method = :authenticate_user!
76
+ config.user_class = "User"
77
+ config.reporter_email_method = :email # symbol or ->(user) { ... }
78
+ config.reporter_name_method = :name
79
+
80
+ # Behaviour
81
+ config.admin_check = ->(user) { user.admin? } # gates /bug_reports/all
82
+ config.reporter_external = ->(user) { false } # shown on the GitHub issue
83
+ config.ask_severity = true # false hides the picker...
84
+ config.default_severity = "medium" # ...and submits this for bugs
85
+ config.screenshots_enabled = true # needs publicly reachable storage
86
+ config.max_screenshots = 5
87
+
88
+ # Connection (default from ENV as above)
89
+ # config.api_url / config.api_key / config.webhook_secret / config.app_host
90
+ # config.callback_url # override the derived callback URL
91
+ # config.mount_path = "/bug_reports" # if you mount somewhere else
92
+ # config.app_name = "My App" # {{app_name}} in issue templates
93
+ end
94
+ ```
95
+
96
+ ## Customising the form
97
+
98
+ `config/bug_report_form.yml` defines the fields per report type:
99
+
100
+ ```yaml
101
+ bug:
102
+ - field: what_happened
103
+ type: textarea # text | textarea | select | checkbox
104
+ required: true
105
+ section: the_problem # starts a new card; heading via i18n or humanised
106
+ rows: 4
107
+ - field: browser
108
+ type: select
109
+ options: [Chrome, Safari, Firefox, Other] # or {value:, label:} pairs
110
+ feature:
111
+ - field: problem
112
+ type: textarea
113
+ required: true
114
+ ```
115
+
116
+ Delete the file to fall back to the engine's default schema (a
117
+ well-rounded bug/feature form). Define only one report type and the
118
+ bug/feature toggle disappears.
119
+
120
+ Answers are stored in a JSON `responses` column, validated server-side from
121
+ the same schema (required flags, and only schema-declared keys are permitted).
122
+
123
+ ### Wording
124
+
125
+ All copy is i18n under the `bug_reports_client` namespace - override any
126
+ subset in your own locale files, no view copying needed:
127
+
128
+ ```yaml
129
+ en:
130
+ bug_reports_client:
131
+ new:
132
+ heading: "Report an issue"
133
+ fields:
134
+ what_happened:
135
+ label: "What happened?"
136
+ placeholder: "Tell us what went wrong"
137
+ ```
138
+
139
+ Inline `label`/`placeholder`/`help` keys in the schema win over i18n.
140
+
141
+ ### GitHub issue body
142
+
143
+ By default the issue body is generated from the schema: a `## <label>` section
144
+ per answered field, a severity line for bugs, and screenshot links. To control
145
+ it yourself, create `config/bug_report_issue.md` (the install generator drops
146
+ an `.example` next to it) using `{{field}}` placeholders - every schema field
147
+ key plus `{{title}}`, `{{report_type}}`, `{{severity}}`, `{{reporter_name}}`,
148
+ `{{app_name}}` and `{{screenshots}}`.
149
+
150
+ ### Views
151
+
152
+ The default views are plain Tailwind. To restyle:
153
+
154
+ ```bash
155
+ bin/rails g bug_reports_client:views
156
+ ```
157
+
158
+ copies them into `app/views/bug_reports_client/` where they shadow the
159
+ engine's per file. The form posts a stable param contract, so you can also
160
+ hand-write a completely custom form - anything that submits these names works:
161
+
162
+ ```
163
+ bug_report[title]
164
+ bug_report[report_type] bug | feature
165
+ bug_report[severity] low | medium | high | critical (bugs)
166
+ bug_report[responses][<field_key>] one per schema field
167
+ bug_report[screenshots][] file uploads (optional)
168
+ ```
169
+
170
+ Hosts can also override just the Turbo Stream dismissal hook
171
+ (`app/views/bug_reports_client/shared/_after_dismiss.turbo_stream.erb`) to
172
+ update their own UI - e.g. a notification badge - when an alert is dismissed.
173
+
174
+ ## Styling
175
+
176
+ The engine views use plain Tailwind utilities; your app compiles the CSS, so
177
+ add the gem's views to your build:
178
+
179
+ **Tailwind v3** (`config/tailwind.config.js`):
180
+
181
+ ```js
182
+ const { execSync } = require('child_process');
183
+ let bugReportsClient = '';
184
+ try { bugReportsClient = execSync('bundle show bug_reports_client').toString().trim(); } catch (e) {}
185
+
186
+ module.exports = {
187
+ content: [
188
+ './app/views/**/*.erb',
189
+ // ...existing globs...
190
+ bugReportsClient && `${bugReportsClient}/app/views/**/*.erb`,
191
+ bugReportsClient && `${bugReportsClient}/app/helpers/**/*.rb`,
192
+ bugReportsClient && `${bugReportsClient}/app/javascript/**/*.js`,
193
+ ].filter(Boolean),
194
+ }
195
+ ```
196
+
197
+ **Tailwind v4** (tailwindcss-rails >= 4.x): the engine ships
198
+ `app/assets/tailwind/bug_reports_client/engine.css`; add to
199
+ `app/assets/tailwind/application.css`:
200
+
201
+ ```css
202
+ @import "../builds/tailwind/bug_reports_client";
203
+ ```
204
+
205
+ ## JavaScript
206
+
207
+ Three Stimulus controllers ship with the engine and are pinned into your
208
+ importmap automatically: `bug-reports-client--report-type` toggles the
209
+ bug/feature field groups, `bug-reports-client--screenshot-dropzone` powers
210
+ the drag-and-drop screenshot picker with thumbnail previews (the hidden file
211
+ input stays the submission source, so custom forms can ignore it), and
212
+ `bug-reports-client--file-limit` is a standalone file-count guard for
213
+ hand-rolled forms. Any standard Stimulus setup that loads controllers from
214
+ the importmap (`eagerLoadControllersFrom` / `lazyLoadControllersFrom`) picks
215
+ them up with no changes.
216
+
217
+ ## Automatic error capture (Sentry-style)
218
+
219
+ Opt in per host and unhandled 500s become GitHub issues, deduplicated:
220
+
221
+ ```ruby
222
+ config.error_reporting_enabled = true
223
+ config.error_ignore = [] # extra exception class names to skip
224
+ config.error_throttle_period = 300 # seconds between posts per fingerprint
225
+ ```
226
+
227
+ The engine subscribes to the Rails error reporter, so anything that would
228
+ render a 500 is captured; exceptions Rails maps to 4xx responses
229
+ (`RecordNotFound`, routing errors, etc) are skipped automatically. Errors
230
+ are fingerprinted by exception class + top application frame (line numbers
231
+ ignored, so deploys don't spawn duplicate issues), throttled through
232
+ `Rails.cache`, and posted in a background job so failing requests are never
233
+ slowed down. The API keeps one open issue per fingerprint, counting repeat
234
+ occurrences; when a closed error recurs, a fresh issue is filed (a
235
+ regression deserves new attention).
236
+
237
+ Captured errors are also attributed to the signed-in user who hit them
238
+ (stored locally in `bug_report_error_events`, pruned after 30 days). When
239
+ that user opens the report form within 24 hours, it asks "did this happen
240
+ alongside an error we caught?" - linking one threads the exception, time
241
+ and fingerprint into their report, so the GitHub issue cross-references the
242
+ automatically-filed error issue (search the repo for the fingerprint).
243
+ Existing installs: run `bin/rails bug_reports_client:install:migrations &&
244
+ bin/rails db:migrate` to pick up the events table.
245
+
246
+ ## Webhook contract
247
+
248
+ The API POSTs closure callbacks to `<APP_HOST><mount_path>/webhook`, signed
249
+ with your app's webhook secret. Preferred scheme: `X-Timestamp` plus
250
+ `X-Signature-Timestamped: sha256=<hex>` where the HMAC-SHA256 covers
251
+ `"<timestamp>.<body>"` - the engine rejects timestamps older than 5 minutes
252
+ (replay protection). A legacy body-only `X-Signature` is also accepted when
253
+ no timestamp is sent. Bodies over 1 MB are rejected outright. The engine
254
+ verifies the signature, marks the local report closed, and returns 200. The
255
+ callback URL must be public HTTPS.
256
+
257
+ ## Security notes
258
+
259
+ - **Screenshots are validated server-side**: content type is sniffed from
260
+ the file bytes (not the client-declared type), and size/count limits apply
261
+ (`screenshot_content_types`, `max_screenshot_size`, `max_screenshots`).
262
+ - **Screenshot URLs are embedded in GitHub issues** so maintainers can see
263
+ them - which means uploads become unauthenticated public URLs on your
264
+ storage service. If that's unacceptable, set `screenshots_enabled = false`
265
+ or serve Active Storage through expiring/signed URLs.
266
+ - **Issue bodies contain verbatim user text.** Treat report content on the
267
+ receiving side (GitHub, the central API) as untrusted: user answers can
268
+ contain markdown, links and @mentions.
269
+ - **The issue template path is trusted config.** `issue_template_path` (and
270
+ `config/bug_report_issue.md`) is read from disk verbatim - only ever point
271
+ it at a file you control.
272
+
273
+ ## Testing
274
+
275
+ The engine has its own test suite with a dummy host app:
276
+
277
+ ```bash
278
+ cd client
279
+ bundle install
280
+ bundle exec rake test
281
+ ```
@@ -0,0 +1,7 @@
1
+ /* Tailwind v4 hosts only (tailwindcss-rails >= 4.x engine support).
2
+ Tells the host's Tailwind build to scan the engine's views and JavaScript
3
+ for utility classes. Tailwind v3 hosts instead add the gem's paths to their
4
+ tailwind.config.js content globs - see the gem README. */
5
+ @source "../../../views/bug_reports_client/**/*.erb";
6
+ @source "../../../helpers/bug_reports_client/**/*.rb";
7
+ @source "../../../javascript/bug_reports_client/**/*.js";
@@ -0,0 +1,35 @@
1
+ module BugReportsClient
2
+ # Inherits from the host app's ApplicationController (configurable) so the
3
+ # engine's pages get the host layout, session handling and flash styling for
4
+ # free. Authentication is enforced through the configured method name, which
5
+ # covers both Devise hosts and bespoke session auth.
6
+ class ApplicationController < BugReportsClient.config.parent_controller.constantize
7
+ # Host layouts reference host route helpers; delegate the ones the engine
8
+ # doesn't define to the host app so layouts render unmodified.
9
+ helper BugReportsClient::MainAppRoutes
10
+
11
+ # Only enforce our own authentication when the host asks us to. Hosts that
12
+ # already authenticate globally (a `before_action :authenticate_user!` in
13
+ # their ApplicationController, which we inherit) set
14
+ # config.authenticate_method = nil to avoid running auth twice per request.
15
+ before_action :authenticate_reporter!, if: -> { BugReportsClient.config.authenticate_method.present? }
16
+
17
+ helper_method :bug_reports_current_user, :bug_reports_admin?
18
+
19
+ private
20
+
21
+ def authenticate_reporter!
22
+ send(BugReportsClient.config.authenticate_method)
23
+ end
24
+
25
+ # The signed-in user, via the host's configured accessor.
26
+ def bug_reports_current_user
27
+ send(BugReportsClient.config.current_user_method)
28
+ end
29
+
30
+ def bug_reports_admin?
31
+ user = bug_reports_current_user
32
+ user.present? && BugReportsClient.config.admin?(user)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,308 @@
1
+ module BugReportsClient
2
+ # Handles report submission, editing, listing and dismissal of resolved
3
+ # alerts. Reports are saved locally (screenshots via Active Storage), then
4
+ # submitted to the central bug-reports API which files a GitHub issue; the
5
+ # remote ID is stored so closure webhooks can find the local record.
6
+ class BugReportsController < ApplicationController
7
+ PER_PAGE = 20
8
+
9
+ before_action :set_bug_report, only: %i[edit update]
10
+ before_action :authorize_bug_report_access!, only: %i[edit update]
11
+ before_action :require_admin!, only: %i[all]
12
+
13
+ # GET / (engine root) - the current user's reports
14
+ def index
15
+ reports = BugReport.where(user: bug_reports_current_user)
16
+ @bug_reports = paginate(filtered(reports).order(created_at: :desc))
17
+
18
+ @counts = status_counts(reports)
19
+ @type_counts = type_counts(reports)
20
+ end
21
+
22
+ # GET /all (admins via config.admin_check) - every report from this app
23
+ def all
24
+ @bug_reports = paginate(filtered(BugReport.includes(:user)).order(created_at: :desc))
25
+
26
+ @counts = status_counts(BugReport)
27
+ @type_counts = type_counts(BugReport)
28
+ end
29
+
30
+ # GET /new
31
+ def new
32
+ @bug_report = BugReport.new
33
+ @recent_error_events = recent_error_events
34
+ end
35
+
36
+ # GET /:id/edit - form for open (editable) and closed (read-only) reports
37
+ def edit
38
+ end
39
+
40
+ # POST /
41
+ # Saves the report and screenshots locally, submits to the API, then
42
+ # stores the returned remote ID. A failed API call rolls the local record
43
+ # back so users can retry without creating orphans.
44
+ def create
45
+ @bug_report = BugReport.new(bug_report_params)
46
+ @bug_report.user = bug_reports_current_user
47
+ apply_default_severity(@bug_report)
48
+ link_related_error(@bug_report)
49
+ @recent_error_events = recent_error_events
50
+
51
+ # Screenshot files are validated server-side (type sniffed, size and
52
+ # count checked) BEFORE anything is attached or saved.
53
+ unless validate_screenshots(@bug_report, submitted_screenshots)
54
+ render :new, status: :unprocessable_entity
55
+ return
56
+ end
57
+ attach_screenshots(@bug_report)
58
+
59
+ unless @bug_report.save
60
+ render :new, status: :unprocessable_entity
61
+ return
62
+ end
63
+
64
+ result = ApiClient.new.create_bug_report(
65
+ title: @bug_report.title,
66
+ description: DescriptionBuilder.new(@bug_report).build,
67
+ severity: @bug_report.severity,
68
+ report_type: @bug_report.report_type,
69
+ reporter_email: BugReportsClient.config.reporter_email_for(bug_reports_current_user),
70
+ reporter_name: BugReportsClient.config.reporter_name_for(bug_reports_current_user),
71
+ reporter_external: BugReportsClient.config.external_reporter?(bug_reports_current_user)
72
+ )
73
+
74
+ if result.success?
75
+ @bug_report.update!(remote_bug_report_id: result.data["id"])
76
+ redirect_to bug_reports_path, notice: t("bug_reports_client.flashes.created", type_noun: @bug_report.type_noun.capitalize)
77
+ else
78
+ # Surface the failure in the form's error box (which scrolls itself
79
+ # into view) rather than a top-of-page flash the user may never see.
80
+ @bug_report.destroy
81
+ @bug_report = BugReport.new
82
+ @bug_report.errors.add(:base, result.message)
83
+ render :new, status: :unprocessable_entity
84
+ end
85
+ rescue ApiClient::ApiError => e
86
+ @bug_report.destroy if @bug_report&.persisted?
87
+ @bug_report = BugReport.new
88
+ @bug_report.errors.add(:base, t("bug_reports_client.flashes.submit_failed"))
89
+ Rails.logger.error "BugReportsClient: submission failed: #{e.message}"
90
+ render :new, status: :unprocessable_entity
91
+ end
92
+
93
+ # PATCH /:id
94
+ # Two cases: dismissing a resolved alert (no bug_report params), or
95
+ # editing an open report (re-syncs the remote issue).
96
+ def update
97
+ return dismiss_bug_report if params[:bug_report].blank?
98
+
99
+ unless @bug_report.open?
100
+ redirect_to edit_bug_report_path(@bug_report), alert: t("bug_reports_client.flashes.only_open_editable", type_noun: @bug_report.type_noun)
101
+ return
102
+ end
103
+
104
+ # The report type is locked once submitted - the remote issue and its
105
+ # labels are already filed as that type.
106
+ @bug_report.assign_attributes(bug_report_params.except(:report_type))
107
+ apply_default_severity(@bug_report)
108
+
109
+ # Attaching to a persisted record writes immediately (bypassing model
110
+ # validations), so the new files must be validated before anything is
111
+ # saved or purged.
112
+ new_screenshots = submitted_screenshots
113
+ unless validate_screenshots(@bug_report, new_screenshots)
114
+ render :edit, status: :unprocessable_entity
115
+ return
116
+ end
117
+
118
+ if @bug_report.save
119
+ # New uploads replace the existing set; editing without choosing any
120
+ # files leaves the current screenshots alone.
121
+ if new_screenshots.any?
122
+ @bug_report.screenshots.purge
123
+ @bug_report.screenshots.attach(new_screenshots)
124
+ end
125
+
126
+ if @bug_report.remote_bug_report_id.present?
127
+ ApiClient.new.update_bug_report(
128
+ @bug_report.remote_bug_report_id,
129
+ title: @bug_report.title,
130
+ description: DescriptionBuilder.new(@bug_report).build,
131
+ severity: @bug_report.severity,
132
+ report_type: @bug_report.report_type
133
+ )
134
+ end
135
+
136
+ redirect_to edit_bug_report_path(@bug_report), notice: t("bug_reports_client.flashes.updated", type_noun: @bug_report.type_noun.capitalize)
137
+ else
138
+ render :edit, status: :unprocessable_entity
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def set_bug_report
145
+ @bug_report = BugReport.find(params[:id])
146
+ end
147
+
148
+ def authorize_bug_report_access!
149
+ unless @bug_report.user_id == bug_reports_current_user.id || bug_reports_admin?
150
+ head :forbidden
151
+ end
152
+ end
153
+
154
+ def require_admin!
155
+ unless bug_reports_admin?
156
+ redirect_to bug_reports_path, alert: t("bug_reports_client.flashes.not_permitted")
157
+ end
158
+ end
159
+
160
+ # Only the SELECTED type's schema field keys are permitted into
161
+ # responses, so a tampered form can't write arbitrary data or smuggle the
162
+ # other type's answers in. Screenshots are permitted (silencing
163
+ # unpermitted-parameter noise) but excluded from mass assignment -
164
+ # attachment is handled explicitly below.
165
+ def bug_report_params
166
+ report_type = @bug_report&.persisted? ? @bug_report.report_type : params.dig(:bug_report, :report_type)
167
+
168
+ params.require(:bug_report).permit(
169
+ :title, :severity, :report_type, :related_error_event_id,
170
+ screenshots: [],
171
+ responses: BugReportsClient.form_schema.field_keys_for(report_type)
172
+ ).except(:screenshots, :related_error_event_id)
173
+ end
174
+
175
+ # Captured 500s this user hit recently, offered on the form as "did your
176
+ # problem relate to this error?".
177
+ def recent_error_events
178
+ ErrorEvent.where(user: bug_reports_current_user).since(24.hours.ago).recent_first.limit(5)
179
+ end
180
+
181
+ # If the reporter linked one of their captured errors, thread its details
182
+ # into the report (rendered into the GitHub issue by DescriptionBuilder,
183
+ # where the fingerprint cross-references the auto-filed error issue).
184
+ # Scoped to the current user so ids can't be guessed across accounts.
185
+ def link_related_error(report)
186
+ event_id = params.dig(:bug_report, :related_error_event_id)
187
+ return if event_id.blank?
188
+
189
+ event = ErrorEvent.where(user: bug_reports_current_user).find_by(id: event_id)
190
+ return unless event
191
+
192
+ # Full technical detail for the issue - the user only ever saw the
193
+ # human description.
194
+ details = [ event.summary ]
195
+ details << "while #{event.activity}" if event.activity.present?
196
+ report.responses = {
197
+ "related_error" => "#{details.join(' - ')} (fingerprint `#{event.fingerprint}`, at #{event.occurred_at.iso8601})"
198
+ }
199
+ end
200
+
201
+ # The submitted screenshot files, minus the blank entry a multiple file
202
+ # input always includes. An edit without new uploads therefore submits
203
+ # no files and existing screenshots are left untouched.
204
+ def submitted_screenshots
205
+ return [] unless BugReportsClient.config.screenshots_enabled
206
+
207
+ Array(params.dig(:bug_report, :screenshots)).reject(&:blank?)
208
+ end
209
+
210
+ # Consumer-facing hosts hide the severity picker; the API still requires
211
+ # a severity for bugs, so fill in the configured default.
212
+ def apply_default_severity(report)
213
+ return if BugReportsClient.config.ask_severity
214
+ report.severity = BugReportsClient.config.default_severity if report.bug?
215
+ end
216
+
217
+ def attach_screenshots(report)
218
+ files = submitted_screenshots
219
+ report.screenshots.attach(files) if files.any?
220
+ end
221
+
222
+ # Server-side screenshot validation, run before any attach: count within
223
+ # the limit, size within bounds, and the content type sniffed from the
224
+ # file bytes (Marcel) rather than trusting the client-declared type.
225
+ # Adds errors to the record and returns false when anything fails.
226
+ def validate_screenshots(report, files)
227
+ config = BugReportsClient.config
228
+ messages = []
229
+
230
+ if files.size > config.max_screenshots
231
+ messages << t("bug_reports_client.errors.too_many_screenshots", max: config.max_screenshots)
232
+ end
233
+
234
+ files.each do |file|
235
+ detected_type = Marcel::MimeType.for(file.tempfile, name: file.original_filename, declared_type: file.content_type)
236
+ unless config.screenshot_content_types.include?(detected_type)
237
+ messages << t("bug_reports_client.errors.screenshot_type", filename: file.original_filename)
238
+ end
239
+ if file.size > config.max_screenshot_size
240
+ messages << t("bug_reports_client.errors.screenshot_size", filename: file.original_filename, max_mb: config.max_screenshot_size / (1024 * 1024))
241
+ end
242
+ end
243
+
244
+ return true if messages.empty?
245
+
246
+ report.validate
247
+ messages.each { |message| report.errors.add(:base, message) }
248
+ false
249
+ end
250
+
251
+ # Minimal offset/limit pagination - deliberately no pagy/kaminari
252
+ # dependency. Sets @page/@total_pages for the shared pagination partial.
253
+ def paginate(scope)
254
+ @page = [ params[:page].to_i, 1 ].max
255
+ @total_pages = [ (scope.count / PER_PAGE.to_f).ceil, 1 ].max
256
+ @page = @total_pages if @page > @total_pages
257
+ scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
258
+ end
259
+
260
+ # Applies the active status and type filters to a bug reports relation.
261
+ def filtered(scope)
262
+ scope = scope.where(status: params[:status]) if params[:status].present?
263
+ scope = scope.where(report_type: params[:report_type]) if params[:report_type].present?
264
+ scope
265
+ end
266
+
267
+ # Status counts within the currently-selected type, so the tabs stay in
268
+ # sync with the type filter (and vice versa for type_counts).
269
+ def status_counts(scope)
270
+ scope = scope.where(report_type: params[:report_type]) if params[:report_type].present?
271
+ { all: scope.count, open: scope.open.count, closed: scope.closed.count }
272
+ end
273
+
274
+ def type_counts(scope)
275
+ scope = scope.where(status: params[:status]) if params[:status].present?
276
+ { all: scope.count, bug: scope.bug.count, feature: scope.feature.count }
277
+ end
278
+
279
+ # Dismisses a resolved-report alert. Removes the alert elements via Turbo
280
+ # Streams and renders the overridable _after_dismiss hook so hosts can
281
+ # update their own UI (e.g. a notification badge) in the same response.
282
+ def dismiss_bug_report
283
+ # Column write, not update!: dismissing must work even for reports that
284
+ # would no longer pass validation (e.g. after a form-schema change).
285
+ @bug_report.update_columns(dismissed_at: Time.current, updated_at: Time.current)
286
+
287
+ @remaining = BugReport.where(user: bug_reports_current_user).resolved_and_undismissed
288
+
289
+ respond_to do |format|
290
+ format.turbo_stream do
291
+ streams = [ turbo_stream.remove("bug_report_#{@bug_report.id}") ]
292
+ streams << turbo_stream.remove("bug_report_alerts") if @remaining.empty?
293
+ streams << render_to_string(partial: "bug_reports_client/shared/after_dismiss", formats: [ :turbo_stream ])
294
+
295
+ render turbo_stream: streams
296
+ end
297
+ format.html do
298
+ redirect_to fallback_root_path, notice: t("bug_reports_client.flashes.dismissed", type_noun: @bug_report.type_noun.capitalize)
299
+ end
300
+ end
301
+ end
302
+
303
+ # Host root if one exists, otherwise the engine's own index.
304
+ def fallback_root_path
305
+ main_app.respond_to?(:root_path) ? main_app.root_path : bug_reports_path
306
+ end
307
+ end
308
+ end