source_monitor 0.1.3 → 0.2.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/Gemfile.lock +1 -1
  4. data/app/assets/javascripts/source_monitor/application.js +4 -0
  5. data/app/assets/javascripts/source_monitor/controllers/confirm_navigation_controller.js +49 -0
  6. data/app/assets/javascripts/source_monitor/controllers/select_all_controller.js +36 -0
  7. data/app/controllers/source_monitor/import_sessions_controller.rb +791 -0
  8. data/app/controllers/source_monitor/sources_controller.rb +5 -36
  9. data/app/helpers/source_monitor/application_helper.rb +17 -0
  10. data/app/jobs/source_monitor/import_opml_job.rb +150 -0
  11. data/app/jobs/source_monitor/import_session_health_check_job.rb +93 -0
  12. data/app/models/source_monitor/import_history.rb +35 -0
  13. data/app/models/source_monitor/import_session.rb +34 -0
  14. data/app/views/source_monitor/import_sessions/_header.html.erb +12 -0
  15. data/app/views/source_monitor/import_sessions/_sidebar.html.erb +23 -0
  16. data/app/views/source_monitor/import_sessions/health_check/_progress.html.erb +20 -0
  17. data/app/views/source_monitor/import_sessions/health_check/_row.html.erb +44 -0
  18. data/app/views/source_monitor/import_sessions/show.html.erb +15 -0
  19. data/app/views/source_monitor/import_sessions/show.turbo_stream.erb +1 -0
  20. data/app/views/source_monitor/import_sessions/steps/_configure.html.erb +53 -0
  21. data/app/views/source_monitor/import_sessions/steps/_confirm.html.erb +121 -0
  22. data/app/views/source_monitor/import_sessions/steps/_health_check.html.erb +82 -0
  23. data/app/views/source_monitor/import_sessions/steps/_navigation.html.erb +29 -0
  24. data/app/views/source_monitor/import_sessions/steps/_preview.html.erb +172 -0
  25. data/app/views/source_monitor/import_sessions/steps/_upload.html.erb +42 -0
  26. data/app/views/source_monitor/sources/_form.html.erb +8 -138
  27. data/app/views/source_monitor/sources/_form_fields.html.erb +142 -0
  28. data/app/views/source_monitor/sources/_import_history_panel.html.erb +53 -0
  29. data/app/views/source_monitor/sources/index.html.erb +7 -1
  30. data/config/coverage_baseline.json +91 -15
  31. data/config/routes.rb +6 -0
  32. data/db/migrate/20251124090000_create_import_sessions.rb +18 -0
  33. data/db/migrate/20251124153000_add_health_fields_to_import_sessions.rb +14 -0
  34. data/db/migrate/20251125094500_create_import_histories.rb +19 -0
  35. data/lib/source_monitor/health/import_source_health_check.rb +55 -0
  36. data/lib/source_monitor/health.rb +1 -0
  37. data/lib/source_monitor/import_sessions/entry_normalizer.rb +30 -0
  38. data/lib/source_monitor/import_sessions/health_check_broadcaster.rb +103 -0
  39. data/lib/source_monitor/sources/params.rb +52 -0
  40. data/lib/source_monitor/version.rb +1 -1
  41. data/tasks/completed/codebase_audit_2025.md +1396 -0
  42. data/tasks/completed/engine-asset-configuration.md +203 -0
  43. data/tasks/completed/opml-import-wizard/opml-import-wizard-product-brief.md +58 -0
  44. data/tasks/completed/opml-import-wizard/opml-import-wizard-tech-brief.md +75 -0
  45. data/tasks/completed/opml-import-wizard/task-01/instructions.md +81 -0
  46. data/tasks/completed/opml-import-wizard/task-01/requirements.md +19 -0
  47. data/tasks/completed/opml-import-wizard/task-02/instructions.md +83 -0
  48. data/tasks/completed/opml-import-wizard/task-02/requirements.md +18 -0
  49. data/tasks/completed/opml-import-wizard/task-03/instructions.md +58 -0
  50. data/tasks/completed/opml-import-wizard/task-03/requirements.md +18 -0
  51. data/tasks/completed/opml-import-wizard/task-04/instructions.md +84 -0
  52. data/tasks/completed/opml-import-wizard/task-04/requirements.md +17 -0
  53. data/tasks/completed/opml-import-wizard/task-05/instructions.md +50 -0
  54. data/tasks/completed/opml-import-wizard/task-05/requirements.md +17 -0
  55. data/tasks/completed/opml-import-wizard/task-06/instructions.md +92 -0
  56. data/tasks/completed/opml-import-wizard/task-06/requirements.md +21 -0
  57. data/tasks/completed/phase_17_01_complexity_audit_2025-10-12.md +62 -0
  58. data/tasks/completed/phase_17_02_complexity_findings_2025-10-12.md +74 -0
  59. data/tasks/completed/phase_17_03_refactor_plan_2025-10-12.md +37 -0
  60. data/tasks/completed/phase_21_01_log_consolidation_2025-10-15.md +30 -0
  61. data/tasks/completed/release_checklist.md +23 -0
  62. data/tasks/completed/routes_refactor_evaluation.md +109 -0
  63. data/tasks/completed/source_monitor_rename_plan.md +70 -0
  64. data/tasks/completed/tasks.md +952 -0
  65. data/tasks/ideas.md +10 -0
  66. metadata +56 -3
  67. /data/tasks/{prd-setup-workflow-streamlining.md → completed/prd-setup-workflow-streamlining.md} +0 -0
  68. /data/tasks/{tasks-setup-workflow-streamlining.md → completed/tasks-setup-workflow-streamlining.md} +0 -0
