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.
Files changed (245) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +71 -0
  3. data/LICENSE +21 -0
  4. data/README.md +177 -0
  5. data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
  6. data/app/jobs/loam/event_delivery_job.rb +26 -0
  7. data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
  8. data/app/jobs/loam/webhook_delivery_job.rb +59 -0
  9. data/app/models/loam/api_token.rb +40 -0
  10. data/app/models/loam/audit_record.rb +18 -0
  11. data/app/models/loam/auth_attempt.rb +13 -0
  12. data/app/models/loam/business_rule.rb +49 -0
  13. data/app/models/loam/business_rule_run.rb +27 -0
  14. data/app/models/loam/comment.rb +28 -0
  15. data/app/models/loam/config.rb +27 -0
  16. data/app/models/loam/custom_field_value.rb +18 -0
  17. data/app/models/loam/dashboard_widget.rb +16 -0
  18. data/app/models/loam/dictionary.rb +20 -0
  19. data/app/models/loam/dictionary_entry.rb +31 -0
  20. data/app/models/loam/event_delivery.rb +38 -0
  21. data/app/models/loam/field_definition.rb +48 -0
  22. data/app/models/loam/inbound_webhook_delivery.rb +24 -0
  23. data/app/models/loam/inbound_webhook_source.rb +55 -0
  24. data/app/models/loam/membership.rb +27 -0
  25. data/app/models/loam/mfa_credential.rb +114 -0
  26. data/app/models/loam/notification.rb +36 -0
  27. data/app/models/loam/pending_action.rb +208 -0
  28. data/app/models/loam/perspective.rb +104 -0
  29. data/app/models/loam/progress_job.rb +103 -0
  30. data/app/models/loam/record_lock.rb +19 -0
  31. data/app/models/loam/scheduled_job.rb +42 -0
  32. data/app/models/loam/search_token.rb +9 -0
  33. data/app/models/loam/sso_identity.rb +13 -0
  34. data/app/models/loam/sso_provider.rb +35 -0
  35. data/app/models/loam/tenant.rb +20 -0
  36. data/app/models/loam/translation.rb +17 -0
  37. data/app/models/loam/webhook_endpoint.rb +26 -0
  38. data/app/views/loam/attachments/_attachments.html.erb +16 -0
  39. data/app/views/loam/comments/_comments.html.erb +24 -0
  40. data/app/views/loam/custom_fields/_fields.html.erb +35 -0
  41. data/lib/generators/loam/entity/entity_generator.rb +160 -0
  42. data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
  43. data/lib/generators/loam/entity/templates/controller.rb +181 -0
  44. data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
  45. data/lib/generators/loam/entity/templates/migration.rb +26 -0
  46. data/lib/generators/loam/entity/templates/model.rb +27 -0
  47. data/lib/generators/loam/entity/templates/policy.rb +10 -0
  48. data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
  49. data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
  50. data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
  51. data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
  52. data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
  53. data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
  54. data/lib/generators/loam/install/install_generator.rb +288 -0
  55. data/lib/generators/loam/install/templates/AGENTS.md +341 -0
  56. data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
  57. data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
  58. data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
  59. data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
  60. data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
  61. data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
  62. data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
  63. data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
  64. data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
  65. data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
  66. data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
  67. data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
  68. data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
  69. data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
  70. data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
  71. data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
  72. data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
  73. data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
  74. data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
  75. data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
  76. data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
  77. data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
  78. data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
  79. data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
  80. data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
  81. data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
  82. data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
  83. data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
  84. data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
  85. data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
  86. data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
  87. data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
  88. data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
  89. data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
  90. data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
  91. data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
  92. data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
  93. data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
  94. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
  95. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
  96. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
  97. data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
  98. data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
  99. data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
  100. data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
  101. data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
  102. data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
  103. data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
  104. data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
  105. data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
  106. data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
  107. data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
  108. data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
  109. data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
  110. data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
  111. data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
  112. data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
  113. data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
  114. data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
  115. data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
  116. data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
  117. data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
  118. data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
  119. data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
  120. data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
  121. data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
  122. data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
  123. data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
  124. data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
  125. data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
  126. data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
  127. data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
  128. data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
  129. data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
  130. data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
  131. data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
  132. data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
  133. data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
  134. data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
  135. data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
  136. data/lib/generators/loam/install/templates/admin.css +29 -0
  137. data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
  138. data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
  139. data/lib/generators/loam/install/templates/import_job.rb +25 -0
  140. data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
  141. data/lib/generators/loam/install/templates/initializer.rb +218 -0
  142. data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
  143. data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
  144. data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
  145. data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
  146. data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
  147. data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
  148. data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
  149. data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
  150. data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
  151. data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
  152. data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
  153. data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
  154. data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
  155. data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
  156. data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
  157. data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
  158. data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
  159. data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
  160. data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
  161. data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
  162. data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
  163. data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
  164. data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
  165. data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
  166. data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
  167. data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
  168. data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
  169. data/lib/generators/loam/install/templates/user.rb +21 -0
  170. data/lib/generators/loam/primary_key_options.rb +101 -0
  171. data/lib/loam/attachable.rb +27 -0
  172. data/lib/loam/auditable.rb +67 -0
  173. data/lib/loam/auth_throttle.rb +83 -0
  174. data/lib/loam/base32.rb +23 -0
  175. data/lib/loam/bulk.rb +59 -0
  176. data/lib/loam/business_rules/actions.rb +92 -0
  177. data/lib/loam/business_rules/condition.rb +102 -0
  178. data/lib/loam/business_rules.rb +123 -0
  179. data/lib/loam/commentable.rb +22 -0
  180. data/lib/loam/configs.rb +115 -0
  181. data/lib/loam/cron.rb +98 -0
  182. data/lib/loam/csv.rb +18 -0
  183. data/lib/loam/current.rb +21 -0
  184. data/lib/loam/custom_field_index.rb +262 -0
  185. data/lib/loam/custom_fields.rb +114 -0
  186. data/lib/loam/dashboard.rb +17 -0
  187. data/lib/loam/dictionaries.rb +55 -0
  188. data/lib/loam/durable_events.rb +174 -0
  189. data/lib/loam/encryptable.rb +145 -0
  190. data/lib/loam/encryption/cipher.rb +75 -0
  191. data/lib/loam/encryption/key_provider.rb +43 -0
  192. data/lib/loam/encryption.rb +125 -0
  193. data/lib/loam/engine.rb +57 -0
  194. data/lib/loam/enrichers.rb +105 -0
  195. data/lib/loam/errors.rb +49 -0
  196. data/lib/loam/eval.rb +47 -0
  197. data/lib/loam/event_stream.rb +80 -0
  198. data/lib/loam/eventful.rb +45 -0
  199. data/lib/loam/events.rb +51 -0
  200. data/lib/loam/export.rb +74 -0
  201. data/lib/loam/features.rb +75 -0
  202. data/lib/loam/generated_key.rb +37 -0
  203. data/lib/loam/import.rb +168 -0
  204. data/lib/loam/inbound_webhooks.rb +125 -0
  205. data/lib/loam/lifecycle.rb +180 -0
  206. data/lib/loam/locales/loam.en.yml +80 -0
  207. data/lib/loam/mcp/server.rb +60 -0
  208. data/lib/loam/mcp.rb +304 -0
  209. data/lib/loam/notifications.rb +36 -0
  210. data/lib/loam/open_api.rb +194 -0
  211. data/lib/loam/overrides.rb +110 -0
  212. data/lib/loam/pending_actions.rb +60 -0
  213. data/lib/loam/permissions.rb +79 -0
  214. data/lib/loam/perspectives.rb +62 -0
  215. data/lib/loam/policy.rb +106 -0
  216. data/lib/loam/progress.rb +31 -0
  217. data/lib/loam/record_locks.rb +89 -0
  218. data/lib/loam/scheduler.rb +153 -0
  219. data/lib/loam/search/driver.rb +31 -0
  220. data/lib/loam/search/like_driver.rb +29 -0
  221. data/lib/loam/search/token_driver.rb +88 -0
  222. data/lib/loam/search.rb +27 -0
  223. data/lib/loam/searchable.rb +90 -0
  224. data/lib/loam/soft_deletable.rb +72 -0
  225. data/lib/loam/sso/claims.rb +10 -0
  226. data/lib/loam/sso/fake_provider.rb +51 -0
  227. data/lib/loam/sso/http_client.rb +42 -0
  228. data/lib/loam/sso/oidc_provider.rb +64 -0
  229. data/lib/loam/sso.rb +129 -0
  230. data/lib/loam/telemetry.rb +43 -0
  231. data/lib/loam/tenant_record.rb +36 -0
  232. data/lib/loam/test_helpers.rb +15 -0
  233. data/lib/loam/totp.rb +63 -0
  234. data/lib/loam/translatable.rb +88 -0
  235. data/lib/loam/undo.rb +126 -0
  236. data/lib/loam/version.rb +3 -0
  237. data/lib/loam/webhooks.rb +34 -0
  238. data/lib/loam/widgets.rb +77 -0
  239. data/lib/loam/workflow.rb +256 -0
  240. data/lib/loam.rb +122 -0
  241. data/lib/open-loam.rb +5 -0
  242. data/lib/tasks/loam.rake +171 -0
  243. data/lib/tasks/loam_eval.rake +32 -0
  244. data/lib/tasks/loam_mcp.rake +20 -0
  245. metadata +332 -0
