agentcode 0.9.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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +59 -0
  3. data/lib/agentcode/blueprint/blueprint_parser.rb +198 -0
  4. data/lib/agentcode/blueprint/blueprint_validator.rb +209 -0
  5. data/lib/agentcode/blueprint/generators/factory_generator.rb +74 -0
  6. data/lib/agentcode/blueprint/generators/policy_generator.rb +154 -0
  7. data/lib/agentcode/blueprint/generators/seeder_generator.rb +160 -0
  8. data/lib/agentcode/blueprint/generators/test_generator.rb +291 -0
  9. data/lib/agentcode/blueprint/manifest_manager.rb +81 -0
  10. data/lib/agentcode/commands/base_command.rb +57 -0
  11. data/lib/agentcode/commands/blueprint_command.rb +549 -0
  12. data/lib/agentcode/commands/export_postman_command.rb +328 -0
  13. data/lib/agentcode/commands/generate_command.rb +563 -0
  14. data/lib/agentcode/commands/install_command.rb +441 -0
  15. data/lib/agentcode/commands/invitation_link_command.rb +107 -0
  16. data/lib/agentcode/concerns/belongs_to_organization.rb +49 -0
  17. data/lib/agentcode/concerns/has_agentcode.rb +93 -0
  18. data/lib/agentcode/concerns/has_audit_trail.rb +125 -0
  19. data/lib/agentcode/concerns/has_auto_scope.rb +91 -0
  20. data/lib/agentcode/concerns/has_permissions.rb +117 -0
  21. data/lib/agentcode/concerns/has_uuid.rb +26 -0
  22. data/lib/agentcode/concerns/has_validation.rb +250 -0
  23. data/lib/agentcode/concerns/hidable_columns.rb +180 -0
  24. data/lib/agentcode/configuration.rb +98 -0
  25. data/lib/agentcode/controllers/auth_controller.rb +242 -0
  26. data/lib/agentcode/controllers/invitations_controller.rb +231 -0
  27. data/lib/agentcode/controllers/resources_controller.rb +813 -0
  28. data/lib/agentcode/engine.rb +65 -0
  29. data/lib/agentcode/mailers/invitation_mailer.rb +22 -0
  30. data/lib/agentcode/middleware/resolve_organization_from_route.rb +72 -0
  31. data/lib/agentcode/models/agentcode_model.rb +387 -0
  32. data/lib/agentcode/models/audit_log.rb +17 -0
  33. data/lib/agentcode/models/organization_invitation.rb +57 -0
  34. data/lib/agentcode/policies/invitation_policy.rb +54 -0
  35. data/lib/agentcode/policies/resource_policy.rb +197 -0
  36. data/lib/agentcode/query_builder.rb +278 -0
  37. data/lib/agentcode/railtie.rb +11 -0
  38. data/lib/agentcode/resource_scope.rb +59 -0
  39. data/lib/agentcode/routes.rb +124 -0
  40. data/lib/agentcode/tasks/agentcode.rake +39 -0
  41. data/lib/agentcode/templates/agentcode.rb +71 -0
  42. data/lib/agentcode/templates/agentcode_model.rb +104 -0
  43. data/lib/agentcode/templates/audit_trail/create_audit_logs.rb.erb +26 -0
  44. data/lib/agentcode/templates/generate/factory.rb.erb +43 -0
  45. data/lib/agentcode/templates/generate/migration.rb.erb +26 -0
  46. data/lib/agentcode/templates/generate/model.rb.erb +55 -0
  47. data/lib/agentcode/templates/generate/policy.rb.erb +52 -0
  48. data/lib/agentcode/templates/generate/scope.rb.erb +31 -0
  49. data/lib/agentcode/templates/multi_tenant/factories/organizations.rb.erb +9 -0
  50. data/lib/agentcode/templates/multi_tenant/factories/roles.rb.erb +9 -0
  51. data/lib/agentcode/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
  52. data/lib/agentcode/templates/multi_tenant/factories/users.rb.erb +9 -0
  53. data/lib/agentcode/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
  54. data/lib/agentcode/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
  55. data/lib/agentcode/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
  56. data/lib/agentcode/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
  57. data/lib/agentcode/templates/multi_tenant/models/organization.rb.erb +18 -0
  58. data/lib/agentcode/templates/multi_tenant/models/role.rb.erb +11 -0
  59. data/lib/agentcode/templates/multi_tenant/models/user.rb.erb +14 -0
  60. data/lib/agentcode/templates/multi_tenant/models/user_role.rb.erb +9 -0
  61. data/lib/agentcode/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
  62. data/lib/agentcode/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
  63. data/lib/agentcode/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
  64. data/lib/agentcode/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
  65. data/lib/agentcode/templates/routes.rb +13 -0
  66. data/lib/agentcode/version.rb +5 -0
  67. data/lib/agentcode/views/lumina/invitation_mailer/invite.html.erb +29 -0
  68. data/lib/agentcode-rails.rb +3 -0
  69. data/lib/agentcode.rb +26 -0
  70. metadata +281 -0
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ # Automatic change logging concern.
5
+ # Mirrors the Laravel HasAuditTrail trait.
6
+ #
7
+ # Tracks: created, updated, deleted, force_deleted, restored
8
+ # Records: old/new values, user_id, organization_id, ip_address, user_agent
9
+ #
10
+ # Usage:
11
+ # class Post < ApplicationRecord
12
+ # include AgentCode::HasAuditTrail
13
+ #
14
+ # # Optional: exclude sensitive fields from audit logging
15
+ # agentcode_audit_exclude :password, :remember_token
16
+ # end
17
+ module HasAuditTrail
18
+ extend ActiveSupport::Concern
19
+
20
+ included do
21
+ class_attribute :audit_exclude_fields, default: %w[password remember_token]
22
+
23
+ has_many :audit_logs, -> { order(created_at: :desc) },
24
+ as: :auditable,
25
+ class_name: "AgentCode::AuditLog",
26
+ dependent: :destroy
27
+
28
+ after_create :log_audit_created
29
+ after_update :log_audit_updated
30
+ after_destroy :log_audit_deleted
31
+
32
+ # For soft deletes restoration
33
+ if respond_to?(:after_undiscard)
34
+ after_undiscard :log_audit_restored
35
+ end
36
+ end
37
+
38
+ class_methods do
39
+ def agentcode_audit_exclude(*fields)
40
+ self.audit_exclude_fields = fields.map(&:to_s)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def log_audit_created
47
+ log_audit("created", nil, auditable_attributes)
48
+ end
49
+
50
+ def log_audit_updated
51
+ changes = saved_changes.except("updated_at")
52
+ return if changes.blank?
53
+
54
+ old_values = {}
55
+ new_values = {}
56
+
57
+ changes.each do |field, (old_val, new_val)|
58
+ next if audit_exclude_fields.include?(field)
59
+ old_values[field] = old_val
60
+ new_values[field] = new_val
61
+ end
62
+
63
+ return if new_values.blank?
64
+
65
+ log_audit("updated", old_values, new_values)
66
+ end
67
+
68
+ def log_audit_deleted
69
+ action = respond_to?(:discarded?) && discarded? ? "deleted" : "force_deleted"
70
+ log_audit(action, auditable_attributes, nil)
71
+ end
72
+
73
+ def log_audit_restored
74
+ log_audit("restored", nil, auditable_attributes)
75
+ end
76
+
77
+ def log_audit(action, old_values, new_values)
78
+ return unless audit_log_table_exists?
79
+
80
+ attributes = {
81
+ auditable: self,
82
+ action: action,
83
+ old_values: old_values,
84
+ new_values: new_values,
85
+ user_id: current_audit_user_id,
86
+ ip_address: current_audit_ip_address,
87
+ user_agent: current_audit_user_agent
88
+ }
89
+
90
+ # Add organization_id if available
91
+ org = current_audit_organization
92
+ attributes[:organization_id] = org.id if org
93
+
94
+ AgentCode::AuditLog.create!(attributes)
95
+ rescue StandardError => e
96
+ Rails.logger.warn("AgentCode::HasAuditTrail: Failed to log audit: #{e.message}")
97
+ end
98
+
99
+ def auditable_attributes
100
+ attributes.except(*audit_exclude_fields)
101
+ end
102
+
103
+ def current_audit_user_id
104
+ RequestStore.store[:agentcode_current_user]&.id if defined?(RequestStore)
105
+ end
106
+
107
+ def current_audit_ip_address
108
+ RequestStore.store[:agentcode_ip_address] if defined?(RequestStore)
109
+ end
110
+
111
+ def current_audit_user_agent
112
+ RequestStore.store[:agentcode_user_agent] if defined?(RequestStore)
113
+ end
114
+
115
+ def current_audit_organization
116
+ RequestStore.store[:agentcode_organization] if defined?(RequestStore)
117
+ end
118
+
119
+ def audit_log_table_exists?
120
+ @_audit_log_table_exists ||= ActiveRecord::Base.connection.table_exists?("audit_logs")
121
+ rescue StandardError
122
+ false
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ # Auto-detect and apply global scopes by convention.
5
+ # Mirrors the Laravel HasAutoScope trait.
6
+ #
7
+ # Looks for a scope class at `Scopes::{ModelName}Scope`
8
+ # (e.g., `Scopes::PostScope` for `Post` model).
9
+ #
10
+ # The scope class can either:
11
+ #
12
+ # 1. Extend +AgentCode::ResourceScope+ (recommended) — provides access to
13
+ # +user+, +organization+, and +role+ inside the +apply+ instance method:
14
+ #
15
+ # module Scopes
16
+ # class PostScope < AgentCode::ResourceScope
17
+ # def apply(relation)
18
+ # if role == "viewer"
19
+ # relation.where(published: true)
20
+ # else
21
+ # relation
22
+ # end
23
+ # end
24
+ # end
25
+ # end
26
+ #
27
+ # 2. Implement +self.apply(relation)+ as a class method (legacy/simple):
28
+ #
29
+ # module Scopes
30
+ # class PostScope
31
+ # def self.apply(relation)
32
+ # relation.where(active: true)
33
+ # end
34
+ # end
35
+ # end
36
+ #
37
+ module HasAutoScope
38
+ extend ActiveSupport::Concern
39
+
40
+ included do
41
+ default_scope lambda {
42
+ model = is_a?(ActiveRecord::Relation) ? self.klass : self
43
+ if model.respond_to?(:agentcode_auto_scope_class)
44
+ scope_class = model.agentcode_auto_scope_class
45
+ if scope_class
46
+ model.apply_agentcode_scope(scope_class, where(nil))
47
+ else
48
+ where(nil)
49
+ end
50
+ else
51
+ where(nil)
52
+ end
53
+ }
54
+ end
55
+
56
+ class_methods do
57
+ def agentcode_auto_scope_class
58
+ return @agentcode_auto_scope_class if instance_variable_defined?(:@agentcode_auto_scope_class)
59
+
60
+ result = find_auto_scope_class
61
+ # Only cache non-nil results to avoid permanently caching nil
62
+ # when the scope class hasn't been autoloaded yet (Zeitwerk)
63
+ @agentcode_auto_scope_class = result if result
64
+ result
65
+ end
66
+
67
+ # Apply the scope class to a relation.
68
+ # Supports both ResourceScope subclasses (instance method) and
69
+ # plain classes with self.apply (class method).
70
+ def apply_agentcode_scope(scope_class, relation)
71
+ if scope_class < AgentCode::ResourceScope
72
+ scope_class.new.apply(relation)
73
+ elsif scope_class.respond_to?(:apply)
74
+ scope_class.apply(relation)
75
+ else
76
+ relation
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def find_auto_scope_class
83
+ return nil if name.nil?
84
+
85
+ model_name = name.demodulize
86
+ "Scopes::#{model_name}Scope".safe_constantize ||
87
+ "ModelScopes::#{model_name}Scope".safe_constantize
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ # Permission checking concern for the User model.
5
+ # Mirrors the Laravel HasPermissions trait.
6
+ #
7
+ # Usage:
8
+ # class User < ApplicationRecord
9
+ # include AgentCode::HasPermissions
10
+ #
11
+ # has_many :user_roles
12
+ # end
13
+ #
14
+ # Permission format: '{slug}.{action}' (e.g., 'posts.index', 'blogs.store')
15
+ # Wildcard support:
16
+ # - '*' grants access to everything
17
+ # - 'posts.*' grants access to all actions on posts
18
+ #
19
+ # Two permission sources:
20
+ # - users.permissions: used for non-tenant route groups (no organization context).
21
+ # Stored as a JSON array directly on the user model.
22
+ # - role.permissions (via user_roles): used for tenant route groups (organization context present).
23
+ # Resolved per-organization via the user_roles → role association.
24
+ #
25
+ # Resolution:
26
+ # 1. When an organization is provided (tenant route group) → checks role.permissions
27
+ # for that specific organization via user_roles.
28
+ # 2. When no organization is provided (non-tenant route group) → checks users.permissions
29
+ # directly on the user model.
30
+ module HasPermissions
31
+ extend ActiveSupport::Concern
32
+
33
+ # Check if the user has a specific permission.
34
+ #
35
+ # @param permission [String] Permission string like 'posts.index'
36
+ # @param organization [Object, nil] Organization to check permissions for
37
+ # @return [Boolean]
38
+ def has_permission?(permission, organization = nil)
39
+ return false if permission.blank?
40
+
41
+ if organization
42
+ # Tenant route group: check permissions for this organization
43
+ user_role = find_user_role(organization)
44
+
45
+ if user_role
46
+ # Check user_role.permissions first (per-user-org permissions on the pivot)
47
+ ur_permissions = parse_permissions(user_role.respond_to?(:permissions) ? user_role.permissions : nil)
48
+ if ur_permissions.present?
49
+ return matches_permission?(permission, ur_permissions)
50
+ end
51
+
52
+ # Fallback to role.permissions (shared role-level permissions)
53
+ role = user_role.respond_to?(:role) ? user_role.role : nil
54
+ if role
55
+ role_permissions = parse_permissions(role.respond_to?(:permissions) ? role.permissions : nil)
56
+ return matches_permission?(permission, role_permissions) if role_permissions.present?
57
+ end
58
+ end
59
+
60
+ return false
61
+ end
62
+
63
+ # Non-tenant route group: check users.permissions directly
64
+ user_perms = parse_permissions(respond_to?(:permissions) ? self.permissions : nil)
65
+ matches_permission?(permission, user_perms)
66
+ end
67
+
68
+ # Get the role slug for validation purposes.
69
+ #
70
+ # @param organization [Object, nil] Organization context
71
+ # @return [String, nil] Role slug or nil
72
+ def role_slug_for_validation(organization = nil)
73
+ user_role = find_user_role(organization)
74
+ return nil unless user_role
75
+
76
+ role = user_role.respond_to?(:role) ? user_role.role : nil
77
+ return nil unless role
78
+
79
+ role.respond_to?(:slug) ? role.slug : nil
80
+ end
81
+
82
+ private
83
+
84
+ def matches_permission?(permission, granted_permissions)
85
+ return true if granted_permissions.include?(permission)
86
+ return true if granted_permissions.include?("*")
87
+
88
+ resource = permission.split(".").first
89
+ return true if granted_permissions.include?("#{resource}.*")
90
+
91
+ false
92
+ end
93
+
94
+ def parse_permissions(perms)
95
+ return [] if perms.blank?
96
+
97
+ if perms.is_a?(String)
98
+ begin
99
+ JSON.parse(perms)
100
+ rescue JSON::ParserError
101
+ []
102
+ end
103
+ elsif perms.is_a?(Array)
104
+ perms
105
+ else
106
+ []
107
+ end
108
+ end
109
+
110
+ def find_user_role(organization)
111
+ return nil unless respond_to?(:user_roles)
112
+ return nil unless organization
113
+
114
+ user_roles.find_by(organization_id: organization.id)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ # Auto-generate UUID on model creation.
5
+ # Mirrors the Laravel HasUuid trait.
6
+ #
7
+ # Usage:
8
+ # class Post < ApplicationRecord
9
+ # include AgentCode::HasUuid
10
+ # end
11
+ #
12
+ # Requires a `uuid` column in the migration.
13
+ module HasUuid
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ before_create :generate_uuid
18
+ end
19
+
20
+ private
21
+
22
+ def generate_uuid
23
+ self.uuid ||= SecureRandom.uuid if respond_to?(:uuid=)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ # Format validation concern for models.
5
+ #
6
+ # This concern runs ActiveModel validations on request data before
7
+ # it reaches the database. Field permissions (which fields each role
8
+ # can write) are controlled by the policy, not the model.
9
+ #
10
+ # Also provides cross-tenant FK validation: any belongs_to FK in the
11
+ # submitted data is checked to ensure the referenced record belongs
12
+ # to the current organization (directly or via FK chain).
13
+ #
14
+ # Usage:
15
+ # class Post < ApplicationRecord
16
+ # include AgentCode::HasValidation
17
+ #
18
+ # # Standard Rails validations for type/format (use allow_nil: true)
19
+ # validates :title, length: { maximum: 255 }, allow_nil: true
20
+ # validates :status, inclusion: { in: %w[draft published] }, allow_nil: true
21
+ # end
22
+ #
23
+ # Field permissions are defined on the policy:
24
+ # class PostPolicy < AgentCode::ResourcePolicy
25
+ # def permitted_attributes_for_create(user)
26
+ # has_role?(user, 'admin') ? ['*'] : ['title', 'content']
27
+ # end
28
+ # end
29
+ module HasValidation
30
+ extend ActiveSupport::Concern
31
+
32
+ # Validate data for a given action.
33
+ # Filters to only permitted fields, then runs ActiveModel validations
34
+ # and cross-tenant FK validation.
35
+ #
36
+ # @param params [Hash] The request data
37
+ # @param permitted_fields [Array<String>] Fields the user is allowed to set (['*'] for all)
38
+ # @param organization [Object, nil] Current organization for FK scoping (optional)
39
+ # @return [Hash] { valid: Boolean, errors: Hash, validated: Hash }
40
+ def validate_for_action(params, permitted_fields:, organization: nil)
41
+ # Filter to only permitted fields
42
+ if permitted_fields == ['*']
43
+ filtered = params.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
44
+ else
45
+ permitted = permitted_fields.map(&:to_s)
46
+ filtered = params.each_with_object({}) do |(k, v), h|
47
+ h[k.to_s] = v if permitted.include?(k.to_s)
48
+ end
49
+ end
50
+
51
+ # Remove organization_id from validated data — managed by framework
52
+ filtered.delete("organization_id") if organization
53
+
54
+ # Run ActiveModel validations on a temp instance
55
+ temp = self.class.new
56
+ safe_attrs = filtered.select { |k, _| temp.respond_to?("#{k}=") }
57
+ temp.assign_attributes(safe_attrs)
58
+
59
+ errors = {}
60
+ unless temp.valid?
61
+ temp.errors.each do |error|
62
+ field_name = error.attribute.to_s
63
+ if filtered.key?(field_name)
64
+ errors[field_name] ||= []
65
+ errors[field_name] << error.message
66
+ end
67
+ end
68
+ end
69
+
70
+ # Cross-tenant FK validation
71
+ if organization
72
+ fk_errors = validate_foreign_keys_for_organization(filtered, organization)
73
+ errors.merge!(fk_errors)
74
+ end
75
+
76
+ if errors.any?
77
+ { valid: false, errors: errors, validated: {} }
78
+ else
79
+ { valid: true, errors: {}, validated: filtered }
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Cache for FK chain lookups (class-level)
86
+ @@fk_chain_cache = {}
87
+ @@org_column_cache = {}
88
+
89
+ # Validate that all FK references in the data belong to the current organization.
90
+ # Walks belongs_to associations on the model, checks if the referenced table
91
+ # is org-scoped (directly or via FK chain), and verifies the record exists
92
+ # within the org's scope.
93
+ def validate_foreign_keys_for_organization(data, organization)
94
+ errors = {}
95
+ org_id = organization.id
96
+
97
+ self.class.reflect_on_all_associations(:belongs_to).each do |assoc|
98
+ fk_column = assoc.foreign_key.to_s
99
+ next unless data.key?(fk_column)
100
+ next if data[fk_column].nil?
101
+
102
+ # Skip organization_id itself
103
+ next if fk_column == "organization_id"
104
+
105
+ begin
106
+ related_class = assoc.klass
107
+ related_table = related_class.table_name
108
+ rescue StandardError
109
+ next
110
+ end
111
+
112
+ # Direct: related table has organization_id
113
+ if table_has_organization_id?(related_table)
114
+ unless related_class.where(
115
+ related_class.primary_key => data[fk_column],
116
+ organization_id: org_id
117
+ ).exists?
118
+ errors[fk_column] = ["does not belong to your organization"]
119
+ end
120
+ next
121
+ end
122
+
123
+ # Indirect: walk FK chain to find org-scoped ancestor
124
+ chain = find_organization_fk_chain(related_table)
125
+ next unless chain
126
+
127
+ unless record_belongs_to_organization?(related_table, related_class.primary_key, data[fk_column], org_id, chain)
128
+ errors[fk_column] = ["does not belong to your organization"]
129
+ end
130
+ end
131
+
132
+ errors
133
+ end
134
+
135
+ # Check if a table has an organization_id column.
136
+ def table_has_organization_id?(table)
137
+ unless @@org_column_cache.key?(table)
138
+ @@org_column_cache[table] = ActiveRecord::Base.connection.column_exists?(table, :organization_id)
139
+ end
140
+ @@org_column_cache[table]
141
+ end
142
+
143
+ # Find the FK chain from a table to an org-scoped ancestor.
144
+ # Returns array of steps or nil.
145
+ # Each step: { local_column:, foreign_table:, foreign_column: }
146
+ def find_organization_fk_chain(table)
147
+ return @@fk_chain_cache[table] if @@fk_chain_cache.key?(table)
148
+
149
+ chain = walk_fk_chain(table, 5, [])
150
+ @@fk_chain_cache[table] = chain
151
+ chain
152
+ end
153
+
154
+ def walk_fk_chain(table, max_depth, visited)
155
+ return nil if max_depth <= 0 || visited.include?(table)
156
+
157
+ visited = visited + [table]
158
+
159
+ begin
160
+ foreign_keys = ActiveRecord::Base.connection.foreign_keys(table)
161
+ rescue StandardError
162
+ return nil
163
+ end
164
+
165
+ foreign_keys.each do |fk|
166
+ local_column = fk.column
167
+ foreign_table = fk.to_table
168
+ foreign_column = fk.primary_key || "id"
169
+
170
+ if table_has_organization_id?(foreign_table)
171
+ return [{ local_column: local_column, foreign_table: foreign_table, foreign_column: foreign_column }]
172
+ end
173
+
174
+ deeper = walk_fk_chain(foreign_table, max_depth - 1, visited)
175
+ if deeper
176
+ deeper.unshift({ local_column: local_column, foreign_table: foreign_table, foreign_column: foreign_column })
177
+ return deeper
178
+ end
179
+ end
180
+
181
+ nil
182
+ end
183
+
184
+ # Check if a specific record belongs to the organization via FK chain.
185
+ # Builds a SQL EXISTS query with nested subqueries.
186
+ def record_belongs_to_organization?(table, pk_column, pk_value, org_id, chain)
187
+ # Build from innermost (org-scoped table) outward
188
+ # The chain goes: table → chain[0].foreign_table → chain[1].foreign_table → ... → org-scoped table
189
+ # We need to verify: record in `table` with pk_value → chain walks → org_id matches
190
+
191
+ query = build_chain_exists_query(table, pk_column, pk_value, org_id, chain, 0)
192
+ ActiveRecord::Base.connection.select_value(query).present?
193
+ end
194
+
195
+ def build_chain_exists_query(table, pk_column, pk_value, org_id, chain, index)
196
+ step = chain[index]
197
+
198
+ if index == chain.length - 1
199
+ # Last step: the foreign table has organization_id
200
+ sanitize_sql([
201
+ "SELECT 1 FROM #{quote_table(table)} " \
202
+ "WHERE #{quote_column(pk_column)} = ? " \
203
+ "AND #{quote_column(step[:local_column])} IN (" \
204
+ "SELECT #{quote_column(step[:foreign_column])} FROM #{quote_table(step[:foreign_table])} " \
205
+ "WHERE organization_id = ?)",
206
+ pk_value, org_id
207
+ ])
208
+ else
209
+ # Intermediate step: recurse deeper
210
+ inner = build_inner_chain_query(step[:foreign_table], step[:foreign_column], org_id, chain, index + 1)
211
+ sanitize_sql([
212
+ "SELECT 1 FROM #{quote_table(table)} " \
213
+ "WHERE #{quote_column(pk_column)} = ? " \
214
+ "AND #{quote_column(step[:local_column])} IN (#{inner})",
215
+ pk_value
216
+ ])
217
+ end
218
+ end
219
+
220
+ def build_inner_chain_query(table, pk_column, org_id, chain, index)
221
+ step = chain[index]
222
+
223
+ if index == chain.length - 1
224
+ sanitize_sql([
225
+ "SELECT #{quote_column(pk_column)} FROM #{quote_table(table)} " \
226
+ "WHERE #{quote_column(step[:local_column])} IN (" \
227
+ "SELECT #{quote_column(step[:foreign_column])} FROM #{quote_table(step[:foreign_table])} " \
228
+ "WHERE organization_id = ?)",
229
+ org_id
230
+ ])
231
+ else
232
+ inner = build_inner_chain_query(step[:foreign_table], step[:foreign_column], org_id, chain, index + 1)
233
+ "SELECT #{quote_column(pk_column)} FROM #{quote_table(table)} " \
234
+ "WHERE #{quote_column(step[:local_column])} IN (#{inner})"
235
+ end
236
+ end
237
+
238
+ def sanitize_sql(args)
239
+ ActiveRecord::Base.send(:sanitize_sql_array, args)
240
+ end
241
+
242
+ def quote_table(name)
243
+ ActiveRecord::Base.connection.quote_table_name(name)
244
+ end
245
+
246
+ def quote_column(name)
247
+ ActiveRecord::Base.connection.quote_column_name(name)
248
+ end
249
+ end
250
+ end