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.
- checksums.yaml +7 -0
- data/README.md +59 -0
- data/lib/agentcode/blueprint/blueprint_parser.rb +198 -0
- data/lib/agentcode/blueprint/blueprint_validator.rb +209 -0
- data/lib/agentcode/blueprint/generators/factory_generator.rb +74 -0
- data/lib/agentcode/blueprint/generators/policy_generator.rb +154 -0
- data/lib/agentcode/blueprint/generators/seeder_generator.rb +160 -0
- data/lib/agentcode/blueprint/generators/test_generator.rb +291 -0
- data/lib/agentcode/blueprint/manifest_manager.rb +81 -0
- data/lib/agentcode/commands/base_command.rb +57 -0
- data/lib/agentcode/commands/blueprint_command.rb +549 -0
- data/lib/agentcode/commands/export_postman_command.rb +328 -0
- data/lib/agentcode/commands/generate_command.rb +563 -0
- data/lib/agentcode/commands/install_command.rb +441 -0
- data/lib/agentcode/commands/invitation_link_command.rb +107 -0
- data/lib/agentcode/concerns/belongs_to_organization.rb +49 -0
- data/lib/agentcode/concerns/has_agentcode.rb +93 -0
- data/lib/agentcode/concerns/has_audit_trail.rb +125 -0
- data/lib/agentcode/concerns/has_auto_scope.rb +91 -0
- data/lib/agentcode/concerns/has_permissions.rb +117 -0
- data/lib/agentcode/concerns/has_uuid.rb +26 -0
- data/lib/agentcode/concerns/has_validation.rb +250 -0
- data/lib/agentcode/concerns/hidable_columns.rb +180 -0
- data/lib/agentcode/configuration.rb +98 -0
- data/lib/agentcode/controllers/auth_controller.rb +242 -0
- data/lib/agentcode/controllers/invitations_controller.rb +231 -0
- data/lib/agentcode/controllers/resources_controller.rb +813 -0
- data/lib/agentcode/engine.rb +65 -0
- data/lib/agentcode/mailers/invitation_mailer.rb +22 -0
- data/lib/agentcode/middleware/resolve_organization_from_route.rb +72 -0
- data/lib/agentcode/models/agentcode_model.rb +387 -0
- data/lib/agentcode/models/audit_log.rb +17 -0
- data/lib/agentcode/models/organization_invitation.rb +57 -0
- data/lib/agentcode/policies/invitation_policy.rb +54 -0
- data/lib/agentcode/policies/resource_policy.rb +197 -0
- data/lib/agentcode/query_builder.rb +278 -0
- data/lib/agentcode/railtie.rb +11 -0
- data/lib/agentcode/resource_scope.rb +59 -0
- data/lib/agentcode/routes.rb +124 -0
- data/lib/agentcode/tasks/agentcode.rake +39 -0
- data/lib/agentcode/templates/agentcode.rb +71 -0
- data/lib/agentcode/templates/agentcode_model.rb +104 -0
- data/lib/agentcode/templates/audit_trail/create_audit_logs.rb.erb +26 -0
- data/lib/agentcode/templates/generate/factory.rb.erb +43 -0
- data/lib/agentcode/templates/generate/migration.rb.erb +26 -0
- data/lib/agentcode/templates/generate/model.rb.erb +55 -0
- data/lib/agentcode/templates/generate/policy.rb.erb +52 -0
- data/lib/agentcode/templates/generate/scope.rb.erb +31 -0
- data/lib/agentcode/templates/multi_tenant/factories/organizations.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/roles.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
- data/lib/agentcode/templates/multi_tenant/factories/users.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/models/organization.rb.erb +18 -0
- data/lib/agentcode/templates/multi_tenant/models/role.rb.erb +11 -0
- data/lib/agentcode/templates/multi_tenant/models/user.rb.erb +14 -0
- data/lib/agentcode/templates/multi_tenant/models/user_role.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
- data/lib/agentcode/templates/routes.rb +13 -0
- data/lib/agentcode/version.rb +5 -0
- data/lib/agentcode/views/lumina/invitation_mailer/invite.html.erb +29 -0
- data/lib/agentcode-rails.rb +3 -0
- data/lib/agentcode.rb +26 -0
- metadata +281 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Base policy for all AgentCode resources.
|
|
5
|
+
# Mirrors the Laravel ResourcePolicy exactly.
|
|
6
|
+
#
|
|
7
|
+
# Permission format: '{slug}.{action}' (e.g., 'posts.index', 'blogs.store')
|
|
8
|
+
# Supports wildcards:
|
|
9
|
+
# - '*' grants access to everything
|
|
10
|
+
# - 'posts.*' grants access to all actions on posts
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# class PostPolicy < AgentCode::ResourcePolicy
|
|
14
|
+
# # Override for custom logic:
|
|
15
|
+
# def update?(user, record)
|
|
16
|
+
# super && record.user_id == user.id
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# # Attribute permissions:
|
|
20
|
+
# def permitted_attributes_for_show(user)
|
|
21
|
+
# has_role?(user, 'admin') ? ['*'] : ['id', 'title']
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# def hidden_attributes_for_show(user)
|
|
25
|
+
# has_role?(user, 'admin') ? [] : ['internal_notes']
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# def permitted_attributes_for_create(user)
|
|
29
|
+
# has_role?(user, 'admin') ? ['*'] : ['title', 'content']
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# def permitted_attributes_for_update(user)
|
|
33
|
+
# has_role?(user, 'admin') ? ['*'] : ['title', 'content']
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
class ResourcePolicy
|
|
37
|
+
attr_reader :user, :record
|
|
38
|
+
|
|
39
|
+
def initialize(user, record)
|
|
40
|
+
@user = user
|
|
41
|
+
@record = record
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The resource slug used for permission checks.
|
|
45
|
+
# Override in child policies, or it will be auto-resolved from config.
|
|
46
|
+
def self.resource_slug
|
|
47
|
+
@resource_slug
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.resource_slug=(slug)
|
|
51
|
+
@resource_slug = slug
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Convention-based CRUD authorization
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def index?
|
|
59
|
+
check_permission("index")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
alias_method :view_any?, :index?
|
|
63
|
+
|
|
64
|
+
def show?
|
|
65
|
+
check_permission("show")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
alias_method :view?, :show?
|
|
69
|
+
|
|
70
|
+
def create?
|
|
71
|
+
check_permission("store")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def update?
|
|
75
|
+
check_permission("update")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def destroy?
|
|
79
|
+
check_permission("destroy")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
alias_method :delete?, :destroy?
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Soft Delete authorization
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def view_trashed?
|
|
89
|
+
check_permission("trashed")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def restore?
|
|
93
|
+
check_permission("restore")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def force_delete?
|
|
97
|
+
check_permission("forceDelete")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Attribute Permissions
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
# Override to whitelist which columns are visible in API responses.
|
|
105
|
+
# Return ['*'] to allow all columns (default).
|
|
106
|
+
#
|
|
107
|
+
# @param user [Object, nil] The authenticated user
|
|
108
|
+
# @return [Array<String>]
|
|
109
|
+
def permitted_attributes_for_show(user)
|
|
110
|
+
['*']
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Override to blacklist columns from API responses.
|
|
114
|
+
# These are always hidden, even if listed in permitted_attributes_for_show.
|
|
115
|
+
#
|
|
116
|
+
# @param user [Object, nil] The authenticated user
|
|
117
|
+
# @return [Array<String>]
|
|
118
|
+
def hidden_attributes_for_show(user)
|
|
119
|
+
[]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Override to whitelist which fields a user can submit on create.
|
|
123
|
+
# Return ['*'] to allow all fields (default).
|
|
124
|
+
#
|
|
125
|
+
# @param user [Object, nil] The authenticated user
|
|
126
|
+
# @return [Array<String>]
|
|
127
|
+
def permitted_attributes_for_create(user)
|
|
128
|
+
['*']
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Override to whitelist which fields a user can submit on update.
|
|
132
|
+
# Return ['*'] to allow all fields (default).
|
|
133
|
+
#
|
|
134
|
+
# @param user [Object, nil] The authenticated user
|
|
135
|
+
# @return [Array<String>]
|
|
136
|
+
def permitted_attributes_for_update(user)
|
|
137
|
+
['*']
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# Helpers
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
# Check if the user has a specific role in the current organization.
|
|
145
|
+
# Convenience method for use in child policies.
|
|
146
|
+
#
|
|
147
|
+
# @param user [Object, nil] The authenticated user
|
|
148
|
+
# @param role_slug [String, Symbol] Role slug (e.g. 'admin', 'editor')
|
|
149
|
+
# @return [Boolean]
|
|
150
|
+
def has_role?(user, role_slug)
|
|
151
|
+
return false unless user
|
|
152
|
+
return false unless user.respond_to?(:role_slug_for_validation)
|
|
153
|
+
|
|
154
|
+
organization = current_organization
|
|
155
|
+
user.role_slug_for_validation(organization) == role_slug.to_s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
# Check if the user has the given permission for this resource.
|
|
161
|
+
def check_permission(action)
|
|
162
|
+
return false unless user
|
|
163
|
+
|
|
164
|
+
slug = resolve_resource_slug
|
|
165
|
+
return false unless slug
|
|
166
|
+
|
|
167
|
+
permission = "#{slug}.#{action}"
|
|
168
|
+
|
|
169
|
+
if user.respond_to?(:has_permission?)
|
|
170
|
+
organization = current_organization
|
|
171
|
+
user.has_permission?(permission, organization)
|
|
172
|
+
else
|
|
173
|
+
# Fallback: if the user model doesn't implement has_permission?, allow
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def resolve_resource_slug
|
|
179
|
+
# 1. Explicit resource_slug on the policy class
|
|
180
|
+
return self.class.resource_slug if self.class.resource_slug
|
|
181
|
+
|
|
182
|
+
# 2. Auto-resolve from AgentCode config
|
|
183
|
+
model_class = record.is_a?(Class) ? record : record.class
|
|
184
|
+
slug = AgentCode.config.slug_for(model_class)
|
|
185
|
+
|
|
186
|
+
# Cache for subsequent calls
|
|
187
|
+
self.class.resource_slug = slug if slug
|
|
188
|
+
slug
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def current_organization
|
|
192
|
+
if defined?(RequestStore)
|
|
193
|
+
RequestStore.store[:agentcode_organization]
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Custom query builder that provides AgentCode's exact URL parameter format.
|
|
5
|
+
# Replaces Spatie QueryBuilder for Rails.
|
|
6
|
+
#
|
|
7
|
+
# Supports:
|
|
8
|
+
# - Filtering: ?filter[status]=published&filter[user_id]=1
|
|
9
|
+
# - Sorting: ?sort=-created_at,title
|
|
10
|
+
# - Search: ?search=term
|
|
11
|
+
# - Pagination: ?page=1&per_page=20
|
|
12
|
+
# - Fields: ?fields[posts]=id,title,status
|
|
13
|
+
# - Includes: ?include=user,comments
|
|
14
|
+
class QueryBuilder
|
|
15
|
+
attr_reader :scope, :model_class, :params
|
|
16
|
+
|
|
17
|
+
def initialize(model_class, params: {})
|
|
18
|
+
@model_class = model_class
|
|
19
|
+
@scope = model_class.all
|
|
20
|
+
@params = params
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Apply all query modifications based on params and model config.
|
|
24
|
+
def build
|
|
25
|
+
apply_filters
|
|
26
|
+
apply_default_sort
|
|
27
|
+
apply_sorts
|
|
28
|
+
apply_search
|
|
29
|
+
apply_fields
|
|
30
|
+
apply_includes
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get the final ActiveRecord relation.
|
|
35
|
+
def to_scope
|
|
36
|
+
@scope
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Execute with pagination. Returns { items:, pagination: }.
|
|
40
|
+
def paginate(per_page: nil, page: nil)
|
|
41
|
+
per_page = (per_page || params[:per_page] || model_class.try(:agentcode_per_page_count) || 25).to_i
|
|
42
|
+
per_page = [[per_page, 1].max, 100].min # clamp between 1 and 100
|
|
43
|
+
page = (page || params[:page] || 1).to_i
|
|
44
|
+
page = [page, 1].max
|
|
45
|
+
|
|
46
|
+
total = @scope.count
|
|
47
|
+
last_page = (total.to_f / per_page).ceil
|
|
48
|
+
last_page = [last_page, 1].max
|
|
49
|
+
|
|
50
|
+
items = @scope.offset((page - 1) * per_page).limit(per_page)
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
items: items,
|
|
54
|
+
pagination: {
|
|
55
|
+
current_page: page,
|
|
56
|
+
last_page: last_page,
|
|
57
|
+
per_page: per_page,
|
|
58
|
+
total: total
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Filtering: ?filter[status]=published&filter[user_id]=1
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def apply_filters
|
|
70
|
+
filter_params = params[:filter]
|
|
71
|
+
return unless filter_params.is_a?(ActionController::Parameters) || filter_params.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
allowed = model_class.try(:allowed_filters) || []
|
|
74
|
+
return if allowed.empty? && filter_params.present?
|
|
75
|
+
|
|
76
|
+
filter_params.each do |key, value|
|
|
77
|
+
key = key.to_s
|
|
78
|
+
next unless allowed.include?(key)
|
|
79
|
+
|
|
80
|
+
if value.to_s.include?(",")
|
|
81
|
+
# Multiple values: OR condition
|
|
82
|
+
values = value.to_s.split(",").map(&:strip)
|
|
83
|
+
values = coerce_filter_values(key, values)
|
|
84
|
+
@scope = @scope.where(key => values)
|
|
85
|
+
else
|
|
86
|
+
@scope = @scope.where(key => coerce_filter_value(key, value))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Sorting: ?sort=-created_at,title
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def apply_default_sort
|
|
96
|
+
return if params[:sort].present?
|
|
97
|
+
|
|
98
|
+
default = model_class.try(:default_sort_field)
|
|
99
|
+
return unless default
|
|
100
|
+
|
|
101
|
+
apply_sort_string(default)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def apply_sorts
|
|
105
|
+
sort_param = params[:sort]
|
|
106
|
+
return unless sort_param.present?
|
|
107
|
+
|
|
108
|
+
apply_sort_string(sort_param.to_s)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def apply_sort_string(sort_string)
|
|
112
|
+
allowed = model_class.try(:allowed_sorts) || []
|
|
113
|
+
|
|
114
|
+
sort_string.split(",").each do |field|
|
|
115
|
+
field = field.strip
|
|
116
|
+
if field.start_with?("-")
|
|
117
|
+
column = field[1..]
|
|
118
|
+
direction = :desc
|
|
119
|
+
else
|
|
120
|
+
column = field
|
|
121
|
+
direction = :asc
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
next unless allowed.empty? || allowed.include?(column)
|
|
125
|
+
|
|
126
|
+
@scope = @scope.order(column => direction)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Search: ?search=term
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def apply_search
|
|
135
|
+
search_term = params[:search]
|
|
136
|
+
return unless search_term.present?
|
|
137
|
+
|
|
138
|
+
columns = model_class.try(:allowed_search) || []
|
|
139
|
+
return if columns.empty?
|
|
140
|
+
|
|
141
|
+
term = "%#{search_term.to_s.downcase}%"
|
|
142
|
+
conditions = []
|
|
143
|
+
values = []
|
|
144
|
+
|
|
145
|
+
columns.each do |column|
|
|
146
|
+
if column.include?(".")
|
|
147
|
+
# Relationship search: 'user.name' -> joins(:user).where("users.name ILIKE ?", term)
|
|
148
|
+
parts = column.split(".", 2)
|
|
149
|
+
relation = parts[0]
|
|
150
|
+
field = parts[1]
|
|
151
|
+
|
|
152
|
+
# Determine the table name from the association
|
|
153
|
+
assoc = model_class.reflect_on_association(relation.to_sym)
|
|
154
|
+
if assoc
|
|
155
|
+
begin
|
|
156
|
+
table_name = assoc.klass.table_name
|
|
157
|
+
@scope = @scope.left_outer_joins(relation.to_sym)
|
|
158
|
+
conditions << "LOWER(#{table_name}.#{field}) LIKE ?"
|
|
159
|
+
values << term
|
|
160
|
+
rescue NoMethodError, NameError
|
|
161
|
+
next
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
else
|
|
165
|
+
conditions << "LOWER(#{model_class.table_name}.#{column}) LIKE ?"
|
|
166
|
+
values << term
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
return if conditions.empty?
|
|
171
|
+
|
|
172
|
+
@scope = @scope.where(conditions.join(" OR "), *values)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Sparse fieldsets: ?fields[posts]=id,title,status
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def apply_fields
|
|
180
|
+
fields_params = params[:fields]
|
|
181
|
+
return unless fields_params.is_a?(ActionController::Parameters) || fields_params.is_a?(Hash)
|
|
182
|
+
|
|
183
|
+
allowed = model_class.try(:allowed_fields) || []
|
|
184
|
+
return if allowed.empty?
|
|
185
|
+
|
|
186
|
+
# Find fields for this model's table
|
|
187
|
+
slug = AgentCode.config.slug_for(model_class)
|
|
188
|
+
model_fields = fields_params[slug.to_s] || fields_params[model_class.table_name]
|
|
189
|
+
return unless model_fields
|
|
190
|
+
|
|
191
|
+
requested = model_fields.to_s.split(",").map(&:strip)
|
|
192
|
+
# Only allow fields that are in the allowed list
|
|
193
|
+
valid_fields = requested.select { |f| allowed.include?(f) }
|
|
194
|
+
|
|
195
|
+
if valid_fields.any?
|
|
196
|
+
# Always include the primary key
|
|
197
|
+
valid_fields.unshift(model_class.primary_key) unless valid_fields.include?(model_class.primary_key)
|
|
198
|
+
@scope = @scope.select(valid_fields.map { |f| "#{model_class.table_name}.#{f}" })
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# Eager loading: ?include=user,comments
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def apply_includes
|
|
207
|
+
include_param = params[:include]
|
|
208
|
+
return unless include_param.present?
|
|
209
|
+
|
|
210
|
+
allowed = model_class.try(:allowed_includes) || []
|
|
211
|
+
return if allowed.empty?
|
|
212
|
+
|
|
213
|
+
requested = include_param.to_s.split(",").map(&:strip)
|
|
214
|
+
|
|
215
|
+
includes_list = []
|
|
216
|
+
requested.each do |inc|
|
|
217
|
+
base = resolve_base_include(inc, allowed)
|
|
218
|
+
next unless base
|
|
219
|
+
|
|
220
|
+
if inc.include?(".")
|
|
221
|
+
# Nested include: 'comments.user' -> { comments: :user }
|
|
222
|
+
parts = inc.split(".")
|
|
223
|
+
nested = parts.reverse.inject { |inner, outer| { outer.to_sym => inner.to_sym } }
|
|
224
|
+
includes_list << nested
|
|
225
|
+
elsif inc.end_with?("Count") || inc.end_with?("Exists")
|
|
226
|
+
# Count/Exists suffixes are handled separately in serialization
|
|
227
|
+
next
|
|
228
|
+
else
|
|
229
|
+
includes_list << inc.to_sym
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
@scope = @scope.includes(*includes_list) if includes_list.any?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Coerce a single filter value to the column's type (e.g. string → integer).
|
|
237
|
+
def coerce_filter_value(column, value)
|
|
238
|
+
col = model_class.columns_hash[column]
|
|
239
|
+
return value unless col
|
|
240
|
+
|
|
241
|
+
case col.type
|
|
242
|
+
when :integer, :bigint
|
|
243
|
+
value.to_s.match?(/\A-?\d+\z/) ? value.to_i : value
|
|
244
|
+
when :float, :decimal
|
|
245
|
+
value.to_s.match?(/\A-?\d+(\.\d+)?\z/) ? value.to_f : value
|
|
246
|
+
when :boolean
|
|
247
|
+
ActiveModel::Type::Boolean.new.cast(value)
|
|
248
|
+
else
|
|
249
|
+
value
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Coerce an array of filter values.
|
|
254
|
+
def coerce_filter_values(column, values)
|
|
255
|
+
values.map { |v| coerce_filter_value(column, v) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Resolve an include segment to the base relationship name.
|
|
259
|
+
# Handles Count/Exists suffixes.
|
|
260
|
+
def resolve_base_include(segment, allowed)
|
|
261
|
+
return segment if allowed.include?(segment)
|
|
262
|
+
|
|
263
|
+
# Check Count suffix
|
|
264
|
+
if segment.end_with?("Count")
|
|
265
|
+
base = segment.sub(/Count\z/, "")
|
|
266
|
+
return base if allowed.include?(base)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Check Exists suffix
|
|
270
|
+
if segment.end_with?("Exists")
|
|
271
|
+
base = segment.sub(/Exists\z/, "")
|
|
272
|
+
return base if allowed.include?(base)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Base class for auto-discovered model scopes.
|
|
5
|
+
#
|
|
6
|
+
# Provides access to the current user and organization from RequestStore,
|
|
7
|
+
# so scopes can implement role-based or user-specific filtering.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# # app/models/scopes/project_scope.rb
|
|
11
|
+
# module Scopes
|
|
12
|
+
# class ProjectScope < AgentCode::ResourceScope
|
|
13
|
+
# def apply(relation)
|
|
14
|
+
# if role == "viewer"
|
|
15
|
+
# relation.where(status: "active")
|
|
16
|
+
# else
|
|
17
|
+
# relation
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Available methods inside +apply+:
|
|
24
|
+
# - +user+ — the current authenticated user (or nil)
|
|
25
|
+
# - +organization+ — the current organization (or nil)
|
|
26
|
+
# - +role+ — shortcut for the user's role slug in the current org (or nil)
|
|
27
|
+
#
|
|
28
|
+
class ResourceScope
|
|
29
|
+
# The current authenticated user, if any.
|
|
30
|
+
# @return [User, nil]
|
|
31
|
+
def user
|
|
32
|
+
RequestStore.store[:agentcode_current_user] if defined?(RequestStore)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The current organization, if any.
|
|
36
|
+
# @return [Organization, nil]
|
|
37
|
+
def organization
|
|
38
|
+
RequestStore.store[:agentcode_organization] if defined?(RequestStore)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Shortcut: the user's role slug in the current organization.
|
|
42
|
+
# @return [String, nil]
|
|
43
|
+
def role
|
|
44
|
+
return nil unless user && organization
|
|
45
|
+
|
|
46
|
+
if user.respond_to?(:role_slug_for_validation)
|
|
47
|
+
user.role_slug_for_validation(organization)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Subclasses must implement this method.
|
|
52
|
+
#
|
|
53
|
+
# @param relation [ActiveRecord::Relation] the current query scope
|
|
54
|
+
# @return [ActiveRecord::Relation] the modified scope
|
|
55
|
+
def apply(relation)
|
|
56
|
+
raise NotImplementedError, "#{self.class.name} must implement #apply(relation)"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Dynamic route registration from AgentCode configuration.
|
|
5
|
+
# Mirrors the Laravel routes/api.php behavior exactly.
|
|
6
|
+
module Routes
|
|
7
|
+
class << self
|
|
8
|
+
def draw(router)
|
|
9
|
+
config = AgentCode.config
|
|
10
|
+
route_groups = config.route_groups
|
|
11
|
+
|
|
12
|
+
# Sort: literal prefixes first, parameterized (containing ':') last
|
|
13
|
+
sorted_groups = route_groups.sort_by { |_name, cfg| cfg[:prefix].include?(":") ? 1 : 0 }
|
|
14
|
+
|
|
15
|
+
router.instance_eval do
|
|
16
|
+
scope path: "api", defaults: { format: :json } do
|
|
17
|
+
# ---------------------------------------------------------------
|
|
18
|
+
# Auth Routes (always registered)
|
|
19
|
+
# ---------------------------------------------------------------
|
|
20
|
+
scope path: "auth" do
|
|
21
|
+
post "login", to: "agentcode/auth#login"
|
|
22
|
+
post "password/recover", to: "agentcode/auth#recover_password"
|
|
23
|
+
post "password/reset", to: "agentcode/auth#reset"
|
|
24
|
+
post "register", to: "agentcode/auth#register_with_invitation"
|
|
25
|
+
post "logout", to: "agentcode/auth#logout"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------
|
|
29
|
+
# Invitation accept (public, always registered)
|
|
30
|
+
# ---------------------------------------------------------------
|
|
31
|
+
post "invitations/accept", to: "agentcode/invitations#accept"
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------
|
|
34
|
+
# Tenant-specific routes (invitations + nested)
|
|
35
|
+
# ---------------------------------------------------------------
|
|
36
|
+
if config.has_tenant_group?
|
|
37
|
+
tenant_config = route_groups[:tenant]
|
|
38
|
+
tenant_prefix = tenant_config[:prefix]
|
|
39
|
+
|
|
40
|
+
# Invitation routes under tenant prefix
|
|
41
|
+
invitation_prefix = tenant_prefix.present? ? "#{tenant_prefix}/invitations" : "invitations"
|
|
42
|
+
|
|
43
|
+
scope path: invitation_prefix do
|
|
44
|
+
get "/", to: "agentcode/invitations#index"
|
|
45
|
+
post "/", to: "agentcode/invitations#create"
|
|
46
|
+
post ":id/resend", to: "agentcode/invitations#resend"
|
|
47
|
+
delete ":id", to: "agentcode/invitations#cancel"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Nested operations under tenant prefix
|
|
51
|
+
nested_config = config.nested
|
|
52
|
+
nested_path = nested_config[:path] || "nested"
|
|
53
|
+
nested_prefix = tenant_prefix.present? ? "#{tenant_prefix}/#{nested_path}" : nested_path
|
|
54
|
+
|
|
55
|
+
post nested_prefix, to: "agentcode/resources#nested", as: :agentcode_nested
|
|
56
|
+
else
|
|
57
|
+
# No tenant group — register nested at top level
|
|
58
|
+
nested_config = config.nested
|
|
59
|
+
nested_path = nested_config[:path] || "nested"
|
|
60
|
+
post nested_path, to: "agentcode/resources#nested", as: :agentcode_nested
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------
|
|
64
|
+
# Per-group CRUD routes
|
|
65
|
+
# ---------------------------------------------------------------
|
|
66
|
+
sorted_groups.each do |group_name, group_config|
|
|
67
|
+
group_prefix = group_config[:prefix]
|
|
68
|
+
group_models = config.models_for_group(group_name)
|
|
69
|
+
|
|
70
|
+
group_models.each do |slug|
|
|
71
|
+
model_class_name = config.models[slug]
|
|
72
|
+
model_class = begin
|
|
73
|
+
model_class_name.constantize
|
|
74
|
+
rescue NameError
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
except_actions = model_class.try(:agentcode_except_actions_list) || []
|
|
79
|
+
|
|
80
|
+
route_prefix = [group_prefix, slug.to_s].reject(&:blank?).join("/")
|
|
81
|
+
|
|
82
|
+
scope path: route_prefix, defaults: { model_slug: slug.to_s, route_group: group_name.to_s } do
|
|
83
|
+
unless except_actions.include?("index")
|
|
84
|
+
get "/", to: "agentcode/resources#index", as: "agentcode_#{group_name}_#{slug}_index"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
unless except_actions.include?("store")
|
|
88
|
+
post "/", to: "agentcode/resources#store", as: "agentcode_#{group_name}_#{slug}_store"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if model_class.try(:uses_soft_deletes?)
|
|
92
|
+
unless except_actions.include?("trashed")
|
|
93
|
+
get "trashed", to: "agentcode/resources#trashed", as: "agentcode_#{group_name}_#{slug}_trashed"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
unless except_actions.include?("restore")
|
|
97
|
+
post ":id/restore", to: "agentcode/resources#restore", as: "agentcode_#{group_name}_#{slug}_restore"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
unless except_actions.include?("forceDelete")
|
|
101
|
+
delete ":id/force-delete", to: "agentcode/resources#force_delete", as: "agentcode_#{group_name}_#{slug}_force_delete"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
unless except_actions.include?("show")
|
|
106
|
+
get ":id", to: "agentcode/resources#show", as: "agentcode_#{group_name}_#{slug}_show"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
unless except_actions.include?("update")
|
|
110
|
+
put ":id", to: "agentcode/resources#update", as: "agentcode_#{group_name}_#{slug}_update"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
unless except_actions.include?("destroy")
|
|
114
|
+
delete ":id", to: "agentcode/resources#destroy", as: "agentcode_#{group_name}_#{slug}_destroy"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|