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.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/agents/rails-concern.md +464 -0
  3. data/.claude/agents/rails-controller.md +424 -0
  4. data/.claude/agents/rails-hotwire.md +446 -0
  5. data/.claude/agents/rails-implement.md +374 -0
  6. data/.claude/agents/rails-job.md +334 -0
  7. data/.claude/agents/rails-lint.md +294 -0
  8. data/.claude/agents/rails-mailer.md +371 -0
  9. data/.claude/agents/rails-migration.md +449 -0
  10. data/.claude/agents/rails-model.md +420 -0
  11. data/.claude/agents/rails-policy.md +443 -0
  12. data/.claude/agents/rails-presenter.md +427 -0
  13. data/.claude/agents/rails-query.md +412 -0
  14. data/.claude/agents/rails-review.md +490 -0
  15. data/.claude/agents/rails-service.md +458 -0
  16. data/.claude/agents/rails-state-records.md +465 -0
  17. data/.claude/agents/rails-tdd.md +314 -0
  18. data/.claude/agents/rails-test.md +441 -0
  19. data/.claude/agents/rails-view-component.md +418 -0
  20. data/.claude/hooks/block-secrets.sh +52 -0
  21. data/.claude/settings.json +85 -0
  22. data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
  23. data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
  24. data/.claude/skills/active-storage-setup/SKILL.md +311 -0
  25. data/.claude/skills/api-versioning/SKILL.md +294 -0
  26. data/.claude/skills/authentication-flow/SKILL.md +335 -0
  27. data/.claude/skills/authentication-flow/reference/current.md +248 -0
  28. data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
  29. data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
  30. data/.claude/skills/authorization-pundit/SKILL.md +462 -0
  31. data/.claude/skills/caching-strategies/SKILL.md +350 -0
  32. data/.claude/skills/database-migrations/SKILL.md +354 -0
  33. data/.claude/skills/form-object-patterns/SKILL.md +399 -0
  34. data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
  35. data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
  36. data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
  37. data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
  38. data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
  39. data/.claude/skills/i18n-patterns/SKILL.md +320 -0
  40. data/.claude/skills/install/SKILL.md +367 -0
  41. data/.claude/skills/performance-optimization/SKILL.md +311 -0
  42. data/.claude/skills/rails-architecture/SKILL.md +259 -0
  43. data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
  44. data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
  45. data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
  46. data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
  47. data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
  48. data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
  49. data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
  50. data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
  51. data/.claude/skills/rails-concern/SKILL.md +399 -0
  52. data/.claude/skills/rails-controller/SKILL.md +336 -0
  53. data/.claude/skills/rails-model-generator/SKILL.md +321 -0
  54. data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
  55. data/.claude/skills/rails-presenter/SKILL.md +274 -0
  56. data/.claude/skills/rails-query-object/SKILL.md +289 -0
  57. data/.claude/skills/rails-service-object/SKILL.md +349 -0
  58. data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
  59. data/.claude/skills/tdd-cycle/SKILL.md +359 -0
  60. data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
  61. data/.gitignore +1 -0
  62. data/.rubocop.yml +2 -0
  63. data/.ruby-version +1 -1
  64. data/.vbw-planning/.notification-log.jsonl +192 -0
  65. data/.vbw-planning/.session-log.jsonl +871 -0
  66. data/.vbw-planning/PROJECT.md +51 -0
  67. data/.vbw-planning/REQUIREMENTS.md +50 -0
  68. data/.vbw-planning/SHIPPED.md +28 -0
  69. data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
  70. data/.vbw-planning/codebase/CONCERNS.md +99 -0
  71. data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
  72. data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
  73. data/.vbw-planning/codebase/INDEX.md +86 -0
  74. data/.vbw-planning/codebase/META.md +42 -0
  75. data/.vbw-planning/codebase/PATTERNS.md +262 -0
  76. data/.vbw-planning/codebase/STACK.md +101 -0
  77. data/.vbw-planning/codebase/STRUCTURE.md +324 -0
  78. data/.vbw-planning/codebase/TESTING.md +154 -0
  79. data/.vbw-planning/config.json +12 -0
  80. data/.vbw-planning/discovery.json +24 -0
  81. data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
  82. data/.vbw-planning/milestones/default/STATE.md +83 -0
  83. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
  84. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
  85. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
  86. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
  87. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
  88. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
  89. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
  90. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
  91. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
  92. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
  93. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
  94. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
  95. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
  96. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
  97. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
  98. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
  99. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
  100. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
  101. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
  102. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
  103. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
  104. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
  105. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
  106. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
  107. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
  108. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
  109. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
  110. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
  111. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
  112. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
  113. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
  114. data/CHANGELOG.md +28 -0
  115. data/CLAUDE.md +179 -0
  116. data/Gemfile +8 -0
  117. data/Gemfile.lock +114 -101
  118. data/Rakefile +2 -0
  119. data/app/assets/builds/source_monitor/application.css +2076 -0
  120. data/app/assets/builds/source_monitor/application.js +2758 -0
  121. data/app/assets/builds/source_monitor/application.js.map +7 -0
  122. data/app/controllers/source_monitor/application_controller.rb +2 -0
  123. data/app/controllers/source_monitor/health_controller.rb +2 -0
  124. data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
  125. data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
  126. data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
  127. data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
  128. data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
  129. data/app/controllers/source_monitor/items_controller.rb +2 -0
  130. data/app/controllers/source_monitor/sources_controller.rb +0 -14
  131. data/app/helpers/source_monitor/application_helper.rb +4 -112
  132. data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
  133. data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
  134. data/app/jobs/source_monitor/application_job.rb +2 -0
  135. data/app/models/source_monitor/application_record.rb +2 -0
  136. data/app/models/source_monitor/log_entry.rb +0 -2
  137. data/config/coverage_baseline.json +217 -1862
  138. data/config/routes.rb +2 -0
  139. data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
  140. data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
  141. data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
  142. data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
  143. data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
  144. data/lib/source_monitor/assets/bundler.rb +2 -0
  145. data/lib/source_monitor/assets.rb +2 -0
  146. data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
  147. data/lib/source_monitor/configuration/events.rb +60 -0
  148. data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
  149. data/lib/source_monitor/configuration/health_settings.rb +27 -0
  150. data/lib/source_monitor/configuration/http_settings.rb +43 -0
  151. data/lib/source_monitor/configuration/model_definition.rb +108 -0
  152. data/lib/source_monitor/configuration/models.rb +36 -0
  153. data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
  154. data/lib/source_monitor/configuration/retention_settings.rb +45 -0
  155. data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
  156. data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
  157. data/lib/source_monitor/configuration/validation_definition.rb +32 -0
  158. data/lib/source_monitor/configuration.rb +12 -579
  159. data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
  160. data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
  161. data/lib/source_monitor/dashboard/queries.rb +2 -195
  162. data/lib/source_monitor/engine.rb +2 -0
  163. data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
  164. data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
  165. data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
  166. data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
  167. data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
  168. data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
  169. data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
  170. data/lib/source_monitor/items/item_creator.rb +28 -455
  171. data/lib/source_monitor/setup/bundle_installer.rb +2 -0
  172. data/lib/source_monitor/setup/cli.rb +2 -0
  173. data/lib/source_monitor/setup/dependency_checker.rb +2 -0
  174. data/lib/source_monitor/setup/detectors.rb +2 -0
  175. data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
  176. data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
  177. data/lib/source_monitor/setup/install_generator.rb +2 -0
  178. data/lib/source_monitor/setup/migration_installer.rb +2 -0
  179. data/lib/source_monitor/setup/node_installer.rb +2 -0
  180. data/lib/source_monitor/setup/prompter.rb +2 -0
  181. data/lib/source_monitor/setup/requirements.rb +2 -0
  182. data/lib/source_monitor/setup/shell_runner.rb +2 -0
  183. data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
  184. data/lib/source_monitor/setup/verification/printer.rb +2 -0
  185. data/lib/source_monitor/setup/verification/result.rb +2 -0
  186. data/lib/source_monitor/setup/verification/runner.rb +2 -0
  187. data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
  188. data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
  189. data/lib/source_monitor/setup/workflow.rb +2 -0
  190. data/lib/source_monitor/version.rb +3 -1
  191. data/lib/source_monitor.rb +140 -58
  192. data/lib/tasks/source_monitor_assets.rake +2 -0
  193. data/lib/tasks/source_monitor_setup.rake +2 -0
  194. data/lib/tasks/source_monitor_tasks.rake +2 -0
  195. data/source_monitor.gemspec +3 -1
  196. metadata +144 -4
