rhino-rails 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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +59 -0
  3. data/lib/rhino/blueprint/blueprint_parser.rb +198 -0
  4. data/lib/rhino/blueprint/blueprint_validator.rb +209 -0
  5. data/lib/rhino/blueprint/generators/factory_generator.rb +74 -0
  6. data/lib/rhino/blueprint/generators/policy_generator.rb +154 -0
  7. data/lib/rhino/blueprint/generators/seeder_generator.rb +160 -0
  8. data/lib/rhino/blueprint/generators/test_generator.rb +291 -0
  9. data/lib/rhino/blueprint/manifest_manager.rb +81 -0
  10. data/lib/rhino/commands/base_command.rb +57 -0
  11. data/lib/rhino/commands/blueprint_command.rb +529 -0
  12. data/lib/rhino/commands/export_postman_command.rb +328 -0
  13. data/lib/rhino/commands/export_types_command.rb +202 -0
  14. data/lib/rhino/commands/generate_command.rb +535 -0
  15. data/lib/rhino/commands/install_command.rb +408 -0
  16. data/lib/rhino/commands/invitation_link_command.rb +107 -0
  17. data/lib/rhino/concerns/belongs_to_organization.rb +49 -0
  18. data/lib/rhino/concerns/has_audit_trail.rb +125 -0
  19. data/lib/rhino/concerns/has_auto_scope.rb +91 -0
  20. data/lib/rhino/concerns/has_permissions.rb +117 -0
  21. data/lib/rhino/concerns/has_rhino.rb +93 -0
  22. data/lib/rhino/concerns/has_uuid.rb +26 -0
  23. data/lib/rhino/concerns/has_validation.rb +250 -0
  24. data/lib/rhino/concerns/hidable_columns.rb +180 -0
  25. data/lib/rhino/configuration.rb +101 -0
  26. data/lib/rhino/controllers/auth_controller.rb +242 -0
  27. data/lib/rhino/controllers/invitations_controller.rb +231 -0
  28. data/lib/rhino/controllers/resources_controller.rb +813 -0
  29. data/lib/rhino/engine.rb +64 -0
  30. data/lib/rhino/mailers/invitation_mailer.rb +22 -0
  31. data/lib/rhino/middleware/resolve_organization_from_route.rb +72 -0
  32. data/lib/rhino/models/audit_log.rb +17 -0
  33. data/lib/rhino/models/organization_invitation.rb +57 -0
  34. data/lib/rhino/models/rhino_model.rb +387 -0
  35. data/lib/rhino/policies/invitation_policy.rb +54 -0
  36. data/lib/rhino/policies/resource_policy.rb +197 -0
  37. data/lib/rhino/query_builder.rb +278 -0
  38. data/lib/rhino/railtie.rb +11 -0
  39. data/lib/rhino/resource_scope.rb +59 -0
  40. data/lib/rhino/routes.rb +124 -0
  41. data/lib/rhino/tasks/rhino.rake +47 -0
  42. data/lib/rhino/templates/audit_trail/create_audit_logs.rb.erb +26 -0
  43. data/lib/rhino/templates/generate/factory.rb.erb +43 -0
  44. data/lib/rhino/templates/generate/migration.rb.erb +26 -0
  45. data/lib/rhino/templates/generate/model.rb.erb +55 -0
  46. data/lib/rhino/templates/generate/policy.rb.erb +52 -0
  47. data/lib/rhino/templates/generate/scope.rb.erb +31 -0
  48. data/lib/rhino/templates/multi_tenant/factories/organizations.rb.erb +9 -0
  49. data/lib/rhino/templates/multi_tenant/factories/roles.rb.erb +9 -0
  50. data/lib/rhino/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
  51. data/lib/rhino/templates/multi_tenant/factories/users.rb.erb +9 -0
  52. data/lib/rhino/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
  53. data/lib/rhino/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
  54. data/lib/rhino/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
  55. data/lib/rhino/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
  56. data/lib/rhino/templates/multi_tenant/models/organization.rb.erb +18 -0
  57. data/lib/rhino/templates/multi_tenant/models/role.rb.erb +11 -0
  58. data/lib/rhino/templates/multi_tenant/models/user.rb.erb +14 -0
  59. data/lib/rhino/templates/multi_tenant/models/user_role.rb.erb +9 -0
  60. data/lib/rhino/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
  61. data/lib/rhino/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
  62. data/lib/rhino/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
  63. data/lib/rhino/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
  64. data/lib/rhino/templates/rhino.rb +71 -0
  65. data/lib/rhino/templates/rhino_model.rb +104 -0
  66. data/lib/rhino/templates/routes.rb +13 -0
  67. data/lib/rhino/version.rb +5 -0
  68. data/lib/rhino/views/lumina/invitation_mailer/invite.html.erb +29 -0
  69. data/lib/rhino-rails.rb +3 -0
  70. data/lib/rhino.rb +26 -0
  71. metadata +282 -0
