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,342 @@
|
|
|
1
|
+
# Query Object Patterns
|
|
2
|
+
|
|
3
|
+
## Basic Query Structure
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# app/queries/[name]_query.rb
|
|
7
|
+
class NameQuery
|
|
8
|
+
attr_reader :account
|
|
9
|
+
|
|
10
|
+
def initialize(account:)
|
|
11
|
+
@account = account
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [ActiveRecord::Relation<Model>]
|
|
15
|
+
def call
|
|
16
|
+
account.models
|
|
17
|
+
.where(conditions)
|
|
18
|
+
.order(created_at: :desc)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Query Categories
|
|
24
|
+
|
|
25
|
+
### 1. Filter Queries
|
|
26
|
+
|
|
27
|
+
Return filtered ActiveRecord relations:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# app/queries/active_events_query.rb
|
|
31
|
+
class ActiveEventsQuery
|
|
32
|
+
attr_reader :account
|
|
33
|
+
|
|
34
|
+
def initialize(account:)
|
|
35
|
+
@account = account
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(date_range: nil)
|
|
39
|
+
scope = account.events.where(status: :active)
|
|
40
|
+
scope = scope.where(event_date: date_range) if date_range
|
|
41
|
+
scope.includes(:venue, :vendors).order(event_date: :asc)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 2. Aggregation Queries
|
|
47
|
+
|
|
48
|
+
Return computed statistics:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# app/queries/revenue_stats_query.rb
|
|
52
|
+
class RevenueStatsQuery
|
|
53
|
+
attr_reader :account
|
|
54
|
+
|
|
55
|
+
def initialize(account:)
|
|
56
|
+
@account = account
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def call(period: :month)
|
|
60
|
+
{
|
|
61
|
+
total: total_revenue,
|
|
62
|
+
by_period: revenue_by_period(period),
|
|
63
|
+
by_category: revenue_by_category,
|
|
64
|
+
growth_rate: calculate_growth
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def total_revenue
|
|
71
|
+
account.orders.completed.sum(:total_cents)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def revenue_by_period(period)
|
|
75
|
+
group_clause = case period
|
|
76
|
+
when :day then "DATE(created_at)"
|
|
77
|
+
when :week then "DATE_TRUNC('week', created_at)"
|
|
78
|
+
when :month then "DATE_TRUNC('month', created_at)"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
account.orders.completed
|
|
82
|
+
.group(Arel.sql(group_clause))
|
|
83
|
+
.sum(:total_cents)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def revenue_by_category
|
|
87
|
+
account.orders.completed
|
|
88
|
+
.joins(line_items: :product)
|
|
89
|
+
.group("products.category")
|
|
90
|
+
.sum(:total_cents)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 3. Dashboard Queries
|
|
96
|
+
|
|
97
|
+
Multiple related metrics:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# app/queries/dashboard_stats_query.rb
|
|
101
|
+
class DashboardStatsQuery
|
|
102
|
+
attr_reader :user, :account
|
|
103
|
+
|
|
104
|
+
def initialize(user:)
|
|
105
|
+
@user = user
|
|
106
|
+
@account = user.account
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def upcoming_events(limit: 5)
|
|
110
|
+
account.events
|
|
111
|
+
.where("event_date >= ?", Date.current)
|
|
112
|
+
.order(event_date: :asc)
|
|
113
|
+
.limit(limit)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def pending_tasks_count
|
|
117
|
+
account.tasks.pending.count
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def leads_by_status
|
|
121
|
+
account.leads.group(:status).count
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def recent_activity(limit: 10)
|
|
125
|
+
account.activities
|
|
126
|
+
.includes(:user, :trackable)
|
|
127
|
+
.order(created_at: :desc)
|
|
128
|
+
.limit(limit)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 4. Search Queries
|
|
134
|
+
|
|
135
|
+
Full-text search with filters:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# app/queries/vendor_search_query.rb
|
|
139
|
+
class VendorSearchQuery
|
|
140
|
+
attr_reader :account
|
|
141
|
+
|
|
142
|
+
def initialize(account:)
|
|
143
|
+
@account = account
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def call(term:, filters: {})
|
|
147
|
+
scope = account.vendors
|
|
148
|
+
|
|
149
|
+
scope = apply_search(scope, term) if term.present?
|
|
150
|
+
scope = apply_filters(scope, filters)
|
|
151
|
+
scope = apply_sorting(scope, filters[:sort])
|
|
152
|
+
|
|
153
|
+
scope.includes(:category, :reviews)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def apply_search(scope, term)
|
|
159
|
+
scope.where(
|
|
160
|
+
"name ILIKE :term OR description ILIKE :term",
|
|
161
|
+
term: "%#{sanitize_like(term)}%"
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def apply_filters(scope, filters)
|
|
166
|
+
scope = scope.where(category_id: filters[:category]) if filters[:category]
|
|
167
|
+
scope = scope.where(active: true) if filters[:active_only]
|
|
168
|
+
scope = scope.where("rating >= ?", filters[:min_rating]) if filters[:min_rating]
|
|
169
|
+
scope
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def apply_sorting(scope, sort)
|
|
173
|
+
case sort
|
|
174
|
+
when "name" then scope.order(name: :asc)
|
|
175
|
+
when "rating" then scope.order(rating: :desc)
|
|
176
|
+
when "recent" then scope.order(created_at: :desc)
|
|
177
|
+
else scope.order(name: :asc)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def sanitize_like(term)
|
|
182
|
+
term.gsub(/[%_]/) { |x| "\\#{x}" }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 5. Report Queries
|
|
188
|
+
|
|
189
|
+
Complex data for exports:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# app/queries/event_report_query.rb
|
|
193
|
+
class EventReportQuery
|
|
194
|
+
attr_reader :account
|
|
195
|
+
|
|
196
|
+
def initialize(account:)
|
|
197
|
+
@account = account
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def call(date_range:)
|
|
201
|
+
account.events
|
|
202
|
+
.where(event_date: date_range)
|
|
203
|
+
.includes(:venue, :vendors, :attendees)
|
|
204
|
+
.select(
|
|
205
|
+
"events.*",
|
|
206
|
+
"COUNT(DISTINCT attendees.id) as attendee_count",
|
|
207
|
+
"SUM(event_vendors.amount_cents) as total_vendor_cost"
|
|
208
|
+
)
|
|
209
|
+
.joins(:attendees, :event_vendors)
|
|
210
|
+
.group("events.id")
|
|
211
|
+
.order(event_date: :asc)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Performance Patterns
|
|
217
|
+
|
|
218
|
+
### Eager Loading
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
def call
|
|
222
|
+
account.events
|
|
223
|
+
.includes(:venue) # Belongs-to
|
|
224
|
+
.includes(:vendors) # Has-many through
|
|
225
|
+
.includes(attendees: :user) # Nested
|
|
226
|
+
.preload(:documents) # Separate query
|
|
227
|
+
.eager_load(:primary_contact) # LEFT JOIN
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Batch Processing
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
def process_all
|
|
235
|
+
account.events.find_each(batch_size: 100) do |event|
|
|
236
|
+
yield event
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Subquery Optimization
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
def call
|
|
245
|
+
# Use subquery instead of pluck for large datasets
|
|
246
|
+
active_vendor_ids = account.vendors.active.select(:id)
|
|
247
|
+
|
|
248
|
+
account.events
|
|
249
|
+
.where(vendor_id: active_vendor_ids)
|
|
250
|
+
.order(created_at: :desc)
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Multi-Tenancy Patterns
|
|
255
|
+
|
|
256
|
+
### Always Scope Through Account
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
# GOOD
|
|
260
|
+
def call
|
|
261
|
+
account.events.where(status: :active)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# BAD - Security risk!
|
|
265
|
+
def call
|
|
266
|
+
Event.where(account_id: account.id, status: :active)
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Test Isolation
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# test/queries/active_events_query_test.rb
|
|
274
|
+
require "test_helper"
|
|
275
|
+
|
|
276
|
+
class ActiveEventsQueryTest < ActiveSupport::TestCase
|
|
277
|
+
test "only returns events for the account" do
|
|
278
|
+
account = accounts(:one)
|
|
279
|
+
our_event = events(:one) # belongs to accounts(:one)
|
|
280
|
+
their_event = events(:other_account_event)
|
|
281
|
+
|
|
282
|
+
result = ActiveEventsQuery.new(account: account).call
|
|
283
|
+
assert_includes result, our_event
|
|
284
|
+
assert_not_includes result, their_event
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Composition Patterns
|
|
290
|
+
|
|
291
|
+
### Query Chaining
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
# Queries return relations, enabling chaining
|
|
295
|
+
events = ActiveEventsQuery.new(account: account).call
|
|
296
|
+
upcoming = events.where("event_date > ?", Date.current)
|
|
297
|
+
paginated = upcoming.page(params[:page]).per(20)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Query Composition
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
class ComplexReportQuery
|
|
304
|
+
def initialize(account:)
|
|
305
|
+
@events_query = ActiveEventsQuery.new(account: account)
|
|
306
|
+
@revenue_query = RevenueStatsQuery.new(account: account)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def call(date_range:)
|
|
310
|
+
{
|
|
311
|
+
events: @events_query.call(date_range: date_range),
|
|
312
|
+
revenue: @revenue_query.call
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Usage in Controllers
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
class EventsController < ApplicationController
|
|
322
|
+
def index
|
|
323
|
+
@events = ActiveEventsQuery.new(account: current_account)
|
|
324
|
+
.call
|
|
325
|
+
.page(params[:page])
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def dashboard
|
|
329
|
+
@stats = DashboardStatsQuery.new(user: current_user)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Checklist
|
|
335
|
+
|
|
336
|
+
- [ ] Constructor accepts `account:` or `user:`
|
|
337
|
+
- [ ] Always scoped through account (multi-tenant)
|
|
338
|
+
- [ ] Return type documented (`@return`)
|
|
339
|
+
- [ ] Uses `.includes()` to prevent N+1
|
|
340
|
+
- [ ] Search terms sanitized
|
|
341
|
+
- [ ] Spec tests tenant isolation
|
|
342
|
+
- [ ] Complex queries explain their purpose
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# Service Object Patterns
|
|
2
|
+
|
|
3
|
+
## Basic Service Structure
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# app/services/[namespace]/[verb]_service.rb
|
|
7
|
+
module Namespace
|
|
8
|
+
class VerbService
|
|
9
|
+
def initialize(dependencies = {})
|
|
10
|
+
@dependency = dependencies[:dependency] || DefaultDependency.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(params)
|
|
14
|
+
validate_input(params)
|
|
15
|
+
perform_operation(params)
|
|
16
|
+
success(result)
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
failure(e.message)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :dependency
|
|
24
|
+
|
|
25
|
+
def success(data)
|
|
26
|
+
Result.new(success: true, data: data)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def failure(error, code = :unknown)
|
|
30
|
+
Result.new(success: false, error: error, code: code)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Service Categories
|
|
37
|
+
|
|
38
|
+
### 1. Command Services (Write Operations)
|
|
39
|
+
|
|
40
|
+
Single action that changes state:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# app/services/orders/create_service.rb
|
|
44
|
+
module Orders
|
|
45
|
+
class CreateService
|
|
46
|
+
def call(user:, items:)
|
|
47
|
+
order = nil
|
|
48
|
+
|
|
49
|
+
ActiveRecord::Base.transaction do
|
|
50
|
+
order = user.orders.create!(status: :pending)
|
|
51
|
+
create_line_items(order, items)
|
|
52
|
+
reserve_inventory(items)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
OrderMailer.confirmation(order).deliver_later
|
|
56
|
+
success(order)
|
|
57
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
58
|
+
failure(e.message, :validation_error)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 2. Query Services (Read Operations)
|
|
65
|
+
|
|
66
|
+
Complex reads that don't fit in Query Objects:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# app/services/reports/generate_service.rb
|
|
70
|
+
module Reports
|
|
71
|
+
class GenerateService
|
|
72
|
+
def call(account:, date_range:, format:)
|
|
73
|
+
data = gather_data(account, date_range)
|
|
74
|
+
formatted = format_data(data, format)
|
|
75
|
+
success(formatted)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def gather_data(account, range)
|
|
81
|
+
{
|
|
82
|
+
events: EventStatsQuery.new(account: account).call(range),
|
|
83
|
+
revenue: RevenueQuery.new(account: account).call(range),
|
|
84
|
+
leads: LeadConversionQuery.new(account: account).call(range)
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 3. Integration Services (External APIs)
|
|
92
|
+
|
|
93
|
+
Wrap external service calls:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# app/services/payments/charge_service.rb
|
|
97
|
+
module Payments
|
|
98
|
+
class ChargeService
|
|
99
|
+
def initialize(gateway: StripeGateway.new)
|
|
100
|
+
@gateway = gateway
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def call(order:, payment_method_id:)
|
|
104
|
+
charge = gateway.charge(
|
|
105
|
+
amount: order.total_cents,
|
|
106
|
+
currency: "eur",
|
|
107
|
+
payment_method_id: payment_method_id
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
order.update!(
|
|
111
|
+
payment_status: :paid,
|
|
112
|
+
payment_reference: charge.id
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
success(charge)
|
|
116
|
+
rescue PaymentGateway::CardDeclined => e
|
|
117
|
+
failure(e.message, :card_declined)
|
|
118
|
+
rescue PaymentGateway::Error => e
|
|
119
|
+
failure(e.message, :payment_error)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
attr_reader :gateway
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 4. Orchestrator Services (Complex Workflows)
|
|
130
|
+
|
|
131
|
+
Coordinate multiple services:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# app/services/onboarding/complete_service.rb
|
|
135
|
+
module Onboarding
|
|
136
|
+
class CompleteService
|
|
137
|
+
def call(user:, params:)
|
|
138
|
+
results = []
|
|
139
|
+
|
|
140
|
+
results << Accounts::SetupService.new.call(user: user, params: params[:account])
|
|
141
|
+
return results.last if results.last.failure?
|
|
142
|
+
|
|
143
|
+
results << Preferences::ConfigureService.new.call(user: user, params: params[:preferences])
|
|
144
|
+
return results.last if results.last.failure?
|
|
145
|
+
|
|
146
|
+
results << Notifications::WelcomeService.new.call(user: user)
|
|
147
|
+
|
|
148
|
+
user.update!(onboarding_completed_at: Time.current)
|
|
149
|
+
success(user)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Dependency Injection Patterns
|
|
156
|
+
|
|
157
|
+
### Constructor Injection (Preferred)
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
class OrderService
|
|
161
|
+
def initialize(
|
|
162
|
+
inventory: InventoryService.new,
|
|
163
|
+
payment: PaymentService.new,
|
|
164
|
+
notifier: NotificationService.new
|
|
165
|
+
)
|
|
166
|
+
@inventory = inventory
|
|
167
|
+
@payment = payment
|
|
168
|
+
@notifier = notifier
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Testing with Mocks
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# test/services/orders/create_service_test.rb
|
|
177
|
+
require "test_helper"
|
|
178
|
+
|
|
179
|
+
class Orders::CreateServiceTest < ActiveSupport::TestCase
|
|
180
|
+
setup do
|
|
181
|
+
@inventory = Minitest::Mock.new
|
|
182
|
+
@payment = Minitest::Mock.new
|
|
183
|
+
@service = Orders::CreateService.new(inventory: @inventory, payment: @payment)
|
|
184
|
+
@user = users(:one)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
test "checks inventory before charging" do
|
|
188
|
+
@inventory.expect :available?, true, [Array]
|
|
189
|
+
@inventory.expect :reserve, true, [Array]
|
|
190
|
+
@payment.expect :charge, true, [Hash]
|
|
191
|
+
|
|
192
|
+
@service.call(user: @user, items: [{ product_id: products(:widget).id, quantity: 1 }])
|
|
193
|
+
|
|
194
|
+
@inventory.verify
|
|
195
|
+
@payment.verify
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Error Handling Patterns
|
|
201
|
+
|
|
202
|
+
### Typed Error Codes
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
module Orders
|
|
206
|
+
class CreateService
|
|
207
|
+
ERROR_CODES = {
|
|
208
|
+
empty_cart: "No items in cart",
|
|
209
|
+
insufficient_inventory: "Item out of stock",
|
|
210
|
+
payment_failed: "Payment could not be processed",
|
|
211
|
+
validation_failed: "Invalid order data"
|
|
212
|
+
}.freeze
|
|
213
|
+
|
|
214
|
+
def call(params)
|
|
215
|
+
return failure(:empty_cart) if params[:items].empty?
|
|
216
|
+
return failure(:insufficient_inventory) unless inventory_available?(params[:items])
|
|
217
|
+
|
|
218
|
+
order = create_order(params)
|
|
219
|
+
success(order)
|
|
220
|
+
rescue PaymentError
|
|
221
|
+
failure(:payment_failed)
|
|
222
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
223
|
+
failure(:validation_failed, e.message)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def failure(code, details = nil)
|
|
229
|
+
message = ERROR_CODES[code]
|
|
230
|
+
message = "#{message}: #{details}" if details
|
|
231
|
+
Result.new(success: false, error: message, code: code)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Controller Error Handling
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
class OrdersController < ApplicationController
|
|
241
|
+
def create
|
|
242
|
+
result = Orders::CreateService.new.call(order_params)
|
|
243
|
+
|
|
244
|
+
if result.success?
|
|
245
|
+
redirect_to result.data, notice: t(".success")
|
|
246
|
+
else
|
|
247
|
+
handle_service_error(result)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
def handle_service_error(result)
|
|
254
|
+
case result.code
|
|
255
|
+
when :empty_cart
|
|
256
|
+
redirect_to cart_path, alert: result.error
|
|
257
|
+
when :insufficient_inventory
|
|
258
|
+
flash.now[:alert] = result.error
|
|
259
|
+
render :new, status: :unprocessable_entity
|
|
260
|
+
when :payment_failed
|
|
261
|
+
redirect_to checkout_path, alert: result.error
|
|
262
|
+
else
|
|
263
|
+
flash.now[:alert] = result.error
|
|
264
|
+
render :new, status: :unprocessable_entity
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Service Naming Conventions
|
|
271
|
+
|
|
272
|
+
| Pattern | Example | Use Case |
|
|
273
|
+
|---------|---------|----------|
|
|
274
|
+
| `VerbNounService` | `CreateOrderService` | Single action |
|
|
275
|
+
| `Namespace::VerbService` | `Orders::CreateService` | Namespaced (preferred) |
|
|
276
|
+
| `NounVerbService` | `OrderCreatorService` | Alternative style |
|
|
277
|
+
|
|
278
|
+
## Checklist
|
|
279
|
+
|
|
280
|
+
- [ ] Single public method (`#call`)
|
|
281
|
+
- [ ] Returns Result object
|
|
282
|
+
- [ ] Dependencies injected via constructor
|
|
283
|
+
- [ ] Errors caught and wrapped
|
|
284
|
+
- [ ] Transaction for multi-model writes
|
|
285
|
+
- [ ] Typed error codes for handling
|
|
286
|
+
- [ ] Spec covers success and failure paths
|