@@ -0,0 +1,1396 @@
1
+ # Rails Codebase Audit - SourceMonitor
2
+
3
+ **Date:** October 2025
4
+ **Overall Grade:** B+
5
+ **Codebase Health:** Strong with optimization opportunities
6
+
7
+ ---
8
+
9
+ ## Executive Summary
10
+
11
+ This comprehensive audit analyzed architecture, code quality, Rails conventions, and frontend patterns across the entire SourceMonitor Rails codebase. The application demonstrates **excellent engineering practices** with 60+ well-designed service objects, modern Hotwire/Turbo integration, and clean separation of concerns.
12
+
13
+ **Key Strengths:**
14
+
15
+ - ✅ Extensive use of service objects (60+ in `lib/source_monitor`)
16
+ - ✅ Modern frontend with Import Maps, Turbo, and Stimulus
17
+ - ✅ No callback hell or fat models
18
+ - ✅ Security-conscious with consistent parameter sanitization
19
+ - ✅ Proper eager loading in most queries
20
+
21
+ **Areas for Improvement:**
22
+
23
+ - 🔴 1 critical fat controller (356 lines)
24
+ - 🔴 1 N+1 query in sources index
25
+ - 🔴 1 inline script violating CSP
26
+ - 🟠 6 high-severity DRY violations
27
+ - 🟡 11 medium-severity issues
28
+
29
+ **Total Issues Identified:** 32 (3 critical, 6 high, 11 medium, 12 low)
30
+
31
+ ---
32
+
33
+ ## Table of Contents
34
+
35
+ 1. [Critical Issues](#critical-issues)
36
+ 2. [High Severity Issues](#high-severity-issues)
37
+ 3. [Medium Severity Issues](#medium-severity-issues)
38
+ 4. [Low Severity Issues](#low-severity-issues)
39
+ 5. [Positive Findings](#positive-findings)
40
+ 6. [Remediation Plan](#remediation-plan)
41
+ 7. [Detailed Issue Analysis](#detailed-issue-analysis)
42
+
43
+ ---
44
+
45
+ ## Critical Issues
46
+
47
+ ### 1. Fat SourcesController (356 lines)
48
+
49
+ **Severity:** 🔴 CRITICAL
50
+ **Location:** `app/controllers/source_monitor/sources_controller.rb`
51
+ **Impact:** High technical debt, difficult to test, poor maintainability
52
+
53
+ **Problem:**
54
+ The `SourcesController` violates the single responsibility principle with multiple methods exceeding 40 lines:
55
+
56
+ - `destroy` (lines 83-143): 61 lines - complex Turbo Stream response building mixed with business logic
57
+ - `bulk_scrape_flash_payload` (lines 297-343): 47 lines - complex presentation logic
58
+ - `respond_to_bulk_scrape` (lines 251-295): 45 lines - duplicated response patterns
59
+ - `index` (lines 14-35): 22 lines - direct analytics object instantiation
60
+
61
+ **Specific Issues:**
62
+
63
+ #### destroy method (61 lines)
64
+
65
+ ```ruby
66
+ def destroy
67
+ search_params = sanitized_search_params
68
+ @source.destroy
69
+ message = "Source deleted"
70
+
71
+ respond_to do |format|
72
+ format.turbo_stream do
73
+ base_scope = Source.all
74
+ query = base_scope.ransack(search_params)
75
+ query.sorts = [ "created_at desc" ] if query.sorts.blank?
76
+ sources = query.result
77
+
78
+ metrics = SourceMonitor::Analytics::SourcesIndexMetrics.new(...)
79
+ # ... 40+ more lines of Turbo Stream building
80
+ end
81
+ end
82
+ end
83
+ ```
84
+
85
+ **Responsibilities mixed in one method:**
86
+
87
+ - Database deletion
88
+ - Query rebuilding
89
+ - Metrics recalculation
90
+ - Partial rendering
91
+ - Redirect handling
92
+ - Toast notifications
93
+
94
+ **Solution:**
95
+
96
+ Extract service objects and presenters:
97
+
98
+ ```ruby
99
+ # app/services/source_monitor/sources/destroy_service.rb
100
+ module SourceMonitor
101
+ module Sources
102
+ class DestroyService
103
+ def initialize(source:, search_params:, redirect_to: nil)
104
+ @source = source
105
+ @search_params = search_params
106
+ @redirect_to = redirect_to
107
+ end
108
+
109
+ def call
110
+ @source.destroy
111
+
112
+ Result.new(
113
+ success: true,
114
+ message: "Source deleted",
115
+ redirect_location: safe_redirect_path,
116
+ updated_query: rebuild_query,
117
+ metrics: recalculate_metrics
118
+ )
119
+ end
120
+
121
+ private
122
+
123
+ def rebuild_query
124
+ base_scope = Source.all
125
+ query = base_scope.ransack(@search_params)
126
+ query.sorts = ["created_at desc"] if query.sorts.blank?
127
+ query
128
+ end
129
+
130
+ def recalculate_metrics
131
+ sources = rebuild_query.result
132
+ SourceMonitor::Analytics::SourcesIndexMetrics.new(
133
+ base_scope: Source.all,
134
+ result_scope: sources,
135
+ search_params: @search_params
136
+ )
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ # app/presenters/source_monitor/sources/turbo_stream_presenter.rb
143
+ module SourceMonitor
144
+ module Sources
145
+ class TurboStreamPresenter
146
+ def initialize(source:, responder:)
147
+ @source = source
148
+ @responder = responder
149
+ end
150
+
151
+ def render_deletion(metrics:, query:)
152
+ @responder.remove_row(@source)
153
+ @responder.remove("source_monitor_sources_empty_state")
154
+ render_heatmap_update(metrics)
155
+ render_empty_state_if_needed(query)
156
+ self
157
+ end
158
+
159
+ private
160
+
161
+ def render_heatmap_update(metrics)
162
+ @responder.replace(
163
+ "source_monitor_sources_heatmap",
164
+ partial: "source_monitor/sources/fetch_interval_heatmap",
165
+ locals: {
166
+ fetch_interval_distribution: metrics.fetch_interval_distribution,
167
+ selected_bucket: metrics.selected_fetch_interval_bucket,
168
+ search_params: @search_params
169
+ }
170
+ )
171
+ end
172
+
173
+ def render_empty_state_if_needed(query)
174
+ unless query.result.exists?
175
+ @responder.append(
176
+ "source_monitor_sources_table_body",
177
+ partial: "source_monitor/sources/empty_state_row"
178
+ )
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+
185
+ # Simplified controller:
186
+ def destroy
187
+ service = SourceMonitor::Sources::DestroyService.new(
188
+ source: @source,
189
+ search_params: sanitized_search_params,
190
+ redirect_to: params[:redirect_to]
191
+ )
192
+
193
+ result = service.call
194
+
195
+ respond_to do |format|
196
+ format.turbo_stream do
197
+ responder = SourceMonitor::TurboStreams::StreamResponder.new
198
+ presenter = SourceMonitor::Sources::TurboStreamPresenter.new(
199
+ source: @source,
200
+ responder: responder
201
+ )
202
+
203
+ presenter.render_deletion(
204
+ metrics: result.metrics,
205
+ query: result.updated_query
206
+ )
207
+
208
+ responder.append_redirect_if_present(result.redirect_location)
209
+ responder.toast(message: result.message, level: :success)
210
+
211
+ render turbo_stream: responder.render(view_context)
212
+ end
213
+
214
+ format.html do
215
+ redirect_to source_monitor.sources_path, notice: result.message
216
+ end
217
+ end
218
+ end
219
+ ```
220
+
221
+ **Estimated Effort:** 6-8 hours
222
+
223
+ ---
224
+
225
+ ### 2. N+1 Query in Sources Index
226
+
227
+ **Severity:** 🔴 CRITICAL
228
+ **Location:**
229
+
230
+ - Controller: `app/controllers/source_monitor/sources_controller.rb:20`
231
+ - View: `app/views/source_monitor/sources/_row.html.erb:3`
232
+
233
+ **Impact:** Performance degradation with large datasets
234
+
235
+ - 100 sources = 100+ database queries
236
+ - 2-5 second page load increase
237
+ - Database connection pool exhaustion under load
238
+
239
+ **Problem:**
240
+
241
+ The view calls `SourceMonitor::Analytics::SourceActivityRates.rate_for(source)` for each source when `item_activity_rates` is nil or incomplete:
242
+
243
+ ```erb
244
+ <% activity_rate = rate_map.fetch(source.id, nil) %>
245
+ <% activity_rate = SourceMonitor::Analytics::SourceActivityRates.rate_for(source) if activity_rate.nil? %>
246
+ ```
247
+
248
+ This triggers a database query **per source** to count items:
249
+
250
+ ```ruby
251
+ # lib/source_monitor/analytics/source_activity_rates.rb:17-21
252
+ def self.rate_for(source)
253
+ return 0.0 if source.items_count.to_i.zero?
254
+
255
+ recent_count = source.items.where("created_at > ?", 7.days.ago).count
256
+ recent_count.to_f / 7.0
257
+ end
258
+ ```
259
+
260
+ **Current Controller Code:**
261
+
262
+ ```ruby
263
+ def index
264
+ base_scope = Source.all
265
+ @search_params = sanitized_search_params
266
+ @q = base_scope.ransack(@search_params)
267
+ @q.sorts = [ "created_at desc" ] if @q.sorts.blank?
268
+
269
+ @sources = @q.result # ⚠️ No activity rates pre-calculation
270
+
271
+ # ... metrics calculated but activity rates may be incomplete
272
+ end
273
+ ```
274
+
275
+ **Solution:**
276
+
277
+ Ensure activity rates are ALWAYS pre-calculated for all sources:
278
+
279
+ ```ruby
280
+ def index
281
+ base_scope = Source.all
282
+ @search_params = sanitized_search_params
283
+ @q = base_scope.ransack(@search_params)
284
+ @q.sorts = [ "created_at desc" ] if @q.sorts.blank?
285
+
286
+ @sources = @q.result
287
+
288
+ @search_term = @search_params[SEARCH_FIELD.to_s].to_s.strip
289
+ @search_field = SEARCH_FIELD
290
+
291
+ metrics = SourceMonitor::Analytics::SourcesIndexMetrics.new(
292
+ base_scope:,
293
+ result_scope: @sources,
294
+ search_params: @search_params
295
+ )
296
+
297
+ @fetch_interval_distribution = metrics.fetch_interval_distribution
298
+ @fetch_interval_filter = metrics.fetch_interval_filter
299
+ @selected_fetch_interval_bucket = metrics.selected_fetch_interval_bucket
300
+ @item_activity_rates = metrics.item_activity_rates
301
+
302
+ # ✅ ADD THIS: Ensure we have rates for ALL sources in the current page
303
+ # This prevents the view from calling rate_for individually
304
+ source_ids = @sources.pluck(:id)
305
+ source_ids.each do |id|
306
+ @item_activity_rates[id] ||= 0.0
307
+ end
308
+ end
309
+ ```
310
+
311
+ Update the view to never fall back:
312
+
313
+ ```erb
314
+ <% rate_map = local_assigns[:item_activity_rates] || {} %>
315
+ <% activity_rate = rate_map.fetch(source.id, 0.0) %>
316
+ <!-- Remove the fallback that causes N+1 -->
317
+ ```
318
+
319
+ **Estimated Effort:** 1-2 hours
320
+
321
+ ---
322
+
323
+ ### 3. Inline JavaScript in View
324
+
325
+ **Severity:** 🔴 CRITICAL
326
+ **Location:** `app/views/source_monitor/shared/_turbo_visit.html.erb:3-8`
327
+ **Impact:** CSP violations, untestable code, violates Rails conventions
328
+
329
+ **Problem:**
330
+
331
+ ```erb
332
+ <script>
333
+ (() => {
334
+ const options = { action: "<%= action %>" };
335
+ Turbo.visit("<%= j url %>", options);
336
+ })();
337
+ </script>
338
+ ```
339
+
340
+ **Issues:**
341
+
342
+ 1. **CSP Violations:** Inline scripts blocked by strict Content Security Policies
343
+ 2. **Maintainability:** JavaScript logic in ERB templates is harder to test
344
+ 3. **Separation of Concerns:** Business logic mixed with presentation
345
+ 4. **Missed Opportunity:** Could use Turbo's built-in mechanisms
346
+
347
+ **Solution Options:**
348
+
349
+ #### Option A: Turbo Stream Action (Recommended)
350
+
351
+ ```ruby
352
+ # In controller where redirect is needed
353
+ respond_to do |format|
354
+ format.turbo_stream do
355
+ render turbo_stream: turbo_stream.action(:redirect, url)
356
+ end
357
+ end
358
+ ```
359
+
360
+ Create custom Turbo Stream action:
361
+
362
+ ```javascript
363
+ // app/assets/javascripts/source_monitor/turbo_actions.js
364
+ import { StreamActions } from "@hotwired/turbo";
365
+
366
+ StreamActions.redirect = function () {
367
+ const url = this.getAttribute("url");
368
+ const action = this.getAttribute("action") || "advance";
369
+ Turbo.visit(url, { action });
370
+ };
371
+ ```
372
+
373
+ #### Option B: Stimulus Controller
374
+
375
+ ```javascript
376
+ // app/assets/javascripts/source_monitor/controllers/redirect_controller.js
377
+ import { Controller } from "@hotwired/stimulus";
378
+
379
+ export default class extends Controller {
380
+ static values = {
381
+ url: String,
382
+ action: { type: String, default: "advance" },
383
+ };
384
+
385
+ connect() {
386
+ Turbo.visit(this.urlValue, { action: this.actionValue });
387
+ }
388
+ }
389
+ ```
390
+
391
+ Usage:
392
+
393
+ ```erb
394
+ <div data-controller="redirect"
395
+ data-redirect-url-value="<%= url %>"
396
+ data-redirect-action-value="<%= action %>"></div>
397
+ ```
398
+
399
+ **Estimated Effort:** 1-2 hours
400
+
401
+ ---
402
+
403
+ ## High Severity Issues
404
+
405
+ ### 4. default_scope Anti-pattern
406
+
407
+ **Severity:** 🟠 HIGH
408
+ **Location:** `app/models/source_monitor/item.rb:13`
409
+ **Impact:** Hidden behavior, counter cache issues, association problems
410
+
411
+ **Problem:**
412
+
413
+ ```ruby
414
+ default_scope { where(deleted_at: nil) }
415
+ scope :with_deleted, -> { unscope(where: :deleted_at) }
416
+ scope :only_deleted, -> { with_deleted.where.not(deleted_at: nil) }
417
+ ```
418
+
419
+ `default_scope` is considered an **anti-pattern** because:
420
+
421
+ 1. Affects ALL queries globally, including associations
422
+ 2. Hard to reason about behavior across codebase
423
+ 3. Causes unexpected bugs with eager loading
424
+ 4. Requires explicit `unscope` calls
425
+ 5. Makes testing more complex
426
+
427
+ **Evidence of Problems:**
428
+
429
+ ```ruby
430
+ # In item.rb (lines 71-72)
431
+ SourceMonitor::Source.decrement_counter(:items_count, source_id) if source_id
432
+
433
+ # The counter cache is manually managed because default_scope makes
434
+ # automatic counter cache unreliable
435
+ ```
436
+
437
+ **Solution:**
438
+
439
+ Remove `default_scope` and use explicit scoping:
440
+
441
+ ```ruby
442
+ # app/models/source_monitor/item.rb
443
+ # Remove: default_scope { where(deleted_at: nil) }
444
+
445
+ # Add explicit scope
446
+ scope :active, -> { where(deleted_at: nil) }
447
+ scope :deleted, -> { where.not(deleted_at: nil) }
448
+ scope :with_deleted, -> { unscope(where: :deleted_at) }
449
+
450
+ # Update associations in source.rb
451
+ has_many :all_items, class_name: "SourceMonitor::Item", inverse_of: :source, dependent: :destroy
452
+ has_many :items, -> { active }, class_name: "SourceMonitor::Item", inverse_of: :source
453
+
454
+ # Update scopes that use items
455
+ scope :recent, -> { active.order(Arel.sql("published_at DESC NULLS LAST, created_at DESC")) }
456
+ scope :pending_scrape, -> { active.where(scraped_at: nil) }
457
+
458
+ # Update controllers to explicitly use .active
459
+ def index
460
+ base_scope = Item.active.includes(:source) # Explicit!
461
+ # ...
462
+ end
463
+ ```
464
+
465
+ **Estimated Effort:** 4-6 hours (requires testing all Item queries)
466
+
467
+ ---
468
+
469
+ ### 5. DRY Violation: URL Validation Logic
470
+
471
+ **Severity:** 🟠 HIGH
472
+ **Location:**
473
+
474
+ - `app/models/source_monitor/source.rb:108-118`
475
+ - `app/models/source_monitor/item.rb:77-87`
476
+
477
+ **Impact:** Maintenance overhead, duplicated logic in 5 methods across 2 files
478
+
479
+ **Problem:**
480
+
481
+ Both models contain nearly identical URL validation methods:
482
+
483
+ ```ruby
484
+ # Source model
485
+ def feed_url_must_be_http_or_https
486
+ return if feed_url.blank?
487
+ errors.add(:feed_url, "must be a valid HTTP(S) URL") if url_invalid?(:feed_url)
488
+ end
489
+
490
+ def website_url_must_be_http_or_https
491
+ return if website_url.blank?
492
+ errors.add(:website_url, "must be a valid HTTP(S) URL") if url_invalid?(:website_url)
493
+ end
494
+
495
+ # Item model
496
+ def url_must_be_http
497
+ errors.add(:url, "must be a valid HTTP(S) URL") if url_invalid?(:url)
498
+ end
499
+
500
+ def canonical_url_must_be_http
501
+ errors.add(:canonical_url, "must be a valid HTTP(S) URL") if url_invalid?(:canonical_url)
502
+ end
503
+
504
+ def comments_url_must_be_http
505
+ errors.add(:comments_url, "must be a valid HTTP(S) URL") if url_invalid?(:comments_url)
506
+ end
507
+ ```
508
+
509
+ **Solution:**
510
+
511
+ Extend the `UrlNormalizable` concern to handle validation declaratively:
512
+
513
+ ```ruby
514
+ # lib/source_monitor/models/url_normalizable.rb
515
+ module SourceMonitor
516
+ module Models
517
+ module UrlNormalizable
518
+ extend ActiveSupport::Concern
519
+
520
+ class_methods do
521
+ def normalizes_urls(*attributes)
522
+ return if attributes.empty?
523
+
524
+ before_validation :normalize_configured_urls
525
+ self.normalized_url_attributes += attributes.map(&:to_sym)
526
+ self.normalized_url_attributes.uniq!
527
+ end
528
+
529
+ def validates_url_format(*attributes)
530
+ attributes.each do |attribute|
531
+ validate :"validate_#{attribute}_format"
532
+
533
+ define_method :"validate_#{attribute}_format" do
534
+ return if self[attribute].blank?
535
+ errors.add(attribute, "must be a valid HTTP(S) URL") if url_invalid?(attribute)
536
+ end
537
+ end
538
+ end
539
+ end
540
+
541
+ # ... rest of concern
542
+ end
543
+ end
544
+ end
545
+
546
+ # Then in models:
547
+ class Source < ApplicationRecord
548
+ normalizes_urls :feed_url, :website_url
549
+ validates_url_format :feed_url, :website_url
550
+ end
551
+
552
+ class Item < ApplicationRecord
553
+ normalizes_urls :url, :canonical_url, :comments_url
554
+ validates_url_format :url, :canonical_url, :comments_url
555
+ end
556
+ ```
557
+
558
+ **Estimated Effort:** 2-3 hours
559
+
560
+ ---
561
+
562
+ ### 6. DRY Violation: Log Model Scopes
563
+
564
+ **Severity:** 🟠 HIGH
565
+ **Location:**
566
+
567
+ - `app/models/source_monitor/fetch_log.rb:14-21`
568
+ - `app/models/source_monitor/scrape_log.rb:11-18`
569
+
570
+ **Impact:** Duplicate validations, scopes, and attribute defaults
571
+
572
+ **Problem:**
573
+
574
+ Both log models share identical code:
575
+
576
+ ```ruby
577
+ # FetchLog
578
+ validates :started_at, presence: true
579
+ validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
580
+
581
+ scope :recent, -> { order(started_at: :desc) }
582
+ scope :successful, -> { where(success: true) }
583
+ scope :failed, -> { where(success: false) }
584
+
585
+ attribute :metadata, default: -> { {} }
586
+
587
+ # ScrapeLog - IDENTICAL
588
+ validates :started_at, presence: true
589
+ validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
590
+
591
+ scope :recent, -> { order(started_at: :desc) }
592
+ scope :successful, -> { where(success: true) }
593
+ scope :failed, -> { where(success: false) }
594
+
595
+ attribute :metadata, default: -> { {} }
596
+ ```
597
+
598
+ **Solution:**
599
+
600
+ Create shared concern:
601
+
602
+ ```ruby
603
+ # app/models/concerns/source_monitor/loggable.rb
604
+ module SourceMonitor
605
+ module Loggable
606
+ extend ActiveSupport::Concern
607
+
608
+ included do
609
+ attribute :metadata, default: -> { {} }
610
+
611
+ validates :started_at, presence: true
612
+ validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
613
+
614
+ scope :recent, -> { order(started_at: :desc) }
615
+ scope :successful, -> { where(success: true) }
616
+ scope :failed, -> { where(success: false) }
617
+ end
618
+ end
619
+ end
620
+
621
+ # Then use in models:
622
+ class FetchLog < ApplicationRecord
623
+ include SourceMonitor::Loggable
624
+ belongs_to :source
625
+
626
+ validates :source, presence: true
627
+ validates :items_created, :items_updated, :items_failed,
628
+ numericality: { greater_than_or_equal_to: 0 }
629
+ end
630
+
631
+ class ScrapeLog < ApplicationRecord
632
+ include SourceMonitor::Loggable
633
+ belongs_to :item
634
+ belongs_to :source
635
+
636
+ validates :item, :source, presence: true
637
+ validates :content_length, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
638
+ end
639
+ ```
640
+
641
+ **Estimated Effort:** 1-2 hours
642
+
643
+ ---
644
+
645
+ ### 7. DRY Violation: Turbo Stream Response Pattern
646
+
647
+ **Severity:** 🟠 HIGH
648
+ **Location:**
649
+
650
+ - `app/controllers/source_monitor/sources_controller.rb:219-249, 251-295`
651
+ - `app/controllers/source_monitor/items_controller.rb:52-72`
652
+
653
+ **Impact:** 50+ lines repeated 5+ times, inconsistent responses
654
+
655
+ **Problem:**
656
+
657
+ Pattern repeated across multiple actions:
658
+
659
+ ```ruby
660
+ # Pattern repeated in multiple action responses:
661
+ refreshed = @source.reload
662
+ respond_to do |format|
663
+ format.turbo_stream do
664
+ responder = SourceMonitor::TurboStreams::StreamResponder.new
665
+
666
+ responder.replace_details(
667
+ refreshed,
668
+ partial: "source_monitor/sources/details_wrapper",
669
+ locals: { source: refreshed }
670
+ )
671
+
672
+ responder.replace_row(
673
+ refreshed,
674
+ partial: "source_monitor/sources/row",
675
+ locals: { source: refreshed, item_activity_rates: {...} }
676
+ )
677
+
678
+ responder.toast(message:, level:, delay_ms: 5000)
679
+ render turbo_stream: responder.render(view_context)
680
+ end
681
+
682
+ format.html do
683
+ redirect_to source_monitor.source_path(refreshed), notice: message
684
+ end
685
+ end
686
+ ```
687
+
688
+ **Solution:**
689
+
690
+ Extract to controller concern:
691
+
692
+ ```ruby
693
+ # app/controllers/concerns/source_monitor/turbo_streamable.rb
694
+ module SourceMonitor
695
+ module TurboStreamable
696
+ extend ActiveSupport::Concern
697
+
698
+ private
699
+
700
+ def respond_with_turbo_update(record, message:, level: :info, status: :ok, &customizer)
701
+ refreshed = record.reload
702
+
703
+ respond_to do |format|
704
+ format.turbo_stream do
705
+ responder = SourceMonitor::TurboStreams::StreamResponder.new
706
+
707
+ # Standard replacements
708
+ replace_record_views(responder, refreshed)
709
+
710
+ # Allow custom turbo streams
711
+ customizer&.call(responder, refreshed)
712
+
713
+ responder.toast(message: message, level: level, delay_ms: 5000)
714
+ render turbo_stream: responder.render(view_context), status: status
715
+ end
716
+
717
+ format.html do
718
+ redirect_to polymorphic_path([:source_monitor, refreshed]), notice: message
719
+ end
720
+ end
721
+ end
722
+
723
+ def replace_record_views(responder, record)
724
+ resource_name = record.class.name.demodulize.underscore
725
+
726
+ responder.replace_details(
727
+ record,
728
+ partial: "source_monitor/#{resource_name.pluralize}/details_wrapper",
729
+ locals: { resource_name.to_sym => record }
730
+ )
731
+
732
+ responder.replace_row(
733
+ record,
734
+ partial: "source_monitor/#{resource_name.pluralize}/row",
735
+ locals: row_locals(record)
736
+ )
737
+ end
738
+ end
739
+ end
740
+
741
+ # Then in controller:
742
+ class SourcesController < ApplicationController
743
+ include SourceMonitor::TurboStreamable
744
+
745
+ def fetch
746
+ SourceMonitor::Fetching::FetchRunner.enqueue(@source.id)
747
+ respond_with_turbo_update(@source, message: "Fetch has been enqueued")
748
+ end
749
+ end
750
+ ```
751
+
752
+ **Estimated Effort:** 3-4 hours
753
+
754
+ ---
755
+
756
+ ### 8. DRY Violation: Ransack Query Setup
757
+
758
+ **Severity:** 🟠 HIGH
759
+ **Location:**
760
+
761
+ - `app/controllers/source_monitor/sources_controller.rb:14-23, 90-93`
762
+ - `app/controllers/source_monitor/items_controller.rb:14-18`
763
+
764
+ **Impact:** Default sort logic scattered, inconsistent query building
765
+
766
+ **Problem:**
767
+
768
+ Ransack setup duplicated:
769
+
770
+ ```ruby
771
+ # SourcesController#index
772
+ base_scope = Source.all
773
+ @search_params = sanitized_search_params
774
+ @q = base_scope.ransack(@search_params)
775
+ @q.sorts = [ "created_at desc" ] if @q.sorts.blank?
776
+ @sources = @q.result
777
+
778
+ # SourcesController#destroy (turbo_stream format)
779
+ base_scope = Source.all
780
+ query = base_scope.ransack(search_params)
781
+ query.sorts = [ "created_at desc" ] if query.sorts.blank?
782
+ sources = query.result
783
+ ```
784
+
785
+ **Solution:**
786
+
787
+ Enhance `SanitizesSearchParams` concern:
788
+
789
+ ```ruby
790
+ # app/controllers/concerns/source_monitor/sanitizes_search_params.rb
791
+ module SourceMonitor
792
+ module SanitizesSearchParams
793
+ extend ActiveSupport::Concern
794
+
795
+ class_methods do
796
+ def searchable_with(scope:, default_sorts: ["created_at desc"])
797
+ define_method(:search_scope) { scope }
798
+ define_method(:default_search_sorts) { default_sorts }
799
+ end
800
+ end
801
+
802
+ private
803
+
804
+ def build_search_query(scope = nil, params: sanitized_search_params)
805
+ base = scope || search_scope
806
+ query = base.ransack(params)
807
+ query.sorts = default_search_sorts if query.sorts.blank?
808
+ query
809
+ end
810
+ end
811
+ end
812
+
813
+ # Then in controllers:
814
+ class SourcesController < ApplicationController
815
+ include SourceMonitor::SanitizesSearchParams
816
+ searchable_with scope: -> { Source.all }, default_sorts: ["created_at desc"]
817
+
818
+ def index
819
+ @search_params = sanitized_search_params
820
+ @q = build_search_query
821
+ @sources = @q.result
822
+ end
823
+ end
824
+ ```
825
+
826
+ **Estimated Effort:** 2-3 hours
827
+
828
+ ---
829
+
830
+ ### 9. Missing NOT NULL Constraints
831
+
832
+ **Severity:** 🟠 HIGH
833
+ **Location:** `test/dummy/db/schema.rb:55-60`
834
+ **Impact:** Data integrity risk, no database-level validation
835
+
836
+ **Problem:**
837
+
838
+ Critical fields lack NOT NULL constraints:
839
+
840
+ ```ruby
841
+ t.string "guid" # Should be NOT NULL
842
+ t.string "url" # Should be NOT NULL
843
+ ```
844
+
845
+ Models have validations but these are **only enforced at application level**, not database level.
846
+
847
+ **Solution:**
848
+
849
+ Create migration:
850
+
851
+ ```ruby
852
+ # db/migrate/YYYYMMDDHHMMSS_add_not_null_constraints_to_items.rb
853
+ class AddNotNullConstraintsToItems < ActiveRecord::Migration[8.0]
854
+ def up
855
+ # First, clean up any existing invalid data
856
+ SourceMonitor::Item.where(guid: nil).find_each do |item|
857
+ item.update_column(:guid, item.content_fingerprint || SecureRandom.uuid)
858
+ end
859
+
860
+ SourceMonitor::Item.where(url: nil).find_each do |item|
861
+ item.update_column(:url, item.canonical_url || 'https://unknown.example.com')
862
+ end
863
+
864
+ # Now add the constraints
865
+ change_column_null :source_monitor_items, :guid, false
866
+ change_column_null :source_monitor_items, :url, false
867
+ end
868
+
869
+ def down
870
+ change_column_null :source_monitor_items, :guid, true
871
+ change_column_null :source_monitor_items, :url, true
872
+ end
873
+ end
874
+ ```
875
+
876
+ **Estimated Effort:** 2-3 hours
877
+
878
+ ---
879
+
880
+ ## Medium Severity Issues
881
+
882
+ ### 10. Non-RESTful Routes
883
+
884
+ **Severity:** 🟡 MEDIUM
885
+ **Location:** `config/routes.rb:8-14`
886
+
887
+ **Problem:**
888
+
889
+ ```ruby
890
+ resources :items, only: %i[index show] do
891
+ post :scrape, on: :member # Non-RESTful
892
+ end
893
+
894
+ resources :sources do
895
+ post :fetch, on: :member # Non-RESTful
896
+ post :retry, on: :member # Non-RESTful
897
+ post :scrape_all, on: :member # Non-RESTful
898
+ end
899
+ ```
900
+
901
+ These are actions/commands, not resource updates.
902
+
903
+ **Solution:**
904
+
905
+ ```ruby
906
+ # Option 1: Nested resources
907
+ resources :sources do
908
+ resource :fetch, only: [:create], controller: 'source_fetches'
909
+ resource :retry, only: [:create], controller: 'source_retries'
910
+ resource :bulk_scrape, only: [:create], controller: 'source_bulk_scrapes'
911
+ end
912
+
913
+ # Option 2: Explicit command namespace
914
+ namespace :commands do
915
+ resources :sources, only: [] do
916
+ post :fetch, on: :member
917
+ post :retry, on: :member
918
+ post :scrape_all, on: :member
919
+ end
920
+ end
921
+ ```
922
+
923
+ **Estimated Effort:** 3-4 hours
924
+
925
+ ---
926
+
927
+ ### 11. Multiple after_initialize Callbacks
928
+
929
+ **Severity:** 🟡 MEDIUM
930
+ **Location:** `app/models/source_monitor/source.rb:32-34`
931
+
932
+ **Problem:**
933
+
934
+ ```ruby
935
+ after_initialize :ensure_hash_defaults, if: :new_record?
936
+ after_initialize :ensure_fetch_status_default
937
+ after_initialize :ensure_health_defaults
938
+ ```
939
+
940
+ **Solution:**
941
+
942
+ Use Rails attribute API:
943
+
944
+ ```ruby
945
+ attribute :scrape_settings, default: -> { {} }
946
+ attribute :custom_headers, default: -> { {} }
947
+ attribute :metadata, default: -> { {} }
948
+ attribute :fetch_status, :string, default: "idle"
949
+ attribute :health_status, :string, default: "healthy"
950
+
951
+ # Remove after_initialize callbacks
952
+ ```
953
+
954
+ **Estimated Effort:** 1 hour
955
+
956
+ ---
957
+
958
+ ### 12. Scope with Complex Logic
959
+
960
+ **Severity:** 🟡 MEDIUM
961
+ **Location:** `app/models/source_monitor/source.rb:20-23`
962
+
963
+ **Problem:**
964
+
965
+ ```ruby
966
+ scope :due_for_fetch, lambda {
967
+ now = Time.current
968
+ active.where(arel_table[:next_fetch_at].eq(nil).or(arel_table[:next_fetch_at].lteq(now)))
969
+ }
970
+ ```
971
+
972
+ Complex logic with variables should be a class method.
973
+
974
+ **Solution:**
975
+
976
+ ```ruby
977
+ def self.due_for_fetch(reference_time: Time.current)
978
+ active.where(
979
+ arel_table[:next_fetch_at].eq(nil).or(arel_table[:next_fetch_at].lteq(reference_time))
980
+ )
981
+ end
982
+ ```
983
+
984
+ **Estimated Effort:** 30 minutes
985
+
986
+ ---
987
+
988
+ ### 13. Over-Engineering: Complex Flash Message Building
989
+
990
+ **Severity:** 🟡 MEDIUM
991
+ **Location:** `app/controllers/source_monitor/sources_controller.rb:297-343`
992
+
993
+ **Problem:** 47 lines of conditional logic in controller
994
+
995
+ **Solution:** Extract to presenter (see Issue #1 solution)
996
+
997
+ **Estimated Effort:** 2-3 hours
998
+
999
+ ---
1000
+
1001
+ ### 14. Manual Counter Cache Updates
1002
+
1003
+ **Severity:** 🟡 MEDIUM
1004
+ **Location:** `app/models/source_monitor/item.rb:71`
1005
+
1006
+ **Problem:**
1007
+
1008
+ ```ruby
1009
+ SourceMonitor::Source.decrement_counter(:items_count, source_id) if source_id
1010
+ ```
1011
+
1012
+ Manual updates are error-prone.
1013
+
1014
+ **Solution:**
1015
+
1016
+ ```ruby
1017
+ def soft_delete!(timestamp: Time.current)
1018
+ return if deleted?
1019
+
1020
+ self.class.transaction do
1021
+ self.deleted_at = timestamp
1022
+ save!(validate: false)
1023
+ source.touch if source
1024
+ end
1025
+ end
1026
+ ```
1027
+
1028
+ **Estimated Effort:** 2 hours
1029
+
1030
+ ---
1031
+
1032
+ ### 15. Overly Permissive Nested Parameters
1033
+
1034
+ **Severity:** 🟡 MEDIUM
1035
+ **Location:** `app/controllers/source_monitor/sources_controller.rb:211-213`
1036
+
1037
+ **Problem:**
1038
+
1039
+ ```ruby
1040
+ scrape_settings: [
1041
+ { selectors: %i[content title] }
1042
+ ]
1043
+ ```
1044
+
1045
+ Permits any keys under `scrape_settings`.
1046
+
1047
+ **Solution:**
1048
+
1049
+ ```ruby
1050
+ def source_params
1051
+ permitted = params.require(:source).permit(
1052
+ :name,
1053
+ :feed_url,
1054
+ # ...
1055
+ scrape_settings: {
1056
+ selectors: [:content, :title],
1057
+ timeout: [],
1058
+ javascript_enabled: []
1059
+ }
1060
+ )
1061
+ end
1062
+ ```
1063
+
1064
+ **Estimated Effort:** 1 hour
1065
+
1066
+ ---
1067
+
1068
+ ### 16-20. Additional Medium Issues
1069
+
1070
+ - **16. Missing Temporal State Concern** - Extract time-based state checks
1071
+ - **17. Inconsistent Naming** - `log_filter_status` vs `filter_fetch_logs`
1072
+ - **18. Poor Naming** - `integer_param` doesn't convey sanitization
1073
+ - **19. Subqueries vs JOINs** - Dashboard queries could use JOINs
1074
+ - **20. Missing Association Defaults** - Add default ordering to associations
1075
+
1076
+ **Combined Estimated Effort:** 6-8 hours
1077
+
1078
+ ---
1079
+
1080
+ ## Low Severity Issues
1081
+
1082
+ ### 21. Search Forms Trigger Full Page Reloads
1083
+
1084
+ **Location:** `app/views/source_monitor/sources/index.html.erb:9`
1085
+
1086
+ **Solution:** Add Turbo Frame targeting
1087
+
1088
+ **Estimated Effort:** 2 hours
1089
+
1090
+ ---
1091
+
1092
+ ### 22. Pagination Triggers Full Page Reloads
1093
+
1094
+ **Location:** `app/views/source_monitor/items/index.html.erb:136-146`
1095
+
1096
+ **Solution:** Add `data: { turbo_frame: "..." }` to links
1097
+
1098
+ **Estimated Effort:** 1 hour
1099
+
1100
+ ---
1101
+
1102
+ ### 23. Global Event Listener
1103
+
1104
+ **Location:** `app/assets/javascripts/source_monitor/application.js:19-21`
1105
+
1106
+ **Problem:**
1107
+
1108
+ ```javascript
1109
+ document.addEventListener("turbo:submit-end", () => {
1110
+ document.dispatchEvent(new CustomEvent("feed-monitor:form-finished"));
1111
+ });
1112
+ ```
1113
+
1114
+ Never cleaned up, purpose unclear.
1115
+
1116
+ **Solution:** Document, move to Stimulus, or remove
1117
+
1118
+ **Estimated Effort:** 30 minutes
1119
+
1120
+ ---
1121
+
1122
+ ### 24-32. Additional Low-Priority Issues
1123
+
1124
+ - **24. Magic Numbers** - Toast delays (5000ms vs 6000ms)
1125
+ - **25. Inconsistent Variable Naming** - `refreshed` vs `@source`
1126
+ - **26. Missing Parameter Validation** - Bulk scrape selection
1127
+ - **27. Missing Check Constraint** - `fetch_status` enum
1128
+ - **28. Inconsistent Callbacks** - Some have conditions, others don't
1129
+ - **29. Complex Content Attribute** - `assign_content_attribute` pattern
1130
+ - **30. Dropdown Async Import** - Could be simplified
1131
+ - **31. Missing Performance Indexes** - Activity rates, due_for_fetch
1132
+ - **32. Database Views** - Dashboard queries could use views
1133
+
1134
+ **Combined Estimated Effort:** 4-6 hours
1135
+
1136
+ ---
1137
+
1138
+ ## Positive Findings
1139
+
1140
+ ### ✅ Excellent Service Object Architecture
1141
+
1142
+ **60+ well-designed service objects in `lib/source_monitor/`:**
1143
+
1144
+ - `SourceMonitor::Fetching::FetchRunner` - Coordinates feed fetching
1145
+ - `SourceMonitor::Scraping::Enqueuer` - Handles scrape job queuing
1146
+ - `SourceMonitor::Scraping::BulkSourceScraper` - Bulk scraping orchestration
1147
+ - `SourceMonitor::Analytics::SourcesIndexMetrics` - Metrics calculation
1148
+ - `SourceMonitor::Dashboard::Queries` - Dashboard data queries
1149
+ - `SourceMonitor::TurboStreams::StreamResponder` - Turbo Stream building
1150
+
1151
+ **Strengths:**
1152
+
1153
+ - Clear single responsibility
1154
+ - Well-tested in isolation
1155
+ - Reusable across controllers and jobs
1156
+ - Return value objects (Result structs)
1157
+
1158
+ ---
1159
+
1160
+ ### ✅ Modern Frontend Architecture (92/100 Score)
1161
+
1162
+ | Category | Score |
1163
+ | --------------------- | ------- |
1164
+ | Dependency Management | 100/100 |
1165
+ | Stimulus Usage | 95/100 |
1166
+ | Turbo Integration | 90/100 |
1167
+ | Code Organization | 95/100 |
1168
+ | Performance | 90/100 |
1169
+ | Maintainability | 85/100 |
1170
+
1171
+ **Strengths:**
1172
+
1173
+ - Import Maps with Propshaft (no webpack/node_modules)
1174
+ - 4 well-structured Stimulus controllers
1175
+ - Effective Turbo Frames and Streams
1176
+ - No jQuery or legacy patterns
1177
+ - No inline event handlers (onclick, etc.)
1178
+ - Progressive enhancement
1179
+
1180
+ ---
1181
+
1182
+ ### ✅ Skinny, Focused Models
1183
+
1184
+ - `Source` (129 lines) - Proper size with validations and scopes
1185
+ - `Item` (109 lines) - Clean soft delete logic
1186
+ - `FetchLog` (26 lines) - Simple log record
1187
+ - `ScrapeLog` (30 lines) - Simple log record
1188
+
1189
+ No fat models found!
1190
+
1191
+ ---
1192
+
1193
+ ### ✅ No Callback Hell
1194
+
1195
+ - Only 3 `after_initialize` callbacks for defaults
1196
+ - No problematic `before_save`, `after_save`, `before_destroy`
1197
+ - Business logic in service objects, not callbacks
1198
+
1199
+ ---
1200
+
1201
+ ### ✅ Security-Conscious
1202
+
1203
+ - Consistent use of `SourceMonitor::Security::ParameterSanitizer`
1204
+ - Proper Ransack whitelisting
1205
+ - Strong parameters throughout
1206
+
1207
+ ---
1208
+
1209
+ ### ✅ Proper Eager Loading
1210
+
1211
+ Most queries use `.includes()` appropriately:
1212
+
1213
+ ```ruby
1214
+ base_scope = Item.includes(:source)
1215
+ @sources = Source.includes(:fetch_logs).all
1216
+ ```
1217
+
1218
+ ---
1219
+
1220
+ ## Remediation Plan
1221
+
1222
+ ### Phase 1: Critical Fixes (8-12 hours)
1223
+
1224
+ **Priority:** Must fix immediately
1225
+
1226
+ 1. **Refactor SourcesController** (6-8 hours)
1227
+
1228
+ - Extract `Sources::DestroyService`
1229
+ - Extract `Sources::TurboStreamPresenter`
1230
+ - Extract `Scraping::BulkResultPresenter`
1231
+
1232
+ 2. **Fix N+1 Query** (1-2 hours)
1233
+
1234
+ - Pre-calculate activity rates in index action
1235
+ - Update view to remove fallback
1236
+
1237
+ 3. **Remove Inline Script** (1-2 hours)
1238
+ - Replace `_turbo_visit.html.erb` with Turbo Stream action
1239
+ - Create custom `redirect` stream action
1240
+
1241
+ **Deliverable:** 356-line controller reduced to <150 lines, no N+1 queries, no inline JS
1242
+
1243
+ ---
1244
+
1245
+ ### Phase 2: High-Impact DRY Violations (12-16 hours)
1246
+
1247
+ **Priority:** High impact on maintainability
1248
+
1249
+ 4. **URL Validation Concern** (2-3 hours)
1250
+
1251
+ - Add `validates_url_format` to `UrlNormalizable`
1252
+ - Update Source and Item models
1253
+
1254
+ 5. **Loggable Concern** (1-2 hours)
1255
+
1256
+ - Extract shared log behavior
1257
+ - Update FetchLog and ScrapeLog
1258
+
1259
+ 6. **TurboStreamable Concern** (3-4 hours)
1260
+
1261
+ - Extract response building pattern
1262
+ - Update all controllers
1263
+
1264
+ 7. **Enhanced SearchParams** (2-3 hours)
1265
+
1266
+ - Add `build_search_query` helper
1267
+ - Update both controllers
1268
+
1269
+ 8. **Replace default_scope** (4-6 hours)
1270
+
1271
+ - Use explicit `.active` scope
1272
+ - Update all Item queries
1273
+ - Test thoroughly
1274
+
1275
+ 9. **Database Constraints** (2-3 hours)
1276
+ - Migration for NOT NULL on guid, url
1277
+ - Data cleanup script
1278
+
1279
+ **Deliverable:** 150+ lines of duplicated code eliminated, explicit scoping
1280
+
1281
+ ---
1282
+
1283
+ ### Phase 3: Medium Priority (10-15 hours)
1284
+
1285
+ **Priority:** Quality of life improvements
1286
+
1287
+ 10. **Consolidate Callbacks** (1 hour)
1288
+ 11. **Convert Complex Scopes** (30 min)
1289
+ 12. **Extract Flash Message Builder** (2-3 hours)
1290
+ 13. **Turbo Frame Search** (2 hours)
1291
+ 14. **Turbo Frame Pagination** (1 hour)
1292
+ 15. **Fix Counter Cache** (2 hours)
1293
+ 16. **Tighten Strong Params** (1 hour)
1294
+ 17. **Refactor RESTful Routes** (3-4 hours) - Optional
1295
+
1296
+ **Deliverable:** Improved UX, cleaner code organization
1297
+
1298
+ ---
1299
+
1300
+ ### Phase 4: Polish & Optimization (6-10 hours)
1301
+
1302
+ **Priority:** Nice-to-have
1303
+
1304
+ 18. **Extract Constants** (30 min)
1305
+ 19. **Clean Up Event Listener** (30 min)
1306
+ 20. **Rename Methods** (1 hour)
1307
+ 21. **Add Performance Indexes** (2-3 hours)
1308
+ 22. **Add Check Constraints** (1-2 hours)
1309
+ 23. **Database Views** (2-3 hours)
1310
+
1311
+ **Deliverable:** Optimized performance, consistent naming
1312
+
1313
+ ---
1314
+
1315
+ ## Total Effort Estimate
1316
+
1317
+ | Phase | Hours | Priority |
1318
+ | ----------------- | --------- | ------------ |
1319
+ | Phase 1: Critical | 8-12 | Must Do |
1320
+ | Phase 2: High DRY | 12-16 | Should Do |
1321
+ | Phase 3: Medium | 10-15 | Nice to Have |
1322
+ | Phase 4: Polish | 6-10 | Optional |
1323
+ | **TOTAL** | **36-53** | - |
1324
+
1325
+ ---
1326
+
1327
+ ## Recommended Approach
1328
+
1329
+ ### Week 1: Critical Fixes
1330
+
1331
+ - Focus on Phase 1 (SourcesController, N+1, inline JS)
1332
+ - Immediate impact on code quality and performance
1333
+
1334
+ ### Week 2-3: DRY Violations
1335
+
1336
+ - Phase 2 (concerns, default_scope, constraints)
1337
+ - High maintainability impact
1338
+
1339
+ ### Week 4: Polish
1340
+
1341
+ - Cherry-pick Phase 3/4 items based on team priorities
1342
+ - Focus on items with highest ROI
1343
+
1344
+ ---
1345
+
1346
+ ## Metrics Summary
1347
+
1348
+ ### Issues by Severity
1349
+
1350
+ | Severity | Count | % of Total |
1351
+ | --------- | ------ | ---------- |
1352
+ | Critical | 3 | 9% |
1353
+ | High | 6 | 19% |
1354
+ | Medium | 11 | 34% |
1355
+ | Low | 12 | 38% |
1356
+ | **TOTAL** | **32** | **100%** |
1357
+
1358
+ ### Issues by Category
1359
+
1360
+ | Category | Count |
1361
+ | --------------------- | ----- |
1362
+ | Architecture & Design | 8 |
1363
+ | Code Quality (DRY) | 9 |
1364
+ | Rails Conventions | 7 |
1365
+ | Frontend | 5 |
1366
+ | Database | 3 |
1367
+
1368
+ ### Code Health Metrics
1369
+
1370
+ ```
1371
+ Service Objects: 60+ ✅
1372
+ Fat Controllers: 1 (SourcesController)
1373
+ Fat Models: 0 ✅
1374
+ Callback Hell: 0 ✅
1375
+ N+1 Queries: 1 (sources#index)
1376
+ Inline Scripts: 1 (_turbo_visit.html.erb)
1377
+ Frontend Score: 92/100 ✅
1378
+ Overall Grade: B+
1379
+ ```
1380
+
1381
+ ---
1382
+
1383
+ ## Conclusion
1384
+
1385
+ This is a **well-architected Rails application** with strong engineering fundamentals. The 60+ service objects, modern Hotwire integration, and clean models demonstrate excellent design principles.
1386
+
1387
+ The issues identified are primarily **opportunities for optimization** rather than fundamental flaws. The critical issues (fat controller, N+1 query, inline script) are addressable in 8-12 hours and will bring immediate benefits.
1388
+
1389
+ **Recommendation:** Execute Phase 1 immediately, then evaluate ROI for Phase 2-4 based on team capacity and priorities.
1390
+
1391
+ ---
1392
+
1393
+ **Report Generated:** January 2025
1394
+ **Analysis Depth:** Comprehensive (4 specialized agents)
1395
+ **Files Analyzed:** 50+ (controllers, models, views, JavaScript, config)
1396
+ **Lines of Code Reviewed:** 5,000+