@@ -0,0 +1,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rhino/commands/base_command"
4
+
5
+ module Rhino
6
+ module Commands
7
+ # Interactive setup wizard — mirrors Laravel `php artisan rhino:install` exactly.
8
+ #
9
+ # Usage: rails rhino:install
10
+ class InstallCommand < BaseCommand
11
+ def perform
12
+ say ""
13
+ say "+ Rhino :: Install :: Let's build something great +", :cyan
14
+ say ""
15
+
16
+ features = multi_select("Which features would you like to configure?") do |menu|
17
+ menu.default 1
18
+ menu.choice "Publish config & routes", "publish"
19
+ menu.choice "Multi-tenant support (Organizations, Roles)", "multi_tenant"
20
+ menu.choice "Audit trail (change logging)", "audit_trail"
21
+ end
22
+
23
+ test_framework = select("Which test framework do you use?") do |menu|
24
+ menu.default 1
25
+ menu.choice "RSpec", "rspec"
26
+ menu.choice "Minitest", "minitest"
27
+ end
28
+
29
+ identifier_column = "id"
30
+ roles = ["admin"]
31
+
32
+ if features.include?("multi_tenant")
33
+ identifier_column = ask("What column should be used to identify organizations?", default: "id")
34
+
35
+ roles_input = ask("What roles should your app have?", default: "admin, editor, viewer")
36
+ roles = (["admin"] + roles_input.split(",").map(&:strip)).uniq
37
+ end
38
+
39
+ say ""
40
+
41
+ if features.include?("publish")
42
+ task("Publishing config") { publish_config(test_framework) }
43
+ task("Publishing routes") { publish_routes }
44
+ task("Creating blueprint directory") { create_blueprint_directory }
45
+ end
46
+
47
+ if features.include?("multi_tenant")
48
+ task("Creating migrations") { create_multi_tenant_migrations }
49
+ task("Creating models") { create_multi_tenant_models(roles) }
50
+ task("Creating factories") { create_factories }
51
+ task("Creating policies") { create_policies }
52
+ task("Updating config") { update_config(identifier_column) }
53
+ task("Creating seeders") { create_seeders(roles) }
54
+ end
55
+
56
+ if features.include?("audit_trail")
57
+ task("Creating audit trail migration") { create_audit_trail_migration }
58
+ end
59
+
60
+ say ""
61
+ run_post_install_steps(features)
62
+
63
+ install_ai_skill
64
+
65
+ say ""
66
+ say "Rhino installed successfully!", :green
67
+ say ""
68
+
69
+ print_next_steps(features)
70
+ end
71
+
72
+ private
73
+
74
+ # ----------------------------------------------------------------
75
+ # Publish
76
+ # ----------------------------------------------------------------
77
+
78
+ def publish_config(test_framework)
79
+ template_path = File.expand_path("../../templates/rhino.rb", __FILE__)
80
+ dest_path = Rails.root.join("config/initializers/rhino.rb")
81
+
82
+ FileUtils.mkdir_p(File.dirname(dest_path))
83
+
84
+ content = File.read(template_path)
85
+ content = content.gsub('test_framework: "rspec"', "test_framework: \"#{test_framework}\"")
86
+ File.write(dest_path, content)
87
+ end
88
+
89
+ def publish_routes
90
+ template_path = File.expand_path("../../templates/routes.rb", __FILE__)
91
+ dest_path = Rails.root.join("config/routes/rhino.rb")
92
+
93
+ FileUtils.mkdir_p(File.dirname(dest_path))
94
+ FileUtils.cp(template_path, dest_path)
95
+ end
96
+
97
+ # ----------------------------------------------------------------
98
+ # Multi-tenant
99
+ # ----------------------------------------------------------------
100
+
101
+ def create_multi_tenant_migrations
102
+ timestamp = Time.current.strftime("%Y%m%d%H%M%S")
103
+ migrations_path = Rails.root.join("db/migrate")
104
+ FileUtils.mkdir_p(migrations_path)
105
+
106
+ templates_dir = File.expand_path("../../templates/multi_tenant/migrations", __FILE__)
107
+
108
+ {
109
+ "create_users" => "#{timestamp}00",
110
+ "create_organizations" => "#{timestamp}01",
111
+ "create_roles" => "#{timestamp}02",
112
+ "create_user_roles" => "#{timestamp}03"
113
+ }.each do |name, ts|
114
+ template = File.join(templates_dir, "#{name}.rb.erb")
115
+ next unless File.exist?(template)
116
+
117
+ content = ERB.new(File.read(template), trim_mode: "-").result(binding)
118
+ File.write(migrations_path.join("#{ts}_#{name}.rb"), content)
119
+ end
120
+ end
121
+
122
+ def create_multi_tenant_models(roles)
123
+ models_path = Rails.root.join("app/models")
124
+ FileUtils.mkdir_p(models_path)
125
+
126
+ templates_dir = File.expand_path("../../templates/multi_tenant/models", __FILE__)
127
+
128
+ %w[user organization role user_role].each do |model|
129
+ template = File.join(templates_dir, "#{model}.rb.erb")
130
+ next unless File.exist?(template)
131
+
132
+ content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(roles: roles)
133
+ File.write(models_path.join("#{model}.rb"), content)
134
+ end
135
+ end
136
+
137
+ def create_factories
138
+ factories_path = Rails.root.join("spec/factories")
139
+ FileUtils.mkdir_p(factories_path)
140
+
141
+ templates_dir = File.expand_path("../../templates/multi_tenant/factories", __FILE__)
142
+
143
+ %w[users organizations roles user_roles].each do |factory|
144
+ template = File.join(templates_dir, "#{factory}.rb.erb")
145
+ next unless File.exist?(template)
146
+
147
+ content = ERB.new(File.read(template), trim_mode: "-").result(binding)
148
+ File.write(factories_path.join("#{factory}.rb"), content)
149
+ end
150
+ end
151
+
152
+ def create_policies
153
+ policies_path = Rails.root.join("app/policies")
154
+ FileUtils.mkdir_p(policies_path)
155
+
156
+ templates_dir = File.expand_path("../../templates/multi_tenant/policies", __FILE__)
157
+
158
+ %w[organization_policy role_policy].each do |policy|
159
+ template = File.join(templates_dir, "#{policy}.rb.erb")
160
+ next unless File.exist?(template)
161
+
162
+ content = ERB.new(File.read(template), trim_mode: "-").result(binding)
163
+ File.write(policies_path.join("#{policy}.rb"), content)
164
+ end
165
+ end
166
+
167
+ def update_config(identifier_column)
168
+ config_path = Rails.root.join("config/initializers/rhino.rb")
169
+ return unless File.exist?(config_path)
170
+
171
+ content = File.read(config_path)
172
+
173
+ # Update organization_identifier_column
174
+ content = content.gsub(
175
+ 'organization_identifier_column: "id"',
176
+ "organization_identifier_column: \"#{identifier_column}\""
177
+ )
178
+
179
+ # Add organization and role models
180
+ unless content.include?("config.model :organizations")
181
+ content = content.gsub(
182
+ "# config.model :posts, 'Post'",
183
+ "config.model :organizations, 'Organization'\n config.model :roles, 'Role'\n # config.model :posts, 'Post'"
184
+ )
185
+ end
186
+
187
+ # Add tenant route group
188
+ unless content.include?("config.route_group :tenant")
189
+ content = content.gsub(
190
+ "# config.route_group :default",
191
+ "config.route_group :tenant, prefix: \":organization\", middleware: [Rhino::Middleware::ResolveOrganizationFromRoute], models: :all\n # config.route_group :default"
192
+ )
193
+ end
194
+
195
+ File.write(config_path, content)
196
+ end
197
+
198
+ def create_seeders(roles)
199
+ seeders_path = Rails.root.join("db/seeds")
200
+ FileUtils.mkdir_p(seeders_path)
201
+
202
+ templates_dir = File.expand_path("../../templates/multi_tenant/seeders", __FILE__)
203
+
204
+ template = File.join(templates_dir, "role_seeder.rb.erb")
205
+ if File.exist?(template)
206
+ content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(roles: roles)
207
+ File.write(seeders_path.join("role_seeder.rb"), content)
208
+ end
209
+
210
+ template = File.join(templates_dir, "organization_seeder.rb.erb")
211
+ if File.exist?(template)
212
+ content = ERB.new(File.read(template), trim_mode: "-").result(binding)
213
+ File.write(seeders_path.join("organization_seeder.rb"), content)
214
+ end
215
+ end
216
+
217
+ # ----------------------------------------------------------------
218
+ # Audit trail
219
+ # ----------------------------------------------------------------
220
+
221
+ def create_audit_trail_migration
222
+ timestamp = Time.current.strftime("%Y%m%d%H%M%S")
223
+ migrations_path = Rails.root.join("db/migrate")
224
+ FileUtils.mkdir_p(migrations_path)
225
+
226
+ # Check for existing
227
+ existing = Dir.glob(migrations_path.join("*_create_audit_logs.rb"))
228
+ return unless existing.empty?
229
+
230
+ template = File.expand_path("../../templates/audit_trail/create_audit_logs.rb.erb", __FILE__)
231
+ if File.exist?(template)
232
+ content = ERB.new(File.read(template), trim_mode: "-").result(binding)
233
+ File.write(migrations_path.join("#{timestamp}_create_audit_logs.rb"), content)
234
+ end
235
+ end
236
+
237
+ # ----------------------------------------------------------------
238
+ # Post-install
239
+ # ----------------------------------------------------------------
240
+
241
+ def run_post_install_steps(features)
242
+ has_migrations = features.include?("multi_tenant") || features.include?("audit_trail")
243
+
244
+ if has_migrations
245
+ if yes?("Would you like to run migrations now?")
246
+ task("Running migrations") { system("rails db:migrate") }
247
+ end
248
+ end
249
+
250
+ if features.include?("multi_tenant")
251
+ if yes?("Would you like to seed the database?")
252
+ task("Seeding database") { system("rails db:seed") }
253
+ end
254
+ end
255
+ end
256
+
257
+ def print_next_steps(features)
258
+ say "Remaining steps:", :yellow
259
+ say ""
260
+
261
+ step = 1
262
+
263
+ if features.include?("audit_trail")
264
+ say " #{step}. Add Rhino::HasAuditTrail concern to your models:"
265
+ say " include Rhino::HasAuditTrail"
266
+ step += 1
267
+ end
268
+
269
+ if features.include?("multi_tenant")
270
+ say " #{step}. Add Rhino::HasPermissions concern to your User model:"
271
+ say " include Rhino::HasPermissions"
272
+ step += 1
273
+ end
274
+
275
+ say ""
276
+ end
277
+
278
+ # ----------------------------------------------------------------
279
+ # Blueprint directory
280
+ # ----------------------------------------------------------------
281
+
282
+ def create_blueprint_directory
283
+ bp_dir = Rails.root.join(".rhino", "blueprints")
284
+ FileUtils.mkdir_p(bp_dir)
285
+
286
+ guide_path = bp_dir.join("..", "BLUEPRINT.md")
287
+ unless File.exist?(guide_path)
288
+ File.write(guide_path, blueprint_guide_content)
289
+ end
290
+ end
291
+
292
+ def blueprint_guide_content
293
+ <<~MD
294
+ # Rhino Blueprint — AI Guide
295
+
296
+ Use this file to teach AI assistants how to generate valid YAML blueprint files.
297
+
298
+ ## Quick Start
299
+
300
+ 1. Create `_roles.yaml` in `.rhino/blueprints/` with your role definitions
301
+ 2. Create `{model_slug}.yaml` for each model
302
+ 3. Run `rails rhino:blueprint` to generate all files
303
+
304
+ ## Roles Format
305
+
306
+ ```yaml
307
+ roles:
308
+ owner:
309
+ name: Owner
310
+ description: "Full access"
311
+ viewer:
312
+ name: Viewer
313
+ description: "Read-only"
314
+ ```
315
+
316
+ ## Model Format
317
+
318
+ ```yaml
319
+ model: Contract
320
+ slug: contracts
321
+
322
+ options:
323
+ belongs_to_organization: true
324
+ soft_deletes: true
325
+
326
+ columns:
327
+ title:
328
+ type: string
329
+ filterable: true
330
+
331
+ permissions:
332
+ owner:
333
+ actions: [index, show, store, update, destroy]
334
+ show_fields: "*"
335
+ create_fields: "*"
336
+ update_fields: "*"
337
+ ```
338
+
339
+ ## Valid Column Types
340
+ string, text, integer, bigInteger, boolean, date, datetime, timestamp, decimal, float, json, uuid, foreignId
341
+
342
+ ## Valid Actions
343
+ index, show, store, update, destroy, trashed, restore, forceDelete
344
+ MD
345
+ end
346
+
347
+ # ----------------------------------------------------------------
348
+ # AI Skill
349
+ # ----------------------------------------------------------------
350
+
351
+ def install_ai_skill
352
+ say ""
353
+
354
+ ai_tools = multi_select("Install Rhino AI Skill for which tools? (select none to skip)") do |menu|
355
+ menu.default 1
356
+ menu.choice "Claude Code (.claude/skills/rhino/)", "claude"
357
+ menu.choice "Cursor (.cursor/rules/rhino/)", "cursor"
358
+ menu.choice "AI Directory (.ai/skills/rhino/)", "ai"
359
+ end
360
+
361
+ return if ai_tools.empty?
362
+
363
+ url = "https://rhino-project.github.io/rhino-docs/skills/rails/SKILL.md"
364
+
365
+ destinations = {
366
+ "claude" => ".claude/skills/rhino/SKILL.md",
367
+ "cursor" => ".cursor/rules/rhino/SKILL.md",
368
+ "ai" => ".ai/skills/rhino/SKILL.md"
369
+ }
370
+
371
+ # Download once
372
+ require "net/http"
373
+ require "uri"
374
+
375
+ uri = URI.parse(url)
376
+ response = Net::HTTP.get_response(uri)
377
+
378
+ # Follow redirect if needed
379
+ if response.is_a?(Net::HTTPRedirection)
380
+ uri = URI.parse(response["location"])
381
+ response = Net::HTTP.get_response(uri)
382
+ end
383
+
384
+ unless response.is_a?(Net::HTTPSuccess)
385
+ say " Could not download skill file. You can manually download it from:", :yellow
386
+ say " #{url}"
387
+ return
388
+ end
389
+
390
+ content = response.body
391
+
392
+ ai_tools.each do |tool|
393
+ dest_file = Rails.root.join(destinations[tool])
394
+
395
+ task("Installing skill for #{tool}") do
396
+ FileUtils.mkdir_p(File.dirname(dest_file))
397
+ File.write(dest_file, content)
398
+ end
399
+ end
400
+
401
+ say " AI Skill installed successfully.", :green
402
+ rescue StandardError => e
403
+ say " Could not download skill file (#{e.message}). You can manually download it from:", :yellow
404
+ say " #{url}"
405
+ end
406
+ end
407
+ end
408
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rhino/commands/base_command"
4
+
5
+ module Rhino
6
+ module Commands
7
+ # Generate an invitation link for testing — mirrors Laravel `php artisan invitation:link` exactly.
8
+ #
9
+ # Usage: rails invitation:link EMAIL ORG [--role=ROLE] [--create]
10
+ class InvitationLinkCommand < BaseCommand
11
+ attr_accessor :email, :organization_identifier, :options
12
+
13
+ def initialize
14
+ super
15
+ @options = { role: nil, create: false }
16
+ end
17
+
18
+ def perform(email = @email, organization_identifier = @organization_identifier)
19
+ role_identifier = options[:role]
20
+ should_create = options[:create]
21
+
22
+ # Find organization
23
+ identifier_column = Rhino.config.multi_tenant[:organization_identifier_column] || "slug"
24
+
25
+ org_class = "Organization".safe_constantize
26
+ unless org_class
27
+ say "Organization model not found.", :red
28
+ return
29
+ end
30
+
31
+ organization = org_class.find_by(identifier_column => organization_identifier)
32
+
33
+ unless organization
34
+ say "Organization '#{organization_identifier}' not found.", :red
35
+ return
36
+ end
37
+
38
+ # Find or create invitation
39
+ invitation = OrganizationInvitation
40
+ .where(email: email, organization_id: organization.id, status: "pending")
41
+ .first
42
+
43
+ if !invitation && !should_create
44
+ say "No pending invitation found for '#{email}' in organization '#{organization.name}'.", :red
45
+ say "Use --create flag to create a new invitation."
46
+ return
47
+ end
48
+
49
+ if !invitation && should_create
50
+ unless role_identifier
51
+ say "Role is required when creating a new invitation. Use --role option.", :red
52
+ return
53
+ end
54
+
55
+ role_class = "Role".safe_constantize
56
+ unless role_class
57
+ say "Role model not found.", :red
58
+ return
59
+ end
60
+
61
+ role = if role_identifier.match?(/\A\d+\z/)
62
+ role_class.find_by(id: role_identifier)
63
+ else
64
+ role_class.find_by(slug: role_identifier)
65
+ end
66
+
67
+ unless role
68
+ say "Role '#{role_identifier}' not found.", :red
69
+ return
70
+ end
71
+
72
+ user_class = "User".safe_constantize
73
+ invited_by = user_class&.first
74
+
75
+ unless invited_by
76
+ say "No user found to assign as 'invited_by'. Please create a user first.", :red
77
+ return
78
+ end
79
+
80
+ invitation = OrganizationInvitation.create!(
81
+ organization_id: organization.id,
82
+ email: email,
83
+ role_id: role.id,
84
+ invited_by: invited_by.id
85
+ )
86
+
87
+ say "Created new invitation for #{email}.", :green
88
+ end
89
+
90
+ # Build the invitation URL
91
+ frontend_url = ENV.fetch("FRONTEND_URL", "http://localhost:5173")
92
+ url = "#{frontend_url}/accept-invitation?token=#{invitation.token}"
93
+
94
+ say ""
95
+ say "Invitation link for #{email}:", :green
96
+ say url
97
+ say ""
98
+ say "Token: #{invitation.token}"
99
+ say "Organization: #{organization.name} (#{organization.try(:slug) || organization.id})"
100
+ say "Role: #{invitation.role&.name}" if invitation.respond_to?(:role) && invitation.role
101
+ say "Status: #{invitation.status}"
102
+ say "Expires: #{invitation.expires_at&.strftime('%Y-%m-%d %H:%M:%S')}" if invitation.expires_at
103
+ say ""
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ # Multi-tenant scoping concern.
5
+ # Mirrors the Laravel BelongsToOrganization trait.
6
+ #
7
+ # Usage:
8
+ # class Post < ApplicationRecord
9
+ # include Rhino::BelongsToOrganization
10
+ # end
11
+ #
12
+ # For nested ownership (model doesn't have organization_id directly),
13
+ # the path is auto-detected from belongs_to associations.
14
+ module BelongsToOrganization
15
+ extend ActiveSupport::Concern
16
+
17
+ included do
18
+ belongs_to :organization
19
+
20
+ # Auto-set organization_id on creation from request context
21
+ before_create :set_organization_from_context
22
+
23
+ # Default scope to filter by current organization
24
+ default_scope lambda {
25
+ if defined?(RequestStore) && RequestStore.store[:rhino_organization]
26
+ where(organization_id: RequestStore.store[:rhino_organization].id)
27
+ else
28
+ all
29
+ end
30
+ }
31
+ end
32
+
33
+ class_methods do
34
+ def for_organization(organization)
35
+ where(organization_id: organization.id)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def set_organization_from_context
42
+ return if organization_id.present?
43
+ return unless defined?(RequestStore)
44
+
45
+ org = RequestStore.store[:rhino_organization]
46
+ self.organization_id = org.id if org
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
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 Rhino::HasAuditTrail
13
+ #
14
+ # # Optional: exclude sensitive fields from audit logging
15
+ # rhino_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: "Rhino::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 rhino_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
+ Rhino::AuditLog.create!(attributes)
95
+ rescue StandardError => e
96
+ Rails.logger.warn("Rhino::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[:rhino_current_user]&.id if defined?(RequestStore)
105
+ end
106
+
107
+ def current_audit_ip_address
108
+ RequestStore.store[:rhino_ip_address] if defined?(RequestStore)
109
+ end
110
+
111
+ def current_audit_user_agent
112
+ RequestStore.store[:rhino_user_agent] if defined?(RequestStore)
113
+ end
114
+
115
+ def current_audit_organization
116
+ RequestStore.store[:rhino_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