findbug 0.3.5 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b51dae70712a19753e3c702d669ad052997bdbd92fdbb5a1906dfe7a4739eb3d
4
- data.tar.gz: ece16e26f2e64f87262172768cb577a7d9344c85039992acc7809f372c146173
3
+ metadata.gz: bc983a190a0ac08221e7bbaf108e52d55ecc20a28712f5bdf380acf61a27e43d
4
+ data.tar.gz: 77bc4565c0686c4d60646b4e7d9dc65f4faae482c83d8df6b98a4c02f540c162
5
5
  SHA512:
6
- metadata.gz: 8637ab43bfd03606bceee3fc7eb955af65f2db8300b39b07848beed6106677efc82fb8bc3931c12368c525d502d7c708a5e006b31fdcc4f2816ba644a546f8df
7
- data.tar.gz: 8a24d784569793cdbabe425af6ca3c7b1740e7b64a30ec2f59f059adfce927faccc52bdc40e3f9f6c5037136987c1ff9d7f9bba9b585f506da63ede93fa7b63a
6
+ metadata.gz: c6d0d26042c62b4ea65bd5f574e6c43d588fdfa83914b416a44638dae85c97d3fa12d74d4ffa6bb08b8575d96daa11ed87aeaefc314ee319353cb4acf961a0d6
7
+ data.tar.gz: 3d8ef1a2aece7c33efb7b629aa5b19013fe96da2b6ec3ec741eba8447b2fa80b36a5c3b6f1be926c4f19cda0ff44388eebc0a6eede1ce42d4e5fe199b448a5b2
data/README.md CHANGED
@@ -8,16 +8,20 @@
8
8
 
9
9
  FindBug provides Sentry-like functionality with all data stored on your own infrastructure using Redis and your database. Zero external dependencies, full data ownership.
10
10
 
