anima-core 0.2.1 → 1.0.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/.reek.yml +27 -1
- data/CHANGELOG.md +19 -0
- data/README.md +213 -43
- data/agents/codebase-analyzer.md +88 -0
- data/agents/codebase-pattern-finder.md +83 -0
- data/agents/documentation-researcher.md +59 -0
- data/agents/thoughts-analyzer.md +102 -0
- data/agents/web-search-researcher.md +71 -0
- data/anima-core.gemspec +3 -0
- data/app/channels/session_channel.rb +195 -45
- data/app/decorators/user_message_decorator.rb +16 -5
- data/app/jobs/agent_request_job.rb +55 -2
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +15 -4
- data/app/models/concerns/event/broadcasting.rb +81 -0
- data/app/models/event.rb +20 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +366 -21
- data/config/application.rb +2 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
- data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
- data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
- data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
- data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
- data/db/migrate/20260315140843_create_goals.rb +16 -0
- data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
- data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +65 -6
- data/lib/agents/definition.rb +116 -0
- data/lib/agents/registry.rb +106 -0
- data/lib/analytical_brain/runner.rb +276 -0
- data/lib/analytical_brain/tools/activate_skill.rb +52 -0
- data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
- data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
- data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
- data/lib/analytical_brain/tools/finish_goal.rb +62 -0
- data/lib/analytical_brain/tools/read_workflow.rb +58 -0
- data/lib/analytical_brain/tools/rename_session.rb +63 -0
- data/lib/analytical_brain/tools/set_goal.rb +60 -0
- data/lib/analytical_brain/tools/update_goal.rb +60 -0
- data/lib/analytical_brain.rb +23 -0
- data/lib/anima/cli/mcp/secrets.rb +76 -0
- data/lib/anima/cli/mcp.rb +197 -0
- data/lib/anima/cli.rb +5 -40
- data/lib/anima/installer.rb +168 -0
- data/lib/anima/settings.rb +226 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +9 -0
- data/lib/credential_store.rb +103 -0
- data/lib/environment_probe.rb +232 -0
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/llm/client.rb +29 -10
- data/lib/mcp/client_manager.rb +86 -0
- data/lib/mcp/config.rb +213 -0
- data/lib/mcp/health_check.rb +77 -0
- data/lib/mcp/secrets.rb +73 -0
- data/lib/mcp/stdio_transport.rb +206 -0
- data/lib/providers/anthropic.rb +11 -20
- data/lib/shell_session.rb +11 -10
- data/lib/skills/definition.rb +97 -0
- data/lib/skills/registry.rb +105 -0
- data/lib/tools/edit.rb +226 -0
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +151 -0
- data/lib/tools/registry.rb +14 -12
- data/lib/tools/request_feature.rb +121 -0
- data/lib/tools/return_result.rb +81 -0
- data/lib/tools/spawn_specialist.rb +109 -0
- data/lib/tools/spawn_subagent.rb +111 -0
- data/lib/tools/subagent_prompts.rb +12 -0
- data/lib/tools/web_get.rb +8 -9
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +985 -26
- data/lib/tui/cable_client.rb +69 -31
- data/lib/tui/message_store.rb +103 -8
- data/lib/tui/screens/chat.rb +293 -45
- data/lib/workflows/definition.rb +97 -0
- data/lib/workflows/registry.rb +89 -0
- data/skills/activerecord/SKILL.md +255 -0
- data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
- data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
- data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
- data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
- data/skills/activerecord/examples/associations/self_referential.rb +302 -0
- data/skills/activerecord/examples/associations/through_associations.rb +203 -0
- data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
- data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
- data/skills/activerecord/examples/basics/inheritance.rb +377 -0
- data/skills/activerecord/examples/basics/type_casting.rb +317 -0
- data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
- data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
- data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
- data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
- data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
- data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
- data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
- data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
- data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
- data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
- data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
- data/skills/activerecord/examples/querying/optimization.rb +275 -0
- data/skills/activerecord/examples/querying/scopes.rb +260 -0
- data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
- data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
- data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
- data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
- data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
- data/skills/activerecord/references/associations.md +709 -0
- data/skills/activerecord/references/basics.md +622 -0
- data/skills/activerecord/references/callbacks.md +738 -0
- data/skills/activerecord/references/migrations.md +657 -0
- data/skills/activerecord/references/querying.md +655 -0
- data/skills/activerecord/references/validations.md +596 -0
- data/skills/dragonruby/SKILL.md +250 -0
- data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
- data/skills/dragonruby/examples/audio/background_music.rb +29 -0
- data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
- data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
- data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
- data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
- data/skills/dragonruby/examples/core/hello_world.rb +24 -0
- data/skills/dragonruby/examples/core/labels.rb +22 -0
- data/skills/dragonruby/examples/core/sprites.rb +35 -0
- data/skills/dragonruby/examples/core/state_management.rb +29 -0
- data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
- data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
- data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
- data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
- data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
- data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
- data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
- data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
- data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
- data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
- data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
- data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
- data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
- data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
- data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
- data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
- data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
- data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
- data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
- data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
- data/skills/dragonruby/examples/input/controller_input.rb +28 -0
- data/skills/dragonruby/examples/input/directional_input.rb +24 -0
- data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
- data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
- data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
- data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
- data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
- data/skills/dragonruby/examples/rendering/labels.rb +32 -0
- data/skills/dragonruby/examples/rendering/layering.rb +51 -0
- data/skills/dragonruby/examples/rendering/solids.rb +61 -0
- data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
- data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
- data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
- data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
- data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
- data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
- data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
- data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
- data/skills/dragonruby/references/audio.md +396 -0
- data/skills/dragonruby/references/core.md +385 -0
- data/skills/dragonruby/references/distribution.md +434 -0
- data/skills/dragonruby/references/entities.md +516 -0
- data/skills/dragonruby/references/game-logic/persistence.md +386 -0
- data/skills/dragonruby/references/game-logic/state.md +389 -0
- data/skills/dragonruby/references/input.md +414 -0
- data/skills/dragonruby/references/rendering/animation.md +467 -0
- data/skills/dragonruby/references/rendering/primitives.md +403 -0
- data/skills/dragonruby/references/scenes.md +443 -0
- data/skills/draper-decorators/SKILL.md +344 -0
- data/skills/draper-decorators/examples/application_decorator.rb +61 -0
- data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
- data/skills/draper-decorators/examples/model_decorator.rb +152 -0
- data/skills/draper-decorators/references/anti-patterns.md +640 -0
- data/skills/draper-decorators/references/patterns.md +507 -0
- data/skills/draper-decorators/references/testing.md +559 -0
- data/skills/gh-issue.md +182 -0
- data/skills/mcp-server/SKILL.md +177 -0
- data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
- data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
- data/skills/mcp-server/examples/http_client.rb +48 -0
- data/skills/mcp-server/examples/http_server.rb +97 -0
- data/skills/mcp-server/examples/rails_integration.rb +88 -0
- data/skills/mcp-server/examples/stdio_server.rb +108 -0
- data/skills/mcp-server/examples/streaming_client.rb +95 -0
- data/skills/mcp-server/references/gotchas.md +183 -0
- data/skills/mcp-server/references/prompts.md +98 -0
- data/skills/mcp-server/references/resources.md +53 -0
- data/skills/mcp-server/references/server.md +140 -0
- data/skills/mcp-server/references/tools.md +146 -0
- data/skills/mcp-server/references/transport.md +104 -0
- data/skills/ratatui-ruby/SKILL.md +315 -0
- data/skills/ratatui-ruby/references/core-concepts.md +340 -0
- data/skills/ratatui-ruby/references/events.md +387 -0
- data/skills/ratatui-ruby/references/frameworks.md +522 -0
- data/skills/ratatui-ruby/references/layout.md +423 -0
- data/skills/ratatui-ruby/references/styling.md +268 -0
- data/skills/ratatui-ruby/references/testing.md +433 -0
- data/skills/ratatui-ruby/references/widgets.md +532 -0
- data/skills/rspec/SKILL.md +340 -0
- data/skills/rspec/examples/core/basic_structure.rb +69 -0
- data/skills/rspec/examples/core/configuration.rb +126 -0
- data/skills/rspec/examples/core/hooks.rb +126 -0
- data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
- data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
- data/skills/rspec/examples/core/shared_examples.rb +145 -0
- data/skills/rspec/examples/factory_bot/associations.rb +314 -0
- data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
- data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
- data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
- data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
- data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
- data/skills/rspec/examples/factory_bot/traits.rb +293 -0
- data/skills/rspec/examples/factory_bot/transients.rb +229 -0
- data/skills/rspec/examples/matchers/change.rb +115 -0
- data/skills/rspec/examples/matchers/collections.rb +154 -0
- data/skills/rspec/examples/matchers/comparisons.rb +79 -0
- data/skills/rspec/examples/matchers/composing.rb +155 -0
- data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
- data/skills/rspec/examples/matchers/equality.rb +58 -0
- data/skills/rspec/examples/matchers/errors.rb +136 -0
- data/skills/rspec/examples/matchers/output.rb +103 -0
- data/skills/rspec/examples/matchers/predicates.rb +87 -0
- data/skills/rspec/examples/matchers/truthiness.rb +101 -0
- data/skills/rspec/examples/matchers/types.rb +82 -0
- data/skills/rspec/examples/matchers/yield.rb +147 -0
- data/skills/rspec/examples/mocks/any_instance.rb +172 -0
- data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
- data/skills/rspec/examples/mocks/constants.rb +177 -0
- data/skills/rspec/examples/mocks/doubles.rb +139 -0
- data/skills/rspec/examples/mocks/expectations.rb +137 -0
- data/skills/rspec/examples/mocks/message_chains.rb +173 -0
- data/skills/rspec/examples/mocks/ordering.rb +144 -0
- data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
- data/skills/rspec/examples/mocks/responses.rb +223 -0
- data/skills/rspec/examples/mocks/spies.rb +149 -0
- data/skills/rspec/examples/mocks/stubbing.rb +133 -0
- data/skills/rspec/examples/rails/channels.rb +250 -0
- data/skills/rspec/examples/rails/controller_specs.rb +302 -0
- data/skills/rspec/examples/rails/helper_specs.rb +245 -0
- data/skills/rspec/examples/rails/job_specs.rb +256 -0
- data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
- data/skills/rspec/examples/rails/matchers.rb +374 -0
- data/skills/rspec/examples/rails/model_specs.rb +193 -0
- data/skills/rspec/examples/rails/request_specs.rb +275 -0
- data/skills/rspec/examples/rails/routing_specs.rb +276 -0
- data/skills/rspec/examples/rails/system_specs.rb +294 -0
- data/skills/rspec/examples/rails/transactions.rb +254 -0
- data/skills/rspec/examples/rails/view_specs.rb +252 -0
- data/skills/rspec/references/core.md +816 -0
- data/skills/rspec/references/factory_bot.md +641 -0
- data/skills/rspec/references/matchers.md +516 -0
- data/skills/rspec/references/mocks.md +381 -0
- data/skills/rspec/references/rails.md +528 -0
- data/templates/soul.md +40 -0
- data/workflows/commit.md +45 -0
- data/workflows/create_handoff.md +98 -0
- data/workflows/create_note.md +82 -0
- data/workflows/create_plan.md +457 -0
- data/workflows/decompose_ticket.md +109 -0
- data/workflows/feature.md +91 -0
- data/workflows/implement_plan.md +87 -0
- data/workflows/iterate_plan.md +247 -0
- data/workflows/research_codebase.md +210 -0
- data/workflows/resume_handoff.md +217 -0
- data/workflows/review_pr.md +320 -0
- data/workflows/thoughts_init.md +71 -0
- data/workflows/validate_plan.md +166 -0
- metadata +290 -3
- data/app/controllers/api/sessions_controller.rb +0 -25
- data/lib/events/subscribers/action_cable_bridge.rb +0 -59
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# Conditional Callbacks Examples
|
|
2
|
+
# Demonstrates :if, :unless, :on options and callback scoping
|
|
3
|
+
|
|
4
|
+
# =============================================================================
|
|
5
|
+
# Symbol Conditions (Method Names)
|
|
6
|
+
# =============================================================================
|
|
7
|
+
|
|
8
|
+
class Order < ApplicationRecord
|
|
9
|
+
before_save :calculate_tax, if: :taxable?
|
|
10
|
+
before_save :apply_discount, if: :has_coupon?
|
|
11
|
+
after_create :send_confirmation, unless: :guest_checkout?
|
|
12
|
+
after_save :notify_warehouse, if: :ready_for_fulfillment?
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def taxable?
|
|
17
|
+
!tax_exempt? && total > 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def has_coupon?
|
|
21
|
+
coupon_code.present?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def guest_checkout?
|
|
25
|
+
user_id.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ready_for_fulfillment?
|
|
29
|
+
saved_change_to_status? && status == "paid"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def calculate_tax
|
|
33
|
+
self.tax_amount = (subtotal * tax_rate).round(2)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def apply_discount
|
|
37
|
+
self.discount_amount = Coupon.find_by(code: coupon_code)&.calculate_discount(self) || 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def send_confirmation
|
|
41
|
+
OrderMailer.confirmation(self).deliver_later
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def notify_warehouse
|
|
45
|
+
WarehouseNotificationJob.perform_later(id)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# =============================================================================
|
|
50
|
+
# Proc/Lambda Conditions
|
|
51
|
+
# =============================================================================
|
|
52
|
+
|
|
53
|
+
class User < ApplicationRecord
|
|
54
|
+
# Simple lambda
|
|
55
|
+
before_save :encrypt_password, if: -> { password.present? }
|
|
56
|
+
|
|
57
|
+
# Lambda with record parameter
|
|
58
|
+
after_create :send_welcome_email, if: ->(user) { user.email_verified? }
|
|
59
|
+
|
|
60
|
+
# Complex inline condition
|
|
61
|
+
before_validation :normalize_phone, if: -> { phone.present? && phone_changed? }
|
|
62
|
+
|
|
63
|
+
# Using saved_change_to_attribute? in after callbacks
|
|
64
|
+
after_save :sync_to_crm, if: -> { saved_change_to_email? || saved_change_to_name? }
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def encrypt_password
|
|
69
|
+
self.encrypted_password = BCrypt::Password.create(password)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def send_welcome_email
|
|
73
|
+
WelcomeMailer.welcome(self).deliver_later
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_phone
|
|
77
|
+
self.phone = phone.gsub(/\D/, "")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def sync_to_crm
|
|
81
|
+
CrmSyncJob.perform_later(id)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# =============================================================================
|
|
86
|
+
# Multiple Conditions (Array)
|
|
87
|
+
# =============================================================================
|
|
88
|
+
|
|
89
|
+
class Article < ApplicationRecord
|
|
90
|
+
# All conditions must be true (AND logic)
|
|
91
|
+
before_save :schedule_publication,
|
|
92
|
+
if: [:published?, :publication_date_set?, :not_already_scheduled?]
|
|
93
|
+
|
|
94
|
+
# Mix of symbols and lambdas
|
|
95
|
+
after_update :notify_author,
|
|
96
|
+
if: [:status_changed?, -> { previous_changes[:status]&.last == "rejected" }]
|
|
97
|
+
|
|
98
|
+
# Multiple unless conditions
|
|
99
|
+
before_destroy :archive_content,
|
|
100
|
+
unless: [:draft?, :never_published?]
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def published?
|
|
105
|
+
status == "published"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def publication_date_set?
|
|
109
|
+
publish_at.present?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def not_already_scheduled?
|
|
113
|
+
!scheduled?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def status_changed?
|
|
117
|
+
saved_change_to_status?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def draft?
|
|
121
|
+
status == "draft"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def never_published?
|
|
125
|
+
published_at.nil?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def schedule_publication
|
|
129
|
+
PublicationJob.set(wait_until: publish_at).perform_later(id)
|
|
130
|
+
self.scheduled = true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def notify_author
|
|
134
|
+
ArticleMailer.rejection_notice(self).deliver_later
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def archive_content
|
|
138
|
+
ArticleArchive.create!(article_attributes: attributes)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# =============================================================================
|
|
143
|
+
# Combining :if and :unless
|
|
144
|
+
# =============================================================================
|
|
145
|
+
|
|
146
|
+
class Payment < ApplicationRecord
|
|
147
|
+
# Callback runs when :if is true AND :unless is false
|
|
148
|
+
after_create :send_receipt,
|
|
149
|
+
if: :successful?,
|
|
150
|
+
unless: :receipt_sent?
|
|
151
|
+
|
|
152
|
+
# Multiple conditions on both
|
|
153
|
+
before_save :validate_card,
|
|
154
|
+
if: [:card_payment?, :new_card?],
|
|
155
|
+
unless: -> { skip_validation? || test_mode? }
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def successful?
|
|
160
|
+
status == "success"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def receipt_sent?
|
|
164
|
+
receipt_sent_at.present?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def card_payment?
|
|
168
|
+
payment_method == "card"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def new_card?
|
|
172
|
+
card_token_changed?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def skip_validation?
|
|
176
|
+
Rails.env.development? && ENV["SKIP_CARD_VALIDATION"]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def test_mode?
|
|
180
|
+
card_token&.start_with?("tok_test_")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def send_receipt
|
|
184
|
+
PaymentMailer.receipt(self).deliver_later
|
|
185
|
+
update_column(:receipt_sent_at, Time.current)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def validate_card
|
|
189
|
+
unless CardValidator.valid?(card_token)
|
|
190
|
+
errors.add(:card_token, "is invalid")
|
|
191
|
+
throw(:abort)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# =============================================================================
|
|
197
|
+
# :on Option - Context Scoping
|
|
198
|
+
# =============================================================================
|
|
199
|
+
|
|
200
|
+
class User < ApplicationRecord
|
|
201
|
+
# Only on create
|
|
202
|
+
before_validation :generate_username, on: :create
|
|
203
|
+
after_create :send_verification_email
|
|
204
|
+
|
|
205
|
+
# Only on update
|
|
206
|
+
before_save :track_email_change, on: :update
|
|
207
|
+
after_update :notify_email_change, if: :saved_change_to_email?
|
|
208
|
+
|
|
209
|
+
# Multiple contexts
|
|
210
|
+
after_save :sync_profile, on: [:create, :update]
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def generate_username
|
|
215
|
+
self.username ||= email.split("@").first.parameterize
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def send_verification_email
|
|
219
|
+
VerificationMailer.verify(self).deliver_later
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def track_email_change
|
|
223
|
+
self.previous_email = email_was if email_changed?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def notify_email_change
|
|
227
|
+
UserMailer.email_changed(self).deliver_later
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def sync_profile
|
|
231
|
+
ProfileSyncJob.perform_later(id)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# =============================================================================
|
|
236
|
+
# Custom Validation Contexts
|
|
237
|
+
# =============================================================================
|
|
238
|
+
|
|
239
|
+
class Article < ApplicationRecord
|
|
240
|
+
# Standard validations run on all contexts
|
|
241
|
+
validates :title, presence: true
|
|
242
|
+
|
|
243
|
+
# Only on :publish context
|
|
244
|
+
validates :body, presence: true, on: :publish
|
|
245
|
+
validates :category_id, presence: true, on: :publish
|
|
246
|
+
validates :meta_description, length: { maximum: 160 }, on: :publish
|
|
247
|
+
|
|
248
|
+
# Callbacks can also use custom contexts
|
|
249
|
+
before_validation :prepare_for_publish, on: :publish
|
|
250
|
+
|
|
251
|
+
def publish!
|
|
252
|
+
self.status = "published"
|
|
253
|
+
self.published_at = Time.current
|
|
254
|
+
save!(context: :publish) # Runs :publish validations and callbacks
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def prepare_for_publish
|
|
260
|
+
self.slug ||= title.parameterize
|
|
261
|
+
self.excerpt ||= body.truncate(200)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# =============================================================================
|
|
266
|
+
# Conditional Callbacks with Dirty Tracking
|
|
267
|
+
# =============================================================================
|
|
268
|
+
|
|
269
|
+
class Product < ApplicationRecord
|
|
270
|
+
# Before save - use *_changed? methods
|
|
271
|
+
before_save :recalculate_margin, if: :cost_or_price_changed?
|
|
272
|
+
|
|
273
|
+
# After save - use saved_change_to_*? methods
|
|
274
|
+
after_save :update_search_index, if: :searchable_fields_changed?
|
|
275
|
+
after_save :notify_price_watchers, if: :price_decreased?
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def cost_or_price_changed?
|
|
280
|
+
cost_changed? || price_changed?
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def searchable_fields_changed?
|
|
284
|
+
saved_change_to_name? || saved_change_to_description? || saved_change_to_category_id?
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def price_decreased?
|
|
288
|
+
saved_change_to_price? && price < price_before_last_save
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def recalculate_margin
|
|
292
|
+
return unless cost.present? && price.present?
|
|
293
|
+
|
|
294
|
+
self.margin = ((price - cost) / price * 100).round(2)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def update_search_index
|
|
298
|
+
SearchIndexJob.perform_later("product", id)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def notify_price_watchers
|
|
302
|
+
PriceAlertJob.perform_later(id, price_before_last_save, price)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# =============================================================================
|
|
307
|
+
# Grouping with with_options
|
|
308
|
+
# =============================================================================
|
|
309
|
+
|
|
310
|
+
class Account < ApplicationRecord
|
|
311
|
+
# Apply same condition to multiple callbacks
|
|
312
|
+
with_options if: :premium_account? do |premium|
|
|
313
|
+
premium.before_save :apply_premium_features
|
|
314
|
+
premium.after_create :send_premium_welcome
|
|
315
|
+
premium.after_save :sync_to_premium_crm
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
with_options unless: :suspended? do |active|
|
|
319
|
+
active.after_save :update_activity_log
|
|
320
|
+
active.after_save :refresh_dashboard_cache
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private
|
|
324
|
+
|
|
325
|
+
def premium_account?
|
|
326
|
+
plan_type == "premium"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def suspended?
|
|
330
|
+
status == "suspended"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def apply_premium_features
|
|
334
|
+
self.storage_limit = 100.gigabytes
|
|
335
|
+
self.api_rate_limit = 10_000
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def send_premium_welcome
|
|
339
|
+
PremiumMailer.welcome(self).deliver_later
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def sync_to_premium_crm
|
|
343
|
+
PremiumCrmSyncJob.perform_later(id)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def update_activity_log
|
|
347
|
+
ActivityLog.create!(account: self, action: "updated")
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def refresh_dashboard_cache
|
|
351
|
+
Rails.cache.delete("dashboard_#{id}")
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# Lifecycle Callbacks Examples
|
|
2
|
+
# Demonstrates before/after/around callbacks for validation, save, create, update, destroy
|
|
3
|
+
|
|
4
|
+
# =============================================================================
|
|
5
|
+
# Basic Callback Declaration
|
|
6
|
+
# =============================================================================
|
|
7
|
+
|
|
8
|
+
class Article < ApplicationRecord
|
|
9
|
+
# Method reference (preferred style)
|
|
10
|
+
before_validation :normalize_title
|
|
11
|
+
before_save :set_published_at
|
|
12
|
+
after_create :notify_subscribers
|
|
13
|
+
after_update :log_changes
|
|
14
|
+
before_destroy :check_deletable
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def normalize_title
|
|
19
|
+
self.title = title&.strip&.titleize
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def set_published_at
|
|
23
|
+
self.published_at ||= Time.current if published?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def notify_subscribers
|
|
27
|
+
NotificationJob.perform_later(id, "article_created")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def log_changes
|
|
31
|
+
Rails.logger.info("Article #{id} updated: #{previous_changes.keys.join(', ')}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def check_deletable
|
|
35
|
+
throw(:abort) if comments.any?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# =============================================================================
|
|
40
|
+
# Around Callbacks (Must Yield!)
|
|
41
|
+
# =============================================================================
|
|
42
|
+
|
|
43
|
+
class Order < ApplicationRecord
|
|
44
|
+
around_save :measure_save_time
|
|
45
|
+
around_create :wrap_with_logging
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def measure_save_time
|
|
50
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
|
+
yield # CRITICAL: Must call yield or save won't happen
|
|
52
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
53
|
+
Rails.logger.info("Order save took #{(duration * 1000).round(2)}ms")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wrap_with_logging
|
|
57
|
+
Rails.logger.info("Creating order...")
|
|
58
|
+
yield
|
|
59
|
+
Rails.logger.info("Order #{id} created successfully")
|
|
60
|
+
rescue => e
|
|
61
|
+
Rails.logger.error("Order creation failed: #{e.message}")
|
|
62
|
+
raise
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# =============================================================================
|
|
67
|
+
# Callback Ordering and Prepend
|
|
68
|
+
# =============================================================================
|
|
69
|
+
|
|
70
|
+
class Topic < ApplicationRecord
|
|
71
|
+
has_many :comments, dependent: :destroy
|
|
72
|
+
|
|
73
|
+
# Without prepend: runs AFTER dependent: :destroy (comments already gone)
|
|
74
|
+
# before_destroy :archive_comments
|
|
75
|
+
|
|
76
|
+
# With prepend: runs BEFORE dependent: :destroy
|
|
77
|
+
before_destroy :archive_comments, prepend: true
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def archive_comments
|
|
82
|
+
comments.find_each do |comment|
|
|
83
|
+
CommentArchive.create!(
|
|
84
|
+
topic_id: id,
|
|
85
|
+
content: comment.content,
|
|
86
|
+
author: comment.author
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# =============================================================================
|
|
93
|
+
# Special Callbacks: after_initialize and after_find
|
|
94
|
+
# =============================================================================
|
|
95
|
+
|
|
96
|
+
class Configuration < ApplicationRecord
|
|
97
|
+
after_initialize :set_defaults
|
|
98
|
+
after_find :decrypt_secrets
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def set_defaults
|
|
103
|
+
# Runs for new() AND records loaded from DB
|
|
104
|
+
self.settings ||= {}
|
|
105
|
+
self.version ||= 1
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def decrypt_secrets
|
|
109
|
+
# Runs only for records loaded from DB (before after_initialize)
|
|
110
|
+
self.api_key = decrypt(encrypted_api_key) if encrypted_api_key.present?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def decrypt(value)
|
|
114
|
+
# decryption logic
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# =============================================================================
|
|
119
|
+
# Halting the Callback Chain
|
|
120
|
+
# =============================================================================
|
|
121
|
+
|
|
122
|
+
class Payment < ApplicationRecord
|
|
123
|
+
before_save :validate_amount
|
|
124
|
+
before_save :check_fraud
|
|
125
|
+
before_save :reserve_funds
|
|
126
|
+
after_save :send_receipt
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def validate_amount
|
|
131
|
+
if amount <= 0
|
|
132
|
+
errors.add(:amount, "must be positive")
|
|
133
|
+
throw(:abort) # Halts chain, rolls back transaction
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def check_fraud
|
|
138
|
+
if FraudDetector.suspicious?(self)
|
|
139
|
+
errors.add(:base, "Payment flagged for review")
|
|
140
|
+
throw(:abort)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def reserve_funds
|
|
145
|
+
# Only runs if previous callbacks didn't abort
|
|
146
|
+
PaymentGateway.reserve(amount, card_token)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def send_receipt
|
|
150
|
+
# Only runs if save succeeded
|
|
151
|
+
PaymentMailer.receipt(self).deliver_later
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# =============================================================================
|
|
156
|
+
# Callback Object Pattern (Reusable)
|
|
157
|
+
# =============================================================================
|
|
158
|
+
|
|
159
|
+
class AuditLogger
|
|
160
|
+
def after_create(record)
|
|
161
|
+
AuditLog.create!(
|
|
162
|
+
action: "create",
|
|
163
|
+
auditable: record,
|
|
164
|
+
changes: record.attributes
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def after_update(record)
|
|
169
|
+
AuditLog.create!(
|
|
170
|
+
action: "update",
|
|
171
|
+
auditable: record,
|
|
172
|
+
changes: record.previous_changes
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def after_destroy(record)
|
|
177
|
+
AuditLog.create!(
|
|
178
|
+
action: "destroy",
|
|
179
|
+
auditable_type: record.class.name,
|
|
180
|
+
auditable_id: record.id,
|
|
181
|
+
changes: record.attributes
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
class Invoice < ApplicationRecord
|
|
187
|
+
after_create AuditLogger.new
|
|
188
|
+
after_update AuditLogger.new
|
|
189
|
+
after_destroy AuditLogger.new
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
class Refund < ApplicationRecord
|
|
193
|
+
after_create AuditLogger.new
|
|
194
|
+
after_update AuditLogger.new
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# =============================================================================
|
|
198
|
+
# Inline Block Callbacks
|
|
199
|
+
# =============================================================================
|
|
200
|
+
|
|
201
|
+
class User < ApplicationRecord
|
|
202
|
+
# Simple inline normalization
|
|
203
|
+
before_validation { self.email = email&.downcase&.strip }
|
|
204
|
+
|
|
205
|
+
# Block with record parameter
|
|
206
|
+
after_create do |user|
|
|
207
|
+
WelcomeMailer.welcome(user).deliver_later
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Multiline block
|
|
211
|
+
before_save do
|
|
212
|
+
if email_changed?
|
|
213
|
+
self.email_verified = false
|
|
214
|
+
self.verification_token = SecureRandom.urlsafe_base64
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# =============================================================================
|
|
220
|
+
# Validation Callbacks
|
|
221
|
+
# =============================================================================
|
|
222
|
+
|
|
223
|
+
class Product < ApplicationRecord
|
|
224
|
+
before_validation :generate_sku, on: :create
|
|
225
|
+
after_validation :log_validation_errors
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def generate_sku
|
|
230
|
+
self.sku ||= "PRD-#{SecureRandom.alphanumeric(8).upcase}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def log_validation_errors
|
|
234
|
+
return if errors.empty?
|
|
235
|
+
|
|
236
|
+
Rails.logger.warn("Product validation failed: #{errors.full_messages.join(', ')}")
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# =============================================================================
|
|
241
|
+
# Touch Callback
|
|
242
|
+
# =============================================================================
|
|
243
|
+
|
|
244
|
+
class Comment < ApplicationRecord
|
|
245
|
+
belongs_to :post, touch: true # Automatically touches post on save
|
|
246
|
+
|
|
247
|
+
after_touch :update_cache
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def update_cache
|
|
252
|
+
Rails.cache.delete("comment_#{id}_preview")
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# =============================================================================
|
|
257
|
+
# Callback Inheritance
|
|
258
|
+
# =============================================================================
|
|
259
|
+
|
|
260
|
+
class Document < ApplicationRecord
|
|
261
|
+
before_save :set_version
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
def set_version
|
|
266
|
+
self.version = (version || 0) + 1
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
class Contract < Document
|
|
271
|
+
before_save :require_signatures
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
def require_signatures
|
|
276
|
+
throw(:abort) if requires_signature? && !signed?
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Contract.create! runs: set_version, then require_signatures
|