@@ -0,0 +1,67 @@
1
+ module Api
2
+ # JSON for <%= class_name %>. Same rules as the admin screen, same policy:
3
+ # the token's user is the actor, so a role that may not write a field cannot
4
+ # write it here either — `permitted_fields` drops it from the permit list.
5
+ class <%= plural_name.camelize %>Controller < BaseController
6
+ FIELDS = %i[<%= field_names.join(" ") %>].freeze
7
+
8
+ before_action :set_record, only: %i[show update destroy]
9
+
10
+ def index
11
+ records = <%= class_name %>.order(created_at: :desc).to_a
12
+ enriched = Loam::Enrichers.enrich_many(records) # one batched pass, no N+1
13
+ render json: records.map { |record| entity_json(record, enrichments: enriched[record.id]) }
14
+ end
15
+
16
+ def show
17
+ authorize!(policy_for(@record), :read?)
18
+ render json: entity_json(@record)
19
+ end
20
+
21
+ def create
22
+ @record = <%= class_name %>.new
23
+ policy = policy_for(@record)
24
+ authorize!(policy, :create?)
25
+ @record.assign_attributes(permitted_params(policy))
26
+ assign_custom_fields!(@record, params, policy)
27
+
28
+ if @record.save
29
+ render json: entity_json(@record), status: :created
30
+ else
31
+ render json: { errors: @record.errors.full_messages }, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def update
36
+ policy = policy_for(@record)
37
+ authorize!(policy, :update?)
38
+ @record.assign_attributes(permitted_params(policy))
39
+ assign_custom_fields!(@record, params, policy)
40
+
41
+ if @record.save
42
+ render json: entity_json(@record)
43
+ else
44
+ render json: { errors: @record.errors.full_messages }, status: :unprocessable_entity
45
+ end
46
+ end
47
+
48
+ # Soft-delete here too, so DELETE is the ONE way to delete across both
49
+ # surfaces: the record is hidden and recoverable, not erased. The recycle
50
+ # bin itself is an admin concern; the API just gets the safer default.
51
+ def destroy
52
+ authorize!(policy_for(@record), :destroy?)
53
+ @record.soft_delete!
54
+ head :no_content
55
+ end
56
+
57
+ private
58
+
59
+ def set_record
60
+ @record = <%= class_name %>.find(params[:id])
61
+ end
62
+
63
+ def permitted_params(policy)
64
+ params.require(:<%= singular_name %>).permit(*policy.permitted_fields(FIELDS))
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,181 @@
1
+ module Admin
2
+ class <%= plural_name.camelize %>Controller < BaseController
3
+ FIELDS = %i[<%= field_names.join(" ") %>].freeze
4
+
5
+ before_action :set_record, only: %i[show edit update destroy]
6
+
7
+ helper_method :current_index_params
8
+
9
+ # The active index filters (search, saved view, custom-field filter, sort) as
10
+ # a plain hash, so pagination links, the sort headers and the export link all
11
+ # carry the SAME state — page 2 of a filtered/sorted view stays filtered.
12
+ def current_index_params
13
+ params.permit(:q, :perspective_id, :cf_field, :cf_op, :cf_value, :sort, :dir)
14
+ .to_h.symbolize_keys.reject { |_, v| v.blank? }
15
+ end
16
+
17
+ def index
18
+ @perspective = Loam::Perspectives.resolve("<%= class_name %>", user: current_actor, id: params[:perspective_id])
19
+ @perspectives = Loam::Perspectives.visible_to("<%= class_name %>", user: current_actor)
20
+ @records, @page, @has_next = paginate(index_scope)
21
+ @index_partial = params[:cf_field].present? && Loam::CustomFieldIndex.partial? # incomplete-index warning (L-919)
22
+ end
23
+
24
+ # CSV of the CURRENT filtered/perspective view — manager-only, policy- and
25
+ # encryption-aware (Loam::Export).
26
+ def export
27
+ require_role!(:manager)
28
+ send_data Loam::Export.csv(index_scope, actor: current_actor),
29
+ filename: "<%= plural_name %>-#{Date.current}.csv", type: "text/csv"
30
+ end
31
+
32
+ # Datatable bulk actions on the selected ids — each is policy-checked per
33
+ # record and tenant-scoped (Loam::Bulk). Zero selection is a no-op.
34
+ def bulk
35
+ ids = Array(params[:ids])
36
+ case params[:bulk_action]
37
+ when "soft_delete"
38
+ count = Loam::Bulk.soft_delete(<%= class_name %>, ids)
39
+ redirect_to polymorphic_path([:admin, <%= class_name %>]), notice: "Deleted #{count} record(s)."
40
+ when "set_field"
41
+ count = Loam::Bulk.set_field(<%= class_name %>, ids, field: params[:field], value: params[:value])
42
+ redirect_to polymorphic_path([:admin, <%= class_name %>]), notice: "Updated #{count} record(s)."
43
+ when "export"
44
+ require_role!(:manager) # same gate as the dedicated export action
45
+ send_data Loam::Export.csv(Loam::Bulk.selected(<%= class_name %>, ids), actor: current_actor),
46
+ filename: "<%= plural_name %>-selected.csv", type: "text/csv"
47
+ else
48
+ redirect_to polymorphic_path([:admin, <%= class_name %>]), alert: "Unknown bulk action."
49
+ end
50
+ end
51
+
52
+ # The recycle bin: only_deleted stays tenant-scoped, so this never shows
53
+ # another tenant's deleted rows.
54
+ def deleted
55
+ authorize!(policy_for(<%= class_name %>.new), :read?)
56
+ scope = <%= class_name %>.only_deleted.order(deleted_at: :desc, id: :desc)
57
+ @records, @page, @has_next = paginate(scope)
58
+ end
59
+
60
+ def show
61
+ authorize!(policy_for(@record), :read?)
62
+ end
63
+
64
+ def new
65
+ @record = <%= class_name %>.new
66
+ authorize!(policy_for(@record), :create?)
67
+ end
68
+
69
+ def create
70
+ @record = <%= class_name %>.new
71
+ policy = policy_for(@record)
72
+ authorize!(policy, :create?)
73
+ @record.assign_attributes(permitted_params(policy))
74
+ assign_custom_fields!(@record, params, policy)
75
+ attach_files!(@record, policy)
76
+
77
+ if @record.save
78
+ redirect_to [:admin, @record], notice: t("loam.flash.created", name: <%= class_name %>.model_name.human)
79
+ else
80
+ render :new, status: :unprocessable_entity
81
+ end
82
+ end
83
+
84
+ def edit
85
+ authorize!(policy_for(@record), :update?)
86
+ # Take the advisory lock (courtesy) or learn who holds it, for the banner.
87
+ @lock = Loam::RecordLocks.acquire(@record, by: current_actor) || Loam::RecordLocks.active_lock(@record)
88
+ end
89
+
90
+ def update
91
+ policy = policy_for(@record)
92
+ authorize!(policy, :update?)
93
+ assign_custom_fields!(@record, params, policy)
94
+ attach_files!(@record, policy)
95
+
96
+ if @record.update(permitted_params(policy))
97
+ Loam::RecordLocks.release(@record, by: current_actor)
98
+ redirect_to [:admin, @record], notice: t("loam.flash.updated", name: <%= class_name %>.model_name.human)
99
+ else
100
+ render :edit, status: :unprocessable_entity
101
+ end
102
+ rescue ActiveRecord::StaleObjectError
103
+ # Someone saved between open and submit — show the diff, reload fresh, retry.
104
+ stale_conflict!(@record, FIELDS)
105
+ @lock = Loam::RecordLocks.acquire(@record, by: current_actor) || Loam::RecordLocks.active_lock(@record)
106
+ render :edit, status: :conflict
107
+ end
108
+
109
+ # Delete hides, it does not erase — the button is undoable. Reach for the
110
+ # model's `destroy` only when a row must genuinely leave the database.
111
+ def destroy
112
+ authorize!(policy_for(@record), :destroy?)
113
+ @record.soft_delete!
114
+ Loam::RecordLocks.release(@record, by: current_actor)
115
+ redirect_to [:admin, <%= class_name %>], notice: t("loam.flash.destroyed", name: <%= class_name %>.model_name.human)
116
+ end
117
+
118
+ # Restore looks through the deleted rows — the default scope hides them, so a
119
+ # plain find would 404 on the record we are trying to bring back.
120
+ def restore
121
+ @record = <%= class_name %>.with_deleted.find(params[:id])
122
+ authorize!(policy_for(@record), :update?)
123
+ @record.restore!
124
+ redirect_to [:deleted, :admin, <%= class_name %>], notice: t("loam.flash.restored", name: <%= class_name %>.model_name.human)
125
+ end
126
+
127
+ private
128
+
129
+ def set_record
130
+ @record = <%= class_name %>.find(params[:id])
131
+ end
132
+
133
+ # The same filtered/perspective/search scope the index shows — reused by
134
+ # export so the CSV matches what the manager is looking at.
135
+ def index_scope
136
+ scope = <%= class_name %>.all
137
+ perspective = Loam::Perspectives.resolve("<%= class_name %>", user: current_actor, id: params[:perspective_id])
138
+ scope = perspective.apply(scope) if perspective
139
+ <% if searchable_attributes.any? -%>
140
+ scope = scope.search(params[:q])
141
+ <% end -%>
142
+ scope = apply_custom_field_filter(scope)
143
+ apply_sort(scope)
144
+ end
145
+
146
+ # Column sort from the clickable index headers. The column is WHITELISTED
147
+ # against real columns (never interpolated from params — that would be SQL
148
+ # injection), and the direction is constrained to asc/desc. With no valid
149
+ # sort param, a perspective's own order is kept, else a stable default.
150
+ def apply_sort(scope)
151
+ column = params[:sort].to_s
152
+ if <%= class_name %>.column_names.include?(column)
153
+ dir = params[:dir].to_s.casecmp("asc").zero? ? :asc : :desc
154
+ scope.reorder(column => dir).order(id: dir)
155
+ elsif scope.order_values.empty?
156
+ scope.order(created_at: :desc, id: :desc)
157
+ else
158
+ scope
159
+ end
160
+ end
161
+
162
+ # A custom-field filter routed through the read-model index
163
+ # (Loam::CustomFieldIndex) — index-backed, not a JSON scan. An unknown field
164
+ # is ignored rather than raising.
165
+ def apply_custom_field_filter(scope)
166
+ return scope if params[:cf_field].blank?
167
+
168
+ scope.merge(Loam::CustomFieldIndex.filter(<%= class_name %>, params[:cf_field], params[:cf_op].presence || "eq", params[:cf_value]))
169
+ rescue Loam::Error
170
+ scope
171
+ end
172
+
173
+ # Field-level enforcement: the permit list comes from the policy, so a
174
+ # role without write access to a field cannot smuggle it in via params.
175
+ # lock_version rides outside the policy filter — it is optimistic-locking
176
+ # plumbing, and a forged value just produces a harmless conflict.
177
+ def permitted_params(policy)
178
+ params.require(:<%= singular_name %>).permit(*policy.permitted_fields(FIELDS), :lock_version)
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,107 @@
1
+ require "test_helper"
2
+
3
+ # Generated guardrail tests for <%= class_name %>. They prove the Loam
4
+ # invariants hold for THIS entity: tenant isolation, loud failure without
5
+ # context, audit-by-default, lifecycle events, membership-gated policies.
6
+ # Extend freely; never delete.
7
+ class <%= class_name %>LoamTest < ActiveSupport::TestCase
8
+ setup do
9
+ @tenant_a = Loam::Tenant.create!(name: "Tenant A", slug: "a-<%= file_name %>")
10
+ @tenant_b = Loam::Tenant.create!(name: "Tenant B", slug: "b-<%= file_name %>")
11
+ @manager = User.create!(name: "Manager", email: "manager@example.test", password: "password")
12
+ @employee = User.create!(name: "Employee", email: "employee@example.test", password: "password")
13
+
14
+ with_tenant(@tenant_a) do
15
+ Loam::Membership.create!(user: @manager, role: "manager")
16
+ Loam::Membership.create!(user: @employee, role: "employee")
17
+ end
18
+ end
19
+
20
+ test "guardrail: records are invisible outside their tenant" do
21
+ record_id = with_tenant(@tenant_a) { <%= class_name %>.create!(<%= sample_attributes %>).id }
22
+
23
+ with_tenant(@tenant_a) { assert_equal 1, <%= class_name %>.count }
24
+
25
+ with_tenant(@tenant_b) do
26
+ assert_equal 0, <%= class_name %>.count
27
+ assert_raises(ActiveRecord::RecordNotFound) { <%= class_name %>.find(record_id) }
28
+ end
29
+ end
30
+
31
+ test "guardrail: touching the model with no tenant context raises" do
32
+ assert_raises(Loam::MissingTenantError) { <%= class_name %>.count }
33
+ assert_raises(Loam::MissingTenantError) { <%= class_name %>.new }
34
+ end
35
+
36
+ test "guardrail: a record cannot be written into a foreign tenant" do
37
+ record = with_tenant(@tenant_a) { <%= class_name %>.create!(<%= sample_attributes %>) }
38
+
39
+ with_tenant(@tenant_b) do
40
+ assert_raises(Loam::MissingTenantError) { record.update!(<%= sample_attributes(1) %>) }
41
+ end
42
+ end
43
+
44
+ test "every change is audited with actor and changeset" do
45
+ with_tenant(@tenant_a, actor: @manager) do
46
+ record = <%= class_name %>.create!(<%= sample_attributes %>)
47
+
48
+ audit = Loam::AuditRecord.find_by(
49
+ auditable_type: "<%= class_name %>", auditable_id: record.id, action: "create"
50
+ )
51
+ assert audit, "expected an audit record for the create"
52
+ assert_equal @manager.id, audit.actor_id
53
+ assert_includes audit.changeset.keys, "<%= first_field %>"
54
+ end
55
+ end
56
+
57
+ test "lifecycle events are published with the tenant stamped" do
58
+ received = []
59
+ subscription = Loam::Events.subscribe("<%= domain %>.<%= singular_name %>.created") do |_name, payload|
60
+ received << payload
61
+ end
62
+
63
+ with_tenant(@tenant_a, actor: @manager) { <%= class_name %>.create!(<%= sample_attributes %>) }
64
+
65
+ assert_equal 1, received.size
66
+ assert_equal @tenant_a.id, received.first[:tenant_id]
67
+ ensure
68
+ ActiveSupport::Notifications.unsubscribe(subscription)
69
+ end
70
+
71
+ test "soft-delete hides a record by default, stays tenant-scoped, and restores" do
72
+ record_id = with_tenant(@tenant_a, actor: @manager) do
73
+ record = <%= class_name %>.create!(<%= sample_attributes %>)
74
+ record.soft_delete
75
+ record.id
76
+ end
77
+
78
+ with_tenant(@tenant_a) do
79
+ assert_equal 0, <%= class_name %>.count, "a soft-deleted record is gone from ordinary queries"
80
+ assert_equal 1, <%= class_name %>.with_deleted.count, "but the row still exists"
81
+ assert Loam::AuditRecord.exists?(auditable_type: "<%= class_name %>", auditable_id: record_id, action: "soft_delete")
82
+ end
83
+
84
+ # with_deleted lifts the deleted_at filter but NOT the tenant filter.
85
+ with_tenant(@tenant_b) do
86
+ assert_equal 0, <%= class_name %>.with_deleted.count, "the recycle bin must never cross tenants"
87
+ end
88
+
89
+ with_tenant(@tenant_a) do
90
+ <%= class_name %>.with_deleted.find(record_id).restore
91
+ assert_equal 1, <%= class_name %>.count, "restore returns the record to the default scope"
92
+ end
93
+ end
94
+
95
+ test "policy: only members of the current tenant may act" do
96
+ record = with_tenant(@tenant_a) { <%= class_name %>.create!(<%= sample_attributes %>) }
97
+
98
+ with_tenant(@tenant_a) do
99
+ assert <%= class_name %>Policy.new(@manager, record).update?
100
+
101
+ outsider = User.create!(name: "Outsider", email: "outsider@example.test", password: "password")
102
+ outsider_policy = <%= class_name %>Policy.new(outsider, record)
103
+ refute outsider_policy.update?
104
+ refute outsider_policy.writable?(:<%= first_field %>)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,26 @@
1
+ class Create<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :<%= table_name %><%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ <% attributes.each do |attribute| -%>
6
+ <% if encrypted?(attribute) -%>
7
+ t.text :<%= attribute.name %> # encrypted at rest (Loam::Encryptable) — text, since ciphertext outgrows varchar
8
+ <% else -%>
9
+ t.<%= attribute.type %> :<%= attribute.name %>
10
+ <% end -%>
11
+ <% end -%>
12
+ <% encrypt_searchable_names.each do |field| -%>
13
+ t.string :<%= field %>_hash # per-tenant blind index of :<%= field %>, for exact-match lookup
14
+ <% end -%>
15
+ t.json :custom_fields, null: false, default: {}
16
+ t.datetime :deleted_at
17
+ t.integer :lock_version, null: false, default: 0 # optimistic locking (Loam::RecordLock)
18
+ t.timestamps
19
+ end
20
+ # Loam::SoftDeletable filters `deleted_at IS NULL` on every query.
21
+ add_index :<%= table_name %>, :deleted_at
22
+ <% encrypt_searchable_names.each do |field| -%>
23
+ add_index :<%= table_name %>, :<%= field %>_hash
24
+ <% end -%>
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # Tenant-scoped, audited, evented — by inheritance, not by remembering.
2
+ class <%= class_name %> < Loam::TenantRecord
3
+ include Loam::Auditable
4
+ include Loam::Eventful
5
+ include Loam::CustomFields
6
+ include Loam::Commentable
7
+ include Loam::Attachable
8
+ include Loam::Searchable
9
+ include Loam::SoftDeletable
10
+ <% if encrypted_field_names.any? -%>
11
+ include Loam::Encryptable
12
+ <% end -%>
13
+
14
+ event_domain :<%= domain %>
15
+ <% if searchable_attributes.any? -%>
16
+ searchable_by <%= searchable_attributes.map { |a| ":#{a.name}" }.join(", ") %>
17
+ <% end -%>
18
+ <% options[:encrypt].each do |field| -%>
19
+ encrypts :<%= field %>
20
+ <% end -%>
21
+ <% encrypt_searchable_names.each do |field| -%>
22
+ encrypts :<%= field %>, searchable: true # exact-match lookup via <%= field %>_hash
23
+ <% end -%>
24
+
25
+ # Business logic goes here. Publish business events explicitly:
26
+ # Loam::Events.publish("<%= domain %>.something.happened", id: id)
27
+ end
@@ -0,0 +1,10 @@
1
+ class <%= class_name %>Policy < Loam::Policy
2
+ # Action defaults: any member of the current tenant may read/create/update/
3
+ # destroy. Tighten by overriding, e.g.:
4
+ #
5
+ # def destroy? = role == :manager
6
+ #
7
+ # Field-level write access is declared, not coded:
8
+ #
9
+ # field :<%= first_field %>, writable: [:manager]
10
+ end
@@ -0,0 +1,63 @@
1
+ <%% policy = Loam::Policy.for(@record) %>
2
+
3
+ <%%# Optimistic-locking conflict: what the user typed vs what is now saved. %>
4
+ <%% if @conflict.present? %>
5
+ <div class="conflict" style="border:2px solid #b00; padding:0.75rem; margin-bottom:1rem">
6
+ <p><strong>This changed while you were editing.</strong> Your entries were not saved. The form now shows the current values — re-apply your changes and save again.</p>
7
+ <table>
8
+ <thead><tr><th>Field</th><th>You entered</th><th>Now saved</th></tr></thead>
9
+ <tbody>
10
+ <%% @conflict.each do |field, values| %>
11
+ <tr><td><%%= field.humanize %></td><td><%%= values["yours"] %></td><td><%%= values["theirs"] %></td></tr>
12
+ <%% end %>
13
+ </tbody>
14
+ </table>
15
+ </div>
16
+ <%% end %>
17
+
18
+ <%%# Advisory lock: warn (do not block) when someone else is editing. %>
19
+ <%% if @lock && @lock.locked_by_id != current_actor.id %>
20
+ <div class="lock-banner" style="background:#ffd; padding:0.5rem; margin-bottom:1rem">
21
+ Locked by <strong><%%= @lock.locked_by.name %></strong> until <%%= @lock.expires_at.strftime("%H:%M") %>.
22
+ You can still save (the version check protects you), but you may collide.
23
+ <%% if current_role == :manager %>
24
+ <%%= button_to "Take over", admin_record_lock_path(lockable_type: @record.class.name, lockable_id: @record.id), method: :delete, form: { style: "display:inline" } %>
25
+ <%% end %>
26
+ </div>
27
+ <%% end %>
28
+
29
+ <%%= form_with model: [:admin, @record] do |f| %>
30
+ <%%= f.hidden_field :lock_version %>
31
+ <%% if @record.errors.any? %>
32
+ <ul>
33
+ <%% @record.errors.full_messages.each do |message| %>
34
+ <li><%%= message %></li>
35
+ <%% end %>
36
+ </ul>
37
+ <%% end %>
38
+
39
+ <% attributes.each do |attribute| -%>
40
+ <%% if policy.writable?(:<%= attribute.name %>) %>
41
+ <p>
42
+ <%%= f.label :<%= attribute.name %> %><br>
43
+ <%%= f.<%= form_field_helper(attribute) %> :<%= attribute.name %> %>
44
+ </p>
45
+ <%% else %>
46
+ <p>
47
+ <%%= f.label :<%= attribute.name %> %>: <%%= @record.<%= attribute.name %> %>
48
+ <em><%%= t("loam.form.read_only") %></em>
49
+ </p>
50
+ <%% end %>
51
+ <% end -%>
52
+
53
+ <%%= render "loam/custom_fields/fields", record: @record, policy: policy, mode: :edit %>
54
+
55
+ <%% if policy.update? %>
56
+ <p>
57
+ <%%= f.label :files, t("loam.form.attach_files") %><br>
58
+ <%%= f.file_field :files, multiple: true %>
59
+ </p>
60
+ <%% end %>
61
+
62
+ <%%= f.submit %>
63
+ <%% end %>
@@ -0,0 +1,41 @@
1
+ <h1><%%= t("loam.actions.deleted_title", name: <%= class_name %>.model_name.human(count: 2)) %></h1>
2
+
3
+ <p><%%= link_to t("loam.actions.back"), [:admin, <%= class_name %>] %></p>
4
+
5
+ <%%# No Show/Edit here: these rows are hidden by the default scope, so a plain
6
+ find would 404. The one action a deleted record offers is Restore. %>
7
+ <table>
8
+ <thead>
9
+ <tr>
10
+ <th><%%= t("loam.labels.id") %></th>
11
+ <% attributes.each do |attribute| -%>
12
+ <th><%%= <%= class_name %>.human_attribute_name("<%= attribute.name %>") %></th>
13
+ <% end -%>
14
+ <th><%%= <%= class_name %>.human_attribute_name("deleted_at") %></th>
15
+ <th></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <%% @records.each do |record| %>
20
+ <tr>
21
+ <td><%%= record.id %></td>
22
+ <% attributes.each do |attribute| -%>
23
+ <td><%%= record.<%= attribute.name %> %></td>
24
+ <% end -%>
25
+ <td><%%= record.deleted_at %></td>
26
+ <td><%%= button_to t("loam.actions.restore"), [:restore, :admin, record], method: :patch %></td>
27
+ </tr>
28
+ <%% end %>
29
+ </tbody>
30
+ </table>
31
+
32
+ <%%# Page links carry nothing but the page: the recycle bin has no filter box. %>
33
+ <p>
34
+ <%% if @page > 1 %>
35
+ <%%= link_to t("loam.pagination.previous"), polymorphic_path([:deleted, :admin, <%= class_name %>], page: @page - 1) %>
36
+ <%% end %>
37
+ <%%= t("loam.pagination.page", page: @page) %>
38
+ <%% if @has_next %>
39
+ <%%= link_to t("loam.pagination.next"), polymorphic_path([:deleted, :admin, <%= class_name %>], page: @page + 1) %>
40
+ <%% end %>
41
+ </p>
@@ -0,0 +1,3 @@
1
+ <h1><%%= t("loam.actions.edit_title", name: <%= class_name %>.model_name.human, id: @record.id) %></h1>
2
+
3
+ <%%= render "form" %>
@@ -0,0 +1,90 @@
1
+ <h1><%%= <%= class_name %>.model_name.human(count: 2) %></h1>
2
+
3
+ <%% if @index_partial %>
4
+ <p style="color:#b00"><%%= t("loam.index.incomplete") %></p>
5
+ <%% end %>
6
+
7
+ <p>
8
+ <%%= link_to t("loam.actions.new", name: <%= class_name %>.model_name.human), new_polymorphic_path([:admin, <%= class_name %>]) %>
9
+ <%%= link_to t("loam.actions.deleted", name: <%= class_name %>.model_name.human(count: 2)), polymorphic_path([:deleted, :admin, <%= class_name %>]) %>
10
+ <%% if current_role == :manager %>
11
+ <%%= link_to t("loam.actions.export_csv"), url_for(action: :export, **current_index_params) %>
12
+ <%%= link_to t("loam.actions.import_csv"), new_admin_import_path(entity_type: "<%= class_name %>") %>
13
+ <%% end %>
14
+ </p>
15
+
16
+ <%%# Saved views (Loam::Perspectives): pick one to apply, or save the current one. %>
17
+ <%% if @perspectives.any? %>
18
+ <%%= form_with url: [:admin, <%= class_name %>], method: :get do %>
19
+ <%%= select_tag :perspective_id,
20
+ options_for_select([[t("loam.views.all", name: <%= class_name %>.model_name.human(count: 2)), "none"]] + @perspectives.map { |p| [p.name, p.id] }, @perspective&.id),
21
+ onchange: "this.form.submit()" %>
22
+ <%%= link_to t("loam.views.manage"), admin_perspectives_path(entity_type: "<%= class_name %>") %>
23
+ <%% end %>
24
+ <%% else %>
25
+ <p><%%= link_to t("loam.views.manage_saved"), admin_perspectives_path(entity_type: "<%= class_name %>") %></p>
26
+ <%% end %>
27
+
28
+ <%%= form_with url: admin_perspectives_path, method: :post do %>
29
+ <%%= hidden_field_tag :entity_type, "<%= class_name %>" %>
30
+ <%%= hidden_field_tag :q, params[:q] %>
31
+ <%%= text_field_tag :name, nil, placeholder: t("loam.views.name_placeholder") %>
32
+ <%%= submit_tag t("loam.views.save_current") %>
33
+ <%% end %>
34
+ <% if searchable_attributes.any? -%>
35
+
36
+ <%%= form_with url: [:admin, <%= class_name %>], method: :get do |f| %>
37
+ <%%= text_field_tag :q, params[:q], placeholder: t("loam.views.filter_placeholder", name: <%= class_name %>.model_name.human(count: 2)) %>
38
+ <%%= f.submit t("loam.actions.filter") %>
39
+ <%% end %>
40
+ <% end -%>
41
+
42
+ <%%# Bulk actions on selected rows (Loam::Bulk). Zero selection = no-op; each
43
+ action is policy-checked per record server-side. %>
44
+ <%%= form_with url: url_for(action: :bulk), method: :post do %>
45
+ <p>
46
+ <%%= select_tag :bulk_action, options_for_select([[t("loam.bulk.delete_selected"), "soft_delete"], [t("loam.bulk.set_field"), "set_field"], [t("loam.bulk.export_selected"), "export"]]) %>
47
+ <%%= select_tag :field, options_for_select(%i[<%= field_names.join(" ") %>]) %>
48
+ <%%= text_field_tag :value, nil, placeholder: t("loam.bulk.new_value") %>
49
+ <%%= submit_tag t("loam.bulk.apply"), data: { turbo_confirm: t("loam.bulk.confirm") } %>
50
+ </p>
51
+
52
+ <table>
53
+ <thead>
54
+ <tr>
55
+ <th><input type="checkbox" onclick="document.querySelectorAll('.bulk-select').forEach(function(c){c.checked=this.checked}.bind(this))"></th>
56
+ <th><%%= t("loam.labels.id") %></th>
57
+ <% attributes.each do |attribute| -%>
58
+ <th><%%= sort_link("<%= attribute.name %>", <%= class_name %>.human_attribute_name("<%= attribute.name %>")) %></th>
59
+ <% end -%>
60
+ <th></th>
61
+ </tr>
62
+ </thead>
63
+ <tbody>
64
+ <%% @records.each do |record| %>
65
+ <tr>
66
+ <td><%%= check_box_tag "ids[]", record.id, false, class: "bulk-select" %></td>
67
+ <td><%%= record.id %></td>
68
+ <% attributes.each do |attribute| -%>
69
+ <td><%%= record.<%= attribute.name %> %></td>
70
+ <% end -%>
71
+ <td>
72
+ <%%= link_to t("loam.actions.show"), [:admin, record] %>
73
+ <%%= link_to t("loam.actions.edit"), edit_polymorphic_path([:admin, record]) %>
74
+ </td>
75
+ </tr>
76
+ <%% end %>
77
+ </tbody>
78
+ </table>
79
+ <%% end %>
80
+
81
+ <%%# Page links carry the full filter state, or page 2 would quietly drop it. %>
82
+ <p>
83
+ <%% if @page > 1 %>
84
+ <%%= link_to t("loam.pagination.previous"), polymorphic_path([:admin, <%= class_name %>], **current_index_params, page: @page - 1) %>
85
+ <%% end %>
86
+ <%%= t("loam.pagination.page", page: @page) %>
87
+ <%% if @has_next %>
88
+ <%%= link_to t("loam.pagination.next"), polymorphic_path([:admin, <%= class_name %>], **current_index_params, page: @page + 1) %>
89
+ <%% end %>
90
+ </p>
@@ -0,0 +1,3 @@
1
+ <h1><%%= t("loam.actions.new_title", name: <%= class_name %>.model_name.human) %></h1>
2
+
3
+ <%%= render "form" %>
@@ -0,0 +1,36 @@
1
+ <%% policy = Loam::Policy.for(@record) %>
2
+
3
+ <h1><%%= t("loam.actions.show_title", name: <%= class_name %>.model_name.human, id: @record.id) %></h1>
4
+
5
+ <% attributes.each do |attribute| -%>
6
+ <p><strong><%%= <%= class_name %>.human_attribute_name("<%= attribute.name %>") %>:</strong> <%%= @record.<%= attribute.name %> %></p>
7
+ <% end -%>
8
+
9
+ <%%= render "loam/custom_fields/fields", record: @record, policy: policy, mode: :show %>
10
+
11
+ <%%# Computed cross-module blocks (Loam::Enrichers) — no section when none registered. %>
12
+ <%% enrichments = Loam::Enrichers.enrich(@record) %>
13
+ <%% if enrichments.any? %>
14
+ <h2><%%= t("loam.labels.enrichments") %></h2>
15
+ <dl>
16
+ <%% enrichments.each do |key, value| %>
17
+ <dt><strong><%%= key.humanize %></strong></dt>
18
+ <dd><%%= value %></dd>
19
+ <%% end %>
20
+ </dl>
21
+ <%% end %>
22
+
23
+ <p>
24
+ <%%= link_to t("loam.actions.edit"), edit_polymorphic_path([:admin, @record]) %>
25
+ <%%= link_to "History", admin_history_path(type: @record.class.name, record_id: @record.id) %>
26
+ <%% if @record.class.respond_to?(:loam_translatable_attributes) && @record.class.loam_translatable_attributes.any? && current_role == :manager %>
27
+ <%%= link_to t("loam.actions.translations"), admin_translations_path(translatable_type: @record.class.name, translatable_id: @record.id) %>
28
+ <%% end %>
29
+ <%%# Delete soft-deletes: the record moves to the recycle bin, not oblivion. %>
30
+ <%%= button_to t("loam.actions.destroy"), [:admin, @record], method: :delete if policy.destroy? %>
31
+ <%%= link_to t("loam.actions.back"), [:admin, <%= class_name %>] %>
32
+ </p>
33
+
34
+ <%%= render "loam/attachments/attachments", record: @record %>
35
+
36
+ <%%= render "loam/comments/comments", record: @record %>