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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/Gemfile.lock +1 -1
- data/app/assets/javascripts/source_monitor/application.js +4 -0
- data/app/assets/javascripts/source_monitor/controllers/confirm_navigation_controller.js +49 -0
- data/app/assets/javascripts/source_monitor/controllers/select_all_controller.js +36 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +791 -0
- data/app/controllers/source_monitor/sources_controller.rb +5 -36
- data/app/helpers/source_monitor/application_helper.rb +17 -0
- data/app/jobs/source_monitor/import_opml_job.rb +150 -0
- data/app/jobs/source_monitor/import_session_health_check_job.rb +93 -0
- data/app/models/source_monitor/import_history.rb +35 -0
- data/app/models/source_monitor/import_session.rb +34 -0
- data/app/views/source_monitor/import_sessions/_header.html.erb +12 -0
- data/app/views/source_monitor/import_sessions/_sidebar.html.erb +23 -0
- data/app/views/source_monitor/import_sessions/health_check/_progress.html.erb +20 -0
- data/app/views/source_monitor/import_sessions/health_check/_row.html.erb +44 -0
- data/app/views/source_monitor/import_sessions/show.html.erb +15 -0
- data/app/views/source_monitor/import_sessions/show.turbo_stream.erb +1 -0
- data/app/views/source_monitor/import_sessions/steps/_configure.html.erb +53 -0
- data/app/views/source_monitor/import_sessions/steps/_confirm.html.erb +121 -0
- data/app/views/source_monitor/import_sessions/steps/_health_check.html.erb +82 -0
- data/app/views/source_monitor/import_sessions/steps/_navigation.html.erb +29 -0
- data/app/views/source_monitor/import_sessions/steps/_preview.html.erb +172 -0
- data/app/views/source_monitor/import_sessions/steps/_upload.html.erb +42 -0
- data/app/views/source_monitor/sources/_form.html.erb +8 -138
- data/app/views/source_monitor/sources/_form_fields.html.erb +142 -0
- data/app/views/source_monitor/sources/_import_history_panel.html.erb +53 -0
- data/app/views/source_monitor/sources/index.html.erb +7 -1
- data/config/coverage_baseline.json +91 -15
- data/config/routes.rb +6 -0
- data/db/migrate/20251124090000_create_import_sessions.rb +18 -0
- data/db/migrate/20251124153000_add_health_fields_to_import_sessions.rb +14 -0
- data/db/migrate/20251125094500_create_import_histories.rb +19 -0
- data/lib/source_monitor/health/import_source_health_check.rb +55 -0
- data/lib/source_monitor/health.rb +1 -0
- data/lib/source_monitor/import_sessions/entry_normalizer.rb +30 -0
- data/lib/source_monitor/import_sessions/health_check_broadcaster.rb +103 -0
- data/lib/source_monitor/sources/params.rb +52 -0
- data/lib/source_monitor/version.rb +1 -1
- data/tasks/completed/codebase_audit_2025.md +1396 -0
- data/tasks/completed/engine-asset-configuration.md +203 -0
- data/tasks/completed/opml-import-wizard/opml-import-wizard-product-brief.md +58 -0
- data/tasks/completed/opml-import-wizard/opml-import-wizard-tech-brief.md +75 -0
- data/tasks/completed/opml-import-wizard/task-01/instructions.md +81 -0
- data/tasks/completed/opml-import-wizard/task-01/requirements.md +19 -0
- data/tasks/completed/opml-import-wizard/task-02/instructions.md +83 -0
- data/tasks/completed/opml-import-wizard/task-02/requirements.md +18 -0
- data/tasks/completed/opml-import-wizard/task-03/instructions.md +58 -0
- data/tasks/completed/opml-import-wizard/task-03/requirements.md +18 -0
- data/tasks/completed/opml-import-wizard/task-04/instructions.md +84 -0
- data/tasks/completed/opml-import-wizard/task-04/requirements.md +17 -0
- data/tasks/completed/opml-import-wizard/task-05/instructions.md +50 -0
- data/tasks/completed/opml-import-wizard/task-05/requirements.md +17 -0
- data/tasks/completed/opml-import-wizard/task-06/instructions.md +92 -0
- data/tasks/completed/opml-import-wizard/task-06/requirements.md +21 -0
- data/tasks/completed/phase_17_01_complexity_audit_2025-10-12.md +62 -0
- data/tasks/completed/phase_17_02_complexity_findings_2025-10-12.md +74 -0
- data/tasks/completed/phase_17_03_refactor_plan_2025-10-12.md +37 -0
- data/tasks/completed/phase_21_01_log_consolidation_2025-10-15.md +30 -0
- data/tasks/completed/release_checklist.md +23 -0
- data/tasks/completed/routes_refactor_evaluation.md +109 -0
- data/tasks/completed/source_monitor_rename_plan.md +70 -0
- data/tasks/completed/tasks.md +952 -0
- data/tasks/ideas.md +10 -0
- metadata +56 -3
- /data/tasks/{prd-setup-workflow-streamlining.md → completed/prd-setup-workflow-streamlining.md} +0 -0
- /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+
|