source_monitor 0.2.0 → 0.3.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/.claude/agents/rails-concern.md +464 -0
- data/.claude/agents/rails-controller.md +424 -0
- data/.claude/agents/rails-hotwire.md +446 -0
- data/.claude/agents/rails-implement.md +374 -0
- data/.claude/agents/rails-job.md +334 -0
- data/.claude/agents/rails-lint.md +294 -0
- data/.claude/agents/rails-mailer.md +371 -0
- data/.claude/agents/rails-migration.md +449 -0
- data/.claude/agents/rails-model.md +420 -0
- data/.claude/agents/rails-policy.md +443 -0
- data/.claude/agents/rails-presenter.md +427 -0
- data/.claude/agents/rails-query.md +412 -0
- data/.claude/agents/rails-review.md +490 -0
- data/.claude/agents/rails-service.md +458 -0
- data/.claude/agents/rails-state-records.md +465 -0
- data/.claude/agents/rails-tdd.md +314 -0
- data/.claude/agents/rails-test.md +441 -0
- data/.claude/agents/rails-view-component.md +418 -0
- data/.claude/hooks/block-secrets.sh +52 -0
- data/.claude/settings.json +85 -0
- data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
- data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
- data/.claude/skills/active-storage-setup/SKILL.md +311 -0
- data/.claude/skills/api-versioning/SKILL.md +294 -0
- data/.claude/skills/authentication-flow/SKILL.md +335 -0
- data/.claude/skills/authentication-flow/reference/current.md +248 -0
- data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
- data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
- data/.claude/skills/authorization-pundit/SKILL.md +462 -0
- data/.claude/skills/caching-strategies/SKILL.md +350 -0
- data/.claude/skills/database-migrations/SKILL.md +354 -0
- data/.claude/skills/form-object-patterns/SKILL.md +399 -0
- data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
- data/.claude/skills/i18n-patterns/SKILL.md +320 -0
- data/.claude/skills/install/SKILL.md +367 -0
- data/.claude/skills/performance-optimization/SKILL.md +311 -0
- data/.claude/skills/rails-architecture/SKILL.md +259 -0
- data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
- data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
- data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
- data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
- data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
- data/.claude/skills/rails-concern/SKILL.md +399 -0
- data/.claude/skills/rails-controller/SKILL.md +336 -0
- data/.claude/skills/rails-model-generator/SKILL.md +321 -0
- data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
- data/.claude/skills/rails-presenter/SKILL.md +274 -0
- data/.claude/skills/rails-query-object/SKILL.md +289 -0
- data/.claude/skills/rails-service-object/SKILL.md +349 -0
- data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
- data/.claude/skills/tdd-cycle/SKILL.md +359 -0
- data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +2 -0
- data/.ruby-version +1 -1
- data/.vbw-planning/.notification-log.jsonl +192 -0
- data/.vbw-planning/.session-log.jsonl +871 -0
- data/.vbw-planning/PROJECT.md +51 -0
- data/.vbw-planning/REQUIREMENTS.md +50 -0
- data/.vbw-planning/SHIPPED.md +28 -0
- data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
- data/.vbw-planning/codebase/CONCERNS.md +99 -0
- data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
- data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
- data/.vbw-planning/codebase/INDEX.md +86 -0
- data/.vbw-planning/codebase/META.md +42 -0
- data/.vbw-planning/codebase/PATTERNS.md +262 -0
- data/.vbw-planning/codebase/STACK.md +101 -0
- data/.vbw-planning/codebase/STRUCTURE.md +324 -0
- data/.vbw-planning/codebase/TESTING.md +154 -0
- data/.vbw-planning/config.json +12 -0
- data/.vbw-planning/discovery.json +24 -0
- data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
- data/.vbw-planning/milestones/default/STATE.md +83 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +179 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +114 -101
- data/Rakefile +2 -0
- data/app/assets/builds/source_monitor/application.css +2076 -0
- data/app/assets/builds/source_monitor/application.js +2758 -0
- data/app/assets/builds/source_monitor/application.js.map +7 -0
- data/app/controllers/source_monitor/application_controller.rb +2 -0
- data/app/controllers/source_monitor/health_controller.rb +2 -0
- data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
- data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
- data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
- data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
- data/app/controllers/source_monitor/items_controller.rb +2 -0
- data/app/controllers/source_monitor/sources_controller.rb +0 -14
- data/app/helpers/source_monitor/application_helper.rb +4 -112
- data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
- data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
- data/app/jobs/source_monitor/application_job.rb +2 -0
- data/app/models/source_monitor/application_record.rb +2 -0
- data/app/models/source_monitor/log_entry.rb +0 -2
- data/config/coverage_baseline.json +217 -1862
- data/config/routes.rb +2 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
- data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
- data/lib/source_monitor/assets/bundler.rb +2 -0
- data/lib/source_monitor/assets.rb +2 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
- data/lib/source_monitor/configuration/events.rb +60 -0
- data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
- data/lib/source_monitor/configuration/health_settings.rb +27 -0
- data/lib/source_monitor/configuration/http_settings.rb +43 -0
- data/lib/source_monitor/configuration/model_definition.rb +108 -0
- data/lib/source_monitor/configuration/models.rb +36 -0
- data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
- data/lib/source_monitor/configuration/retention_settings.rb +45 -0
- data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
- data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
- data/lib/source_monitor/configuration/validation_definition.rb +32 -0
- data/lib/source_monitor/configuration.rb +12 -579
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
- data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
- data/lib/source_monitor/dashboard/queries.rb +2 -195
- data/lib/source_monitor/engine.rb +2 -0
- data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
- data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
- data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
- data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
- data/lib/source_monitor/items/item_creator.rb +28 -455
- data/lib/source_monitor/setup/bundle_installer.rb +2 -0
- data/lib/source_monitor/setup/cli.rb +2 -0
- data/lib/source_monitor/setup/dependency_checker.rb +2 -0
- data/lib/source_monitor/setup/detectors.rb +2 -0
- data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
- data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
- data/lib/source_monitor/setup/install_generator.rb +2 -0
- data/lib/source_monitor/setup/migration_installer.rb +2 -0
- data/lib/source_monitor/setup/node_installer.rb +2 -0
- data/lib/source_monitor/setup/prompter.rb +2 -0
- data/lib/source_monitor/setup/requirements.rb +2 -0
- data/lib/source_monitor/setup/shell_runner.rb +2 -0
- data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/printer.rb +2 -0
- data/lib/source_monitor/setup/verification/result.rb +2 -0
- data/lib/source_monitor/setup/verification/runner.rb +2 -0
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
- data/lib/source_monitor/setup/workflow.rb +2 -0
- data/lib/source_monitor/version.rb +3 -1
- data/lib/source_monitor.rb +140 -58
- data/lib/tasks/source_monitor_assets.rake +2 -0
- data/lib/tasks/source_monitor_setup.rake +2 -0
- data/lib/tasks/source_monitor_tasks.rake +2 -0
- data/source_monitor.gemspec +3 -1
- metadata +144 -4
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-concern
|
|
3
|
+
description: Creates Rails concerns for shared behavior across models or controllers with TDD. Use when extracting shared code, creating reusable modules, DRYing up models/controllers, or when user mentions concerns, modules, mixins, or shared behavior.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Rails Concern Generator (TDD)
|
|
8
|
+
|
|
9
|
+
Creates concerns (ActiveSupport::Concern modules) for shared behavior with tests first.
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
1. Write failing test for the concern behavior
|
|
14
|
+
2. Run test to confirm RED
|
|
15
|
+
3. Implement concern in `app/models/concerns/` or `app/controllers/concerns/`
|
|
16
|
+
4. Run test to confirm GREEN
|
|
17
|
+
|
|
18
|
+
## When to Use Concerns
|
|
19
|
+
|
|
20
|
+
**Good use cases:**
|
|
21
|
+
- Shared validations across multiple models
|
|
22
|
+
- Common scopes used by several models
|
|
23
|
+
- Shared callbacks (e.g., UUID generation, slug creation)
|
|
24
|
+
- Controller authentication/authorization helpers
|
|
25
|
+
- Pagination or filtering logic
|
|
26
|
+
- Auditing and tracking behavior
|
|
27
|
+
|
|
28
|
+
**Avoid concerns when:**
|
|
29
|
+
- Logic is only used in one place (YAGNI)
|
|
30
|
+
- Creating "god" concerns with unrelated methods
|
|
31
|
+
- Logic should be a service object instead
|
|
32
|
+
- Concern would need its own state/config (use a class)
|
|
33
|
+
|
|
34
|
+
## TDD Workflow
|
|
35
|
+
|
|
36
|
+
### Step 1: Create Concern Test (RED)
|
|
37
|
+
|
|
38
|
+
For **Model Concerns**, test via a model that includes it:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# test/models/concerns/has_uuid_test.rb
|
|
42
|
+
require "test_helper"
|
|
43
|
+
|
|
44
|
+
class HasUuidTest < ActiveSupport::TestCase
|
|
45
|
+
test "generates uuid before validation on create" do
|
|
46
|
+
event = Event.new(name: "Test", account: accounts(:one))
|
|
47
|
+
event.valid?
|
|
48
|
+
assert_present event.uuid
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
test "does not overwrite existing uuid" do
|
|
52
|
+
event = Event.new(name: "Test", uuid: "custom-uuid", account: accounts(:one))
|
|
53
|
+
event.valid?
|
|
54
|
+
assert_equal "custom-uuid", event.uuid
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
test "validates uuid uniqueness" do
|
|
58
|
+
existing = events(:one)
|
|
59
|
+
event = Event.new(uuid: existing.uuid)
|
|
60
|
+
assert_not event.valid?
|
|
61
|
+
assert_includes event.errors[:uuid], "has already been taken"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
test "find_by_uuid! finds record" do
|
|
65
|
+
event = events(:one)
|
|
66
|
+
assert_equal event, Event.find_by_uuid!(event.uuid)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
test "find_by_uuid! raises for missing uuid" do
|
|
70
|
+
assert_raises(ActiveRecord::RecordNotFound) do
|
|
71
|
+
Event.find_by_uuid!("nonexistent")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Alternative: Use a shared test module for concerns used by many models:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# test/support/shared_tests/has_uuid_tests.rb
|
|
81
|
+
module HasUuidTests
|
|
82
|
+
extend ActiveSupport::Concern
|
|
83
|
+
|
|
84
|
+
included do
|
|
85
|
+
test "generates uuid on create" do
|
|
86
|
+
record = build_record_for_concern
|
|
87
|
+
record.valid?
|
|
88
|
+
assert_present record.uuid
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# test/models/event_test.rb
|
|
94
|
+
class EventTest < ActiveSupport::TestCase
|
|
95
|
+
include HasUuidTests
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def build_record_for_concern
|
|
100
|
+
Event.new(name: "Test", account: accounts(:one))
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
For **Controller Concerns**, test via integration tests:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# test/controllers/concerns/filterable_test.rb
|
|
109
|
+
require "test_helper"
|
|
110
|
+
|
|
111
|
+
class FilterableTest < ActionDispatch::IntegrationTest
|
|
112
|
+
setup do
|
|
113
|
+
sign_in users(:one)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
test "filters resources by status" do
|
|
117
|
+
get resources_path(status: "active")
|
|
118
|
+
assert_response :success
|
|
119
|
+
assert_includes response.body, resources(:active).name
|
|
120
|
+
assert_not_includes response.body, resources(:inactive).name
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Step 2: Run Test (Confirm RED)
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
bin/rails test test/models/concerns/has_uuid_test.rb
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Step 3: Implement Concern (GREEN)
|
|
132
|
+
|
|
133
|
+
**Model Concern:**
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# app/models/concerns/has_uuid.rb
|
|
137
|
+
module HasUuid
|
|
138
|
+
extend ActiveSupport::Concern
|
|
139
|
+
|
|
140
|
+
included do
|
|
141
|
+
before_validation :generate_uuid, on: :create
|
|
142
|
+
validates :uuid, presence: true, uniqueness: true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
class_methods do
|
|
146
|
+
def find_by_uuid!(uuid)
|
|
147
|
+
find_by!(uuid: uuid)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def generate_uuid
|
|
154
|
+
self.uuid ||= SecureRandom.uuid
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Controller Concern:**
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
# app/controllers/concerns/filterable.rb
|
|
163
|
+
module Filterable
|
|
164
|
+
extend ActiveSupport::Concern
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def apply_filters(scope, allowed_filters)
|
|
169
|
+
allowed_filters.each do |filter|
|
|
170
|
+
if params[filter].present?
|
|
171
|
+
scope = scope.where(filter => params[filter])
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
scope
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Step 4: Run Test (Confirm GREEN)
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
bin/rails test test/models/concerns/has_uuid_test.rb
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Common Concern Patterns
|
|
186
|
+
|
|
187
|
+
### Pattern 1: UUID Generation
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
# app/models/concerns/has_uuid.rb
|
|
191
|
+
module HasUuid
|
|
192
|
+
extend ActiveSupport::Concern
|
|
193
|
+
|
|
194
|
+
included do
|
|
195
|
+
before_validation :generate_uuid, on: :create
|
|
196
|
+
validates :uuid, presence: true, uniqueness: true
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def generate_uuid
|
|
202
|
+
self.uuid ||= SecureRandom.uuid
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Pattern 2: Soft Delete
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# app/models/concerns/soft_deletable.rb
|
|
211
|
+
module SoftDeletable
|
|
212
|
+
extend ActiveSupport::Concern
|
|
213
|
+
|
|
214
|
+
included do
|
|
215
|
+
scope :kept, -> { where(deleted_at: nil) }
|
|
216
|
+
scope :discarded, -> { where.not(deleted_at: nil) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def discard
|
|
220
|
+
update(deleted_at: Time.current)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def undiscard
|
|
224
|
+
update(deleted_at: nil)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def discarded?
|
|
228
|
+
deleted_at.present?
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Pattern 3: Searchable
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# app/models/concerns/searchable.rb
|
|
237
|
+
module Searchable
|
|
238
|
+
extend ActiveSupport::Concern
|
|
239
|
+
|
|
240
|
+
class_methods do
|
|
241
|
+
def search(query)
|
|
242
|
+
return all if query.blank?
|
|
243
|
+
|
|
244
|
+
columns = searchable_columns.map { |c| "#{table_name}.#{c}" }
|
|
245
|
+
conditions = columns.map { |c| "#{c} LIKE :q" }.join(" OR ")
|
|
246
|
+
where(conditions, q: "%#{sanitize_sql_like(query)}%")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def searchable_columns
|
|
250
|
+
%w[name]
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Pattern 4: Auditable
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
# app/models/concerns/auditable.rb
|
|
260
|
+
module Auditable
|
|
261
|
+
extend ActiveSupport::Concern
|
|
262
|
+
|
|
263
|
+
included do
|
|
264
|
+
has_many :audit_logs, as: :auditable, dependent: :destroy
|
|
265
|
+
after_create :log_creation
|
|
266
|
+
after_update :log_update
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
def log_creation
|
|
272
|
+
audit_logs.create(action: "created", changes_data: attributes)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def log_update
|
|
276
|
+
return unless saved_changes.any?
|
|
277
|
+
audit_logs.create(action: "updated", changes_data: saved_changes)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Pattern 5: Sluggable
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
# app/models/concerns/sluggable.rb
|
|
286
|
+
module Sluggable
|
|
287
|
+
extend ActiveSupport::Concern
|
|
288
|
+
|
|
289
|
+
included do
|
|
290
|
+
before_validation :generate_slug, on: :create
|
|
291
|
+
validates :slug, presence: true, uniqueness: { scope: slug_scope }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
class_methods do
|
|
295
|
+
def slug_scope
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def find_by_slug!(slug)
|
|
300
|
+
find_by!(slug: slug)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def to_param
|
|
305
|
+
slug
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
private
|
|
309
|
+
|
|
310
|
+
def generate_slug
|
|
311
|
+
return if slug.present?
|
|
312
|
+
base_slug = slug_source.parameterize
|
|
313
|
+
self.slug = base_slug
|
|
314
|
+
counter = 1
|
|
315
|
+
while self.class.exists?(slug: self.slug)
|
|
316
|
+
self.slug = "#{base_slug}-#{counter}"
|
|
317
|
+
counter += 1
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def slug_source
|
|
322
|
+
respond_to?(:name) ? name : to_s
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Pattern 6: Accountable (Multi-Tenancy)
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
# app/models/concerns/accountable.rb
|
|
331
|
+
module Accountable
|
|
332
|
+
extend ActiveSupport::Concern
|
|
333
|
+
|
|
334
|
+
included do
|
|
335
|
+
belongs_to :account
|
|
336
|
+
validates :account, presence: true
|
|
337
|
+
|
|
338
|
+
scope :for_account, ->(account) { where(account: account) }
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Pattern 7: Tokenizable
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
# app/models/concerns/tokenizable.rb
|
|
347
|
+
module Tokenizable
|
|
348
|
+
extend ActiveSupport::Concern
|
|
349
|
+
|
|
350
|
+
included do
|
|
351
|
+
has_secure_token :api_token
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
class_methods do
|
|
355
|
+
def find_by_api_token!(token)
|
|
356
|
+
find_by!(api_token: token)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def regenerate_api_token!
|
|
361
|
+
regenerate_api_token
|
|
362
|
+
save!
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Usage
|
|
368
|
+
|
|
369
|
+
**In Models:**
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
class Event < ApplicationRecord
|
|
373
|
+
include HasUuid
|
|
374
|
+
include SoftDeletable
|
|
375
|
+
include Searchable
|
|
376
|
+
include Accountable
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**In Controllers:**
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
class ApplicationController < ActionController::Base
|
|
384
|
+
include Authentication
|
|
385
|
+
include Filterable
|
|
386
|
+
end
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Checklist
|
|
390
|
+
|
|
391
|
+
- [ ] Test written first (RED)
|
|
392
|
+
- [ ] Uses `extend ActiveSupport::Concern`
|
|
393
|
+
- [ ] `included` block for callbacks/validations/scopes
|
|
394
|
+
- [ ] `class_methods` block for class-level methods
|
|
395
|
+
- [ ] Instance methods outside blocks
|
|
396
|
+
- [ ] Single responsibility (one purpose per concern)
|
|
397
|
+
- [ ] Well-named (describes what it adds: `HasUuid`, `SoftDeletable`, `Searchable`)
|
|
398
|
+
- [ ] Database-agnostic (no PostgreSQL-specific SQL like ILIKE)
|
|
399
|
+
- [ ] All tests GREEN
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-controller
|
|
3
|
+
description: Creates Rails controllers with TDD approach - integration test first, then implementation. Use when creating new controllers, adding controller actions, implementing CRUD operations, or when user mentions controllers, routes, or API endpoints.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Rails Controller Generator (TDD)
|
|
8
|
+
|
|
9
|
+
Creates RESTful controllers following project conventions with integration tests first.
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
1. Write failing integration test in `test/controllers/` or `test/integration/`
|
|
14
|
+
2. Run test to confirm RED
|
|
15
|
+
3. Implement controller action
|
|
16
|
+
4. Run test to confirm GREEN
|
|
17
|
+
5. Refactor if needed
|
|
18
|
+
|
|
19
|
+
## Project Conventions
|
|
20
|
+
|
|
21
|
+
This project uses:
|
|
22
|
+
- **Pundit** for authorization (`authorize @resource`, `policy_scope(Model)`)
|
|
23
|
+
- **Pagy** for pagination
|
|
24
|
+
- **Presenters** for view formatting
|
|
25
|
+
- **Multi-tenancy** via `current_account`
|
|
26
|
+
- **Turbo Stream** responses for dynamic updates
|
|
27
|
+
|
|
28
|
+
## TDD Workflow
|
|
29
|
+
|
|
30
|
+
### Step 1: Create Integration Test (RED)
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# test/controllers/[resources]_controller_test.rb
|
|
34
|
+
require "test_helper"
|
|
35
|
+
|
|
36
|
+
class ResourcesControllerTest < ActionDispatch::IntegrationTest
|
|
37
|
+
setup do
|
|
38
|
+
@user = users(:one)
|
|
39
|
+
@resource = resources(:one)
|
|
40
|
+
sign_in @user
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# === INDEX ===
|
|
44
|
+
test "GET /resources returns success" do
|
|
45
|
+
get resources_path
|
|
46
|
+
assert_response :success
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
test "GET /resources shows only current_account resources (multi-tenant)" do
|
|
50
|
+
other_resource = resources(:other_account)
|
|
51
|
+
|
|
52
|
+
get resources_path
|
|
53
|
+
|
|
54
|
+
assert_includes response.body, @resource.name
|
|
55
|
+
assert_not_includes response.body, other_resource.name
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
test "GET /resources paginates results" do
|
|
59
|
+
get resources_path
|
|
60
|
+
assert_response :success
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# === SHOW ===
|
|
64
|
+
test "GET /resources/:id returns success" do
|
|
65
|
+
get resource_path(@resource)
|
|
66
|
+
assert_response :success
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
test "GET /resources/:id returns 404 for other account" do
|
|
70
|
+
other_resource = resources(:other_account)
|
|
71
|
+
|
|
72
|
+
assert_raises(ActiveRecord::RecordNotFound) do
|
|
73
|
+
get resource_path(other_resource)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# === NEW ===
|
|
78
|
+
test "GET /resources/new returns success" do
|
|
79
|
+
get new_resource_path
|
|
80
|
+
assert_response :success
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# === CREATE ===
|
|
84
|
+
test "POST /resources creates with valid params" do
|
|
85
|
+
assert_difference("Resource.count", 1) do
|
|
86
|
+
post resources_path, params: {
|
|
87
|
+
resource: { name: "New Resource", field1: "value" }
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
assert_redirected_to resources_path
|
|
92
|
+
assert_equal @user.account, Resource.last.account
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
test "POST /resources rejects invalid params" do
|
|
96
|
+
assert_no_difference("Resource.count") do
|
|
97
|
+
post resources_path, params: {
|
|
98
|
+
resource: { name: "" }
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
assert_response :unprocessable_entity
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# === EDIT ===
|
|
106
|
+
test "GET /resources/:id/edit returns success" do
|
|
107
|
+
get edit_resource_path(@resource)
|
|
108
|
+
assert_response :success
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# === UPDATE ===
|
|
112
|
+
test "PATCH /resources/:id updates with valid params" do
|
|
113
|
+
patch resource_path(@resource), params: {
|
|
114
|
+
resource: { name: "Updated Name" }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
assert_redirected_to resource_path(@resource)
|
|
118
|
+
assert_equal "Updated Name", @resource.reload.name
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
test "PATCH /resources/:id rejects invalid params" do
|
|
122
|
+
patch resource_path(@resource), params: {
|
|
123
|
+
resource: { name: "" }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
assert_response :unprocessable_entity
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# === DESTROY ===
|
|
130
|
+
test "DELETE /resources/:id destroys resource" do
|
|
131
|
+
assert_difference("Resource.count", -1) do
|
|
132
|
+
delete resource_path(@resource)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
assert_redirected_to resources_path
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# === AUTHORIZATION ===
|
|
139
|
+
test "unauthenticated user is redirected" do
|
|
140
|
+
sign_out
|
|
141
|
+
|
|
142
|
+
get resources_path
|
|
143
|
+
assert_redirected_to new_session_path
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Step 2: Run Test (Confirm RED)
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
bin/rails test test/controllers/resources_controller_test.rb
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Step 3: Implement Controller (GREEN)
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# app/controllers/[resources]_controller.rb
|
|
158
|
+
class ResourcesController < ApplicationController
|
|
159
|
+
before_action :set_resource, only: [:show, :edit, :update, :destroy]
|
|
160
|
+
|
|
161
|
+
def index
|
|
162
|
+
authorize Resource, :index?
|
|
163
|
+
@pagy, resources = pagy(policy_scope(Resource).order(created_at: :desc))
|
|
164
|
+
@resources = resources.map { |r| ResourcePresenter.new(r) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def show
|
|
168
|
+
authorize @resource
|
|
169
|
+
@resource = ResourcePresenter.new(@resource)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def new
|
|
173
|
+
@resource = current_account.resources.build
|
|
174
|
+
authorize @resource
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def create
|
|
178
|
+
@resource = current_account.resources.build(resource_params)
|
|
179
|
+
authorize @resource
|
|
180
|
+
|
|
181
|
+
if @resource.save
|
|
182
|
+
redirect_to resources_path, notice: "Resource created successfully"
|
|
183
|
+
else
|
|
184
|
+
render :new, status: :unprocessable_entity
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def edit
|
|
189
|
+
authorize @resource
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def update
|
|
193
|
+
authorize @resource
|
|
194
|
+
|
|
195
|
+
if @resource.update(resource_params)
|
|
196
|
+
redirect_to @resource, notice: "Resource updated successfully"
|
|
197
|
+
else
|
|
198
|
+
render :edit, status: :unprocessable_entity
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def destroy
|
|
203
|
+
authorize @resource
|
|
204
|
+
@resource.destroy
|
|
205
|
+
redirect_to resources_path, notice: "Resource deleted successfully"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def set_resource
|
|
211
|
+
@resource = policy_scope(Resource).find(params[:id])
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def resource_params
|
|
215
|
+
params.require(:resource).permit(:name, :field1, :field2)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Step 4: Run Test (Confirm GREEN)
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
bin/rails test test/controllers/resources_controller_test.rb
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Test Helpers
|
|
227
|
+
|
|
228
|
+
Add to `test/test_helper.rb`:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class ActionDispatch::IntegrationTest
|
|
232
|
+
def sign_in(user)
|
|
233
|
+
session = user.identity.sessions.create!
|
|
234
|
+
cookies.signed[:session_token] = session.token
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def sign_out
|
|
238
|
+
cookies.delete(:session_token)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Namespaced Controllers
|
|
244
|
+
|
|
245
|
+
For nested routes like `settings/accounts`:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
# app/controllers/settings/accounts_controller.rb
|
|
249
|
+
module Settings
|
|
250
|
+
class AccountsController < ApplicationController
|
|
251
|
+
before_action :set_account
|
|
252
|
+
|
|
253
|
+
def show
|
|
254
|
+
authorize @account
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def set_account
|
|
260
|
+
@account = current_account
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Turbo Stream Response Pattern
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
def create
|
|
270
|
+
@resource = current_account.resources.build(resource_params)
|
|
271
|
+
authorize @resource
|
|
272
|
+
|
|
273
|
+
if @resource.save
|
|
274
|
+
respond_to do |format|
|
|
275
|
+
format.html { redirect_to resources_path, notice: "Created" }
|
|
276
|
+
format.turbo_stream do
|
|
277
|
+
flash.now[:notice] = "Created"
|
|
278
|
+
@pagy, @resources = pagy(policy_scope(Resource).order(created_at: :desc))
|
|
279
|
+
render turbo_stream: [
|
|
280
|
+
turbo_stream.replace("resources-list", partial: "resources/list"),
|
|
281
|
+
turbo_stream.update("modal", "")
|
|
282
|
+
]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
else
|
|
286
|
+
render :new, status: :unprocessable_entity
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Testing Turbo Streams
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
test "POST /resources with turbo_stream format" do
|
|
295
|
+
post resources_path, params: {
|
|
296
|
+
resource: { name: "Turbo Resource" }
|
|
297
|
+
}, as: :turbo_stream
|
|
298
|
+
|
|
299
|
+
assert_response :success
|
|
300
|
+
assert_includes response.body, "turbo-stream"
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Full CRUD Fixture Setup
|
|
305
|
+
|
|
306
|
+
```yaml
|
|
307
|
+
# test/fixtures/resources.yml
|
|
308
|
+
one:
|
|
309
|
+
name: "My Resource"
|
|
310
|
+
field1: "Value 1"
|
|
311
|
+
account: one
|
|
312
|
+
|
|
313
|
+
two:
|
|
314
|
+
name: "Another Resource"
|
|
315
|
+
field1: "Value 2"
|
|
316
|
+
account: one
|
|
317
|
+
|
|
318
|
+
other_account:
|
|
319
|
+
name: "Other Account Resource"
|
|
320
|
+
field1: "Value 3"
|
|
321
|
+
account: two
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Checklist
|
|
325
|
+
|
|
326
|
+
- [ ] Integration test written first (RED)
|
|
327
|
+
- [ ] Multi-tenant isolation tested
|
|
328
|
+
- [ ] Authorization tested (redirect/404 for unauthorized)
|
|
329
|
+
- [ ] Controller uses `authorize` on every action
|
|
330
|
+
- [ ] Controller uses `policy_scope` for queries
|
|
331
|
+
- [ ] Presenter wraps models for views
|
|
332
|
+
- [ ] Strong parameters defined
|
|
333
|
+
- [ ] All 7 CRUD actions tested (index, show, new, create, edit, update, destroy)
|
|
334
|
+
- [ ] Invalid params tested (422 response)
|
|
335
|
+
- [ ] Turbo Stream responses tested (if applicable)
|
|
336
|
+
- [ ] All tests GREEN
|