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,535 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rhino/commands/base_command"
4
+
5
+ module Rhino
6
+ module Commands
7
+ # Interactive scaffold generator — mirrors Laravel `php artisan rhino:generate` exactly.
8
+ #
9
+ # Usage: rails rhino:generate (or rails rhino:g)
10
+ class GenerateCommand < BaseCommand
11
+ def perform
12
+ print_styled_header
13
+
14
+ type = select("What type of resource would you like to generate?") do |menu|
15
+ menu.choice "Model (with migration and factory)", "model"
16
+ menu.choice "Policy (extends ResourcePolicy)", "policy"
17
+ menu.choice "Scope (for ScopedDB)", "scope"
18
+ end
19
+
20
+ name = ask("What is the resource name? (PascalCase singular, e.g., Post):")
21
+ name = name.strip.camelize
22
+
23
+ if name.blank? || name !~ /\A[A-Za-z][A-Za-z0-9]*\z/
24
+ say "Invalid name. Must start with a letter and contain only alphanumeric characters.", :red
25
+ return
26
+ end
27
+
28
+ case type
29
+ when "model"
30
+ generate_model(name)
31
+ when "policy"
32
+ generate_policy(name)
33
+ when "scope"
34
+ generate_scope(name)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def print_styled_header
41
+ text = "+ Rhino :: Generate :: Scaffold your resources +"
42
+ say ""
43
+ say " ┌#{"─" * (text.length + 8)}┐", :cyan
44
+ say " │ #{text} │", :cyan
45
+ say " └#{"─" * (text.length + 8)}┘", :cyan
46
+ say ""
47
+ end
48
+
49
+ # ----------------------------------------------------------------
50
+ # Model generation
51
+ # ----------------------------------------------------------------
52
+
53
+ def generate_model(name)
54
+ table_name = name.underscore.pluralize
55
+
56
+ # Multi-tenant check
57
+ belongs_to_org = false
58
+ owner_relation = nil
59
+ is_multi_tenant = multi_tenant_enabled?
60
+
61
+ if is_multi_tenant
62
+ belongs_to_org = yes?("Does this model belong to an organization?")
63
+
64
+ unless belongs_to_org
65
+ existing_models = get_existing_models
66
+ if existing_models.any?
67
+ has_parent = yes?("Does this model have a parent that belongs to an organization?")
68
+ if has_parent
69
+ owner_model = select("Which model is the parent owner?", existing_models)
70
+ owner_relation = owner_model.underscore.camelize(:lower)
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ # Collect columns
77
+ columns = []
78
+ if yes?("Would you like to define columns interactively?")
79
+ columns = collect_columns
80
+ end
81
+
82
+ # Auto-add organization_id FK
83
+ if belongs_to_org
84
+ columns.unshift({
85
+ name: "organization_id",
86
+ type: "references",
87
+ nullable: false,
88
+ unique: false,
89
+ index: true,
90
+ default: nil,
91
+ foreign_model: "Organization"
92
+ })
93
+ end
94
+
95
+ # Auto-add owner FK
96
+ if owner_relation
97
+ owner_fk = "#{owner_relation.underscore}_id"
98
+ unless columns.any? { |c| c[:name] == owner_fk }
99
+ columns.unshift({
100
+ name: owner_fk,
101
+ type: "references",
102
+ nullable: false,
103
+ unique: false,
104
+ index: true,
105
+ default: nil,
106
+ foreign_model: owner_relation.camelize
107
+ })
108
+ end
109
+ end
110
+
111
+ # Additional options
112
+ options = collect_additional_options
113
+
114
+ # Role access
115
+ role_access = {}
116
+ if options[:policy] && is_multi_tenant
117
+ role_access = collect_role_access(name)
118
+ end
119
+
120
+ # Generate model
121
+ task("Creating #{name} model") do
122
+ write_model_file(name, columns, belongs_to_org, owner_relation, options)
123
+ end
124
+
125
+ # Generate migration
126
+ unless columns.empty?
127
+ task("Creating migration for #{table_name}") do
128
+ write_migration_file(name, columns, options[:soft_deletes])
129
+ end
130
+ end
131
+
132
+ # Generate factory
133
+ task("Creating #{name} factory") do
134
+ write_factory_file(name, columns)
135
+ end
136
+
137
+ # Register in config
138
+ task("Registering #{name} in config/initializers/rhino.rb") do
139
+ register_model_in_config(name)
140
+ end
141
+
142
+ # Generate policy
143
+ if options[:policy]
144
+ task("Generating #{name}Policy") do
145
+ write_policy_file(name)
146
+ end
147
+ end
148
+
149
+ # Generate scope
150
+ task("Generating #{name}Scope") do
151
+ write_scope_file(name)
152
+ end
153
+
154
+ say ""
155
+ say "#{name} model generated successfully!", :green
156
+ print_created_files(name, options)
157
+ print_model_next_steps(name, table_name)
158
+ end
159
+
160
+ # ----------------------------------------------------------------
161
+ # Policy generation
162
+ # ----------------------------------------------------------------
163
+
164
+ def generate_policy(name)
165
+ policy_name = name.end_with?("Policy") ? name : "#{name}Policy"
166
+ model_name = policy_name.sub(/Policy\z/, "")
167
+
168
+ task("Generating #{policy_name}") do
169
+ write_policy_file(model_name)
170
+ end
171
+
172
+ say ""
173
+ say "#{policy_name} generated successfully!", :green
174
+ say ""
175
+ say " Created: app/policies/#{policy_name.underscore}.rb"
176
+ say ""
177
+ say " Next steps:", :yellow
178
+ say " 1. Customize the authorization methods you need."
179
+ say ""
180
+ end
181
+
182
+ # ----------------------------------------------------------------
183
+ # Scope generation
184
+ # ----------------------------------------------------------------
185
+
186
+ def generate_scope(name)
187
+ scope_name = name.end_with?("Scope") ? name : "#{name}Scope"
188
+ model_name = scope_name.sub(/Scope\z/, "")
189
+
190
+ task("Generating #{scope_name}") do
191
+ write_scope_file(model_name)
192
+ end
193
+
194
+ say ""
195
+ say "#{scope_name} generated successfully!", :green
196
+ say ""
197
+ say " Created: app/models/scopes/#{scope_name.underscore}.rb"
198
+ say ""
199
+ end
200
+
201
+ # ----------------------------------------------------------------
202
+ # Column collection
203
+ # ----------------------------------------------------------------
204
+
205
+ def collect_columns
206
+ columns = []
207
+
208
+ loop do
209
+ col_name = ask("Column name (snake_case, e.g., title) — leave blank to finish:")
210
+ break if col_name.nil? || col_name.blank?
211
+
212
+ col_type = select("Column type for '#{col_name}'") do |menu|
213
+ menu.choice "string (VARCHAR 255)", "string"
214
+ menu.choice "text (TEXT)", "text"
215
+ menu.choice "integer", "integer"
216
+ menu.choice "bigInteger", "bigint"
217
+ menu.choice "boolean", "boolean"
218
+ menu.choice "date", "date"
219
+ menu.choice "datetime", "datetime"
220
+ menu.choice "decimal (8, 2)", "decimal"
221
+ menu.choice "float", "float"
222
+ menu.choice "json", "json"
223
+ menu.choice "uuid", "uuid"
224
+ menu.choice "references (foreign key)", "references"
225
+ end
226
+
227
+ column = {
228
+ name: col_name,
229
+ type: col_type,
230
+ nullable: false,
231
+ unique: false,
232
+ index: false,
233
+ default: nil,
234
+ foreign_model: nil
235
+ }
236
+
237
+ if col_type == "references"
238
+ existing = get_existing_models
239
+ if existing.any?
240
+ column[:foreign_model] = select("Which model does '#{col_name}' reference?", existing)
241
+ end
242
+ end
243
+
244
+ column[:nullable] = yes?("Is '#{col_name}' nullable?")
245
+ column[:unique] = yes?("Should '#{col_name}' be unique?")
246
+
247
+ columns << column
248
+
249
+ break unless yes?("Add another column?")
250
+ end
251
+
252
+ columns
253
+ end
254
+
255
+ def collect_additional_options
256
+ say "Additional options:", :yellow
257
+
258
+ options = multi_select("Select additional options:") do |menu|
259
+ menu.choice "Soft deletes", :soft_deletes
260
+ menu.choice "Generate policy", :policy
261
+ menu.choice "Audit trail", :audit_trail
262
+ end
263
+
264
+ {
265
+ soft_deletes: options.include?(:soft_deletes),
266
+ policy: options.include?(:policy),
267
+ audit_trail: options.include?(:audit_trail)
268
+ }
269
+ end
270
+
271
+ def collect_role_access(name)
272
+ roles = get_roles_from_config
273
+ return {} if roles.empty?
274
+
275
+ slug = name.underscore.pluralize
276
+ role_access = { "admin" => "editor" }
277
+
278
+ non_admin_roles = roles.reject { |r| r == "admin" }
279
+ return role_access if non_admin_roles.empty?
280
+
281
+ say ""
282
+ say "Define role access for #{slug}:", :cyan
283
+ say ""
284
+
285
+ non_admin_roles.each do |role|
286
+ access = select("Access level for '#{role}'") do |menu|
287
+ menu.choice "Editor — all actions on this model", "editor"
288
+ menu.choice "Viewer — read-only (index, show)", "viewer"
289
+ menu.choice "Writer — create & edit (index, show, store, update)", "writer"
290
+ menu.choice "No access", "none"
291
+ end
292
+ role_access[role] = access
293
+ end
294
+
295
+ role_access
296
+ end
297
+
298
+ # ----------------------------------------------------------------
299
+ # File writers
300
+ # ----------------------------------------------------------------
301
+
302
+ def write_model_file(name, columns, belongs_to_org, owner_relation, options)
303
+ template = File.expand_path("../../templates/generate/model.rb.erb", __FILE__)
304
+ dest = Rails.root.join("app/models/#{name.underscore}.rb")
305
+ FileUtils.mkdir_p(File.dirname(dest))
306
+
307
+ table_name = name.underscore.pluralize
308
+
309
+ # Build data for template
310
+ fillable = columns.map { |c| c[:name] }.reject { |n| n == "organization_id" && belongs_to_org }
311
+ filter_cols = columns.reject { |c| %w[text json].include?(c[:type]) }.map { |c| c[:name] }
312
+ sort_cols = (columns.reject { |c| %w[text json].include?(c[:type]) }.map { |c| c[:name] } + ["created_at"]).uniq
313
+ field_cols = (["id"] + columns.map { |c| c[:name] } + ["created_at"]).uniq
314
+ include_cols = columns.select { |c| c[:type] == "references" && c[:foreign_model] }
315
+ .map { |c| c[:name].sub(/_id\z/, "") }
316
+
317
+ validation_rules = columns.to_h { |c| [c[:name], column_to_rails_validations(c)] }
318
+
319
+ content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(
320
+ name: name,
321
+ table_name: table_name,
322
+ fillable: fillable,
323
+ filter_cols: filter_cols,
324
+ sort_cols: sort_cols,
325
+ field_cols: field_cols,
326
+ include_cols: include_cols,
327
+ validation_rules: validation_rules,
328
+ columns: columns,
329
+ belongs_to_org: belongs_to_org,
330
+ owner_relation: owner_relation,
331
+ soft_deletes: options[:soft_deletes],
332
+ audit_trail: options[:audit_trail]
333
+ )
334
+
335
+ File.write(dest, content)
336
+ end
337
+
338
+ def write_migration_file(name, columns, soft_deletes)
339
+ template = File.expand_path("../../templates/generate/migration.rb.erb", __FILE__)
340
+ table_name = name.underscore.pluralize
341
+ timestamp = Time.current.strftime("%Y%m%d%H%M%S")
342
+ dest = Rails.root.join("db/migrate/#{timestamp}_create_#{table_name}.rb")
343
+ FileUtils.mkdir_p(File.dirname(dest))
344
+
345
+ content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(
346
+ table_name: table_name,
347
+ class_name: "Create#{name.pluralize}",
348
+ columns: columns,
349
+ soft_deletes: soft_deletes
350
+ )
351
+
352
+ File.write(dest, content)
353
+ end
354
+
355
+ def write_factory_file(name, columns)
356
+ template = File.expand_path("../../templates/generate/factory.rb.erb", __FILE__)
357
+ dest = Rails.root.join("spec/factories/#{name.underscore.pluralize}.rb")
358
+ FileUtils.mkdir_p(File.dirname(dest))
359
+
360
+ content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(
361
+ name: name,
362
+ columns: columns
363
+ )
364
+
365
+ File.write(dest, content)
366
+ end
367
+
368
+ def write_policy_file(name)
369
+ template = File.expand_path("../../templates/generate/policy.rb.erb", __FILE__)
370
+ dest = Rails.root.join("app/policies/#{name.underscore}_policy.rb")
371
+ FileUtils.mkdir_p(File.dirname(dest))
372
+
373
+ content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(name: name)
374
+
375
+ File.write(dest, content)
376
+ end
377
+
378
+ def write_scope_file(name)
379
+ template = File.expand_path("../../templates/generate/scope.rb.erb", __FILE__)
380
+ dest = Rails.root.join("app/models/scopes/#{name.underscore}_scope.rb")
381
+ FileUtils.mkdir_p(File.dirname(dest))
382
+
383
+ table_name = name.underscore.pluralize
384
+ content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(
385
+ name: name,
386
+ table_name: table_name
387
+ )
388
+
389
+ File.write(dest, content)
390
+ end
391
+
392
+ def register_model_in_config(name)
393
+ config_path = Rails.root.join("config/initializers/rhino.rb")
394
+ return unless File.exist?(config_path)
395
+
396
+ content = File.read(config_path)
397
+ slug = name.underscore.pluralize
398
+
399
+ # Check if model is already registered (non-commented line)
400
+ return if content.match?(/^\s+\w+\.model\s+:#{slug}\b/)
401
+
402
+ # Detect the block variable name used in the config file
403
+ block_var = content.match(/Rhino\.configure\s+do\s+\|(\w+)\|/)&.captures&.first || "config"
404
+
405
+ new_entry = " #{block_var}.model :#{slug}, '#{name}'"
406
+
407
+ if content.include?("# #{block_var}.model :posts, 'Post'")
408
+ content = content.gsub(
409
+ "# #{block_var}.model :posts, 'Post'",
410
+ "#{new_entry}\n # #{block_var}.model :posts, 'Post'"
411
+ )
412
+ elsif content.match?(/# \w+\.model :posts, 'Post'/)
413
+ content = content.sub(
414
+ /# (\w+)\.model :posts, 'Post'/,
415
+ "#{new_entry}\n # \\1.model :posts, 'Post'"
416
+ )
417
+ else
418
+ content = content.sub(
419
+ /(# Register your models here.*?\n)/,
420
+ "\\1#{new_entry}\n"
421
+ )
422
+ end
423
+
424
+ File.write(config_path, content)
425
+ end
426
+
427
+ # ----------------------------------------------------------------
428
+ # Helpers
429
+ # ----------------------------------------------------------------
430
+
431
+ def column_to_rails_validations(column)
432
+ validations = []
433
+
434
+ case column[:type]
435
+ when "string"
436
+ validations << "length: { maximum: 255 }"
437
+ when "integer", "bigint"
438
+ validations << "numericality: { only_integer: true }"
439
+ when "boolean"
440
+ validations << "inclusion: { in: [true, false] }"
441
+ when "references"
442
+ validations << "numericality: { only_integer: true }"
443
+ end
444
+
445
+ validations
446
+ end
447
+
448
+ def column_to_faker(column)
449
+ case column[:name]
450
+ when "name", "full_name" then "Faker::Name.name"
451
+ when "email" then "Faker::Internet.email"
452
+ when "title" then "Faker::Lorem.sentence(word_count: 3)"
453
+ when "description", "content", "body" then "Faker::Lorem.paragraph"
454
+ when "slug" then "Faker::Internet.slug"
455
+ when "phone", "phone_number" then "Faker::PhoneNumber.phone_number"
456
+ when "url", "website" then "Faker::Internet.url"
457
+ when /\Ais_/ then "[true, false].sample"
458
+ else
459
+ case column[:type]
460
+ when "string" then "Faker::Lorem.sentence(word_count: 3)"
461
+ when "text" then "Faker::Lorem.paragraph"
462
+ when "integer", "bigint" then "Faker::Number.between(from: 1, to: 100)"
463
+ when "boolean" then "[true, false].sample"
464
+ when "date" then "Faker::Date.between(from: 1.year.ago, to: Date.today)"
465
+ when "datetime" then "Faker::Time.between(from: 1.year.ago, to: Time.current)"
466
+ when "decimal", "float" then "Faker::Number.decimal(l_digits: 3, r_digits: 2)"
467
+ when "json" then "{}"
468
+ when "uuid" then "SecureRandom.uuid"
469
+ when "references"
470
+ if column[:foreign_model]
471
+ "association :#{column[:name].sub(/_id\z/, '')}"
472
+ else
473
+ "Faker::Number.between(from: 1, to: 10)"
474
+ end
475
+ else
476
+ "Faker::Lorem.word"
477
+ end
478
+ end
479
+ end
480
+
481
+ def multi_tenant_enabled?
482
+ config_path = Rails.root.join("config/initializers/rhino.rb")
483
+ return false unless File.exist?(config_path)
484
+
485
+ content = File.read(config_path)
486
+ content.include?("route_group :tenant")
487
+ end
488
+
489
+ def get_existing_models
490
+ models_path = Rails.root.join("app/models")
491
+ return [] unless Dir.exist?(models_path)
492
+
493
+ Dir.glob(models_path.join("*.rb")).map do |f|
494
+ File.basename(f, ".rb").camelize
495
+ end.reject { |m| m == "ApplicationRecord" }
496
+ end
497
+
498
+ def get_roles_from_config
499
+ config_path = Rails.root.join("config/initializers/rhino.rb")
500
+ return [] unless File.exist?(config_path)
501
+
502
+ content = File.read(config_path)
503
+ if content =~ /roles.*?\[(.*?)\]/m
504
+ $1.scan(/"([^"]+)"/).flatten
505
+ else
506
+ []
507
+ end
508
+ end
509
+
510
+ def print_created_files(name, options)
511
+ table_name = name.underscore.pluralize
512
+ say ""
513
+ say "Created files:", :yellow
514
+ say ""
515
+ say " Model app/models/#{name.underscore}.rb"
516
+ say " Migration db/migrate/..._create_#{table_name}.rb"
517
+ say " Factory spec/factories/#{table_name}.rb"
518
+ say " Config config/initializers/rhino.rb (registered as '#{table_name}')"
519
+ say " Policy app/policies/#{name.underscore}_policy.rb" if options[:policy]
520
+ say " Scope app/models/scopes/#{name.underscore}_scope.rb"
521
+ end
522
+
523
+ def print_model_next_steps(name, table_name)
524
+ say ""
525
+ say "Next steps:", :yellow
526
+ say ""
527
+ say " 1. Run migrations: rails db:migrate"
528
+ say " 2. Review the generated model at: app/models/#{name.underscore}.rb"
529
+ say " 3. Run tests: rspec"
530
+ say " 4. Your API endpoints: GET/POST /api/#{table_name}, GET/PUT/DELETE /api/#{table_name}/{id}"
531
+ say ""
532
+ end
533
+ end
534
+ end
535
+ end