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,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
|
+
```
|