11
+ 📚 **Full documentation:** [findbug.dev/docs](https://findbug.dev/docs)
12
+
11
13
  ## Features
12
14
 
13
15
  - **Error Tracking** - Capture exceptions with full context, stack traces, and request data
14
16
  - **Performance Monitoring** - Track request timing, SQL queries, and automatic N+1 detection
15
- - **Self-Hosted** - All data stays on your infrastructure (Redis + PostgreSQL/MySQL)
17
+ - **Self-Hosted** - All data stays on your infrastructure (Redis + your existing database)
18
+ - **Database-Agnostic** - Works on PostgreSQL, MySQL, and SQLite — adapter detected automatically
16
19
  - **Zero Performance Impact** - Async writes via Redis buffer, never blocks your requests
17
20
  - **Built-in Dashboard** - Beautiful web UI for viewing errors and performance metrics
18
21
  - **Multi-channel Alerts** - Email, Slack, Discord, and custom webhooks
19
22
  - **Works Out of the Box** - Built-in background persister, no job scheduler required
20
23
  - **Rails 7+ Native** - Designed for modern Rails applications
24
+ - **Tested** - Comprehensive RSpec suite covering every adapter path
21
25
 
22
26
  ## Why FindBug?
23
27
 
@@ -34,7 +38,7 @@ FindBug provides Sentry-like functionality with all data stored on your own infr
34
38
  - Ruby 3.1+
35
39
  - Rails 7.0+
36
40
  - Redis 4.0+
37
- - PostgreSQL or MySQL
41
+ - A relational database — PostgreSQL, MySQL, or SQLite
38
42
 
39
43
  ## Installation
40
44
 
@@ -142,34 +146,11 @@ Findbug.configure do |config|
142
146
  # ===================
143
147
  # Alerts (Optional)
144
148
  # ===================
149
+ # Alert channels (Email, Slack, Discord, Webhook) are configured via the
150
+ # dashboard UI at /findbug/alerts — no code changes needed.
151
+
145
152
  config.alerts do |alerts|
146
153
  alerts.throttle_period = 5.minutes
147
-
148
- # Slack
149
- # alerts.slack(
150
- # enabled: true,
151
- # webhook_url: ENV["SLACK_WEBHOOK_URL"],
152
- # channel: "#errors"
153
- # )
154
-
155
- # Email
156
- # alerts.email(
157
- # enabled: true,
158
- # recipients: ["team@example.com"]
159
- # )
160
-
161
- # Discord
162
- # alerts.discord(
163
- # enabled: true,
164
- # webhook_url: ENV["DISCORD_WEBHOOK_URL"]
165
- # )
166
-
167
- # Custom Webhook
168
- # alerts.webhook(
169
- # enabled: true,
170
- # url: "https://your-service.com/webhook",
171
- # headers: { "Authorization" => "Bearer token" }
172
- # )
173
154
  end
174
155
  end
175
156
  ```
@@ -263,7 +244,7 @@ end
263
244
  │ └────────┬─────────┘ │
264
245
  │ │ │
265
246
  │ ▼ │
266
- │ Dashboard ◄──────────────────── Database (PostgreSQL/MySQL)
247
+ │ Dashboard ◄──────────────────── Database (PostgreSQL/MySQL/SQLite)
267
248
  │ (/findbug) │
268
249
  │ │
269
250
  └─────────────────────────────────────────────────────────────────┘
@@ -297,11 +278,44 @@ rails findbug:clear_buffers
297
278
  rails findbug:db:stats
298
279
  ```
299
280
 
300
- ## Multi-Tenant Applications (Apartment/ros-apartment)
281
+ ## Database Support
282
+
283
+ As of v0.5.0 FindBug is database-agnostic. The install generator's migrations and the model layer detect your `ActiveRecord::Base.connection.adapter_name` at runtime and pick the right column types and SQL functions automatically.
284
+
285
+ | Adapter | JSON column | Time bucketing SQL |
286
+ |--------------------|-------------|------------------------------------|
287
+ | PostgreSQL/PostGIS | `jsonb` | `date_trunc(...)` |
288
+ | MySQL/Mysql2 | `json` | `DATE_FORMAT(...)` / `DATE(...)` |
289
+ | SQLite | `text` (with JSON serialisation in the model) | `strftime(...)` / `DATE(...)` |
290
+
291
+ The JSON accessors on the models normalise reads to a native Ruby `Hash` / `Array` regardless of the underlying column type, so your application code is identical across adapters.
292
+
293
+ If you're writing your own migrations against the same multi-DB strategy, the `Findbug::AdapterHelper` module is part of the public API:
294
+
295
+ ```ruby
296
+ Findbug::AdapterHelper.json_column_type # :jsonb / :json / :text
297
+ Findbug::AdapterHelper.json_default({}) # adapter-appropriate default
298
+ Findbug::AdapterHelper.date_trunc_sql("hour", "captured_at")
299
+ ```
300
+
301
+ ## Multi-Tenant Applications
302
+
303
+ Multi-tenant Rails apps are supported, but the setup depends on *how* your app isolates tenants. The matrix below shows what's possible per adapter:
304
+
305
+ | Adapter | Tenancy model | FindBug support |
306
+ |--------------------------|--------------------------------------------------------------|----------------------------------------------------------------------------------|
307
+ | PostgreSQL | Schema-per-tenant (Apartment's default) | ✅ First-class — keep FindBug tables in the `public` schema (see below). |
308
+ | PostgreSQL/MySQL/SQLite | Row-level (`tenant_id` column) | ✅ Nothing special — FindBug's tables aren't tenant-scoped. |
309
+ | MySQL | Database-per-tenant (Apartment with `use_schemas = false`) | ⚠️ Doable but awkward — point FindBug at a separate connection via `connects_to`. |
310
+ | SQLite | File-per-tenant | ❌ Not practical — use row-level scoping instead. |
311
+
312
+ For the full discussion (including MySQL setup options), see the [Multi-tenant section in the docs](https://findbug.dev/docs#multi-tenant).
313
+
314
+ ### PostgreSQL + Apartment (schema-per-tenant)
301
315
 
302
- If you're using [ros-apartment](https://github.com/rails-on-services/apartment) or similar multi-tenant gems with PostgreSQL schemas, FindBug's tables need to stay in the public schema and the dashboard path should be excluded from tenant switching.
316
+ If you're using [ros-apartment](https://github.com/rails-on-services/apartment) with PostgreSQL schemas, FindBug's tables need to stay in the public schema and the dashboard path should be excluded from tenant switching.
303
317
 
304
- ### 1. Exclude FindBug Models
318
+ #### 1. Exclude FindBug Models
305
319
 
306
320
  Add FindBug models to the `excluded_models` list in `config/initializers/apartment.rb`:
307
321
 
@@ -311,11 +325,12 @@ Apartment.configure do |config|
311
325
  # Your existing excluded models...
312
326
  Findbug::ErrorEvent
313
327
  Findbug::PerformanceEvent
328
+ Findbug::AlertChannel
314
329
  ]
315
330
  end
316
331
  ```
317
332
 
318
- ### 2. Exclude FindBug Dashboard Path
333
+ #### 2. Exclude FindBug Dashboard Path
319
334
 
320
335
  Add `/findbug` to your tenant switching middleware's excluded paths:
321
336
 
@@ -339,7 +354,7 @@ class SwitchTenantMiddleware < Apartment::Elevators::Generic
339
354
  end
340
355
  ```
341
356
 
342
- ### 3. Run Migrations in Public Schema
357
+ #### 3. Run Migrations in Public Schema
343
358
 
344
359
  Ensure FindBug migrations run in the public schema:
345
360
 
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ module Findbug
6
+ # AlertsController manages alert channel configuration via the dashboard.
7
+ #
8
+ # Users can create, edit, enable/disable, delete, and test alert channels
9
+ # directly from the UI instead of editing the Rails initializer.
10
+ #
11
+ class AlertsController < ApplicationController
12
+ before_action :set_alert_channel, only: [:edit, :update, :destroy, :toggle, :test]
13
+
14
+ # GET /findbug/alerts
15
+ #
16
+ # List all configured alert channels.
17
+ #
18
+ def index
19
+ @channels = Findbug::AlertChannel.order(created_at: :asc)
20
+ @enabled_count = @channels.count(&:enabled?)
21
+
22
+ render template: "findbug/alerts/index", layout: "findbug/application"
23
+ end
24
+
25
+ # GET /findbug/alerts/new
26
+ #
27
+ # Form to create a new alert channel.
28
+ #
29
+ def new
30
+ @channel = Findbug::AlertChannel.new
31
+ render template: "findbug/alerts/new", layout: "findbug/application"
32
+ end
33
+
34
+ # POST /findbug/alerts
35
+ #
36
+ # Save a new alert channel.
37
+ #
38
+ def create
39
+ @channel = Findbug::AlertChannel.new(channel_params)
40
+ @channel.config = build_config_from_params
41
+
42
+ if @channel.save
43
+ flash_success "#{@channel.display_type} alert channel created"
44
+ redirect_to findbug.alerts_path
45
+ else
46
+ flash_error @channel.errors.full_messages.join(", ")
47
+ render template: "findbug/alerts/new", layout: "findbug/application", status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ # GET /findbug/alerts/:id/edit
52
+ #
53
+ # Form to edit an existing alert channel.
54
+ #
55
+ def edit
56
+ render template: "findbug/alerts/edit", layout: "findbug/application"
57
+ end
58
+
59
+ # PATCH /findbug/alerts/:id
60
+ #
61
+ # Update an existing alert channel.
62
+ #
63
+ def update
64
+ @channel.assign_attributes(channel_params)
65
+ @channel.config = build_config_from_params
66
+
67
+ if @channel.save
68
+ flash_success "#{@channel.display_type} alert channel updated"
69
+ redirect_to findbug.alerts_path
70
+ else
71
+ flash_error @channel.errors.full_messages.join(", ")
72
+ render template: "findbug/alerts/edit", layout: "findbug/application", status: :unprocessable_entity
73
+ end
74
+ end
75
+
76
+ # DELETE /findbug/alerts/:id
77
+ #
78
+ # Delete an alert channel.
79
+ #
80
+ def destroy
81
+ name = @channel.name
82
+ @channel.destroy
83
+ flash_success "Alert channel \"#{name}\" deleted"
84
+ redirect_to findbug.alerts_path
85
+ end
86
+
87
+ # POST /findbug/alerts/:id/toggle
88
+ #
89
+ # Toggle enable/disable for an alert channel.
90
+ #
91
+ def toggle
92
+ @channel.enabled = !@channel.enabled?
93
+
94
+ if @channel.save
95
+ status = @channel.enabled? ? "enabled" : "disabled"
96
+ flash_success "#{@channel.name} #{status}"
97
+ else
98
+ flash_error @channel.errors.full_messages.join(", ")
99
+ end
100
+
101
+ redirect_to findbug.alerts_path
102
+ end
103
+
104
+ # POST /findbug/alerts/:id/test
105
+ #
106
+ # Send a test alert to this channel.
107
+ #
108
+ # Creates a synthetic error event (not persisted to DB) and sends it
109
+ # directly to the channel, bypassing throttling.
110
+ #
111
+ def test
112
+ unless @channel.enabled?
113
+ flash_error "Cannot test a disabled channel. Enable it first."
114
+ redirect_to findbug.alerts_path and return
115
+ end
116
+
117
+ error_event = build_test_error_event
118
+ channel_instance = @channel.channel_class.new(@channel.config.symbolize_keys)
119
+
120
+ begin
121
+ channel_instance.send_alert(error_event)
122
+ flash_success "Test alert sent to #{@channel.name} successfully!"
123
+ rescue StandardError => e
124
+ flash_error "Failed to send test alert: #{e.message}"
125
+ end
126
+
127
+ redirect_to findbug.alerts_path
128
+ end
129
+
130
+ private
131
+
132
+ def set_alert_channel
133
+ @channel = Findbug::AlertChannel.find(params[:id])
134
+ end
135
+
136
+ def channel_params
137
+ params.require(:alert_channel).permit(:name, :channel_type, :enabled)
138
+ end
139
+
140
+ # Build the config hash from channel-type-specific form params
141
+ #
142
+ # Each channel type has different fields. We extract them from
143
+ # params[:config] and build a clean hash.
144
+ #
145
+ def build_config_from_params
146
+ config_params = params[:config] || {}
147
+ channel_type = params.dig(:alert_channel, :channel_type) || @channel&.channel_type
148
+
149
+ case channel_type
150
+ when "email"
151
+ recipients = (config_params[:recipients] || "").split(/[\n,]/).map(&:strip).compact_blank
152
+ {
153
+ "recipients" => recipients,
154
+ "from" => config_params[:from].presence
155
+ }.compact
156
+ when "slack"
157
+ {
158
+ "webhook_url" => config_params[:webhook_url],
159
+ "channel" => config_params[:channel].presence,
160
+ "username" => config_params[:username].presence,
161
+ "icon_emoji" => config_params[:icon_emoji].presence
162
+ }.compact
163
+ when "discord"
164
+ {
165
+ "webhook_url" => config_params[:webhook_url],
166
+ "username" => config_params[:username].presence,
167
+ "avatar_url" => config_params[:avatar_url].presence
168
+ }.compact
169
+ when "webhook"
170
+ headers = parse_headers(config_params[:headers])
171
+ {
172
+ "url" => config_params[:url],
173
+ "method" => config_params[:method].presence || "POST",
174
+ "headers" => headers.presence
175
+ }.compact
176
+ else
177
+ {}
178
+ end
179
+ end
180
+
181
+ # Parse headers from textarea format ("Key: Value" per line) into a hash
182
+ def parse_headers(raw)
183
+ return {} if raw.blank?
184
+
185
+ raw.split("\n").each_with_object({}) do |line, hash|
186
+ key, value = line.split(":", 2).map(&:strip)
187
+ hash[key] = value if key.present? && value.present?
188
+ end
189
+ end
190
+
191
+ # Build a synthetic error event for testing alerts
192
+ #
193
+ # Uses OpenStruct to duck-type ErrorEvent without touching the database.
194
+ # Includes all attributes that channel implementations access.
195
+ #
196
+ def build_test_error_event
197
+ now = Time.current
198
+
199
+ OpenStruct.new(
200
+ id: 0,
201
+ fingerprint: "findbug-test-alert-#{now.to_i}",
202
+ exception_class: "Findbug::TestAlert",
203
+ message: "This is a test alert from the Findbug dashboard. If you see this, your alert channel is working correctly!",
204
+ severity: "error",
205
+ status: "unresolved",
206
+ handled: false,
207
+ occurrence_count: 1,
208
+ first_seen_at: now,
209
+ last_seen_at: now,
210
+ environment: Findbug.config.environment || "production",
211
+ release_version: Findbug::VERSION,
212
+ backtrace_lines: [
213
+ "app/controllers/findbug/alerts_controller.rb:42:in `test'",
214
+ "lib/findbug/alerts/dispatcher.rb:57:in `send_alerts'",
215
+ "lib/findbug/alerts/channels/base.rb:34:in `send_alert'"
216
+ ],
217
+ context: {},
218
+ user: nil,
219
+ request: { "method" => "POST", "path" => "/findbug/alerts/test" },
220
+ tags: { "source" => "test_alert" }
221
+ )
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../lib/findbug/alerts/channels/base"
4
+ require_relative "../../../lib/findbug/alerts/channels/email"
5
+ require_relative "../../../lib/findbug/alerts/channels/slack"
6
+ require_relative "../../../lib/findbug/alerts/channels/discord"
7
+ require_relative "../../../lib/findbug/alerts/channels/webhook"
8
+
9
+ module Findbug
10
+ # AlertChannel stores alert channel configurations in the database.
11
+ #
12
+ # DATABASE SCHEMA
13
+ # ===============
14
+ #
15
+ # This model expects a table created by the install generator:
16
+ #
17
+ # create_table :findbug_alert_channels do |t|
18
+ # t.string :channel_type, null: false
19
+ # t.string :name, null: false
20
+ # t.boolean :enabled, default: false
21
+ # t.text :config_data
22
+ # t.timestamps
23
+ # end
24
+ #
25
+ # WHY DB INSTEAD OF INITIALIZER?
26
+ # ==============================
27
+ #
28
+ # Storing alert config in the database lets users create, edit, and
29
+ # manage alert channels from the dashboard UI without code changes
30
+ # or redeployment.
31
+ #
32
+ # WHY TEXT + SERIALIZE INSTEAD OF JSONB?
33
+ # ======================================
34
+ #
35
+ # ActiveRecord::Encryption works on text columns. We serialize the
36
+ # config hash as JSON and encrypt the entire blob at rest, so webhook
37
+ # URLs and tokens are never stored in plain text.
38
+ #
39
+ class AlertChannel < ActiveRecord::Base
40
+ self.table_name = "findbug_alert_channels"
41
+
42
+ # Channel types
43
+ CHANNEL_TYPES = %w[email slack discord webhook].freeze
44
+
45
+ # Check if Rails encryption is configured
46
+ def self.encryption_available?
47
+ return false unless defined?(ActiveRecord::Encryption)
48
+
49
+ ActiveRecord::Encryption.config.primary_key.present?
50
+ rescue StandardError
51
+ false
52
+ end
53
+
54
+ # Serialize config as JSON
55
+ serialize :config_data, coder: JSON
56
+
57
+ # Encrypt config at rest if Rails encryption is configured
58
+ encrypts :config_data if encryption_available?
59
+
60
+ # Validations
61
+ validates :name, presence: true
62
+ validates :channel_type, presence: true, inclusion: { in: CHANNEL_TYPES }
63
+ validate :validate_required_config
64
+
65
+ # Scopes
66
+ scope :enabled, -> { where(enabled: true) }
67
+ scope :by_type, ->(type) { where(channel_type: type) }
68
+
69
+ # Convenience accessor for config
70
+ def config
71
+ config_data || {}
72
+ end
73
+
74
+ def config=(value)
75
+ self.config_data = value
76
+ end
77
+
78
+ # Returns the channel class for sending alerts
79
+ #
80
+ # Maps channel_type to the corresponding Alerts::Channels class.
81
+ #
82
+ def channel_class
83
+ case channel_type
84
+ when "email" then Findbug::Alerts::Channels::Email
85
+ when "slack" then Findbug::Alerts::Channels::Slack
86
+ when "discord" then Findbug::Alerts::Channels::Discord
87
+ when "webhook" then Findbug::Alerts::Channels::Webhook
88
+ end
89
+ end
90
+
91
+ # Human-readable channel type
92
+ def display_type
93
+ channel_type&.titleize
94
+ end
95
+
96
+ # Returns config with sensitive values masked for display
97
+ #
98
+ # Shows scheme + host for URLs, masks everything else.
99
+ # Email recipients are shown in full (not sensitive).
100
+ #
101
+ def masked_config
102
+ masked = {}
103
+
104
+ case channel_type
105
+ when "email"
106
+ masked["Recipients"] = Array(config["recipients"]).join(", ").presence || "None"
107
+ masked["From"] = config["from"] || "findbug@localhost"
108
+ when "slack"
109
+ masked["Webhook URL"] = mask_url(config["webhook_url"])
110
+ masked["Channel"] = config["channel"] || "Default"
111
+ masked["Username"] = config["username"] || "Findbug"
112
+ when "discord"
113
+ masked["Webhook URL"] = mask_url(config["webhook_url"])
114
+ masked["Username"] = config["username"] || "Findbug"
115
+ when "webhook"
116
+ masked["URL"] = mask_url(config["url"])
117
+ masked["Method"] = (config["method"] || "POST").upcase
118
+ headers_count = (config["headers"] || {}).size
119
+ masked["Custom Headers"] = "#{headers_count} configured" if headers_count > 0
120
+ end
121
+
122
+ masked
123
+ end
124
+
125
+ private
126
+
127
+ # Mask a URL for safe display
128
+ #
129
+ # Shows scheme + host but masks the path (which typically contains
130
+ # secret tokens in webhook URLs).
131
+ #
132
+ def mask_url(url)
133
+ return "Not configured" if url.blank?
134
+
135
+ uri = URI.parse(url)
136
+ path = uri.path.to_s
137
+ masked_path = path.length > 8 ? "#{path[0..7]}********" : "********"
138
+ "#{uri.scheme}://#{uri.host}#{masked_path}"
139
+ rescue URI::InvalidURIError
140
+ "#{url[0..15]}********"
141
+ end
142
+
143
+ # Validate that required config fields are present for each channel type
144
+ def validate_required_config
145
+ return if config.blank? && !enabled?
146
+
147
+ case channel_type
148
+ when "email"
149
+ if enabled? && Array(config["recipients"]).compact_blank.empty?
150
+ errors.add(:base, "Email channel requires at least one recipient")
151
+ end
152
+ when "slack"
153
+ if enabled? && config["webhook_url"].blank?
154
+ errors.add(:base, "Slack channel requires a webhook URL")
155
+ end
156
+ when "discord"
157
+ if enabled? && config["webhook_url"].blank?
158
+ errors.add(:base, "Discord channel requires a webhook URL")
159
+ end
160
+ when "webhook"
161
+ if enabled? && config["url"].blank?
162
+ errors.add(:base, "Webhook channel requires a URL")
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -27,14 +27,15 @@ module Findbug
27
27
  # t.timestamps
28
28
  # end
29
29
  #
30
- # WHY JSONB FOR CONTEXT?
31
- # ======================
30
+ # WHY OVERRIDE JSON ACCESSORS?
31
+ # =============================
32
32
  #
33
- # Context is semi-structured - different errors have different context.
34
- # JSONB (in PostgreSQL) or JSON (in other DBs) lets us store any shape
35
- # of data without schema migrations.
33
+ # The column type for context/request_data varies by adapter:
34
+ # PostgreSQL jsonb (AR returns Hash natively)
35
+ # MySQL → json (AR returns Hash natively)
36
+ # SQLite → text (AR returns raw JSON String)
36
37
  #
37
- # For querying, we create GIN indexes on commonly queried paths.
38
+ # The overrides below normalise both cases so callers always get a Hash.
38
39
  #
39
40
  class ErrorEvent < ActiveRecord::Base
40
41
  self.table_name = "findbug_error_events"
@@ -55,6 +56,33 @@ module Findbug
55
56
  validates :status, inclusion: { in: [STATUS_UNRESOLVED, STATUS_RESOLVED, STATUS_IGNORED] }
56
57
  validates :severity, inclusion: { in: [SEVERITY_ERROR, SEVERITY_WARNING, SEVERITY_INFO] }
57
58
 
59
+ # JSON field accessors — normalise across adapters (jsonb/json/text).
60
+ # Reader always returns a Hash; writer stores a JSON string on text columns
61
+ # and the native object on json/jsonb columns.
62
+ %i[context request_data].each do |field|
63
+ define_method(field) do
64
+ val = read_attribute(field)
65
+ return {} if val.nil?
66
+ val.is_a?(String) ? JSON.parse(val) : val
67
+ rescue JSON::ParserError
68
+ {}
69
+ end
70
+
71
+ define_method(:"#{field}=") do |val|
72
+ col_type = self.class.columns_hash[field.to_s]&.type
73
+ if col_type == :text
74
+ serialized = case val
75
+ when nil then nil
76
+ when String then val
77
+ else val.to_json
78
+ end
79
+ write_attribute(field, serialized)
80
+ else
81
+ write_attribute(field, val)
82
+ end
83
+ end
84
+ end
85
+
58
86
  # Scopes
59
87
  scope :unresolved, -> { where(status: STATUS_UNRESOLVED) }
60
88
  scope :resolved, -> { where(status: STATUS_RESOLVED) }