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,17 @@
1
+ # Public endpoint that receives webhooks FROM external systems at
2
+ # /webhooks/:token. No session, no bearer token: the HMAC signature is the auth
3
+ # (ActionController::API, so there is no CSRF to skip). All the verified,
4
+ # replay-resistant work lives in Loam::InboundWebhooks.ingest; this controller
5
+ # only maps its Result to an HTTP status and never leaks tenant context to the
6
+ # next request on this thread.
7
+ class InboundWebhooksController < ActionController::API
8
+ def receive
9
+ result = Loam::InboundWebhooks.ingest(
10
+ token: params[:token], raw_body: request.raw_post, headers: request.headers
11
+ )
12
+ Rails.logger.info("[loam inbound] #{result.status} #{result.reason}") if result.status >= 400
13
+ head result.status
14
+ ensure
15
+ Loam::Current.reset
16
+ end
17
+ end
@@ -0,0 +1,218 @@
1
+ # Loam configuration, tenant lifecycle hooks, and domain event subscriptions.
2
+
3
+ # Master key for field encryption at rest (Loam::Encryptable). Per-tenant keys
4
+ # are derived from it via HKDF, so it must be STABLE and SECRET — never commit it.
5
+ # Set LOAM_MASTER_KEY in the environment (or wire Rails.application.credentials)
6
+ # to a high-entropy value, e.g. `SecureRandom.hex(32)`. Only apps that actually
7
+ # `encrypts` a field need it; encryption raises a clear error if it is missing.
8
+ #
9
+ # Loam::Encryption.master_key = ENV.fetch("LOAM_MASTER_KEY")
10
+ #
11
+ # Rotating the key without re-encrypting orphans existing ciphertext — see
12
+ # `bin/rails loam:encryption:rotate[Model,tenant_id]`.
13
+
14
+ # Roles every tenant of this app is expected to have. A registry read by your
15
+ # own seeding/admin code — Loam does not create memberships for you, because
16
+ # who gets which role is business logic.
17
+ Loam.default_roles = %w[manager employee]
18
+
19
+ # Locales that CONTENT translations (Loam::Translatable) may be authored in — the
20
+ # admin's language switcher and the per-record Translations screen offer these.
21
+ # The first is the base/default; the rest overlay onto it. This is for user data
22
+ # (a product name), NOT developer UI strings — those stay Rails i18n. Loam ships
23
+ # a `loam.*` base locale (English) for its own chrome; the admin switcher sets
24
+ # I18n.locale too, so add `config/locales/loam.pl.yml` (etc.) to translate the UI
25
+ # for each locale you list here.
26
+ Loam.locales = %w[en]
27
+
28
+ # Observability (Loam::Telemetry). By default Loam wraps its async hot paths
29
+ # (scheduler tick, durable event delivery, inbound webhook ingest) in spans that
30
+ # emit ActiveSupport::Notifications events ("loam.span.*") — subscribe to those,
31
+ # or plug a real tracer in here:
32
+ #
33
+ # Loam::Telemetry.backend = ->(name, attributes, work) do
34
+ # OpenTelemetry.tracer_provider.tracer("loam").in_span(name, attributes: attributes.transform_keys(&:to_s)) { work.call }
35
+ # end
36
+
37
+ # Feature-string permissions (Loam::Permissions) — a finer capability layer under
38
+ # the coarse role. Deny-by-default; `*` grants everything, a trailing `.*` is a
39
+ # prefix ("equipment.*"). Check with `Loam.can?("equipment.edit")`,
40
+ # `require_permission!("...")` in a controller, or the `can?` view helper.
41
+ # Orthogonal to roles (Loam::Membership) and field-level policies (Loam::Policy).
42
+ #
43
+ # Loam::Permissions.configure do
44
+ # role :admin, allow: "*"
45
+ # role :manager, allow: %w[equipment.* billing.read]
46
+ # role :clerk, allow: %w[equipment.read]
47
+ # end
48
+
49
+ # Customization WITHOUT forking (Loam::Overrides): disable or replace an entry in
50
+ # one of Loam's keyed registries (:widgets, :broadcast_events). A stale override
51
+ # (a key that no longer exists) is warned about at boot by `check!`, so a
52
+ # typo'd override is visible instead of silently doing nothing.
53
+ #
54
+ # Loam::Overrides.disable(:widgets, "open_progress") # drop a built-in widget
55
+ # Loam::Overrides.replace(:widgets, "audit_recent") { |actor| { kind: "count", value: 0 } }
56
+ # Loam::Overrides.disable(:broadcast_events, "loam.progress.") # stop pushing it over SSE
57
+ #
58
+ # BOUNDARY: this is only for Loam's in-gem registries. Override a VIEW, CONTROLLER,
59
+ # or ROUTE the standard Rails way (create a file at the same path to shadow the
60
+ # engine's, or `prepend` a module) — not here.
61
+
62
+ # Events pushed live to the browser over SSE (Loam::EventStream). Default off; the
63
+ # notification pattern is enabled here so the admin bell increments without a
64
+ # reload. Add patterns (e.g. "billing.") to stream domain events to live widgets —
65
+ # nothing reaches the browser unless it matches a pattern here (security posture).
66
+ Loam.broadcast_events = [ "loam.notification.", "loam.progress." ]
67
+
68
+ # Response enrichers (Loam::Enrichers): attach a computed block onto ANOTHER
69
+ # module's entity in admin/API responses, with no foreign-key coupling — billing
70
+ # can annotate an Equipment without Equipment knowing billing exists. Pass a
71
+ # `batch:` resolver (array -> { id => value }) so an index stays one query, not N.
72
+ #
73
+ # Loam::Enrichers.register("Equipment", key: "outstanding_balance", batch: ->(equipments) do
74
+ # totals = Invoice.where(equipment_id: equipments.map(&:id)).group(:equipment_id).sum(:balance)
75
+ # equipments.map(&:id).index_with { |id| totals.fetch(id, 0) }
76
+ # end)
77
+
78
+ # Search backend (Loam::Search). The default is a substring LIKE — portable and
79
+ # zero-setup. Opt into the word-level TokenDriver (also portable, no external
80
+ # service: it keeps a normalized-token index in loam_search_tokens) for
81
+ # order-independent, whole-word matching. `searchable_by` and every
82
+ # `Model.search(q)` call site are UNCHANGED — only this line differs; an external
83
+ # engine (Meilisearch/Elasticsearch) is a third driver behind the same seam.
84
+ # After switching, backfill existing rows once with `bin/rails loam:search:reindex`
85
+ # (new and updated records index themselves on save).
86
+ #
87
+ # Loam::Search.driver = Loam::Search::TokenDriver
88
+
89
+ # Custom-field read-model index (Loam::CustomFieldIndex). Filtering/sorting on a
90
+ # custom field is index-backed (a typed projection in loam_custom_field_values)
91
+ # instead of a per-row JSON scan. Records project themselves on save; backfill
92
+ # existing data once with `bin/rails loam:index:reindex` (or
93
+ # `Loam::CustomFieldIndex.reindex(Model)`). Query with
94
+ # `Loam::CustomFieldIndex.filter(Model, field_key, op, value)` — the generated
95
+ # entity index routes a `cf_field`/`cf_op`/`cf_value` filter through it.
96
+
97
+ # Single sign-on (Loam::Sso). OIDC is shipped end-to-end: configure a per-tenant
98
+ # provider under /admin/sso_providers (issuer, client_id, client_secret, email
99
+ # domain, default JIT role). Home-realm discovery routes a user to their tenant's
100
+ # IdP by email domain; a verified identity is JIT-provisioned or linked to an
101
+ # existing User. The client_secret is encrypted at rest, so LOAM_MASTER_KEY must
102
+ # be set (same key as the rest of Loam::Encryptable). SAML and SCIM are documented
103
+ # seams (see docs/_foundation/overview.md). Nothing to register here for real OIDC.
104
+ #
105
+ # In tests, inject the offline fake so the flow never hits the network:
106
+ #
107
+ # Loam::Sso.builder = ->(record, redirect_uri) { Loam::Sso::FakeProvider.new(record, redirect_uri: redirect_uri) }
108
+
109
+ # Roles that MUST use two-factor auth (Loam::MfaCredential). At login, a user
110
+ # whose role in the chosen tenant is on this list and who has not enrolled is
111
+ # sent to set MFA up before they can work. Resolved via Loam::Configs, so it can
112
+ # be a global default or a per-tenant override; empty means MFA is optional.
113
+ #
114
+ # Loam::Configs.set("security.mfa_required_roles", ["manager"], scope: :global)
115
+ #
116
+ # Auth rate-limiting / lockout (Loam::AuthThrottle) guards failed password/TOTP/
117
+ # sudo attempts. Defaults: lock after 10 failures within a 15-minute window.
118
+ # Tune via Loam::Configs (global or per-tenant):
119
+ #
120
+ # Loam::Configs.set("security.max_auth_attempts", 5, scope: :global)
121
+ # Loam::Configs.set("security.auth_window_minutes", 10, scope: :global)
122
+ #
123
+ # Rack::Attack (with a cache store) is the production-scale path across multiple
124
+ # processes; the built-in DB counter is the portable single-process default.
125
+
126
+ # App-wide setting defaults (Loam::Configs). A key resolves override → global
127
+ # row → this declared default, so declaring one here needs no migration and no
128
+ # row — a tenant reads the default until someone overrides it in the admin
129
+ # Settings screen (/admin/configs). Values keep their type (bool/number/string/
130
+ # hash), and a per-tenant override never leaks to another tenant.
131
+ #
132
+ # Loam.config_defaults = { "billing.currency" => "USD", "billing.net_terms" => 30 }
133
+ #
134
+ # To change the company-wide baseline at runtime (not per tenant), write a
135
+ # global row: `Loam::Configs.set("billing.currency", "EUR", scope: :global)`.
136
+
137
+ # Feature flags (Loam::Features). Declare known capabilities and their default
138
+ # state; a flag with no row resolves to its declared default, and managers flip
139
+ # a tenant's override at /admin/features. A flag gates a CAPABILITY (is this on
140
+ # for the tenant), orthogonal to roles/policies, which gate a PERSON.
141
+ #
142
+ # Loam.feature_defaults = {
143
+ # "beta_dashboard" => { default: false, description: "New dashboard, rolled out per tenant." }
144
+ # }
145
+ #
146
+ # In a controller: `require_feature!(:beta_dashboard)` (404s when off); in a
147
+ # view: `<%% if feature_on?(:beta_dashboard) %>`. Flip app-wide with
148
+ # `Loam::Features.enable(:beta_dashboard, scope: :global)`.
149
+
150
+ # What a brand-new tenant gets for free. Registered at file scope on purpose:
151
+ # inside `to_prepare` this would re-register on every code reload.
152
+ Loam.on_tenant_created do |tenant|
153
+ # Seed roles/defaults for a new tenant here. MUST be idempotent —
154
+ # `bin/rails loam:sync` re-runs these for every existing tenant, which is how
155
+ # a default added in a later release reaches tenants that already exist.
156
+ # Use find_or_create_by!, never create!. The block runs inside
157
+ # Loam.as_tenant(tenant), so tenant-scoped writes need no extra ceremony.
158
+ #
159
+ # Baseline managed lookup lists (Loam::Dictionary) — a FieldDefinition of type
160
+ # "dictionary" can then point a custom field at the key, rendering a select:
161
+ #
162
+ # severity = Loam::Dictionary.find_or_create_by!(key: "damage_severity") { |d| d.name = "Damage severity" }
163
+ # [%w[minor Minor], %w[major Major], %w[critical Critical]].each_with_index do |(value, label), i|
164
+ # severity.entries.find_or_create_by!(value: value) { |e| e.label = label; e.position = i }
165
+ # end
166
+
167
+ # Materialize the registered tenant-scope schedules (see Loam::Scheduler.register
168
+ # below) as rows for this tenant. A no-op until you register one.
169
+ Loam::Scheduler.sync_tenant(tenant)
170
+ end
171
+
172
+ # Recurring jobs (Loam::Scheduler). Register default schedules here at file scope;
173
+ # on_tenant_created materializes the tenant-scope ones per tenant. A "tenant" job
174
+ # is enqueued with `tenant_id:` (establish it with Loam.as_tenant); a "system" job
175
+ # runs once with no tenant. job_class MUST be a real ActiveJob (validated — never
176
+ # arbitrary code). Wire the runner to system cron, every minute:
177
+ #
178
+ # * * * * * cd /path/to/app && bin/rails loam:scheduler:tick
179
+ #
180
+ # The claim is atomic, so running it from several hosts never double-fires a job.
181
+ #
182
+ # Loam::Scheduler.register(key: "nightly_digest", name: "Nightly digest",
183
+ # job_class: "DigestJob", schedule: "0 7 * * *", scope: "tenant")
184
+ #
185
+ # Only ALLOWLISTED job classes are schedulable (a registered one, or one listed
186
+ # here) — so a tenant admin can't schedule ActiveStorage::PurgeJob or a mailer:
187
+ #
188
+ # Loam.schedulable_jobs = %w[DigestJob ReindexJob]
189
+
190
+ # Domain event subscriptions. Subscribe to a single event or a whole domain
191
+ # (trailing dot = prefix). Register them here at file scope, not inside
192
+ # `to_prepare`: subscriptions are global, so a reload would add a second copy
193
+ # of each one and every event would be handled twice.
194
+ #
195
+ # Loam::Events.subscribe("billing.subscription.renewed") do |name, payload|
196
+ # BillingMailer.renewal_receipt(payload[:id]).deliver_later
197
+ # end
198
+ #
199
+ # Loam::Events.subscribe("rental.") do |name, payload|
200
+ # Rails.logger.info("[loam event] #{name} #{payload.inspect}")
201
+ # end
202
+ #
203
+ # Event -> in-app notification is the intended pattern for telling someone
204
+ # something. The subscriber runs in the publisher's tenant context, so the
205
+ # notification lands in the right tenant with no extra ceremony:
206
+ #
207
+ # Loam::Events.subscribe("rental.damage_report.approve") do |_name, payload|
208
+ # Loam::Notifications.notify_role(:manager, title: "Damage report approved")
209
+ # end
210
+
211
+ # Business rules (Loam::BusinessRule) are the admin-editable cousin of the
212
+ # subscriptions above: a manager declares, per tenant, WHEN a condition holds on
213
+ # a triggering record THEN run a fixed vocabulary of safe actions (notify,
214
+ # emit_event, set_field, block_transition). The condition is DATA — a
215
+ # whitelisted {field, op, value} tree, never evaluated as code — so a tenant
216
+ # admin can wire up automation without a deploy and without a code-injection
217
+ # surface. The engine subscribes itself at boot; nothing to register here.
218
+ # Manage rules under /admin/business_rules and see why they fired in the run log.
@@ -0,0 +1,15 @@
1
+ class CreateLoamApiTokens < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_api_tokens<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.references :user, null: false, foreign_key: true<%= loam_type_option %>
6
+ t.string :token, null: false
7
+ t.string :label
8
+ t.datetime :last_used_at
9
+ t.timestamps
10
+ end
11
+ # Tokens are looked up before a tenant is known, so the uniqueness that
12
+ # matters is global.
13
+ add_index :loam_api_tokens, :token, unique: true
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ class CreateLoamAuditRecords < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_audit_records<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :auditable_type, null: false
6
+ t.<%= loam_key_column_type %> :auditable_id<%= loam_key_limit_option %>, null: false
7
+ t.string :action, null: false
8
+ t.<%= loam_key_column_type %> :actor_id<%= loam_key_limit_option %>
9
+ t.text :changeset
10
+ t.timestamps
11
+ end
12
+ add_index :loam_audit_records, %i[tenant_id auditable_type auditable_id]
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ class CreateLoamAuthAttempts < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_auth_attempts<%= loam_id_option %> do |t|
4
+ t.string :identifier, null: false # the submitted login (normalized), NOT tenant-scoped
5
+ t.string :kind, null: false # password / totp / recovery / sudo
6
+ t.string :ip # nullable; for an optional per-ip throttle
7
+ t.timestamps
8
+ end
9
+ add_index :loam_auth_attempts, %i[identifier created_at]
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ class CreateLoamBusinessRules < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_business_rules<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :name, null: false
6
+ t.string :entity_type # the entity this rule watches (nil = event-only)
7
+ t.string :trigger, null: false # an event pattern (Loam::Events.pattern_matches?)
8
+ t.json :condition, null: false, default: {} # a safe condition tree (data, never code)
9
+ t.json :actions, null: false, default: [] # an ordered list of typed action descriptors
10
+ t.boolean :active, null: false, default: true
11
+ t.integer :priority, null: false, default: 0
12
+ t.timestamps
13
+ end
14
+ add_index :loam_business_rules, %i[tenant_id active]
15
+
16
+ create_table :loam_business_rule_runs<%= loam_id_option %> do |t|
17
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
18
+ t.references :business_rule, null: false, foreign_key: { to_table: :loam_business_rules }<%= loam_type_option %>
19
+ t.string :subject_type
20
+ t.<%= loam_key_column_type %> :subject_id<%= loam_key_limit_option %>
21
+ t.string :event_name
22
+ t.boolean :matched, null: false, default: false
23
+ t.json :actions_taken, null: false, default: []
24
+ t.text :error
25
+ t.timestamps
26
+ end
27
+ add_index :loam_business_rule_runs, %i[tenant_id business_rule_id created_at]
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ class CreateLoamComments < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_comments<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :commentable_type, null: false
6
+ t.<%= loam_key_column_type %> :commentable_id<%= loam_key_limit_option %>, null: false
7
+ t.references :author, null: false, foreign_key: { to_table: :users }<%= loam_type_option %>
8
+ t.text :body, null: false
9
+ t.timestamps
10
+ end
11
+ add_index :loam_comments, %i[tenant_id commentable_type commentable_id]
12
+ end
13
+ end
@@ -0,0 +1,21 @@
1
+ class CreateLoamConfigs < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_configs<%= loam_id_option %> do |t|
4
+ t.string :key, null: false
5
+ # Nullable: a NULL tenant_id is the GLOBAL row; a set one is a per-tenant
6
+ # override. So this is NOT a tenant-scoped table.
7
+ t.references :tenant, null: true, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
8
+ # Any JSON-able value under one key. Nullable on purpose: row existence is
9
+ # the "is it set" signal, and JSON null is itself a storable value.
10
+ t.json :value_json
11
+ t.timestamps
12
+ end
13
+
14
+ # One global row per key (partial: only where there is no tenant).
15
+ add_index :loam_configs, :key, unique: true, where: "tenant_id IS NULL",
16
+ name: "index_loam_configs_global_key"
17
+ # One override row per key per tenant.
18
+ add_index :loam_configs, %i[key tenant_id], unique: true,
19
+ name: "index_loam_configs_on_key_and_tenant"
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ class CreateLoamCustomFieldValues < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_custom_field_values<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :indexable_type, null: false
6
+ t.<%= loam_key_column_type %> :indexable_id<%= loam_key_limit_option %>, null: false
7
+ t.string :field_key, null: false
8
+ t.text :value_text # canonical string form (contains/present/eq)
9
+ t.decimal :value_number # integer/decimal fields (range queries)
10
+ t.boolean :value_boolean # boolean fields
11
+ t.datetime :value_datetime # date/datetime fields
12
+ t.timestamps
13
+ end
14
+ add_index :loam_custom_field_values, %i[indexable_type indexable_id field_key],
15
+ unique: true, name: "index_loam_cfv_unique"
16
+ add_index :loam_custom_field_values, %i[tenant_id indexable_type field_key value_text], name: "index_loam_cfv_text"
17
+ add_index :loam_custom_field_values, %i[tenant_id indexable_type field_key value_number], name: "index_loam_cfv_number"
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ class CreateLoamDashboardWidgets < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_dashboard_widgets<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :widget_key, null: false # a registered Loam::Widgets key
6
+ t.integer :position, null: false, default: 0
7
+ t.boolean :active, null: false, default: true
8
+ t.timestamps
9
+ end
10
+ add_index :loam_dashboard_widgets, %i[tenant_id widget_key], unique: true
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ class CreateLoamDictionaries < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_dictionaries<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :key, null: false # the code the field def / API references (e.g. "damage_severity")
6
+ t.string :name, null: false # human label for the list
7
+ t.timestamps
8
+ end
9
+ add_index :loam_dictionaries, %i[tenant_id key], unique: true
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ class CreateLoamDictionaryEntries < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_dictionary_entries<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.references :dictionary, null: false, foreign_key: { to_table: :loam_dictionaries }<%= loam_type_option %>
6
+ t.string :value, null: false # the stored code
7
+ t.string :label, null: false # display text
8
+ t.string :color # optional hex
9
+ t.string :icon # optional icon name
10
+ t.integer :position, null: false, default: 0 # ordering
11
+ t.boolean :is_default, null: false, default: false
12
+ t.boolean :active, null: false, default: true
13
+ t.timestamps
14
+ end
15
+ add_index :loam_dictionary_entries, %i[dictionary_id value], unique: true
16
+ add_index :loam_dictionary_entries, %i[tenant_id dictionary_id position]
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ class CreateLoamEventDeliveries < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_event_deliveries<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :subscriber_key, null: false # a registered Loam::DurableEvents subscriber
6
+ t.string :event_name, null: false
7
+ t.json :payload, null: false # JSON-scalar event payload
8
+ t.string :status, null: false, default: "pending" # pending | delivered | dead
9
+ t.integer :attempts, null: false, default: 0
10
+ t.text :last_error
11
+ t.datetime :next_attempt_at # backoff gate for the next retry
12
+ t.datetime :delivered_at
13
+ t.timestamps
14
+ end
15
+ add_index :loam_event_deliveries, %i[tenant_id status next_attempt_at]
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ class CreateLoamFieldDefinitions < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_field_definitions<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :entity_type, null: false
6
+ t.string :name, null: false
7
+ t.string :field_type, null: false
8
+ t.json :writable_roles, default: [], null: false
9
+ t.json :readable_roles, default: [], null: false # empty = any member may read (used by the index oracle guard)
10
+ t.json :config, null: false, default: {} # type-specific settings (e.g. a dictionary field's key)
11
+ t.timestamps
12
+ end
13
+ add_index :loam_field_definitions, %i[tenant_id entity_type name], unique: true, name: "index_loam_field_definitions_on_tenant_entity_name"
14
+ end
15
+ end
@@ -0,0 +1,31 @@
1
+ class CreateLoamInboundWebhooks < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_inbound_webhook_sources<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :name, null: false
6
+ t.string :token, null: false # unguessable URL id: /webhooks/:token
7
+ t.string :secret, null: false # HMAC key (authenticates the call)
8
+ t.string :signature_header, null: false, default: "X-Loam-Signature"
9
+ t.string :delivery_id_header # optional: external delivery-id for dedupe
10
+ t.string :timestamp_header # optional: enables the freshness window
11
+ t.integer :timestamp_tolerance # seconds; nil = default 300
12
+ t.string :event_name, null: false # what to publish on the bus
13
+ t.boolean :active, null: false, default: true
14
+ t.timestamps
15
+ end
16
+ add_index :loam_inbound_webhook_sources, :token, unique: true
17
+
18
+ create_table :loam_inbound_webhook_deliveries<%= loam_id_option %> do |t|
19
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
20
+ t.references :source, null: false, foreign_key: { to_table: :loam_inbound_webhook_sources }<%= loam_type_option %>
21
+ t.string :external_id, null: false # delivery-id header, or a body hash
22
+ t.string :event_name, null: false
23
+ t.string :status, null: false, default: "received"
24
+ t.datetime :received_at
25
+ t.json :payload, null: false
26
+ t.timestamps
27
+ end
28
+ # The idempotency ledger: a replayed (source, external_id) can't be inserted twice.
29
+ add_index :loam_inbound_webhook_deliveries, %i[source_id external_id], unique: true
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ class CreateLoamMemberships < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_memberships<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.references :user, null: false, foreign_key: true<%= loam_type_option %>
6
+ t.string :role, null: false
7
+ t.timestamps
8
+ end
9
+ add_index :loam_memberships, %i[tenant_id user_id], unique: true
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ class CreateLoamMfaCredentials < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_mfa_credentials<%= loam_id_option %> do |t|
4
+ # Per USER, not per tenant — MFA belongs to the person (no tenant_id).
5
+ t.references :user, null: false, foreign_key: { to_table: :users }, index: { unique: true }<%= loam_type_option %>
6
+ t.text :totp_secret # encrypted at rest, user-scoped (Loam::Encryptable)
7
+ t.text :recovery_codes # JSON: [{ digest, used_at }], codes stored hashed
8
+ t.datetime :activated_at # nil until the user confirms a live code
9
+ t.bigint :last_totp_step # last accepted TOTP timestep — rejects replays
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ class CreateLoamNotifications < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_notifications<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.references :user, null: false, foreign_key: true<%= loam_type_option %>
6
+ t.string :title, null: false
7
+ t.text :body
8
+ t.datetime :read_at
9
+ t.string :source_type
10
+ t.<%= loam_key_column_type %> :source_id<%= loam_key_limit_option %>
11
+ t.timestamps
12
+ end
13
+ add_index :loam_notifications, %i[tenant_id user_id read_at]
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ class CreateLoamPendingActions < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_pending_actions<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.<%= loam_key_column_type %> :actor_id<%= loam_key_limit_option %> # who staged it (the proposer)
6
+ t.string :status, null: false, default: "pending"
7
+ t.string :action_type, null: false # create / update / destroy
8
+ t.string :target_type # the model being mutated
9
+ t.<%= loam_key_column_type %> :target_id<%= loam_key_limit_option %> # null for a staged create
10
+ t.text :changeset # encrypted JSON of the proposed changes
11
+ t.text :summary, null: false # human-readable description
12
+ t.string :idempotency_key, null: false # keyed HMAC; same proposal collapses to one row
13
+ t.<%= loam_key_column_type %> :reviewed_by_id<%= loam_key_limit_option %> # the approving/rejecting manager
14
+ t.datetime :reviewed_at
15
+ t.text :result # execution outcome, or "rejected: <reason>"
16
+ t.text :error # execution failure message
17
+ t.timestamps
18
+ end
19
+ # One row per distinct proposal per tenant.
20
+ # Only ONE PENDING row per proposal per tenant — a rejected/executed row with
21
+ # the same key may coexist, so a rejected proposal can be re-staged later.
22
+ add_index :loam_pending_actions, %i[tenant_id idempotency_key], unique: true,
23
+ where: "status = 'pending'", name: "index_loam_pending_actions_pending_key"
24
+ add_index :loam_pending_actions, %i[tenant_id status]
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ class CreateLoamPerspectives < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_perspectives<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :entity_type, null: false # which entity this view is for
6
+ t.string :name, null: false # human label
7
+ t.<%= loam_key_column_type %> :owner_id<%= loam_key_limit_option %> # the user who owns a private view
8
+ t.string :visibility, null: false, default: "private" # private / role / tenant
9
+ t.string :role # set when visibility is "role"
10
+ t.boolean :is_default, null: false, default: false
11
+ t.json :config, null: false, default: {} # { columns, filters, sort, page_size }
12
+ t.integer :lock_version, null: false, default: 0 # optimistic locking
13
+ t.timestamps
14
+ end
15
+ add_index :loam_perspectives, %i[tenant_id entity_type]
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ class CreateLoamProgressJobs < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_progress_jobs<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :key # optional caller-chosen id (e.g. "reindex:Gadget")
6
+ t.string :name, null: false # human label
7
+ t.string :status, null: false, default: "running" # running / completed / failed / cancelled
8
+ t.integer :total, null: false, default: 0
9
+ t.integer :completed, null: false, default: 0
10
+ t.text :message # latest status line
11
+ t.text :error # set when failed
12
+ t.<%= loam_key_column_type %> :actor_id<%= loam_key_limit_option %> # who started it
13
+ t.datetime :started_at
14
+ t.datetime :finished_at
15
+ t.json :result # structured result for a finished job (e.g. an import summary)
16
+ t.timestamps # updated_at is the heartbeat
17
+ end
18
+ add_index :loam_progress_jobs, %i[tenant_id status]
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ class CreateLoamRecordLocks < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_record_locks<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :lockable_type, null: false
6
+ t.<%= loam_key_column_type %> :lockable_id<%= loam_key_limit_option %>, null: false
7
+ t.references :locked_by, null: false, foreign_key: { to_table: :users }<%= loam_type_option %>
8
+ t.string :token, null: false
9
+ t.datetime :expires_at, null: false # TTL; an expired lock is treated as free
10
+ t.timestamps # updated_at is the heartbeat
11
+ end
12
+ # One advisory lock per record.
13
+ add_index :loam_record_locks, %i[tenant_id lockable_type lockable_id], unique: true,
14
+ name: "index_loam_record_locks_on_lockable"
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ class CreateLoamScheduledJobs < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_scheduled_jobs<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :key, null: false # unique per tenant
6
+ t.string :name, null: false
7
+ t.string :job_class, null: false # a whitelisted ActiveJob class name
8
+ t.string :schedule, null: false # "0 7 * * *" or "interval:3600"
9
+ t.string :timezone # IANA zone; UTC when blank
10
+ t.string :scope, null: false, default: "tenant" # "tenant" (per-tenant) or "system" (once)
11
+ t.datetime :next_run_at
12
+ t.datetime :last_run_at
13
+ t.datetime :locked_until # the atomic-claim lock (no double-fire)
14
+ t.boolean :active, null: false, default: true
15
+ t.timestamps
16
+ end
17
+ add_index :loam_scheduled_jobs, %i[tenant_id key], unique: true
18
+ add_index :loam_scheduled_jobs, %i[active next_run_at locked_until]
19
+ end
20
+ end