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,813 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ # Global CRUD controller that handles all registered models.
5
+ # Mirrors the Laravel GlobalController exactly.
6
+ #
7
+ # Routes pass the model slug via route defaults, and this controller
8
+ # resolves the appropriate ActiveRecord class to operate on.
9
+ class ResourcesController < ActionController::API
10
+ include Pundit::Authorization
11
+
12
+ # Disable parameter wrapping — AgentCode expects flat JSON params
13
+ wrap_parameters false
14
+
15
+ rescue_from Pundit::NotAuthorizedError do |_exception|
16
+ render json: { message: "This action is unauthorized." }, status: :forbidden
17
+ end
18
+
19
+ # Cache for auto-detected organization paths (class-level, survives across requests)
20
+ @@organization_path_cache = {}
21
+
22
+ before_action :set_model_class
23
+ before_action :resolve_organization
24
+ before_action :authenticate_user!, unless: :public_route_group?
25
+
26
+ # GET /api/{slug}
27
+ def index
28
+ authorize model_class, :index?, policy_class: policy_for(model_class)
29
+
30
+ builder = QueryBuilder.new(model_class, params: params)
31
+ apply_organization_scope(builder)
32
+ builder.build
33
+
34
+ per_page = params[:per_page]
35
+ pagination_enabled = model_class.try(:pagination_enabled) || false
36
+
37
+ if per_page.present? || pagination_enabled
38
+ result = builder.paginate
39
+ set_pagination_headers(result[:pagination])
40
+ render json: serialize_collection(result[:items])
41
+ else
42
+ render json: serialize_collection(builder.to_scope)
43
+ end
44
+ end
45
+
46
+ # POST /api/{slug}
47
+ def store
48
+ authorize model_class, :create?, policy_class: policy_for(model_class)
49
+
50
+ data = params_hash
51
+
52
+ # Strip organization_id — it's auto-set by the framework
53
+ data.delete("organization_id") if current_organization
54
+
55
+ permitted_fields = resolve_permitted_fields(current_user, "create")
56
+
57
+ # Check for forbidden fields → 403
58
+ forbidden = find_forbidden_fields(data, permitted_fields)
59
+ if forbidden.any?
60
+ return render json: {
61
+ message: "You are not allowed to set the following field(s): #{forbidden.join(', ')}"
62
+ }, status: :forbidden
63
+ end
64
+
65
+ model_instance = model_class.new
66
+ validation = model_instance.validate_for_action(
67
+ data, permitted_fields: permitted_fields, organization: current_organization
68
+ )
69
+
70
+ unless validation[:valid]
71
+ return render json: { errors: validation[:errors] }, status: :unprocessable_entity
72
+ end
73
+
74
+ validated = validation[:validated]
75
+ add_organization_to_data(validated)
76
+
77
+ record = model_class.create!(validated)
78
+ render json: serialize_record(record), status: :created
79
+ end
80
+
81
+ # GET /api/{slug}/:id
82
+ def show
83
+ record = find_record
84
+ authorize record, :show?, policy_class: policy_for(record)
85
+
86
+ # Apply includes if requested
87
+ if params[:include].present?
88
+ auth_response = authorize_includes
89
+ return auth_response if auth_response
90
+
91
+ builder = QueryBuilder.new(model_class, params: params)
92
+ builder.instance_variable_set(:@scope, model_class.where(id: record.id))
93
+ apply_organization_scope(builder)
94
+ builder.build
95
+ record = builder.to_scope.first!
96
+ end
97
+
98
+ render json: serialize_record(record)
99
+ end
100
+
101
+ # PUT /api/{slug}/:id
102
+ def update
103
+ record = find_record
104
+ authorize record, :update?, policy_class: policy_for(record)
105
+
106
+ data = params_hash
107
+
108
+ # Reject organization_id changes — cross-tenant reassignment is not allowed
109
+ if current_organization && data.key?("organization_id")
110
+ return render json: {
111
+ message: "You are not allowed to change the organization_id."
112
+ }, status: :forbidden
113
+ end
114
+
115
+ permitted_fields = resolve_permitted_fields(current_user, "update")
116
+
117
+ # Check for forbidden fields → 403
118
+ forbidden = find_forbidden_fields(data, permitted_fields)
119
+ if forbidden.any?
120
+ return render json: {
121
+ message: "You are not allowed to set the following field(s): #{forbidden.join(', ')}"
122
+ }, status: :forbidden
123
+ end
124
+
125
+ model_instance = model_class.new
126
+ validation = model_instance.validate_for_action(
127
+ data, permitted_fields: permitted_fields, organization: current_organization
128
+ )
129
+
130
+ unless validation[:valid]
131
+ return render json: { errors: validation[:errors] }, status: :unprocessable_entity
132
+ end
133
+
134
+ record.update!(validation[:validated])
135
+ record.reload
136
+
137
+ render json: serialize_record(record)
138
+ end
139
+
140
+ # DELETE /api/{slug}/:id
141
+ def destroy
142
+ record = find_record
143
+ authorize record, :destroy?, policy_class: policy_for(record)
144
+
145
+ if record.respond_to?(:discard!)
146
+ record.discard!
147
+ else
148
+ record.destroy!
149
+ end
150
+
151
+ head :no_content
152
+ end
153
+
154
+ # ------------------------------------------------------------------
155
+ # Soft Delete Endpoints
156
+ # ------------------------------------------------------------------
157
+
158
+ # GET /api/{slug}/trashed
159
+ def trashed
160
+ authorize model_class, :view_trashed?, policy_class: policy_for(model_class)
161
+
162
+ builder = QueryBuilder.new(model_class.discarded, params: params)
163
+ apply_organization_scope(builder)
164
+ builder.build
165
+
166
+ per_page = params[:per_page]
167
+ pagination_enabled = model_class.try(:pagination_enabled) || false
168
+
169
+ if per_page.present? || pagination_enabled
170
+ result = builder.paginate
171
+ set_pagination_headers(result[:pagination])
172
+ render json: serialize_collection(result[:items])
173
+ else
174
+ render json: serialize_collection(builder.to_scope)
175
+ end
176
+ end
177
+
178
+ # POST /api/{slug}/:id/restore
179
+ def restore
180
+ record = model_class.discarded.find(params[:id])
181
+ authorize record, :restore?, policy_class: policy_for(record)
182
+
183
+ record.undiscard!
184
+ record.reload
185
+
186
+ render json: serialize_record(record)
187
+ end
188
+
189
+ # DELETE /api/{slug}/:id/force-delete
190
+ def force_delete
191
+ record = model_class.discarded.find(params[:id])
192
+ authorize record, :force_delete?, policy_class: policy_for(record)
193
+
194
+ record.destroy!
195
+
196
+ head :no_content
197
+ end
198
+
199
+ # ------------------------------------------------------------------
200
+ # Nested Operations
201
+ # ------------------------------------------------------------------
202
+
203
+ # POST /api/nested
204
+ def nested
205
+ operations = validate_nested_structure
206
+ return if performed?
207
+
208
+ nested_config = AgentCode.config.nested
209
+ max_ops = nested_config[:max_operations]
210
+
211
+ if max_ops && operations.length > max_ops
212
+ return render json: {
213
+ message: "Too many operations.",
214
+ errors: { operations: ["Maximum #{max_ops} operations allowed."] }
215
+ }, status: :unprocessable_entity
216
+ end
217
+
218
+ allowed_models = nested_config[:allowed_models]
219
+ if allowed_models.is_a?(Array)
220
+ operations.each_with_index do |op, index|
221
+ unless allowed_models.include?(op["model"])
222
+ return render json: {
223
+ message: "Operation not allowed.",
224
+ errors: { "operations.#{index}.model" => ["Model \"#{op['model']}\" is not allowed for nested operations."] }
225
+ }, status: :unprocessable_entity
226
+ end
227
+ end
228
+ end
229
+
230
+ # Validate and authorize each operation
231
+ validated_per_op = []
232
+ auth_results = []
233
+
234
+ operations.each_with_index do |operation, index|
235
+ validated = validate_nested_operation(operation, index)
236
+ return if performed?
237
+ validated_per_op << validated
238
+
239
+ auth_result = authorize_nested_operation(operation, validated, index)
240
+ return if performed?
241
+ auth_results << auth_result
242
+ end
243
+
244
+ # Execute all operations in a transaction
245
+ results = execute_nested_operations(operations, validated_per_op, auth_results)
246
+ render json: { results: results }
247
+ end
248
+
249
+ private
250
+
251
+ # ------------------------------------------------------------------
252
+ # Model resolution
253
+ # ------------------------------------------------------------------
254
+
255
+ def set_model_class
256
+ slug = params[:model_slug] || request.env["agentcode.model_slug"]
257
+ @model_class = AgentCode.config.resolve_model(slug)
258
+ rescue ActiveRecord::RecordNotFound => e
259
+ render json: { message: e.message }, status: :not_found
260
+ end
261
+
262
+ def model_class
263
+ @model_class
264
+ end
265
+
266
+ def model_slug
267
+ params[:model_slug] || request.env["agentcode.model_slug"]
268
+ end
269
+
270
+ # ------------------------------------------------------------------
271
+ # Authentication
272
+ # ------------------------------------------------------------------
273
+
274
+ def public_route_group?
275
+ current_route_group == "public"
276
+ end
277
+
278
+ def current_route_group
279
+ params[:route_group]
280
+ end
281
+
282
+ def authenticate_user!
283
+ unless current_user
284
+ render json: { message: "Unauthenticated." }, status: :unauthorized
285
+ end
286
+ end
287
+
288
+ def current_user
289
+ # Override in host app or use token auth
290
+ @current_user ||= begin
291
+ token = request.headers["Authorization"]&.sub(/\ABearer /, "")
292
+ return nil unless token
293
+
294
+ # Look for user by API token
295
+ user_class = "User".safe_constantize
296
+ return nil unless user_class
297
+
298
+ user = if user_class.respond_to?(:find_by_api_token)
299
+ user_class.find_by_api_token(token)
300
+ elsif user_class.column_names.include?("api_token")
301
+ user_class.find_by(api_token: token)
302
+ end
303
+
304
+ # Store in RequestStore so scopes and concerns can access it
305
+ RequestStore.store[:agentcode_current_user] = user if defined?(RequestStore) && user
306
+
307
+ user
308
+ end
309
+ end
310
+
311
+ # ------------------------------------------------------------------
312
+ # Organization (multi-tenant)
313
+ # ------------------------------------------------------------------
314
+
315
+ def resolve_organization
316
+ org_identifier = params[:organization]
317
+ return unless org_identifier.present?
318
+
319
+ org_class = "Organization".safe_constantize
320
+ return unless org_class
321
+
322
+ column = AgentCode.config.multi_tenant[:organization_identifier_column] || "id"
323
+ organization = org_class.find_by(column => org_identifier)
324
+
325
+ unless organization
326
+ render json: { message: "Organization not found" }, status: :not_found
327
+ return
328
+ end
329
+
330
+ # Check if authenticated user belongs to this organization
331
+ user = current_user
332
+ if user && user.respond_to?(:user_roles) && !user.user_roles.exists?(organization_id: organization.id)
333
+ render json: { message: "Organization not found" }, status: :not_found
334
+ return
335
+ end
336
+
337
+ request.env["agentcode.organization"] = organization
338
+
339
+ if defined?(RequestStore)
340
+ RequestStore.store[:agentcode_organization] = organization
341
+ end
342
+ end
343
+
344
+ def current_organization
345
+ request.env["agentcode.organization"]
346
+ end
347
+
348
+ def apply_organization_scope(builder)
349
+ org = current_organization
350
+ return unless org
351
+
352
+ # When the resource IS the Organization model
353
+ if org.class == model_class
354
+ builder.instance_variable_set(
355
+ :@scope,
356
+ builder.scope.where(model_class.primary_key => org.send(model_class.primary_key))
357
+ )
358
+ return
359
+ end
360
+
361
+ # Check for scopeForOrganization
362
+ if model_class.respond_to?(:for_organization)
363
+ builder.instance_variable_set(:@scope, model_class.for_organization(org))
364
+ return
365
+ end
366
+
367
+ # Check for organization_id column
368
+ if model_class.column_names.include?("organization_id")
369
+ builder.instance_variable_set(
370
+ :@scope,
371
+ builder.scope.where(organization_id: org.id)
372
+ )
373
+ return
374
+ end
375
+
376
+ # Auto-detect from belongs_to relationships
377
+ detected_path = discover_organization_path(model_class)
378
+ if detected_path.present?
379
+ apply_organization_scope_through_relationship(builder, org, detected_path)
380
+ end
381
+ end
382
+
383
+ def apply_organization_scope_through_relationship(builder, organization, relationship_path)
384
+ if relationship_path.include?(".")
385
+ # Nested path: 'post.blog' -> joins(post: :blog).where(blogs: { organization_id: org.id })
386
+ parts = relationship_path.split(".")
387
+ join_chain = parts.reverse.inject(:organization) { |inner, outer| { outer.to_sym => inner } }
388
+
389
+ builder.instance_variable_set(
390
+ :@scope,
391
+ builder.scope.joins(join_chain.is_a?(Symbol) ? join_chain : parts.first.to_sym => join_chain)
392
+ .where(organizations: { id: organization.id })
393
+ )
394
+ else
395
+ # Single relationship
396
+ assoc = model_class.reflect_on_association(relationship_path.to_sym)
397
+ return unless assoc
398
+
399
+ if assoc.klass.column_names.include?("organization_id")
400
+ builder.instance_variable_set(
401
+ :@scope,
402
+ builder.scope.joins(relationship_path.to_sym)
403
+ .where(assoc.klass.table_name => { organization_id: organization.id })
404
+ )
405
+ end
406
+ end
407
+ end
408
+
409
+ def add_organization_to_data(data)
410
+ org = current_organization
411
+ return unless org
412
+
413
+ if model_class.column_names.include?("organization_id")
414
+ data["organization_id"] = org.id
415
+ end
416
+ end
417
+
418
+ # Recursively discover the relationship path from a model to Organization
419
+ # by introspecting BelongsTo associations. Returns dot-notation path or nil.
420
+ #
421
+ # Results are cached per model class to avoid repeated reflection.
422
+ def discover_organization_path(klass, visited = [], max_depth = 3)
423
+ # Return cached result (including nil)
424
+ if @@organization_path_cache.key?(klass.name)
425
+ return @@organization_path_cache[klass.name]
426
+ end
427
+
428
+ result = _discover_organization_path_recursive(klass, visited, max_depth)
429
+ @@organization_path_cache[klass.name] = result
430
+ result
431
+ end
432
+
433
+ def _discover_organization_path_recursive(klass, visited, max_depth)
434
+ return nil if max_depth <= 0 || visited.include?(klass.name)
435
+
436
+ visited = visited + [klass.name]
437
+
438
+ begin
439
+ associations = klass.reflect_on_all_associations(:belongs_to)
440
+ rescue StandardError
441
+ return nil
442
+ end
443
+
444
+ matching_paths = []
445
+
446
+ associations.each do |assoc|
447
+ begin
448
+ related_class = assoc.klass
449
+ rescue StandardError
450
+ next
451
+ end
452
+
453
+ # Direct match: related model IS Organization
454
+ if related_class.name == "Organization"
455
+ matching_paths << assoc.name.to_s
456
+ next
457
+ end
458
+
459
+ # Related model has organization_id column
460
+ begin
461
+ if related_class.column_names.include?("organization_id")
462
+ matching_paths << assoc.name.to_s
463
+ next
464
+ end
465
+ rescue StandardError
466
+ # Table may not exist yet
467
+ end
468
+
469
+ # Related model includes BelongsToOrganization concern
470
+ if related_class.include?(AgentCode::BelongsToOrganization)
471
+ matching_paths << assoc.name.to_s
472
+ next
473
+ end
474
+
475
+ # Recurse into related model's BelongsTo associations
476
+ sub_path = _discover_organization_path_recursive(related_class, visited, max_depth - 1)
477
+ if sub_path.present?
478
+ matching_paths << "#{assoc.name}.#{sub_path}"
479
+ end
480
+ end
481
+
482
+ return nil if matching_paths.empty?
483
+
484
+ if matching_paths.length > 1
485
+ Rails.logger&.debug(
486
+ "AgentCode: Model #{klass.name} has multiple BelongsTo paths to Organization. " \
487
+ "Using '#{matching_paths[0]}'. " \
488
+ "Paths found: #{matching_paths.inspect}"
489
+ )
490
+ end
491
+
492
+ matching_paths[0]
493
+ end
494
+
495
+ # ------------------------------------------------------------------
496
+ # Record finding
497
+ # ------------------------------------------------------------------
498
+
499
+ def find_record
500
+ scope = model_class.all
501
+
502
+ org = current_organization
503
+ if org && model_class.column_names.include?("organization_id")
504
+ scope = scope.where(organization_id: org.id)
505
+ end
506
+
507
+ scope.find(params[:id])
508
+ end
509
+
510
+ # ------------------------------------------------------------------
511
+ # Include authorization
512
+ # ------------------------------------------------------------------
513
+
514
+ def authorize_includes
515
+ include_param = params[:include]
516
+ return nil unless include_param.present?
517
+
518
+ allowed = model_class.try(:allowed_includes) || []
519
+ return nil if allowed.empty?
520
+
521
+ requested = include_param.to_s.split(",").map(&:strip)
522
+
523
+ requested.each do |include_path|
524
+ segments = include_path.split(".")
525
+ current_model = model_class
526
+
527
+ segments.each do |segment|
528
+ base = resolve_base_include_segment(segment, allowed)
529
+ next unless base
530
+
531
+ assoc = current_model.reflect_on_association(base.to_sym)
532
+ next unless assoc
533
+
534
+ related_class = assoc.klass
535
+ policy = policy_for(related_class)
536
+
537
+ begin
538
+ unless policy.new(current_user, related_class).index?
539
+ render json: {
540
+ message: "You do not have permission to include #{include_path}."
541
+ }, status: :forbidden
542
+ return true
543
+ end
544
+ rescue StandardError
545
+ # If policy check fails, deny
546
+ render json: {
547
+ message: "You do not have permission to include #{include_path}."
548
+ }, status: :forbidden
549
+ return true
550
+ end
551
+
552
+ current_model = related_class
553
+ end
554
+ end
555
+
556
+ nil
557
+ end
558
+
559
+ def resolve_base_include_segment(segment, allowed)
560
+ return segment if allowed.include?(segment)
561
+
562
+ if segment.end_with?("Count")
563
+ base = segment.sub(/Count\z/, "")
564
+ return base if allowed.include?(base)
565
+ end
566
+
567
+ if segment.end_with?("Exists")
568
+ base = segment.sub(/Exists\z/, "")
569
+ return base if allowed.include?(base)
570
+ end
571
+
572
+ nil
573
+ end
574
+
575
+ # ------------------------------------------------------------------
576
+ # Serialization
577
+ # ------------------------------------------------------------------
578
+
579
+ def serialize_record(record)
580
+ if record.respond_to?(:as_agentcode_json)
581
+ record.as_agentcode_json
582
+ else
583
+ record.as_json
584
+ end
585
+ end
586
+
587
+ def serialize_collection(records)
588
+ records.map { |r| serialize_record(r) }
589
+ end
590
+
591
+ # ------------------------------------------------------------------
592
+ # Pagination headers
593
+ # ------------------------------------------------------------------
594
+
595
+ def set_pagination_headers(pagination)
596
+ response.headers["X-Current-Page"] = pagination[:current_page].to_s
597
+ response.headers["X-Last-Page"] = pagination[:last_page].to_s
598
+ response.headers["X-Per-Page"] = pagination[:per_page].to_s
599
+ response.headers["X-Total"] = pagination[:total].to_s
600
+ end
601
+
602
+ # ------------------------------------------------------------------
603
+ # Policy resolution
604
+ # ------------------------------------------------------------------
605
+
606
+ def policy_for(record_or_class)
607
+ klass = record_or_class.is_a?(Class) ? record_or_class : record_or_class.class
608
+
609
+ # Try to find a specific policy (e.g., PostPolicy)
610
+ policy_name = "#{klass.name}Policy"
611
+ policy_class = policy_name.safe_constantize
612
+
613
+ # Fall back to AgentCode::ResourcePolicy
614
+ policy_class || AgentCode::ResourcePolicy
615
+ end
616
+
617
+ # ------------------------------------------------------------------
618
+ # Nested operations helpers
619
+ # ------------------------------------------------------------------
620
+
621
+ def validate_nested_structure
622
+ data = params.to_unsafe_h
623
+ operations = data["operations"]
624
+
625
+ unless operations.is_a?(Array)
626
+ render json: {
627
+ message: "The operations field is required and must be an array.",
628
+ errors: { operations: ["The operations field is required and must be an array."] }
629
+ }, status: :unprocessable_entity
630
+ return nil
631
+ end
632
+
633
+ operations.each_with_index do |op, index|
634
+ unless op.is_a?(Hash)
635
+ render json: {
636
+ message: "Invalid structure.",
637
+ errors: { "operations.#{index}" => ["Each operation must be an object."] }
638
+ }, status: :unprocessable_entity
639
+ return nil
640
+ end
641
+
642
+ if op["model"].blank?
643
+ render json: {
644
+ message: "Invalid structure.",
645
+ errors: { "operations.#{index}.model" => ["The model field is required."] }
646
+ }, status: :unprocessable_entity
647
+ return nil
648
+ end
649
+
650
+ unless %w[create update].include?(op["action"])
651
+ render json: {
652
+ message: "Invalid structure.",
653
+ errors: { "operations.#{index}.action" => ["The action must be create or update."] }
654
+ }, status: :unprocessable_entity
655
+ return nil
656
+ end
657
+
658
+ unless op["data"].is_a?(Hash)
659
+ render json: {
660
+ message: "Invalid structure.",
661
+ errors: { "operations.#{index}.data" => ["The data field is required and must be an object."] }
662
+ }, status: :unprocessable_entity
663
+ return nil
664
+ end
665
+
666
+ if op["action"] == "update" && !op.key?("id")
667
+ render json: {
668
+ message: "Invalid structure.",
669
+ errors: { "operations.#{index}.id" => ["The id field is required for update operations."] }
670
+ }, status: :unprocessable_entity
671
+ return nil
672
+ end
673
+ end
674
+
675
+ operations
676
+ end
677
+
678
+ def validate_nested_operation(operation, index)
679
+ slug = operation["model"]
680
+ op_model_class = begin
681
+ AgentCode.config.resolve_model(slug)
682
+ rescue ActiveRecord::RecordNotFound
683
+ render json: {
684
+ message: "Unknown model.",
685
+ errors: { "operations.#{index}.model" => ["The model \"#{slug}\" does not exist."] }
686
+ }, status: :unprocessable_entity
687
+ return nil
688
+ end
689
+
690
+ action = operation["action"] == "create" ? "create" : "update"
691
+ op_policy = policy_for(op_model_class)
692
+ op_policy_instance = op_policy.new(current_user, op_model_class)
693
+
694
+ permitted_fields = if action == "create" && op_policy_instance.respond_to?(:permitted_attributes_for_create)
695
+ op_policy_instance.permitted_attributes_for_create(current_user)
696
+ elsif action == "update" && op_policy_instance.respond_to?(:permitted_attributes_for_update)
697
+ op_policy_instance.permitted_attributes_for_update(current_user)
698
+ else
699
+ ['*']
700
+ end
701
+
702
+ # Check for forbidden fields → 403
703
+ forbidden = find_forbidden_fields(operation["data"], permitted_fields)
704
+ if forbidden.any?
705
+ render json: {
706
+ message: "You are not allowed to set the following field(s): #{forbidden.join(', ')}"
707
+ }, status: :forbidden
708
+ return nil
709
+ end
710
+
711
+ model_instance = op_model_class.new
712
+ validation = model_instance.validate_for_action(operation["data"], permitted_fields: permitted_fields)
713
+
714
+ unless validation[:valid]
715
+ errors = {}
716
+ validation[:errors].each do |key, messages|
717
+ errors["operations.#{index}.data.#{key}"] = messages
718
+ end
719
+ render json: { message: "Validation failed.", errors: errors }, status: :unprocessable_entity
720
+ return nil
721
+ end
722
+
723
+ validation[:validated]
724
+ end
725
+
726
+ def authorize_nested_operation(operation, _validated, _index)
727
+ slug = operation["model"]
728
+ op_model_class = AgentCode.config.resolve_model(slug)
729
+ policy = policy_for(op_model_class)
730
+
731
+ if operation["action"] == "create"
732
+ unless policy.new(current_user, op_model_class).create?
733
+ render json: { message: "This action is unauthorized." }, status: :forbidden
734
+ return nil
735
+ end
736
+ nil
737
+ else
738
+ record = op_model_class.find(operation["id"])
739
+ unless policy.new(current_user, record).update?
740
+ render json: { message: "This action is unauthorized." }, status: :forbidden
741
+ return nil
742
+ end
743
+ record
744
+ end
745
+ end
746
+
747
+ def execute_nested_operations(operations, validated_per_op, auth_results)
748
+ results = []
749
+
750
+ ActiveRecord::Base.transaction do
751
+ operations.each_with_index do |op, index|
752
+ validated = validated_per_op[index]
753
+ model_or_nil = auth_results[index]
754
+
755
+ if op["action"] == "create"
756
+ op_model_class = AgentCode.config.resolve_model(op["model"])
757
+ data = validated.dup
758
+ add_organization_to_data(data)
759
+ record = op_model_class.create!(data)
760
+ results << {
761
+ model: op["model"],
762
+ action: "create",
763
+ id: record.id,
764
+ data: serialize_record(record)
765
+ }
766
+ else
767
+ model_or_nil.update!(validated)
768
+ model_or_nil.reload
769
+ results << {
770
+ model: op["model"],
771
+ action: "update",
772
+ id: model_or_nil.id,
773
+ data: serialize_record(model_or_nil)
774
+ }
775
+ end
776
+ end
777
+ end
778
+
779
+ results
780
+ end
781
+
782
+ # ------------------------------------------------------------------
783
+ # Permitted fields resolution
784
+ # ------------------------------------------------------------------
785
+
786
+ def resolve_permitted_fields(user, action)
787
+ policy = policy_for(model_class)
788
+ policy_instance = policy.new(user, model_class)
789
+
790
+ case action.to_s
791
+ when "create"
792
+ policy_instance.respond_to?(:permitted_attributes_for_create) ?
793
+ policy_instance.permitted_attributes_for_create(user) : ["*"]
794
+ when "update"
795
+ policy_instance.respond_to?(:permitted_attributes_for_update) ?
796
+ policy_instance.permitted_attributes_for_update(user) : ["*"]
797
+ else
798
+ ["*"]
799
+ end
800
+ end
801
+
802
+ def find_forbidden_fields(params_data, permitted_fields)
803
+ return [] if permitted_fields == ["*"]
804
+
805
+ permitted = permitted_fields.map(&:to_s)
806
+ params_data.keys.map(&:to_s) - permitted
807
+ end
808
+
809
+ def params_hash
810
+ params.except(:controller, :action, :model_slug, :route_group, :organization, :id, :format).to_unsafe_h
811
+ end
812
+ end
813
+ end