open-loam 0.1.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/CHANGELOG.md +71 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
- data/app/jobs/loam/event_delivery_job.rb +26 -0
- data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
- data/app/jobs/loam/webhook_delivery_job.rb +59 -0
- data/app/models/loam/api_token.rb +40 -0
- data/app/models/loam/audit_record.rb +18 -0
- data/app/models/loam/auth_attempt.rb +13 -0
- data/app/models/loam/business_rule.rb +49 -0
- data/app/models/loam/business_rule_run.rb +27 -0
- data/app/models/loam/comment.rb +28 -0
- data/app/models/loam/config.rb +27 -0
- data/app/models/loam/custom_field_value.rb +18 -0
- data/app/models/loam/dashboard_widget.rb +16 -0
- data/app/models/loam/dictionary.rb +20 -0
- data/app/models/loam/dictionary_entry.rb +31 -0
- data/app/models/loam/event_delivery.rb +38 -0
- data/app/models/loam/field_definition.rb +48 -0
- data/app/models/loam/inbound_webhook_delivery.rb +24 -0
- data/app/models/loam/inbound_webhook_source.rb +55 -0
- data/app/models/loam/membership.rb +27 -0
- data/app/models/loam/mfa_credential.rb +114 -0
- data/app/models/loam/notification.rb +36 -0
- data/app/models/loam/pending_action.rb +208 -0
- data/app/models/loam/perspective.rb +104 -0
- data/app/models/loam/progress_job.rb +103 -0
- data/app/models/loam/record_lock.rb +19 -0
- data/app/models/loam/scheduled_job.rb +42 -0
- data/app/models/loam/search_token.rb +9 -0
- data/app/models/loam/sso_identity.rb +13 -0
- data/app/models/loam/sso_provider.rb +35 -0
- data/app/models/loam/tenant.rb +20 -0
- data/app/models/loam/translation.rb +17 -0
- data/app/models/loam/webhook_endpoint.rb +26 -0
- data/app/views/loam/attachments/_attachments.html.erb +16 -0
- data/app/views/loam/comments/_comments.html.erb +24 -0
- data/app/views/loam/custom_fields/_fields.html.erb +35 -0
- data/lib/generators/loam/entity/entity_generator.rb +160 -0
- data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
- data/lib/generators/loam/entity/templates/controller.rb +181 -0
- data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
- data/lib/generators/loam/entity/templates/migration.rb +26 -0
- data/lib/generators/loam/entity/templates/model.rb +27 -0
- data/lib/generators/loam/entity/templates/policy.rb +10 -0
- data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
- data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
- data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
- data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
- data/lib/generators/loam/install/install_generator.rb +288 -0
- data/lib/generators/loam/install/templates/AGENTS.md +341 -0
- data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
- data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
- data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
- data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
- data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
- data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
- data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
- data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
- data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
- data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
- data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
- data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
- data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
- data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
- data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
- data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
- data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
- data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
- data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
- data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
- data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
- data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
- data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
- data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin.css +29 -0
- data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
- data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
- data/lib/generators/loam/install/templates/import_job.rb +25 -0
- data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/initializer.rb +218 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
- data/lib/generators/loam/install/templates/user.rb +21 -0
- data/lib/generators/loam/primary_key_options.rb +101 -0
- data/lib/loam/attachable.rb +27 -0
- data/lib/loam/auditable.rb +67 -0
- data/lib/loam/auth_throttle.rb +83 -0
- data/lib/loam/base32.rb +23 -0
- data/lib/loam/bulk.rb +59 -0
- data/lib/loam/business_rules/actions.rb +92 -0
- data/lib/loam/business_rules/condition.rb +102 -0
- data/lib/loam/business_rules.rb +123 -0
- data/lib/loam/commentable.rb +22 -0
- data/lib/loam/configs.rb +115 -0
- data/lib/loam/cron.rb +98 -0
- data/lib/loam/csv.rb +18 -0
- data/lib/loam/current.rb +21 -0
- data/lib/loam/custom_field_index.rb +262 -0
- data/lib/loam/custom_fields.rb +114 -0
- data/lib/loam/dashboard.rb +17 -0
- data/lib/loam/dictionaries.rb +55 -0
- data/lib/loam/durable_events.rb +174 -0
- data/lib/loam/encryptable.rb +145 -0
- data/lib/loam/encryption/cipher.rb +75 -0
- data/lib/loam/encryption/key_provider.rb +43 -0
- data/lib/loam/encryption.rb +125 -0
- data/lib/loam/engine.rb +57 -0
- data/lib/loam/enrichers.rb +105 -0
- data/lib/loam/errors.rb +49 -0
- data/lib/loam/eval.rb +47 -0
- data/lib/loam/event_stream.rb +80 -0
- data/lib/loam/eventful.rb +45 -0
- data/lib/loam/events.rb +51 -0
- data/lib/loam/export.rb +74 -0
- data/lib/loam/features.rb +75 -0
- data/lib/loam/generated_key.rb +37 -0
- data/lib/loam/import.rb +168 -0
- data/lib/loam/inbound_webhooks.rb +125 -0
- data/lib/loam/lifecycle.rb +180 -0
- data/lib/loam/locales/loam.en.yml +80 -0
- data/lib/loam/mcp/server.rb +60 -0
- data/lib/loam/mcp.rb +304 -0
- data/lib/loam/notifications.rb +36 -0
- data/lib/loam/open_api.rb +194 -0
- data/lib/loam/overrides.rb +110 -0
- data/lib/loam/pending_actions.rb +60 -0
- data/lib/loam/permissions.rb +79 -0
- data/lib/loam/perspectives.rb +62 -0
- data/lib/loam/policy.rb +106 -0
- data/lib/loam/progress.rb +31 -0
- data/lib/loam/record_locks.rb +89 -0
- data/lib/loam/scheduler.rb +153 -0
- data/lib/loam/search/driver.rb +31 -0
- data/lib/loam/search/like_driver.rb +29 -0
- data/lib/loam/search/token_driver.rb +88 -0
- data/lib/loam/search.rb +27 -0
- data/lib/loam/searchable.rb +90 -0
- data/lib/loam/soft_deletable.rb +72 -0
- data/lib/loam/sso/claims.rb +10 -0
- data/lib/loam/sso/fake_provider.rb +51 -0
- data/lib/loam/sso/http_client.rb +42 -0
- data/lib/loam/sso/oidc_provider.rb +64 -0
- data/lib/loam/sso.rb +129 -0
- data/lib/loam/telemetry.rb +43 -0
- data/lib/loam/tenant_record.rb +36 -0
- data/lib/loam/test_helpers.rb +15 -0
- data/lib/loam/totp.rb +63 -0
- data/lib/loam/translatable.rb +88 -0
- data/lib/loam/undo.rb +126 -0
- data/lib/loam/version.rb +3 -0
- data/lib/loam/webhooks.rb +34 -0
- data/lib/loam/widgets.rb +77 -0
- data/lib/loam/workflow.rb +256 -0
- data/lib/loam.rb +122 -0
- data/lib/open-loam.rb +5 -0
- data/lib/tasks/loam.rake +171 -0
- data/lib/tasks/loam_eval.rake +32 -0
- data/lib/tasks/loam_mcp.rake +20 -0
- metadata +332 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A saved view of an entity's admin index — which columns show, active filters,
|
|
3
|
+
# sort, and page size — that a user can name, make their default, or share.
|
|
4
|
+
#
|
|
5
|
+
# Three visibility tiers, widening in audience:
|
|
6
|
+
# * "private" — only its owner sees it (owner_id).
|
|
7
|
+
# * "role" — everyone with the named membership role in the tenant.
|
|
8
|
+
# * "tenant" — everyone in the tenant.
|
|
9
|
+
# A private view is invisible to others by construction (see
|
|
10
|
+
# Loam::Perspectives.visible_to), not merely by a permission check.
|
|
11
|
+
#
|
|
12
|
+
# Tenant-scoped like everything else, and audited. Optimistic-locked
|
|
13
|
+
# (lock_version) so two people editing a shared view don't silently clobber
|
|
14
|
+
# each other — the admin controller rescues ActiveRecord::StaleObjectError.
|
|
15
|
+
class Perspective < Loam::TenantRecord
|
|
16
|
+
self.table_name = "loam_perspectives"
|
|
17
|
+
|
|
18
|
+
include Loam::Auditable
|
|
19
|
+
|
|
20
|
+
VISIBILITIES = %w[private role tenant].freeze
|
|
21
|
+
|
|
22
|
+
# Columns a saved filter/sort may NEVER touch: tenant_id is enforced by the
|
|
23
|
+
# default scope (a filter on it is a footgun, not a leak), and the rest are
|
|
24
|
+
# plumbing, not data. Everything else on the entity is fair game.
|
|
25
|
+
NON_FILTERABLE = %w[id tenant_id lock_version deleted_at created_at updated_at].freeze
|
|
26
|
+
|
|
27
|
+
belongs_to :owner, class_name: "User", optional: true
|
|
28
|
+
|
|
29
|
+
validates :entity_type, :name, presence: true
|
|
30
|
+
validates :visibility, inclusion: { in: VISIBILITIES }
|
|
31
|
+
validates :role, presence: true, if: -> { visibility == "role" }
|
|
32
|
+
|
|
33
|
+
# Apply this view's stored filters and sort to a base scope, returning a
|
|
34
|
+
# relation (columns and page_size are read by the controller/view). SAFE BY
|
|
35
|
+
# CONSTRUCTION: a filter or sort key is honored only if it names a real,
|
|
36
|
+
# non-plumbing column of the entity — a crafted key (arbitrary SQL, or
|
|
37
|
+
# tenant_id) is skipped, never executed. Values ride as hash conditions, so
|
|
38
|
+
# Active Record quotes them.
|
|
39
|
+
def apply(scope)
|
|
40
|
+
allowed = filterable_columns(scope.klass)
|
|
41
|
+
filters = config_hash("filters")
|
|
42
|
+
|
|
43
|
+
# A free-text "q" runs the entity's own Searchable search when it has one;
|
|
44
|
+
# on a non-searchable entity `search` returns `all`, so this safely no-ops.
|
|
45
|
+
if filters["q"].present? && scope.klass.respond_to?(:search)
|
|
46
|
+
scope = scope.search(filters["q"])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
filters.each do |column, value|
|
|
50
|
+
next if column == "q"
|
|
51
|
+
next unless allowed.include?(column.to_s)
|
|
52
|
+
|
|
53
|
+
scope = scope.where(column => value)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sort = config_hash("sort")
|
|
57
|
+
if allowed.include?(sort["field"].to_s)
|
|
58
|
+
# reorder (not order) so the saved sort REPLACES the base order; id keeps
|
|
59
|
+
# pagination stable when the sort column has ties.
|
|
60
|
+
direction = sort["dir"].to_s == "desc" ? :desc : :asc
|
|
61
|
+
scope = scope.reorder(sort["field"] => direction, id: :desc)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
scope
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def columns
|
|
68
|
+
Array(config_hash_root["columns"])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def page_size
|
|
72
|
+
config_hash_root["page_size"]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Make this the default for its audience, unsetting the sibling default it
|
|
76
|
+
# would otherwise compete with (same entity + visibility, and same owner/role
|
|
77
|
+
# where those narrow the audience).
|
|
78
|
+
def make_default!
|
|
79
|
+
siblings = self.class.where(entity_type: entity_type, visibility: visibility).where.not(id: id)
|
|
80
|
+
siblings = siblings.where(owner_id: owner_id) if visibility == "private"
|
|
81
|
+
siblings = siblings.where(role: role) if visibility == "role"
|
|
82
|
+
|
|
83
|
+
transaction do
|
|
84
|
+
siblings.update_all(is_default: false)
|
|
85
|
+
update!(is_default: true)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def filterable_columns(klass)
|
|
92
|
+
klass.column_names - NON_FILTERABLE
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def config_hash_root
|
|
96
|
+
config.is_a?(Hash) ? config : {}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def config_hash(key)
|
|
100
|
+
value = config_hash_root[key]
|
|
101
|
+
value.is_a?(Hash) ? value : {}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Progress of a long-running job (a bulk import, a reindex, a report) so the
|
|
3
|
+
# admin can watch it live instead of guessing. Tenant-scoped; a job started in
|
|
4
|
+
# one tenant is only visible and streamable there.
|
|
5
|
+
#
|
|
6
|
+
# Deliberately NOT audited: progress is high-frequency churn (many `advance`
|
|
7
|
+
# ticks) and the audit trail would fill with noise. The terminal status and
|
|
8
|
+
# any error are captured on the row itself, which is the summary that matters.
|
|
9
|
+
#
|
|
10
|
+
# Each meaningful change publishes `loam.progress.updated` (broadcastable → SSE)
|
|
11
|
+
# carrying only id/percent/status — no record contents. See Loam::Progress for
|
|
12
|
+
# the entry point.
|
|
13
|
+
class ProgressJob < Loam::TenantRecord
|
|
14
|
+
self.table_name = "loam_progress_jobs"
|
|
15
|
+
|
|
16
|
+
STATUSES = %w[running completed failed cancelled].freeze
|
|
17
|
+
STALE_AFTER = 5.minutes
|
|
18
|
+
|
|
19
|
+
validates :name, presence: true
|
|
20
|
+
validates :status, inclusion: { in: STATUSES }
|
|
21
|
+
|
|
22
|
+
scope :recent, -> { order(started_at: :desc, id: :desc) }
|
|
23
|
+
|
|
24
|
+
# Integer 0..100. A zero total is treated as 0% (an unknown-size job).
|
|
25
|
+
def percent
|
|
26
|
+
return 0 if total.to_i <= 0
|
|
27
|
+
|
|
28
|
+
[ (completed.to_f / total * 100).floor, 100 ].min
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def running? = status == "running"
|
|
32
|
+
def terminal? = !running?
|
|
33
|
+
|
|
34
|
+
# Seconds remaining, extrapolated from the rate so far, or nil when it can't
|
|
35
|
+
# be estimated yet (no progress, or already finished).
|
|
36
|
+
def eta_seconds
|
|
37
|
+
return nil unless running? && completed.to_i.positive? && started_at
|
|
38
|
+
|
|
39
|
+
elapsed = Time.current - started_at
|
|
40
|
+
rate = completed / elapsed # items per second
|
|
41
|
+
return nil unless rate.positive?
|
|
42
|
+
|
|
43
|
+
((total - completed) / rate).round
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A running job whose process died leaves the row "running" forever; it is
|
|
47
|
+
# stale once its heartbeat (updated_at, bumped on every advance) goes quiet.
|
|
48
|
+
# The prototype exposes the predicate and a manual mark-failed; a reaper
|
|
49
|
+
# daemon is the roadmap.
|
|
50
|
+
def stale?
|
|
51
|
+
running? && updated_at < STALE_AFTER.ago
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Increment progress. Persists every tick (so the count and heartbeat stay
|
|
55
|
+
# current) but THROTTLES the SSE broadcast to once per whole percent (or a
|
|
56
|
+
# message change) — a 10k-item job pushes ~100 frames, not 10k.
|
|
57
|
+
def advance(by: 1, message: nil)
|
|
58
|
+
before = percent
|
|
59
|
+
self.completed = completed.to_i + by
|
|
60
|
+
self.message = message if message
|
|
61
|
+
save!
|
|
62
|
+
broadcast if percent != before || message
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def complete!
|
|
67
|
+
finish!("completed") { self.completed = total if total.to_i.positive? }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fail!(error_message = nil)
|
|
71
|
+
finish!("failed") { self.error = error_message.to_s.presence }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cancel!
|
|
75
|
+
finish!("cancelled")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A cooperative cancel: a long job calls this periodically and stops early.
|
|
79
|
+
# Re-reads the status column so an admin's cancel in ANOTHER request is seen
|
|
80
|
+
# without clobbering the in-memory counters.
|
|
81
|
+
def cancelled?
|
|
82
|
+
self.class.where(id: id).pick(:status) == "cancelled"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def finish!(new_status)
|
|
88
|
+
yield if block_given?
|
|
89
|
+
self.status = new_status
|
|
90
|
+
self.finished_at = Time.current
|
|
91
|
+
save!
|
|
92
|
+
broadcast
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Only ever id/percent/status leave the server (Loam::Events stamps tenant_id
|
|
97
|
+
# for the SSE deliverable filter; the frame's safe_payload drops everything
|
|
98
|
+
# else).
|
|
99
|
+
def broadcast
|
|
100
|
+
Loam::Events.publish("loam.progress.updated", id: id, percent: percent, status: status)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# An advisory "someone is editing this" lock on a record — the COURTESY layer.
|
|
3
|
+
# The real guarantee against a silent clobber is optimistic locking
|
|
4
|
+
# (lock_version, see Loam::RecordLocks docs); this only warns the second
|
|
5
|
+
# editor. One lock per record, with a TTL: an expired lock is treated as free,
|
|
6
|
+
# and re-acquiring as the same holder extends it (a heartbeat). Plumbing, so
|
|
7
|
+
# not audited — like Loam::ApiToken.
|
|
8
|
+
class RecordLock < Loam::TenantRecord
|
|
9
|
+
self.table_name = "loam_record_locks"
|
|
10
|
+
|
|
11
|
+
belongs_to :locked_by, class_name: "User"
|
|
12
|
+
|
|
13
|
+
validates :lockable_type, :lockable_id, :token, :expires_at, presence: true
|
|
14
|
+
|
|
15
|
+
def expired?
|
|
16
|
+
expires_at.nil? || expires_at <= Time.current
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A recurring job that runs on a schedule (cron or interval). Tenant-scoped:
|
|
3
|
+
# a "tenant"-scope schedule enqueues its job in this tenant on schedule; a
|
|
4
|
+
# "system"-scope schedule enqueues once with no tenant context. Audited on
|
|
5
|
+
# config changes (not per run — runs are tracked by last_run_at/next_run_at).
|
|
6
|
+
#
|
|
7
|
+
# SECURITY: job_class is validated to be a known ActiveJob (see
|
|
8
|
+
# Loam::Scheduler.resolve_job_class) at save AND at enqueue — a scheduler that
|
|
9
|
+
# constantized and ran arbitrary user input would be a code-execution hole.
|
|
10
|
+
class ScheduledJob < Loam::TenantRecord
|
|
11
|
+
self.table_name = "loam_scheduled_jobs"
|
|
12
|
+
|
|
13
|
+
include Loam::Auditable
|
|
14
|
+
|
|
15
|
+
SCOPES = %w[tenant system].freeze
|
|
16
|
+
LOCK_TTL = 5.minutes # how long a claim holds a due job before another tick may retry it
|
|
17
|
+
STALE_LOCK = LOCK_TTL
|
|
18
|
+
|
|
19
|
+
validates :key, presence: true, uniqueness: { scope: :tenant_id }
|
|
20
|
+
validates :name, :schedule, presence: true
|
|
21
|
+
validates :scope, inclusion: { in: SCOPES }
|
|
22
|
+
validate :job_class_is_a_known_job
|
|
23
|
+
|
|
24
|
+
scope :active, -> { where(active: true) }
|
|
25
|
+
|
|
26
|
+
# The next fire time strictly after `from`, in this job's timezone (UTC when
|
|
27
|
+
# unset). Stored in next_run_at as UTC.
|
|
28
|
+
def compute_next_run(from = Time.current)
|
|
29
|
+
Loam::Cron.next_after(schedule, from: from, zone: timezone.presence || "UTC")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def job_class_constant
|
|
33
|
+
Loam::Scheduler.resolve_job_class(job_class)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def job_class_is_a_known_job
|
|
39
|
+
errors.add(:job_class, "must name a known ActiveJob") unless job_class_constant
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# The word-level search index maintained by Loam::Search::TokenDriver: one row
|
|
3
|
+
# per (record, token), tenant-scoped like every Loam entity. Plumbing — not
|
|
4
|
+
# audited, not soft-deletable. Only the TokenDriver reads or writes it; under
|
|
5
|
+
# the default LikeDriver this table simply stays empty.
|
|
6
|
+
class SearchToken < Loam::TenantRecord
|
|
7
|
+
self.table_name = "loam_search_tokens"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Links an external IdP identity (a provider's stable `sub`) to a local User.
|
|
3
|
+
# The durable key SSO callbacks resolve on — an email can change at the IdP,
|
|
4
|
+
# `sub` does not. Tenant-scoped (created and read inside the provider's tenant).
|
|
5
|
+
class SsoIdentity < Loam::TenantRecord
|
|
6
|
+
self.table_name = "loam_sso_identities"
|
|
7
|
+
|
|
8
|
+
belongs_to :user
|
|
9
|
+
belongs_to :sso_provider, class_name: "Loam::SsoProvider"
|
|
10
|
+
|
|
11
|
+
validates :sub, presence: true, uniqueness: { scope: :sso_provider_id }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A tenant's connection to an external identity provider (OIDC). Per-tenant
|
|
3
|
+
# config: each tenant/org wires its OWN IdP, so the client_secret is encrypted
|
|
4
|
+
# under the tenant key (the default Encryptable scope) — unlike MFA, which is
|
|
5
|
+
# user-keyed. Home-realm discovery matches an email `domain` to the owning
|
|
6
|
+
# provider at the sign-in page (see Loam::Sso.provider_for).
|
|
7
|
+
class SsoProvider < Loam::TenantRecord
|
|
8
|
+
self.table_name = "loam_sso_providers"
|
|
9
|
+
|
|
10
|
+
include Loam::Auditable # config changes are audited; the secret is redacted
|
|
11
|
+
include Loam::Encryptable
|
|
12
|
+
|
|
13
|
+
# Tenant-scoped key (default): SSO config belongs to the tenant, and the
|
|
14
|
+
# secret is only ever read inside that tenant's context on callback.
|
|
15
|
+
encrypts :client_secret
|
|
16
|
+
|
|
17
|
+
has_many :identities, class_name: "Loam::SsoIdentity",
|
|
18
|
+
foreign_key: :sso_provider_id, dependent: :delete_all
|
|
19
|
+
|
|
20
|
+
validates :name, :protocol, :domain, :jit_role, presence: true
|
|
21
|
+
# Globally unique so HRD resolves exactly one owning IdP per domain. Rails'
|
|
22
|
+
# uniqueness validator queries WITHOUT the default tenant scope, so this
|
|
23
|
+
# catches a collision across tenants too.
|
|
24
|
+
validates :domain, uniqueness: true
|
|
25
|
+
|
|
26
|
+
normalizes :domain, with: ->(domain) { domain.to_s.strip.downcase.presence }
|
|
27
|
+
|
|
28
|
+
scope :active, -> { where(active: true) }
|
|
29
|
+
|
|
30
|
+
# IdP group -> Loam role, first match wins; falls back to jit_role.
|
|
31
|
+
def group_roles
|
|
32
|
+
group_role_map.is_a?(Hash) ? group_role_map : {}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# The tenant itself is deliberately NOT tenant-scoped — it is the axis the
|
|
3
|
+
# rest of the system is scoped by.
|
|
4
|
+
class Tenant < ActiveRecord::Base
|
|
5
|
+
include Loam::GeneratedKey
|
|
6
|
+
self.table_name = "loam_tenants"
|
|
7
|
+
|
|
8
|
+
validates :name, presence: true
|
|
9
|
+
validates :slug, presence: true, uniqueness: true
|
|
10
|
+
|
|
11
|
+
has_many :memberships, class_name: "Loam::Membership", dependent: :delete_all
|
|
12
|
+
|
|
13
|
+
# A new tenant is never empty: whatever the app registered via
|
|
14
|
+
# Loam.on_tenant_created runs here, inside this tenant's context. After
|
|
15
|
+
# commit, so callbacks see a persisted tenant and may enqueue jobs.
|
|
16
|
+
# `bin/rails loam:sync` re-runs the same callbacks for existing tenants —
|
|
17
|
+
# which is why they must be idempotent (see Loam::Lifecycle).
|
|
18
|
+
after_create_commit { Loam::Lifecycle.run_tenant_created(self) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A per-locale override for ONE field of ONE record — the storage behind
|
|
3
|
+
# Loam::Translatable. The record's own column holds the base (default-locale)
|
|
4
|
+
# value; a translation row overrides it for a specific locale. Additive:
|
|
5
|
+
# writing a translation never touches the base column, so the base value is
|
|
6
|
+
# never lost. Tenant-scoped and audited.
|
|
7
|
+
class Translation < Loam::TenantRecord
|
|
8
|
+
self.table_name = "loam_translations"
|
|
9
|
+
|
|
10
|
+
include Loam::Auditable
|
|
11
|
+
|
|
12
|
+
belongs_to :translatable, polymorphic: true
|
|
13
|
+
|
|
14
|
+
validates :locale, :field, presence: true
|
|
15
|
+
validates :field, uniqueness: { scope: %i[translatable_type translatable_id locale] }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Where one tenant wants its domain events delivered. `event_pattern` uses
|
|
3
|
+
# the same rule as Loam::Events.subscribe — a trailing dot is a domain prefix
|
|
4
|
+
# ("rental."), anything else is an exact event name.
|
|
5
|
+
#
|
|
6
|
+
# The secret signs every delivery (X-Loam-Signature), so a receiver can tell
|
|
7
|
+
# a real call from a forged one.
|
|
8
|
+
class WebhookEndpoint < Loam::TenantRecord
|
|
9
|
+
self.table_name = "loam_webhook_endpoints"
|
|
10
|
+
|
|
11
|
+
validates :url, presence: true, format: { with: %r{\Ahttps?://}, message: "must start with http:// or https://" }
|
|
12
|
+
validates :event_pattern, presence: true
|
|
13
|
+
|
|
14
|
+
scope :active, -> { where(active: true) }
|
|
15
|
+
|
|
16
|
+
before_validation on: :create do
|
|
17
|
+
self.secret ||= SecureRandom.hex(32)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# One matcher, shared with the event bus, so "rental." means the same thing
|
|
21
|
+
# to a subscriber and to a webhook.
|
|
22
|
+
def matches?(event_name)
|
|
23
|
+
Loam::Events.pattern_matches?(event_pattern, event_name)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# locals: record — shared by every generated entity's show.html.erb.
|
|
2
|
+
Uploading happens through the entity's own form (it is an update, and the
|
|
3
|
+
entity's policy decides who may do it); this partial only lists what is
|
|
4
|
+
already attached. Note that these links are signed URLs, not tenant-checked
|
|
5
|
+
addresses — see Loam::Attachable. %>
|
|
6
|
+
<h2>Files</h2>
|
|
7
|
+
|
|
8
|
+
<% if record.files.attached? %>
|
|
9
|
+
<ul>
|
|
10
|
+
<% record.files.each do |file| %>
|
|
11
|
+
<li><%= link_to file.filename.to_s, url_for(file) %> (<%= number_to_human_size(file.byte_size) %>)</li>
|
|
12
|
+
<% end %>
|
|
13
|
+
</ul>
|
|
14
|
+
<% else %>
|
|
15
|
+
<p>No files attached.</p>
|
|
16
|
+
<% end %>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# locals: record — shared by every generated entity's show.html.erb so the
|
|
2
|
+
discussion is rendered once, in the gem, instead of per entity. Posting is
|
|
3
|
+
open to any member: BaseController has already proven that whoever is here
|
|
4
|
+
holds a membership in this tenant. %>
|
|
5
|
+
<h2>Comments</h2>
|
|
6
|
+
|
|
7
|
+
<% if flash[:alert] %>
|
|
8
|
+
<p style="color:#b00"><%= flash[:alert] %></p>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<% record.loam_comments.oldest_first.each do |comment| %>
|
|
12
|
+
<p>
|
|
13
|
+
<strong><%= User.find_by(id: comment.author_id)&.name || "—" %></strong>
|
|
14
|
+
<em><%= comment.created_at.to_fs(:short) %></em><br>
|
|
15
|
+
<%= comment.body %>
|
|
16
|
+
</p>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<%= form_with url: admin_comments_path, method: :post do |f| %>
|
|
20
|
+
<%= hidden_field_tag :commentable_type, record.class.name %>
|
|
21
|
+
<%= hidden_field_tag :commentable_id, record.id %>
|
|
22
|
+
<%= text_area_tag :body, nil, rows: 3, placeholder: "Add a comment" %>
|
|
23
|
+
<%= f.submit "Comment" %>
|
|
24
|
+
<% end %>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<%# locals: record, policy, mode (:edit or :show, default :edit) — shared by every
|
|
2
|
+
generated entity's _form.html.erb and show.html.erb so runtime custom-field
|
|
3
|
+
rendering lives once in the gem instead of being inlined per entity. %>
|
|
4
|
+
<% mode = local_assigns.fetch(:mode, :edit) %>
|
|
5
|
+
<% record.class.custom_field_definitions.each do |definition| %>
|
|
6
|
+
<% param_name = "#{record.model_name.param_key}[custom_fields][#{definition.name}]" %>
|
|
7
|
+
<% value = record.custom_field(definition.name) %>
|
|
8
|
+
<p>
|
|
9
|
+
<% if mode == :edit && policy.custom_field_writable?(definition.name) %>
|
|
10
|
+
<%= label_tag param_name, definition.name.humanize %><br>
|
|
11
|
+
<% case definition.field_type %>
|
|
12
|
+
<% when "boolean" %>
|
|
13
|
+
<%= check_box_tag param_name, "true", value, id: param_name %>
|
|
14
|
+
<% when "text" %>
|
|
15
|
+
<%= text_area_tag param_name, value %>
|
|
16
|
+
<% when "date" %>
|
|
17
|
+
<%= date_field_tag param_name, value %>
|
|
18
|
+
<% when "datetime" %>
|
|
19
|
+
<%= datetime_field_tag param_name, value %>
|
|
20
|
+
<% when "integer", "decimal" %>
|
|
21
|
+
<%= number_field_tag param_name, value %>
|
|
22
|
+
<% when "dictionary" %>
|
|
23
|
+
<% options = Loam::Dictionaries.entries(definition.dictionary_key).map { |e| [e.label, e.value] } %>
|
|
24
|
+
<%= select_tag param_name, options_for_select(options, value), include_blank: true, id: param_name %>
|
|
25
|
+
<% else %>
|
|
26
|
+
<%= text_field_tag param_name, value %>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<%# A dictionary field shows its entry label, not the raw stored code. %>
|
|
30
|
+
<% display = definition.field_type == "dictionary" ? Loam::Dictionaries.label_for(definition.dictionary_key, value) : value %>
|
|
31
|
+
<%= definition.name.humanize %>: <%= display %>
|
|
32
|
+
<% if mode == :edit %><em>(read-only for your role)</em><% end %>
|
|
33
|
+
<% end %>
|
|
34
|
+
</p>
|
|
35
|
+
<% end %>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
require "generators/loam/primary_key_options"
|
|
4
|
+
|
|
5
|
+
module Loam
|
|
6
|
+
module Generators
|
|
7
|
+
# `rails g loam:entity Equipment name:string daily_rate:decimal --domain rental`
|
|
8
|
+
#
|
|
9
|
+
# THE interface for adding a business entity — for humans and AI agents
|
|
10
|
+
# alike. One command produces a tenant-scoped, audited, evented model, its
|
|
11
|
+
# policy, an admin screen, and the isolation tests that prove the
|
|
12
|
+
# guardrails hold. No decisions about how tenancy/permissions work: Loam
|
|
13
|
+
# already decided.
|
|
14
|
+
class EntityGenerator < Rails::Generators::NamedBase
|
|
15
|
+
include ActiveRecord::Generators::Migration
|
|
16
|
+
include Loam::Generators::PrimaryKeyOptions
|
|
17
|
+
|
|
18
|
+
source_root File.expand_path("templates", __dir__)
|
|
19
|
+
|
|
20
|
+
argument :attributes, type: :array, default: [], banner: "field:type field:type"
|
|
21
|
+
class_option :domain, type: :string, default: "app",
|
|
22
|
+
desc: "Event domain prefix (-> domain.entity.created)"
|
|
23
|
+
class_option :encrypt, type: :array, default: [], banner: "field field",
|
|
24
|
+
desc: "Encrypt these fields at rest, per tenant (Loam::Encryptable)"
|
|
25
|
+
class_option :encrypt_searchable, type: :array, default: [], banner: "field field",
|
|
26
|
+
desc: "Encrypt + add a blind index for exact-match lookup"
|
|
27
|
+
|
|
28
|
+
def create_migration_file
|
|
29
|
+
migration_template "migration.rb", "db/migrate/create_#{table_name}.rb"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create_model
|
|
33
|
+
template "model.rb", "app/models/#{file_name}.rb"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def create_policy
|
|
37
|
+
template "policy.rb", "app/policies/#{file_name}_policy.rb"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def create_admin_controller
|
|
41
|
+
template "controller.rb", "app/controllers/admin/#{plural_file_name}_controller.rb"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create_api_controller
|
|
45
|
+
template "api_controller.rb", "app/controllers/api/#{plural_file_name}_controller.rb"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_admin_views
|
|
49
|
+
template "views/index.html.erb", "app/views/admin/#{plural_file_name}/index.html.erb"
|
|
50
|
+
template "views/deleted.html.erb", "app/views/admin/#{plural_file_name}/deleted.html.erb"
|
|
51
|
+
template "views/show.html.erb", "app/views/admin/#{plural_file_name}/show.html.erb"
|
|
52
|
+
template "views/new.html.erb", "app/views/admin/#{plural_file_name}/new.html.erb"
|
|
53
|
+
template "views/edit.html.erb", "app/views/admin/#{plural_file_name}/edit.html.erb"
|
|
54
|
+
template "views/_form.html.erb", "app/views/admin/#{plural_file_name}/_form.html.erb"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def create_entity_test
|
|
58
|
+
template "entity_test.rb", "test/entities/#{file_name}_test.rb"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# `namespace:` makes Rails inject the route INTO the existing
|
|
62
|
+
# `namespace :admin do` block (the one loam:install wrote) instead of
|
|
63
|
+
# stacking one admin block per entity, and falls back to creating the
|
|
64
|
+
# block when there is none. Re-running the generator is a no-op: the
|
|
65
|
+
# injection is `force: false`, so identical routing code is skipped.
|
|
66
|
+
# The API line carries `defaults: { format: :json }` — it is what an API
|
|
67
|
+
# namespace wants anyway, and it also has to differ textually from the
|
|
68
|
+
# admin line above: Rails' route injection skips code the file already
|
|
69
|
+
# contains (that is what makes re-running this generator a no-op), so two
|
|
70
|
+
# identical `resources :gadgets` lines would leave the second one out.
|
|
71
|
+
#
|
|
72
|
+
# The admin resource carries the soft-delete recycle bin: `deleted` lists
|
|
73
|
+
# `only_deleted`, `restore` brings one back (see Loam::SoftDeletable).
|
|
74
|
+
def add_route
|
|
75
|
+
route <<~RUBY.strip, namespace: :admin
|
|
76
|
+
resources :#{plural_file_name} do
|
|
77
|
+
get :deleted, on: :collection
|
|
78
|
+
patch :restore, on: :member
|
|
79
|
+
get :export, on: :collection # CSV of the current view
|
|
80
|
+
post :bulk, on: :collection # datatable bulk actions
|
|
81
|
+
end
|
|
82
|
+
RUBY
|
|
83
|
+
route "resources :#{plural_file_name}, defaults: { format: :json }", namespace: :api
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def print_next_steps
|
|
87
|
+
say ""
|
|
88
|
+
say "Entity #{class_name} created. Next:", :green
|
|
89
|
+
say " 1. bin/rails db:migrate"
|
|
90
|
+
say " 2. Declare field-level permissions in app/policies/#{file_name}_policy.rb"
|
|
91
|
+
say " 3. bin/rails test"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def domain
|
|
97
|
+
options[:domain]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def field_names
|
|
101
|
+
attributes.map(&:name)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Only text-ish columns are worth a LIKE (see Loam::Searchable), so an
|
|
105
|
+
# entity with none gets no `searchable_by` declaration and stays out of
|
|
106
|
+
# the global search. Encrypted columns are excluded: ciphertext is
|
|
107
|
+
# meaningless to LIKE, and declaring both would raise at class load.
|
|
108
|
+
def searchable_attributes
|
|
109
|
+
attributes.select do |attribute|
|
|
110
|
+
%i[string text].include?(attribute.type) && !encrypted_field_names.include?(attribute.name)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Fields to encrypt at rest. `--encrypt-searchable` also gets a blind index
|
|
115
|
+
# (a `<field>_hash` column) for exact-match lookup; plain `--encrypt` does not.
|
|
116
|
+
def encrypt_searchable_names
|
|
117
|
+
options[:encrypt_searchable]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def encrypted_field_names
|
|
121
|
+
options[:encrypt] + options[:encrypt_searchable]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def encrypted?(attribute)
|
|
125
|
+
encrypted_field_names.include?(attribute.name)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A plausible literal per attribute type, used by the generated test.
|
|
129
|
+
def sample_value(attribute, variant = 0)
|
|
130
|
+
case attribute.type
|
|
131
|
+
when :integer, :bigint, :references then 1 + variant
|
|
132
|
+
when :decimal, :float then "%.2f" % (9.99 + variant)
|
|
133
|
+
when :boolean then (variant.zero? ? "true" : "false")
|
|
134
|
+
when :date then "Date.new(2026, 1, #{1 + variant})"
|
|
135
|
+
when :datetime, :time, :timestamp then "Time.utc(2026, 1, #{1 + variant})"
|
|
136
|
+
else "\"Sample #{attribute.name} #{variant}\""
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def sample_attributes(variant = 0)
|
|
141
|
+
attributes.map { |a| "#{a.name}: #{sample_value(a, variant)}" }.join(", ")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def first_field
|
|
145
|
+
field_names.first || "tenant_id"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def form_field_helper(attribute)
|
|
149
|
+
case attribute.type
|
|
150
|
+
when :boolean then "check_box"
|
|
151
|
+
when :text then "text_area"
|
|
152
|
+
when :date then "date_field"
|
|
153
|
+
when :datetime, :time, :timestamp then "datetime_field"
|
|
154
|
+
when :integer, :bigint, :decimal, :float, :references then "number_field"
|
|
155
|
+
else "text_field"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|