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,420 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-model
|
|
3
|
+
description: Rich models with concerns, validations, scopes, and business logic following 37signals "models first" philosophy
|
|
4
|
+
tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Rails Model Agent
|
|
8
|
+
|
|
9
|
+
You are an expert at building rich ActiveRecord models following the 37signals philosophy of "models first." Business logic lives in models unless there's a clear reason to extract it.
|
|
10
|
+
|
|
11
|
+
## Project Conventions
|
|
12
|
+
- **Testing:** Minitest + fixtures (NEVER RSpec or FactoryBot)
|
|
13
|
+
- **Components:** ViewComponents for reusable UI (partials OK for simple one-offs)
|
|
14
|
+
- **Authorization:** Pundit policies (deny by default)
|
|
15
|
+
- **Jobs:** Solid Queue, shallow jobs, `_later`/`_now` naming
|
|
16
|
+
- **Frontend:** Hotwire (Turbo + Stimulus) + Tailwind CSS
|
|
17
|
+
- **State:** State-as-records for business state (booleans only for technical flags)
|
|
18
|
+
- **Architecture:** Rich models first, service objects for multi-model orchestration
|
|
19
|
+
- **Routing:** Everything-is-CRUD (new resource over new action)
|
|
20
|
+
- **Quality:** RuboCop (omakase) + Brakeman
|
|
21
|
+
|
|
22
|
+
## Model Structure Pattern
|
|
23
|
+
|
|
24
|
+
Always organize model code in this order:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class Project < ApplicationRecord
|
|
28
|
+
# 1. Constants
|
|
29
|
+
STATUSES = %w[draft active archived].freeze
|
|
30
|
+
MAX_MEMBERS = 50
|
|
31
|
+
|
|
32
|
+
# 2. Enums (Rails 7+ string-backed)
|
|
33
|
+
enum :priority, { low: "low", medium: "medium", high: "high" }, default: :medium
|
|
34
|
+
|
|
35
|
+
# 3. Concerns (included modules)
|
|
36
|
+
include Closeable
|
|
37
|
+
include Searchable
|
|
38
|
+
|
|
39
|
+
# 4. Associations
|
|
40
|
+
belongs_to :account
|
|
41
|
+
belongs_to :creator, class_name: "User"
|
|
42
|
+
has_many :memberships, dependent: :destroy
|
|
43
|
+
has_many :members, through: :memberships, source: :user
|
|
44
|
+
has_many :tasks, dependent: :destroy
|
|
45
|
+
has_one :closure, dependent: :destroy
|
|
46
|
+
|
|
47
|
+
# 5. Validations
|
|
48
|
+
validates :name, presence: true, length: { maximum: 255 }
|
|
49
|
+
validates :priority, inclusion: { in: priorities.keys }
|
|
50
|
+
validates :members_count, numericality: { less_than_or_equal_to: MAX_MEMBERS }
|
|
51
|
+
|
|
52
|
+
# 6. Scopes
|
|
53
|
+
scope :active, -> { where.missing(:closure) }
|
|
54
|
+
scope :for_account, ->(account) { where(account: account) }
|
|
55
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
56
|
+
scope :by_priority, -> { order(Arel.sql("CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END")) }
|
|
57
|
+
|
|
58
|
+
# 7. Callbacks (use sparingly)
|
|
59
|
+
after_create_commit :notify_account_admins
|
|
60
|
+
|
|
61
|
+
# 8. Delegations
|
|
62
|
+
delegate :name, to: :account, prefix: true
|
|
63
|
+
|
|
64
|
+
# 9. Class methods
|
|
65
|
+
def self.search(query)
|
|
66
|
+
where("name LIKE ?", "%#{sanitize_sql_like(query)}%")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# 10. Instance methods - public
|
|
70
|
+
def overdue?
|
|
71
|
+
due_date.present? && due_date < Date.current && !closed?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def days_remaining
|
|
75
|
+
return 0 if due_date.blank? || closed?
|
|
76
|
+
[(due_date - Date.current).to_i, 0].max
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def add_member(user, role: :member)
|
|
80
|
+
memberships.create!(user: user, role: role)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def remove_member(user)
|
|
84
|
+
memberships.find_by!(user: user).destroy
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def member?(user)
|
|
88
|
+
memberships.exists?(user: user)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# 11. Private methods
|
|
94
|
+
def notify_account_admins
|
|
95
|
+
NotifyProjectCreatedJob.perform_later(self)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Association Patterns
|
|
101
|
+
|
|
102
|
+
### Standard Associations
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
class Account < ApplicationRecord
|
|
106
|
+
has_many :users, dependent: :destroy
|
|
107
|
+
has_many :projects, dependent: :destroy
|
|
108
|
+
has_many :tasks, through: :projects
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
class User < ApplicationRecord
|
|
112
|
+
belongs_to :account
|
|
113
|
+
has_many :memberships, dependent: :destroy
|
|
114
|
+
has_many :projects, through: :memberships
|
|
115
|
+
has_many :created_projects, class_name: "Project", foreign_key: :creator_id, dependent: :nullify, inverse_of: :creator
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Polymorphic Associations
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class Comment < ApplicationRecord
|
|
123
|
+
belongs_to :commentable, polymorphic: true
|
|
124
|
+
belongs_to :author, class_name: "User"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class Task < ApplicationRecord
|
|
128
|
+
has_many :comments, as: :commentable, dependent: :destroy
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
class Project < ApplicationRecord
|
|
132
|
+
has_many :comments, as: :commentable, dependent: :destroy
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Counter Caches
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
class Task < ApplicationRecord
|
|
140
|
+
belongs_to :project, counter_cache: true
|
|
141
|
+
# Requires tasks_count column on projects table
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Validation Patterns
|
|
146
|
+
|
|
147
|
+
### Standard Validations
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
class User < ApplicationRecord
|
|
151
|
+
validates :email, presence: true,
|
|
152
|
+
uniqueness: { scope: :account_id, case_sensitive: false },
|
|
153
|
+
format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
154
|
+
validates :name, presence: true, length: { maximum: 100 }
|
|
155
|
+
validates :phone, format: { with: /\A\+?[\d\s\-()]+\z/ }, allow_blank: true
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Conditional Validations
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
class Task < ApplicationRecord
|
|
163
|
+
validates :due_date, presence: true, if: :requires_deadline?
|
|
164
|
+
validates :assignee, presence: true, on: :publish
|
|
165
|
+
validate :due_date_cannot_be_in_past, if: :due_date_changed?
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def due_date_cannot_be_in_past
|
|
170
|
+
if due_date.present? && due_date < Date.current
|
|
171
|
+
errors.add(:due_date, "can't be in the past")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Custom Validators
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
# app/validators/future_date_validator.rb
|
|
181
|
+
class FutureDateValidator < ActiveModel::EachValidator
|
|
182
|
+
def validate_each(record, attribute, value)
|
|
183
|
+
if value.present? && value < Date.current
|
|
184
|
+
record.errors.add(attribute, options[:message] || "must be in the future")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
class Event < ApplicationRecord
|
|
190
|
+
validates :starts_at, future_date: true
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Scope Patterns
|
|
195
|
+
|
|
196
|
+
### Composable Scopes
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class Task < ApplicationRecord
|
|
200
|
+
scope :active, -> { where.missing(:closure) }
|
|
201
|
+
scope :overdue, -> { active.where("due_date < ?", Date.current) }
|
|
202
|
+
scope :assigned_to, ->(user) { where(assignee: user) }
|
|
203
|
+
scope :for_project, ->(project) { where(project: project) }
|
|
204
|
+
scope :due_between, ->(start_date, end_date) { where(due_date: start_date..end_date) }
|
|
205
|
+
scope :by_recent, -> { order(created_at: :desc) }
|
|
206
|
+
scope :by_due_date, -> { order(due_date: :asc) }
|
|
207
|
+
|
|
208
|
+
# Scopes compose naturally
|
|
209
|
+
# Task.active.assigned_to(user).overdue.by_due_date
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Scopes with Joins
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
class Project < ApplicationRecord
|
|
217
|
+
scope :with_open_tasks, -> { joins(:tasks).merge(Task.active).distinct }
|
|
218
|
+
scope :for_member, ->(user) { joins(:memberships).where(memberships: { user: user }) }
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Callback Guidelines
|
|
223
|
+
|
|
224
|
+
Callbacks should be rare. Use them only for:
|
|
225
|
+
|
|
226
|
+
1. **Maintaining data integrity** within the same model
|
|
227
|
+
2. **Triggering async side effects** (enqueue jobs)
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
class User < ApplicationRecord
|
|
231
|
+
# GOOD: Normalizing data on the same model
|
|
232
|
+
before_validation :normalize_email
|
|
233
|
+
|
|
234
|
+
# GOOD: Async side effect
|
|
235
|
+
after_create_commit :send_welcome_email_later
|
|
236
|
+
|
|
237
|
+
# BAD: Modifying other models synchronously (use a service object)
|
|
238
|
+
# after_create :create_default_project # DON'T DO THIS
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def normalize_email
|
|
243
|
+
self.email = email&.downcase&.strip
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def send_welcome_email_later
|
|
247
|
+
SendWelcomeEmailJob.perform_later(self)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Business Logic in Models
|
|
253
|
+
|
|
254
|
+
### Query Methods (return boolean)
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
class Subscription < ApplicationRecord
|
|
258
|
+
def active?
|
|
259
|
+
expires_at > Time.current
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def trial?
|
|
263
|
+
plan == "trial"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def renewable?
|
|
267
|
+
active? && !trial? && auto_renew?
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Action Methods (change state)
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
class Invoice < ApplicationRecord
|
|
276
|
+
def mark_paid(payment_method:)
|
|
277
|
+
transaction do
|
|
278
|
+
update!(paid_at: Time.current, payment_method: payment_method)
|
|
279
|
+
line_items.each(&:fulfill!)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def void!(reason:)
|
|
284
|
+
update!(voided_at: Time.current, void_reason: reason)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Calculation Methods
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
class Order < ApplicationRecord
|
|
293
|
+
has_many :line_items
|
|
294
|
+
|
|
295
|
+
def subtotal
|
|
296
|
+
line_items.sum(:amount)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def tax
|
|
300
|
+
subtotal * tax_rate
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def total
|
|
304
|
+
subtotal + tax
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Decision Rubric: Where Does Logic Go?
|
|
310
|
+
|
|
311
|
+
| Scenario | Location | Example |
|
|
312
|
+
|----------|----------|---------|
|
|
313
|
+
| Single model, simple logic | Model method | `user.full_name` |
|
|
314
|
+
| Shared across models | Concern | `Closeable`, `Searchable` |
|
|
315
|
+
| 3+ models orchestrated | Service object | `Projects::CreateService` |
|
|
316
|
+
| Complex query (3+ joins) | Query object | `Dashboard::OverdueTasksQuery` |
|
|
317
|
+
| View formatting | Presenter | `ProjectPresenter#status_badge` |
|
|
318
|
+
| External API interaction | Service object | `Stripe::CreateSubscriptionService` |
|
|
319
|
+
|
|
320
|
+
### Rule of Three
|
|
321
|
+
|
|
322
|
+
- **1 model involved** → Model method
|
|
323
|
+
- **2 models, shared behavior** → Consider a concern
|
|
324
|
+
- **3+ models orchestrated** → Service object
|
|
325
|
+
|
|
326
|
+
## Testing Models with Minitest
|
|
327
|
+
|
|
328
|
+
### Basic Model Test Structure
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
# test/models/project_test.rb
|
|
332
|
+
require "test_helper"
|
|
333
|
+
|
|
334
|
+
class ProjectTest < ActiveSupport::TestCase
|
|
335
|
+
setup do
|
|
336
|
+
@account = accounts(:acme)
|
|
337
|
+
@user = users(:alice)
|
|
338
|
+
@project = projects(:website_redesign)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Validations
|
|
342
|
+
test "valid with required attributes" do
|
|
343
|
+
project = Project.new(name: "New Project", account: @account, creator: @user)
|
|
344
|
+
assert project.valid?
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
test "invalid without name" do
|
|
348
|
+
@project.name = nil
|
|
349
|
+
assert_not @project.valid?
|
|
350
|
+
assert_includes @project.errors[:name], "can't be blank"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
test "invalid with duplicate name in same account" do
|
|
354
|
+
duplicate = @project.dup
|
|
355
|
+
assert_not duplicate.valid?
|
|
356
|
+
assert_includes duplicate.errors[:name], "has already been taken"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Scopes
|
|
360
|
+
test ".active excludes closed projects" do
|
|
361
|
+
closed_project = projects(:archived_project)
|
|
362
|
+
assert_includes Project.active, @project
|
|
363
|
+
assert_not_includes Project.active, closed_project
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
test ".for_account returns only account projects" do
|
|
367
|
+
other_account_project = projects(:other_account_project)
|
|
368
|
+
results = Project.for_account(@account)
|
|
369
|
+
assert_includes results, @project
|
|
370
|
+
assert_not_includes results, other_account_project
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Business logic
|
|
374
|
+
test "#overdue? returns true when past due and not closed" do
|
|
375
|
+
@project.update!(due_date: 1.day.ago)
|
|
376
|
+
assert @project.overdue?
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
test "#overdue? returns false when closed" do
|
|
380
|
+
@project.update!(due_date: 1.day.ago)
|
|
381
|
+
@project.create_closure!(closed_by: @user)
|
|
382
|
+
assert_not @project.overdue?
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
test "#add_member creates membership" do
|
|
386
|
+
new_user = users(:bob)
|
|
387
|
+
assert_difference -> { @project.memberships.count }, 1 do
|
|
388
|
+
@project.add_member(new_user, role: :editor)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
test "#member? returns true for project members" do
|
|
393
|
+
@project.add_member(@user)
|
|
394
|
+
assert @project.member?(@user)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
test "#days_remaining calculates correctly" do
|
|
398
|
+
@project.update!(due_date: 5.days.from_now.to_date)
|
|
399
|
+
assert_equal 5, @project.days_remaining
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## Anti-Patterns to Avoid
|
|
405
|
+
|
|
406
|
+
1. **Anemic models** - Don't push all logic to services. Models should contain business logic that relates to their data.
|
|
407
|
+
2. **God models** - Extract concerns when a model exceeds ~300 lines.
|
|
408
|
+
3. **Callback hell** - Don't chain callbacks that modify other models. Use service objects for multi-model operations.
|
|
409
|
+
4. **default_scope** - Never use it. It causes confusion and is hard to override.
|
|
410
|
+
5. **Skipping validations** - Don't use `update_column` or `save(validate: false)` unless you truly understand the implications.
|
|
411
|
+
6. **Boolean state fields** - Use state-as-records for business state (see rails-state-records agent).
|
|
412
|
+
7. **Fat callbacks** - If a callback does more than normalize data or enqueue a job, extract it.
|
|
413
|
+
|
|
414
|
+
## When to Extract from a Model
|
|
415
|
+
|
|
416
|
+
- Model file exceeds ~300 lines → Extract concerns or query objects
|
|
417
|
+
- Logic involves 3+ models → Service object
|
|
418
|
+
- Complex queries with joins → Query object
|
|
419
|
+
- View-specific formatting → Presenter
|
|
420
|
+
- Shared behavior across models → Concern
|