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,45 @@
1
+ <h1>Dictionary: <code><%%= @dictionary.key %></code></h1>
2
+
3
+ <%%= render "form" %>
4
+
5
+ <h2>Entries</h2>
6
+ <p>Ordered by position; an inactive entry stops being offered but keeps existing values readable.</p>
7
+
8
+ <table>
9
+ <thead>
10
+ <tr><th>Pos</th><th>Value</th><th>Label</th><th>Color</th><th>Icon</th><th>Default</th><th>Active</th><th></th></tr>
11
+ </thead>
12
+ <tbody>
13
+ <%% @dictionary.entries.ordered.each do |entry| %>
14
+ <tr>
15
+ <%%= form_with model: entry, scope: :dictionary_entry, url: admin_dictionary_entry_path(@dictionary, entry) do |f| %>
16
+ <td><%%= f.number_field :position, style: "width:4rem" %></td>
17
+ <td><%%= f.text_field :value %></td>
18
+ <td><%%= f.text_field :label %></td>
19
+ <td><%%= f.text_field :color, placeholder: "#a00", style: "width:6rem" %></td>
20
+ <td><%%= f.text_field :icon, style: "width:6rem" %></td>
21
+ <td><%%= f.check_box :is_default %></td>
22
+ <td><%%= f.check_box :active %></td>
23
+ <td>
24
+ <%%= f.submit "Save" %>
25
+ <%%= button_to "Delete", admin_dictionary_entry_path(@dictionary, entry), method: :delete, form: { style: "display:inline" } %>
26
+ </td>
27
+ <%% end %>
28
+ </tr>
29
+ <%% end %>
30
+ </tbody>
31
+ </table>
32
+
33
+ <h3>Add entry</h3>
34
+ <%%= form_with model: @entry, scope: :dictionary_entry, url: admin_dictionary_entries_path(@dictionary) do |f| %>
35
+ <p><%%= f.label :value %> <%%= f.text_field :value %>
36
+ <%%= f.label :label %> <%%= f.text_field :label %>
37
+ <%%= f.label :position %> <%%= f.number_field :position, value: 0, style: "width:4rem" %></p>
38
+ <p><%%= f.label :color %> <%%= f.text_field :color, placeholder: "#a00" %>
39
+ <%%= f.label :icon %> <%%= f.text_field :icon %>
40
+ <%%= f.label :is_default %> <%%= f.check_box :is_default %>
41
+ <%%= f.label :active %> <%%= f.check_box :active, checked: true %></p>
42
+ <%%= f.submit "Add entry" %>
43
+ <%% end %>
44
+
45
+ <p><%%= link_to "Back to dictionaries", admin_dictionaries_path %></p>
@@ -0,0 +1,10 @@
1
+ <%%# Loam::Dictionary is namespaced, so pin the URL + param scope. %>
2
+ <%%= form_with model: @dictionary, scope: :dictionary,
3
+ url: (@dictionary.persisted? ? admin_dictionary_path(@dictionary) : admin_dictionaries_path) do |f| %>
4
+ <%% if @dictionary.errors.any? %>
5
+ <ul><%% @dictionary.errors.full_messages.each do |m| %><li><%%= m %></li><%% end %></ul>
6
+ <%% end %>
7
+ <p><%%= f.label :key, "Key (code, e.g. damage_severity)" %><br><%%= f.text_field :key %></p>
8
+ <p><%%= f.label :name %><br><%%= f.text_field :name %></p>
9
+ <%%= f.submit %>
10
+ <%% end %>
@@ -0,0 +1,21 @@
1
+ <h1>Dictionaries</h1>
2
+
3
+ <p>Managed lookup lists for this tenant — curate the values here, then use one as
4
+ a "dictionary" custom-field type. <%%= link_to "New dictionary", new_admin_dictionary_path %></p>
5
+
6
+ <table>
7
+ <thead><tr><th>Key</th><th>Name</th><th>Entries</th><th></th></tr></thead>
8
+ <tbody>
9
+ <%% @dictionaries.each do |dictionary| %>
10
+ <tr>
11
+ <td><code><%%= dictionary.key %></code></td>
12
+ <td><%%= dictionary.name %></td>
13
+ <td><%%= dictionary.entries.count %></td>
14
+ <td>
15
+ <%%= link_to "Edit", edit_admin_dictionary_path(dictionary) %>
16
+ <%%= button_to "Delete", admin_dictionary_path(dictionary), method: :delete, form: { style: "display:inline" } %>
17
+ </td>
18
+ </tr>
19
+ <%% end %>
20
+ </tbody>
21
+ </table>
@@ -0,0 +1,5 @@
1
+ <h1>New dictionary</h1>
2
+
3
+ <%%= render "form" %>
4
+
5
+ <p><%%= link_to "Back", admin_dictionaries_path %></p>
@@ -0,0 +1,47 @@
1
+ module Admin
2
+ # Entries of a Loam::Dictionary — manager-only, nested under a dictionary.
3
+ # Add / edit (value, label, color, icon, position, default, active) / delete;
4
+ # reordering is by editing `position`.
5
+ class DictionaryEntriesController < BaseController
6
+ before_action { require_role!(:manager) }
7
+ before_action :set_dictionary
8
+ before_action :set_entry, only: %i[update destroy]
9
+
10
+ def create
11
+ @entry = @dictionary.entries.new(entry_params)
12
+ if @entry.save
13
+ redirect_to edit_admin_dictionary_path(@dictionary), notice: "Entry added."
14
+ else
15
+ @dictionary_errors = @entry.errors.full_messages
16
+ redirect_to edit_admin_dictionary_path(@dictionary), alert: @dictionary_errors.join(", ")
17
+ end
18
+ end
19
+
20
+ def update
21
+ if @entry.update(entry_params)
22
+ redirect_to edit_admin_dictionary_path(@dictionary), notice: "Entry updated."
23
+ else
24
+ redirect_to edit_admin_dictionary_path(@dictionary), alert: @entry.errors.full_messages.join(", ")
25
+ end
26
+ end
27
+
28
+ def destroy
29
+ @entry.destroy!
30
+ redirect_to edit_admin_dictionary_path(@dictionary), notice: "Entry removed."
31
+ end
32
+
33
+ private
34
+
35
+ def set_dictionary
36
+ @dictionary = Loam::Dictionary.find(params[:dictionary_id])
37
+ end
38
+
39
+ def set_entry
40
+ @entry = @dictionary.entries.find(params[:id])
41
+ end
42
+
43
+ def entry_params
44
+ params.require(:dictionary_entry).permit(:value, :label, :color, :icon, :position, :is_default, :active)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,23 @@
1
+ module Admin
2
+ # Durable event deliveries (Loam::EventDelivery) — the dead-letter view.
3
+ # Manager-only. Lists deliveries parked as `dead` (handler removed, or retries
4
+ # exhausted) and lets an operator requeue one after fixing the handler. Pending
5
+ # deliveries drain on their own; a small sample is shown for visibility.
6
+ class EventDeliveriesController < BaseController
7
+ before_action { require_role!(:manager) }
8
+
9
+ def index
10
+ @dead = Loam::EventDelivery.dead.order(updated_at: :desc).limit(200)
11
+ @pending = Loam::EventDelivery.pending.order(Arel.sql("next_attempt_at IS NULL DESC, next_attempt_at ASC")).limit(50)
12
+ end
13
+
14
+ # Re-arm one delivery: back to pending with a clean slate, and nudge a job.
15
+ # The sweep would eventually pick it up anyway; this is the "try now" button.
16
+ def redeliver
17
+ delivery = Loam::EventDelivery.find(params[:id])
18
+ delivery.update!(status: "pending", attempts: 0, next_attempt_at: nil, last_error: nil)
19
+ Loam::EventDeliveryJob.perform_later(delivery.tenant_id, delivery.id)
20
+ redirect_to admin_event_deliveries_path, notice: "Delivery requeued."
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,49 @@
1
+ <h1>Event deliveries</h1>
2
+
3
+ <p>Durable subscribers (<code>Loam::DurableEvents</code>). Delivery is at-least-once
4
+ with retry; a delivery that exhausts its retries — or whose subscriber was removed —
5
+ is parked here as <strong>dead</strong> for you to fix and requeue.</p>
6
+
7
+ <h2>Dead-letter (<%%= @dead.size %>)</h2>
8
+ <%% if @dead.any? %>
9
+ <table>
10
+ <thead>
11
+ <tr><th>Subscriber</th><th>Event</th><th>Attempts</th><th>Last error</th><th>When</th><th></th></tr>
12
+ </thead>
13
+ <tbody>
14
+ <%% @dead.each do |d| %>
15
+ <tr>
16
+ <td><code><%%= d.subscriber_key %></code></td>
17
+ <td><code><%%= d.event_name %></code></td>
18
+ <td><%%= d.attempts %></td>
19
+ <td><small><%%= d.last_error %></small></td>
20
+ <td><%%= d.updated_at.strftime("%Y-%m-%d %H:%M UTC") %></td>
21
+ <td><%%= button_to "Requeue", redeliver_admin_event_delivery_path(d), form: { style: "display:inline" } %></td>
22
+ </tr>
23
+ <%% end %>
24
+ </tbody>
25
+ </table>
26
+ <%% else %>
27
+ <p>No dead deliveries. 🎉</p>
28
+ <%% end %>
29
+
30
+ <h2>Pending (next <%%= @pending.size %>)</h2>
31
+ <%% if @pending.any? %>
32
+ <table>
33
+ <thead>
34
+ <tr><th>Subscriber</th><th>Event</th><th>Attempts</th><th>Next attempt</th></tr>
35
+ </thead>
36
+ <tbody>
37
+ <%% @pending.each do |d| %>
38
+ <tr>
39
+ <td><code><%%= d.subscriber_key %></code></td>
40
+ <td><code><%%= d.event_name %></code></td>
41
+ <td><%%= d.attempts %></td>
42
+ <td><%%= d.next_attempt_at ? d.next_attempt_at.strftime("%Y-%m-%d %H:%M UTC") : "now" %></td>
43
+ </tr>
44
+ <%% end %>
45
+ </tbody>
46
+ </table>
47
+ <%% else %>
48
+ <p>Nothing pending.</p>
49
+ <%% end %>
@@ -0,0 +1,42 @@
1
+ module Admin
2
+ # Server-Sent Events: a per-tenant push stream (Loam::EventStream) so the admin
3
+ # updates live instead of polling. Authentication runs in the normal
4
+ # before_action chain (set_loam_context) BEFORE any stream write — so an
5
+ # unauthenticated request redirects to login the ordinary way, since no headers
6
+ # have been committed yet. Once the loop starts writing, the response is
7
+ # committed and there is no going back; that ordering is the whole safety of a
8
+ # Live action.
9
+ class EventsController < BaseController
10
+ include ActionController::Live
11
+
12
+ HEARTBEAT_SECONDS = 20
13
+
14
+ def stream
15
+ response.headers["Content-Type"] = "text/event-stream"
16
+ response.headers["Cache-Control"] = "no-cache"
17
+ handle = nil # so the ensure never raises NameError if subscribe below does
18
+ queue = Queue.new
19
+
20
+ # Subscribe as the FIRST thing in the begin whose ensure unsubscribes: a
21
+ # leaked AS::Notifications subscriber would fire on every future event in
22
+ # the process, holding this dead stream's queue forever.
23
+ handle = Loam::EventStream.broadcaster.subscribe(tenant: current_tenant, actor: current_actor) do |sse|
24
+ queue << sse
25
+ end
26
+
27
+ loop do
28
+ # A heartbeat comment both keeps the connection alive AND detects a dead
29
+ # client: writing to a closed socket raises IOError → the loop exits →
30
+ # ensure runs and the subscription is dropped.
31
+ message = queue.pop(timeout: HEARTBEAT_SECONDS)
32
+ response.stream.write(message || ": heartbeat\n\n")
33
+ end
34
+ rescue IOError, ActionController::Live::ClientDisconnected
35
+ # The client went away — nothing to do but clean up in `ensure`.
36
+ ensure
37
+ Loam::EventStream.broadcaster.unsubscribe(handle)
38
+ response.stream.close
39
+ Loam::Current.reset
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,35 @@
1
+ module Admin
2
+ # Feature flags (Loam::Features). Structural and privileged, so manager-only,
3
+ # like Settings. Everything routes through Loam::Features; the admin toggles
4
+ # the CURRENT tenant's override, and Reset drops it back to the declared /
5
+ # global state. Global (app-wide) flips are a deploy concern, not this screen.
6
+ class FeaturesController < BaseController
7
+ before_action { require_role!(:manager) }
8
+
9
+ def index
10
+ @flags = Loam::Features.declared.map do |name|
11
+ {
12
+ name: name,
13
+ on: Loam::Features.on?(name),
14
+ overridden: Loam::Features.overridden?(name),
15
+ description: Loam::Features.description(name)
16
+ }
17
+ end
18
+ end
19
+
20
+ def enable
21
+ Loam::Features.enable(params[:name])
22
+ redirect_to admin_features_path, notice: "#{params[:name]} enabled for this tenant."
23
+ end
24
+
25
+ def disable
26
+ Loam::Features.disable(params[:name])
27
+ redirect_to admin_features_path, notice: "#{params[:name]} disabled for this tenant."
28
+ end
29
+
30
+ def reset
31
+ Loam::Features.reset(params[:name])
32
+ redirect_to admin_features_path, notice: "#{params[:name]} reset to the default."
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,37 @@
1
+ <h1>Features</h1>
2
+
3
+ <p>Feature flags turn a capability on or off for <strong><%%= current_tenant.name %></strong>,
4
+ independent of who is signed in. Toggling sets an override for this tenant;
5
+ Reset drops it back to the default.</p>
6
+
7
+ <table>
8
+ <thead>
9
+ <tr>
10
+ <th>Flag</th>
11
+ <th>State</th>
12
+ <th>Source</th>
13
+ <th>Description</th>
14
+ <th></th>
15
+ </tr>
16
+ </thead>
17
+ <tbody>
18
+ <%% @flags.each do |flag| %>
19
+ <tr>
20
+ <td><%%= flag[:name] %></td>
21
+ <td><%%= flag[:on] ? "on" : "off" %></td>
22
+ <td><%%= flag[:overridden] ? "override" : "default" %></td>
23
+ <td><%%= flag[:description] %></td>
24
+ <td>
25
+ <%% if flag[:on] %>
26
+ <%%= button_to "Disable", admin_disable_feature_path(name: flag[:name]), form: { style: "display:inline" } %>
27
+ <%% else %>
28
+ <%%= button_to "Enable", admin_enable_feature_path(name: flag[:name]), form: { style: "display:inline" } %>
29
+ <%% end %>
30
+ <%% if flag[:overridden] %>
31
+ <%%= button_to "Reset", admin_features_path(name: flag[:name]), method: :delete, form: { style: "display:inline" } %>
32
+ <%% end %>
33
+ </td>
34
+ </tr>
35
+ <%% end %>
36
+ </tbody>
37
+ </table>
@@ -0,0 +1,52 @@
1
+ module Admin
2
+ # Manages Loam::FieldDefinition records: the "migration-free field" screen.
3
+ # Structural, not per-entity — gated on the manager role rather than a
4
+ # generated entity policy, since a field definition applies to a whole
5
+ # entity_type, not one record.
6
+ class FieldDefinitionsController < BaseController
7
+ before_action { require_role!(:manager) }
8
+ before_action :set_record, only: %i[destroy]
9
+
10
+ def index
11
+ @entity_type = params[:entity_type]
12
+ @records = Loam::FieldDefinition.order(:entity_type, :name)
13
+ @records = @records.where(entity_type: @entity_type) if @entity_type.present?
14
+ end
15
+
16
+ def new
17
+ @record = Loam::FieldDefinition.new(entity_type: params[:entity_type])
18
+ end
19
+
20
+ def create
21
+ @record = Loam::FieldDefinition.new(permitted_params)
22
+ @record.writable_roles = parse_roles(params.dig(:field_definition, :writable_roles))
23
+ @record.readable_roles = parse_roles(params.dig(:field_definition, :readable_roles))
24
+
25
+ if @record.save
26
+ redirect_to admin_field_definitions_path(entity_type: @record.entity_type)
27
+ else
28
+ render :new, status: :unprocessable_entity
29
+ end
30
+ end
31
+
32
+ def destroy
33
+ entity_type = @record.entity_type
34
+ @record.destroy!
35
+ redirect_to admin_field_definitions_path(entity_type: entity_type)
36
+ end
37
+
38
+ private
39
+
40
+ def set_record
41
+ @record = Loam::FieldDefinition.find(params[:id])
42
+ end
43
+
44
+ def permitted_params
45
+ params.require(:field_definition).permit(:entity_type, :name, :field_type, :dictionary_key)
46
+ end
47
+
48
+ def parse_roles(raw)
49
+ raw.to_s.split(",").map(&:strip).reject(&:blank?)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,30 @@
1
+ <h1>Field definitions<%%= " — #{@entity_type}" if @entity_type.present? %></h1>
2
+
3
+ <p>Add a field to an entity with no migration and no deploy.</p>
4
+
5
+ <%%= link_to "New field definition", new_admin_field_definition_path(entity_type: @entity_type) %>
6
+
7
+ <table>
8
+ <thead>
9
+ <tr>
10
+ <th>Entity type</th>
11
+ <th>Name</th>
12
+ <th>Field type</th>
13
+ <th>Writable by</th>
14
+ <th>Readable by</th>
15
+ <th></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <%% @records.each do |record| %>
20
+ <tr>
21
+ <td><%%= record.entity_type %></td>
22
+ <td><%%= record.name %></td>
23
+ <td><%%= record.field_type %></td>
24
+ <td><%%= record.writable_roles.presence&.join(", ") || "any member" %></td>
25
+ <td><%%= record.readable_roles.presence&.join(", ") || "any member" %></td>
26
+ <td><%%= button_to "Delete", admin_field_definition_path(record), method: :delete, form: { style: "display:inline" }, data: { turbo_confirm: "Delete this field? Existing data under this key becomes inaccessible." } %></td>
27
+ </tr>
28
+ <%% end %>
29
+ </tbody>
30
+ </table>
@@ -0,0 +1,39 @@
1
+ <h1>New field definition</h1>
2
+
3
+ <%%= form_with model: @record, url: admin_field_definitions_path do |f| %>
4
+ <%% if @record.errors.any? %>
5
+ <ul>
6
+ <%% @record.errors.full_messages.each do |message| %>
7
+ <li><%%= message %></li>
8
+ <%% end %>
9
+ </ul>
10
+ <%% end %>
11
+
12
+ <p>
13
+ <%%= f.label :entity_type %><br>
14
+ <%%= f.text_field :entity_type %>
15
+ </p>
16
+ <p>
17
+ <%%= f.label :name %><br>
18
+ <%%= f.text_field :name %>
19
+ </p>
20
+ <p>
21
+ <%%= f.label :field_type %><br>
22
+ <%%= f.select :field_type, Loam::FieldDefinition::FIELD_TYPES %>
23
+ </p>
24
+ <p>
25
+ <%%= f.label :dictionary_key, "Dictionary key (only for a 'dictionary' field type)" %><br>
26
+ <%%= f.select :dictionary_key, Loam::Dictionary.order(:key).pluck(:key), include_blank: true %>
27
+ </p>
28
+ <p>
29
+ <%%= f.label :writable_roles, "Writable by (comma-separated roles, blank = any member)" %><br>
30
+ <%%= f.text_field :writable_roles %>
31
+ </p>
32
+ <p>
33
+ <%%= f.label :readable_roles, "Readable by (comma-separated roles, blank = any member)" %><br>
34
+ <%%= f.text_field :readable_roles %>
35
+ <small>Restricts who can see, filter, and sort by this field.</small>
36
+ </p>
37
+
38
+ <%%= f.submit "Create field definition" %>
39
+ <%% end %>
@@ -0,0 +1,38 @@
1
+ module Admin
2
+ # A record's change history (Loam::AuditRecord) with one-click Undo (L-704).
3
+ # Undo applies the inverse of a change and records ITSELF as an audit, so
4
+ # undoing an "undo" entry is redo — no separate redo action. Reading needs read
5
+ # on the record; undoing needs update (the same gate as editing it).
6
+ class HistoryController < BaseController
7
+ before_action :set_target
8
+
9
+ def index
10
+ authorize!(policy_for(@record), :read?)
11
+ @audits = Loam::AuditRecord
12
+ .where(auditable_type: @record.class.name, auditable_id: @record.id)
13
+ .order(id: :desc).limit(100)
14
+ end
15
+
16
+ def undo
17
+ authorize!(policy_for(@record), :update?)
18
+ audit = Loam::AuditRecord.find(params[:id])
19
+ Loam::Undo.undo(audit, policy: policy_for(@record))
20
+ redirect_to admin_history_path(type: @record.class.name, record_id: @record.id), notice: "Change undone."
21
+ rescue Loam::Undo::NotUndoableError => error
22
+ redirect_to admin_history_path(type: @record.class.name, record_id: @record.id), alert: error.message
23
+ end
24
+
25
+ private
26
+
27
+ # The target record, looked up through its OWN default scope (tenancy holds;
28
+ # a soft-deleted row is still reachable to restore). The type is whitelisted
29
+ # to a Loam::TenantRecord — never a bare constantize of a param.
30
+ def set_target
31
+ klass = params[:type].to_s.safe_constantize
32
+ raise Loam::NotAuthorizedError unless klass.is_a?(Class) && klass < Loam::TenantRecord
33
+
34
+ scope = klass.respond_to?(:with_deleted) ? klass.with_deleted : klass.all
35
+ @record = scope.find(params[:record_id])
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ <h1>History: <%%= @record.class.model_name.human %> #<%%= @record.id %></h1>
2
+
3
+ <p><%%= link_to "← Back", [:admin, @record] %></p>
4
+
5
+ <p>Undo applies the inverse of a change and is itself recorded — so undoing an
6
+ <code>undo</code> row is redo. Only the most recent field change can be undone
7
+ (undo walks back one step at a time); encrypted fields and the workflow state are
8
+ never reverted here.</p>
9
+
10
+ <table>
11
+ <thead>
12
+ <tr><th>When</th><th>Action</th><th>By</th><th>Changes</th><th></th></tr>
13
+ </thead>
14
+ <tbody>
15
+ <%% @audits.each do |audit| %>
16
+ <tr>
17
+ <td><%%= audit.created_at.strftime("%Y-%m-%d %H:%M") %></td>
18
+ <td><code><%%= audit.action %></code></td>
19
+ <td><%%= audit.actor&.name || "—" %></td>
20
+ <td>
21
+ <%% (audit.changeset || {}).each do |field, values| %>
22
+ <div><code><%%= field %></code>:
23
+ <%%= values.is_a?(Array) ? "#{values.first.inspect} → #{values.last.inspect}" : values %></div>
24
+ <%% end %>
25
+ </td>
26
+ <td>
27
+ <%% if Loam::Undo.undoable?(audit) %>
28
+ <%%= button_to "Undo", admin_undo_history_path(id: audit.id, type: @record.class.name, record_id: @record.id),
29
+ form: { style: "display:inline" } %>
30
+ <%% end %>
31
+ </td>
32
+ </tr>
33
+ <%% end %>
34
+ </tbody>
35
+ </table>
@@ -0,0 +1,75 @@
1
+ module Admin
2
+ # Generic CSV importer, scoped by entity_type — manager-only (a privileged bulk
3
+ # mutation). Flow: new (upload) → preview (map columns) → create (dry-run
4
+ # summary, or a backgrounded run reporting live progress). The mapping only
5
+ # offers fields the manager may write (Loam::Import.allowed_targets); a crafted
6
+ # target is refused by the engine.
7
+ class ImportsController < BaseController
8
+ before_action { require_role!(:manager) }
9
+ before_action :set_model, only: %i[preview create]
10
+
11
+ def new
12
+ @entity_type = params[:entity_type]
13
+ end
14
+
15
+ # Parse the uploaded file, show the mapping form + a few sample rows.
16
+ def preview
17
+ @csv = read_upload
18
+ preview = Loam::Import.preview(@csv)
19
+ @headers = preview[:headers]
20
+ @sample = preview[:rows]
21
+ @targets = Loam::Import.allowed_targets(@model, current_actor).to_a.sort
22
+ rescue Loam::Error => error
23
+ redirect_to new_admin_import_path(entity_type: @entity_type), alert: error.message
24
+ end
25
+
26
+ def create
27
+ @csv = params[:csv].to_s
28
+ mapping = params[:mapping].to_unsafe_h.reject { |_h, target| target.blank? }
29
+ match_key = params[:match_key].presence
30
+
31
+ if params[:commit] == "Dry run"
32
+ run_dry(mapping, match_key)
33
+ else
34
+ ImportJob.perform_later(entity_type: @entity_type, csv: @csv, mapping: mapping,
35
+ match_key: match_key, tenant_id: current_tenant.id, actor_id: current_actor.id)
36
+ redirect_to admin_progress_jobs_path, notice: "Import started — watch it progress under Tasks."
37
+ end
38
+ rescue Loam::Error => error
39
+ redirect_to new_admin_import_path(entity_type: @entity_type), alert: error.message
40
+ end
41
+
42
+ # The failed rows as a fix-and-re-upload CSV.
43
+ def download_errors
44
+ @entity_type = params[:entity_type]
45
+ model = Loam::Import.allowed_model(@entity_type)
46
+ csv = params[:csv].to_s
47
+ mapping = params[:mapping].to_unsafe_h.reject { |_h, target| target.blank? }
48
+ result = Loam::Import.run(csv, model: model, mapping: mapping, actor: current_actor,
49
+ match_key: params[:match_key].presence, dry_run: true)
50
+ send_data Loam::Import.error_csv(result, csv),
51
+ filename: "#{@entity_type.underscore}-import-errors.csv", type: "text/csv"
52
+ end
53
+
54
+ private
55
+
56
+ def set_model
57
+ @entity_type = params[:entity_type]
58
+ @model = Loam::Import.allowed_model(@entity_type)
59
+ end
60
+
61
+ def run_dry(mapping, match_key)
62
+ @result = Loam::Import.run(@csv, model: @model, mapping: mapping, actor: current_actor,
63
+ match_key: match_key, dry_run: true)
64
+ @headers = Loam::Import.preview(@csv)[:headers]
65
+ render :summary
66
+ end
67
+
68
+ def read_upload
69
+ file = params[:file]
70
+ raise Loam::Error, "choose a CSV file to upload" if file.blank?
71
+
72
+ file.read.force_encoding("UTF-8")
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,10 @@
1
+ <h1>Import <%%= @entity_type %></h1>
2
+
3
+ <p>Upload a CSV, map its columns to fields, then dry-run or import. Rows that
4
+ fail validation are skipped and reported (with a downloadable error file).</p>
5
+
6
+ <%%= form_with url: preview_admin_imports_path, method: :post, multipart: true do %>
7
+ <%%= hidden_field_tag :entity_type, @entity_type %>
8
+ <p><%%= file_field_tag :file, accept: ".csv" %></p>
9
+ <%%= submit_tag "Upload & map" %>
10
+ <%% end %>
@@ -0,0 +1,32 @@
1
+ <h1>Map columns — <%%= @entity_type %></h1>
2
+
3
+ <%%= form_with url: admin_imports_path, method: :post do %>
4
+ <%%= hidden_field_tag :entity_type, @entity_type %>
5
+ <%%= hidden_field_tag :csv, @csv %>
6
+
7
+ <table>
8
+ <thead><tr><th>CSV column</th><th>Maps to field</th><th>Sample</th></tr></thead>
9
+ <tbody>
10
+ <%% @headers.each_with_index do |header, i| %>
11
+ <tr>
12
+ <td><%%= header %></td>
13
+ <td>
14
+ <%%= select_tag "mapping[#{header}]",
15
+ options_for_select([["— skip —", ""]] + @targets.map { |t| [t, t] }, @targets.include?(header) ? header : "") %>
16
+ </td>
17
+ <td><%%= @sample.map { |r| r[i] }.compact.first %></td>
18
+ </tr>
19
+ <%% end %>
20
+ </tbody>
21
+ </table>
22
+
23
+ <p>
24
+ <%%= label_tag :match_key, "Update existing rows matching (blank = create only)" %>
25
+ <%%= select_tag :match_key, options_for_select([["— create only —", ""]] + @targets.map { |t| [t, t] }) %>
26
+ </p>
27
+
28
+ <p>
29
+ <%%= submit_tag "Dry run" %>
30
+ <%%= submit_tag "Import" %>
31
+ </p>
32
+ <%% end %>