@@ -0,0 +1,142 @@
1
+ # Event Tracking Patterns
2
+
3
+ ## Philosophy: Domain Event Records, Not Generic Tracking
4
+
5
+ Events are rich domain models (CardMoved, CommentAdded) — not generic Event rows with JSON blobs.
6
+
7
+ ## Domain Event Records
8
+
9
+ ```ruby
10
+ # GOOD: Rich domain event
11
+ class CardMoved < ApplicationRecord
12
+ belongs_to :card
13
+ belongs_to :from_column, class_name: "Column"
14
+ belongs_to :to_column, class_name: "Column"
15
+ belongs_to :creator
16
+
17
+ has_one :activity, as: :subject, dependent: :destroy
18
+
19
+ after_create_commit :create_activity
20
+ after_create_commit :broadcast_update_later
21
+ after_create_commit :deliver_webhooks_later
22
+
23
+ validates :card, :from_column, :to_column, presence: true
24
+
25
+ def description
26
+ "#{creator.name} moved #{card.title} from #{from_column.name} to #{to_column.name}"
27
+ end
28
+
29
+ private
30
+
31
+ def create_activity
32
+ Activity.create!(subject: self, creator: creator)
33
+ end
34
+
35
+ def broadcast_update_later
36
+ card.broadcast_replace_later
37
+ end
38
+
39
+ def deliver_webhooks_later
40
+ WebhookDeliveryJob.perform_later(self)
41
+ end
42
+ end
43
+
44
+ # BAD: Generic event blob
45
+ Event.create(event_type: "card.moved", data: { card_id: 1 })
46
+ ```
47
+
48
+ ## Activity Feed (Polymorphic)
49
+
50
+ ```ruby
51
+ class Activity < ApplicationRecord
52
+ belongs_to :subject, polymorphic: true # CardMoved, CommentAdded, etc.
53
+ belongs_to :creator, optional: true
54
+
55
+ scope :recent, -> { order(created_at: :desc).limit(50) }
56
+ end
57
+ ```
58
+
59
+ ## Webhook System
60
+
61
+ ```ruby
62
+ # Webhook endpoint configuration
63
+ class WebhookEndpoint < ApplicationRecord
64
+ has_many :deliveries, class_name: "WebhookDelivery", dependent: :destroy
65
+
66
+ validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
67
+ validates :events, presence: true
68
+
69
+ serialize :events, coder: JSON
70
+
71
+ def subscribed_to?(event_type)
72
+ events.include?(event_type)
73
+ end
74
+ end
75
+
76
+ # Delivery tracking
77
+ class WebhookDelivery < ApplicationRecord
78
+ belongs_to :webhook_endpoint
79
+ belongs_to :event, polymorphic: true
80
+
81
+ enum :status, { pending: 0, delivered: 1, failed: 2 }
82
+
83
+ scope :pending, -> { where(status: :pending) }
84
+ scope :failed, -> { where(status: :failed) }
85
+ end
86
+ ```
87
+
88
+ ## Webhook Delivery Job
89
+
90
+ ```ruby
91
+ class WebhookDeliveryJob < ApplicationJob
92
+ queue_as :webhooks
93
+ retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
94
+
95
+ def perform(event)
96
+ WebhookEndpoint.all.select { |ep| ep.subscribed_to?(event.class.name.underscore) }.each do |endpoint|
97
+ delivery = endpoint.deliveries.create!(event: event, status: :pending)
98
+ response = deliver(endpoint.url, payload(event))
99
+ delivery.update!(status: :delivered, response_code: response.code)
100
+ rescue => e
101
+ delivery&.update!(status: :failed, error_message: e.message)
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def deliver(url, body)
108
+ Net::HTTP.post(URI(url), body.to_json, "Content-Type" => "application/json")
109
+ end
110
+
111
+ def payload(event)
112
+ { type: event.class.name.underscore, data: event.as_json, timestamp: Time.current.iso8601 }
113
+ end
114
+ end
115
+ ```
116
+
117
+ ## Testing Events
118
+
119
+ ```ruby
120
+ # test/models/card_moved_test.rb
121
+ require "test_helper"
122
+
123
+ class CardMovedTest < ActiveSupport::TestCase
124
+ test "creates activity on create" do
125
+ card = cards(:one)
126
+ assert_difference "Activity.count", 1 do
127
+ CardMoved.create!(
128
+ card: card,
129
+ from_column: columns(:todo),
130
+ to_column: columns(:done),
131
+ creator: users(:one)
132
+ )
133
+ end
134
+ end
135
+
136
+ test "#description includes details" do
137
+ moved = card_moveds(:recent)
138
+ assert_match moved.card.title, moved.description
139
+ assert_match moved.from_column.name, moved.description
140
+ end
141
+ end
142
+ ```
@@ -0,0 +1,417 @@
1
+ # Layer Interactions
2
+
3
+ Detailed examples of how architectural layers communicate in a Rails 8 application.
4
+
5
+ ## Request Flow Example
6
+
7
+ A complete example showing how layers interact for creating an event with vendors.
8
+
9
+ ### 1. Controller (Entry Point)
10
+
11
+ ```ruby
12
+ # app/controllers/events_controller.rb
13
+ class EventsController < ApplicationController
14
+ def create
15
+ # 1. Authorization (Policy)
16
+ authorize Event
17
+
18
+ # 2. Use Form Object for complex input
19
+ @form = EventCreationForm.new(event_params)
20
+
21
+ if @form.valid?
22
+ # 3. Delegate to Service
23
+ result = Events::CreateService.new.call(
24
+ account: current_account,
25
+ params: @form.attributes
26
+ )
27
+
28
+ if result.success?
29
+ # 4. Background job for notifications
30
+ EventCreatedJob.perform_later(result.data.id)
31
+
32
+ redirect_to result.data, notice: t(".success")
33
+ else
34
+ flash.now[:alert] = result.error
35
+ render :new, status: :unprocessable_entity
36
+ end
37
+ else
38
+ render :new, status: :unprocessable_entity
39
+ end
40
+ end
41
+ end
42
+ ```
43
+
44
+ ### 2. Form Object (Input Handling)
45
+
46
+ ```ruby
47
+ # app/forms/event_creation_form.rb
48
+ class EventCreationForm < ApplicationForm
49
+ attribute :name, :string
50
+ attribute :event_date, :date
51
+ attribute :event_type, :string
52
+ attribute :vendor_ids, array: true, default: []
53
+
54
+ validates :name, presence: true
55
+ validates :event_date, presence: true
56
+ validate :event_date_in_future
57
+
58
+ private
59
+
60
+ def event_date_in_future
61
+ return if event_date.blank?
62
+ errors.add(:event_date, :in_past) if event_date < Date.current
63
+ end
64
+ end
65
+ ```
66
+
67
+ ### 3. Service Object (Business Logic)
68
+
69
+ ```ruby
70
+ # app/services/events/create_service.rb
71
+ module Events
72
+ class CreateService < ApplicationService
73
+ def call(account:, params:)
74
+ event = nil
75
+
76
+ ActiveRecord::Base.transaction do
77
+ # Create event
78
+ event = account.events.create!(
79
+ name: params[:name],
80
+ event_date: params[:event_date],
81
+ event_type: params[:event_type]
82
+ )
83
+
84
+ # Attach vendors
85
+ attach_vendors(event, params[:vendor_ids])
86
+
87
+ # Update statistics
88
+ update_account_stats(account)
89
+ end
90
+
91
+ success(event)
92
+ rescue ActiveRecord::RecordInvalid => e
93
+ failure(e.message, :validation_error)
94
+ end
95
+
96
+ private
97
+
98
+ def attach_vendors(event, vendor_ids)
99
+ return if vendor_ids.blank?
100
+
101
+ vendor_ids.each do |vendor_id|
102
+ event.event_vendors.create!(vendor_id: vendor_id)
103
+ end
104
+ end
105
+
106
+ def update_account_stats(account)
107
+ # Could use a Query Object here
108
+ account.update_column(:events_count, account.events.count)
109
+ end
110
+ end
111
+ end
112
+ ```
113
+
114
+ ### 4. Model (Data & Validations)
115
+
116
+ ```ruby
117
+ # app/models/event.rb
118
+ class Event < ApplicationRecord
119
+ belongs_to :account
120
+ has_many :event_vendors, dependent: :destroy
121
+ has_many :vendors, through: :event_vendors
122
+
123
+ validates :name, presence: true
124
+ validates :event_date, presence: true
125
+
126
+ enum :event_type, { wedding: 0, corporate: 1, private: 2 }
127
+ enum :status, { draft: 0, confirmed: 1, completed: 2, cancelled: 3 }
128
+
129
+ scope :upcoming, -> { where("event_date >= ?", Date.current) }
130
+ scope :recent, -> { order(created_at: :desc) }
131
+ end
132
+ ```
133
+
134
+ ### 5. Policy (Authorization)
135
+
136
+ ```ruby
137
+ # app/policies/event_policy.rb
138
+ class EventPolicy < ApplicationPolicy
139
+ def create?
140
+ user.account_id.present?
141
+ end
142
+
143
+ def show?
144
+ owner?
145
+ end
146
+
147
+ def update?
148
+ owner? && !record.completed?
149
+ end
150
+
151
+ private
152
+
153
+ def owner?
154
+ record.account_id == user.account_id
155
+ end
156
+
157
+ class Scope < ApplicationPolicy::Scope
158
+ def resolve
159
+ scope.where(account_id: user.account_id)
160
+ end
161
+ end
162
+ end
163
+ ```
164
+
165
+ ### 6. Background Job (Async Processing)
166
+
167
+ ```ruby
168
+ # app/jobs/event_created_job.rb
169
+ class EventCreatedJob < ApplicationJob
170
+ queue_as :default
171
+
172
+ def perform(event_id)
173
+ event = Event.find(event_id)
174
+
175
+ # Send email notification
176
+ EventMailer.created(event).deliver_later
177
+
178
+ # Broadcast to dashboard
179
+ DashboardChannel.broadcast_stats(event.account)
180
+
181
+ # Log activity
182
+ ActivityService.new.log(
183
+ account: event.account,
184
+ action: :event_created,
185
+ resource: event
186
+ )
187
+ end
188
+ end
189
+ ```
190
+
191
+ ### 7. Mailer (Email)
192
+
193
+ ```ruby
194
+ # app/mailers/event_mailer.rb
195
+ class EventMailer < ApplicationMailer
196
+ def created(event)
197
+ @event = event
198
+ @user = event.account.users.first
199
+
200
+ mail(
201
+ to: @user.email_address,
202
+ subject: t(".subject", name: event.name)
203
+ )
204
+ end
205
+ end
206
+ ```
207
+
208
+ ### 8. Query Object (Complex Queries)
209
+
210
+ ```ruby
211
+ # app/queries/dashboard_stats_query.rb
212
+ class DashboardStatsQuery
213
+ attr_reader :account
214
+
215
+ def initialize(account:)
216
+ @account = account
217
+ end
218
+
219
+ def call
220
+ {
221
+ total_events: account.events.count,
222
+ upcoming_events: upcoming_events_count,
223
+ events_by_type: events_by_type,
224
+ recent_events: recent_events
225
+ }
226
+ end
227
+
228
+ private
229
+
230
+ def upcoming_events_count
231
+ account.events.upcoming.count
232
+ end
233
+
234
+ def events_by_type
235
+ account.events.group(:event_type).count
236
+ end
237
+
238
+ def recent_events
239
+ account.events.recent.limit(5)
240
+ end
241
+ end
242
+ ```
243
+
244
+ ### 9. Presenter (View Formatting)
245
+
246
+ ```ruby
247
+ # app/presenters/event_presenter.rb
248
+ class EventPresenter < BasePresenter
249
+ STATUS_COLORS = {
250
+ draft: "bg-slate-100 text-slate-800",
251
+ confirmed: "bg-green-100 text-green-800",
252
+ completed: "bg-blue-100 text-blue-800",
253
+ cancelled: "bg-red-100 text-red-800"
254
+ }.freeze
255
+
256
+ def status_badge
257
+ tag.span(
258
+ status_text,
259
+ class: "inline-flex px-2 py-1 rounded-full text-xs font-medium #{status_color}"
260
+ )
261
+ end
262
+
263
+ def formatted_date
264
+ return not_specified_span if event_date.nil?
265
+ I18n.l(event_date, format: :long)
266
+ end
267
+
268
+ def vendor_count_text
269
+ I18n.t("events.vendors_count", count: vendors.size)
270
+ end
271
+
272
+ private
273
+
274
+ def status_text
275
+ I18n.t("activerecord.attributes.event/statuses.#{status}")
276
+ end
277
+
278
+ def status_color
279
+ STATUS_COLORS.fetch(status.to_sym, STATUS_COLORS[:draft])
280
+ end
281
+ end
282
+ ```
283
+
284
+ ### 10. ViewComponent (Reusable UI)
285
+
286
+ ```ruby
287
+ # app/components/event_card_component.rb
288
+ class EventCardComponent < ApplicationComponent
289
+ def initialize(event:)
290
+ @event = EventPresenter.new(event)
291
+ end
292
+
293
+ attr_reader :event
294
+ end
295
+ ```
296
+
297
+ ```erb
298
+ <%# app/components/event_card_component.html.erb %>
299
+ <article class="bg-white rounded-lg shadow p-6">
300
+ <header class="flex justify-between items-start">
301
+ <h3 class="text-lg font-semibold"><%= event.name %></h3>
302
+ <%= event.status_badge %>
303
+ </header>
304
+
305
+ <dl class="mt-4 space-y-2">
306
+ <div>
307
+ <dt class="text-sm text-slate-500"><%= t(".date") %></dt>
308
+ <dd><%= event.formatted_date %></dd>
309
+ </div>
310
+ <div>
311
+ <dt class="text-sm text-slate-500"><%= t(".vendors") %></dt>
312
+ <dd><%= event.vendor_count_text %></dd>
313
+ </div>
314
+ </dl>
315
+
316
+ <footer class="mt-4 flex gap-2">
317
+ <%= link_to t("common.view"), event, class: "btn btn-primary" %>
318
+ <% if policy(event.model).edit? %>
319
+ <%= link_to t("common.edit"), edit_event_path(event), class: "btn btn-secondary" %>
320
+ <% end %>
321
+ </footer>
322
+ </article>
323
+ ```
324
+
325
+ ### 11. Channel (Real-time)
326
+
327
+ ```ruby
328
+ # app/channels/dashboard_channel.rb
329
+ class DashboardChannel < ApplicationCable::Channel
330
+ def subscribed
331
+ stream_for current_user.account
332
+ end
333
+
334
+ def self.broadcast_stats(account)
335
+ stats = DashboardStatsQuery.new(account: account).call
336
+
337
+ broadcast_to(account, {
338
+ type: "stats_update",
339
+ data: stats
340
+ })
341
+ end
342
+ end
343
+ ```
344
+
345
+ ## Layer Communication Rules
346
+
347
+ ### Who Can Call Whom
348
+
349
+ ```
350
+ Controller → Service, Query, Policy, Form
351
+ Service → Model, Query, Job, Mailer, Channel
352
+ Query → Model (read-only)
353
+ Job → Service, Mailer, Channel
354
+ Presenter → Model (read-only)
355
+ Component → Presenter, Policy (for authorization checks)
356
+ Channel → Query (for broadcasting data)
357
+ ```
358
+
359
+ ### Who Should NOT Call Whom
360
+
361
+ ```
362
+ Model → Controller, Service, Job (avoid callbacks that do this)
363
+ Presenter → Service, Job (no side effects)
364
+ Query → Service, Job (read-only)
365
+ Component → Service, Job (presentation only)
366
+ ```
367
+
368
+ ## Data Flow Patterns
369
+
370
+ ### Pattern 1: Simple CRUD
371
+
372
+ ```
373
+ Request → Controller → Model → View
374
+ ```
375
+
376
+ ### Pattern 2: Complex Business Logic
377
+
378
+ ```
379
+ Request → Controller → Service → Model → Presenter → Component → Response
380
+ ↘ Job → Mailer
381
+ ```
382
+
383
+ ### Pattern 3: Dashboard with Stats
384
+
385
+ ```
386
+ Request → Controller → Query → Presenter → Component → Response
387
+ ↘ Policy (for authorization)
388
+ ```
389
+
390
+ ### Pattern 4: Real-time Updates
391
+
392
+ ```
393
+ Service → Channel → WebSocket → Client
394
+ ↘ Job (async)
395
+ ```
396
+
397
+ ### Pattern 5: Form with Multiple Models
398
+
399
+ ```
400
+ Request → Controller → Form Object → Service → Models → Response
401
+ ```
402
+
403
+ ## Testing Each Layer
404
+
405
+ | Layer | Test Type | What to Test |
406
+ |-------|-----------|--------------|
407
+ | Controller | Request spec | HTTP flow, status codes, redirects |
408
+ | Service | Unit spec | Business logic, Result object |
409
+ | Query | Unit spec | SQL results, tenant isolation |
410
+ | Model | Model spec | Validations, associations, scopes |
411
+ | Policy | Policy spec | Authorization rules |
412
+ | Form | Unit spec | Validations, attribute handling |
413
+ | Presenter | Unit spec | Formatting, HTML output |
414
+ | Component | Component spec | Rendering |
415
+ | Job | Job spec | Execution, side effects |
416
+ | Mailer | Mailer spec | Recipients, content |
417
+ | Channel | Channel spec | Subscriptions, broadcasts |
@@ -0,0 +1,152 @@
1
+ # Multi-Tenancy Patterns
2
+
3
+ ## URL-Based Multi-Tenancy
4
+
5
+ The preferred pattern for Rails multi-tenancy: account ID in the URL path.
6
+
7
+ ```ruby
8
+ # config/routes.rb
9
+ Rails.application.routes.draw do
10
+ scope "/:account_id" do
11
+ resources :boards do
12
+ resources :cards
13
+ end
14
+ end
15
+ end
16
+ # Routes: /accounts/123/boards/456/cards/789
17
+ ```
18
+
19
+ ## Current Attributes for Context
20
+
21
+ ```ruby
22
+ # app/models/current.rb
23
+ class Current < ActiveSupport::CurrentAttributes
24
+ attribute :user, :account
25
+
26
+ def user=(user)
27
+ super
28
+ self.account = user&.account
29
+ end
30
+ end
31
+ ```
32
+
33
+ ## Controller Scoping
34
+
35
+ ```ruby
36
+ class ApplicationController < ActionController::Base
37
+ before_action :set_current_account
38
+
39
+ private
40
+
41
+ def set_current_account
42
+ Current.account = current_user.accounts.find(params[:account_id])
43
+ rescue ActiveRecord::RecordNotFound
44
+ redirect_to root_path, alert: "Account not found"
45
+ end
46
+ end
47
+
48
+ class BoardsController < ApplicationController
49
+ def index
50
+ @boards = Current.account.boards
51
+ end
52
+
53
+ def show
54
+ @board = Current.account.boards.find(params[:id])
55
+ end
56
+ end
57
+ ```
58
+
59
+ ## Account Model
60
+
61
+ ```ruby
62
+ class Account < ApplicationRecord
63
+ has_many :memberships, dependent: :destroy
64
+ has_many :users, through: :memberships
65
+
66
+ # All account resources
67
+ has_many :boards, dependent: :destroy
68
+ has_many :cards, dependent: :destroy
69
+
70
+ validates :name, presence: true
71
+
72
+ def member?(user)
73
+ users.exists?(user.id)
74
+ end
75
+
76
+ def add_member(user, role: :member)
77
+ memberships.find_or_create_by!(user: user) do |m|
78
+ m.role = role
79
+ end
80
+ end
81
+ end
82
+ ```
83
+
84
+ ## Membership Model
85
+
86
+ ```ruby
87
+ class Membership < ApplicationRecord
88
+ belongs_to :user
89
+ belongs_to :account
90
+
91
+ enum :role, { member: 0, admin: 1, owner: 2 }
92
+
93
+ validates :user_id, uniqueness: { scope: :account_id }
94
+ end
95
+ ```
96
+
97
+ ## Every Table Gets account_id
98
+
99
+ ```ruby
100
+ class CreateBoards < ActiveRecord::Migration[8.0]
101
+ def change
102
+ create_table :boards do |t|
103
+ t.references :account, null: false, foreign_key: true
104
+ t.string :name, null: false
105
+ t.timestamps
106
+ end
107
+
108
+ add_index :boards, [:account_id, :name], unique: true
109
+ end
110
+ end
111
+ ```
112
+
113
+ ## Scoping Pattern (Explicit, Not Default Scope)
114
+
115
+ ```ruby
116
+ # GOOD: Explicit scoping through association
117
+ Current.account.boards.find(params[:id])
118
+
119
+ # BAD: Default scope (implicit, hard to debug)
120
+ class Board < ApplicationRecord
121
+ default_scope { where(account_id: Current.account&.id) }
122
+ end
123
+ ```
124
+
125
+ ## Testing Multi-Tenancy
126
+
127
+ ```ruby
128
+ # test/models/board_test.rb
129
+ require "test_helper"
130
+
131
+ class BoardTest < ActiveSupport::TestCase
132
+ test "boards are scoped to account" do
133
+ account = accounts(:one)
134
+ other_account = accounts(:two)
135
+ board = boards(:one) # belongs to accounts(:one)
136
+
137
+ assert_includes account.boards, board
138
+ assert_not_includes other_account.boards, board
139
+ end
140
+ end
141
+
142
+ # test/controllers/boards_controller_test.rb
143
+ class BoardsControllerTest < ActionDispatch::IntegrationTest
144
+ test "cannot access other account's boards" do
145
+ sign_in_as users(:one) # belongs to accounts(:one)
146
+ board = boards(:other_account_board) # belongs to accounts(:two)
147
+
148
+ get board_url(board, account_id: accounts(:two).id)
149
+ assert_redirected_to root_path
150
+ end
151
+ end
152
+ ```