seams 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 (414) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +335 -0
  3. data/LICENSE +21 -0
  4. data/README.md +104 -0
  5. data/lib/generators/seams/accounts/accounts_generator.rb +272 -0
  6. data/lib/generators/seams/accounts/templates/README.md.tt +219 -0
  7. data/lib/generators/seams/accounts/templates/app/models/account.rb.tt +124 -0
  8. data/lib/generators/seams/accounts/templates/app/models/application_record.rb.tt +7 -0
  9. data/lib/generators/seams/accounts/templates/app/models/current.rb.tt +38 -0
  10. data/lib/generators/seams/accounts/templates/app/models/membership.rb.tt +114 -0
  11. data/lib/generators/seams/accounts/templates/config/routes.rb.tt +8 -0
  12. data/lib/generators/seams/accounts/templates/db/migrate/create_accounts.rb.tt +29 -0
  13. data/lib/generators/seams/accounts/templates/db/migrate/create_accounts_memberships.rb.tt +49 -0
  14. data/lib/generators/seams/accounts/templates/lib/accounts.rb.tt +21 -0
  15. data/lib/generators/seams/accounts/templates/lib/concerns/account_scoped.rb.tt +97 -0
  16. data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +105 -0
  17. data/lib/generators/seams/accounts/templates/lib/configuration.rb.tt +26 -0
  18. data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +55 -0
  19. data/lib/generators/seams/accounts/templates/spec/factories/accounts.rb.tt +49 -0
  20. data/lib/generators/seams/accounts/templates/spec/models/accounts/account_spec.rb.tt +64 -0
  21. data/lib/generators/seams/accounts/templates/spec/models/accounts/membership_spec.rb.tt +99 -0
  22. data/lib/generators/seams/accounts/templates/spec/runtime/accounts_boot_spec.rb.tt +181 -0
  23. data/lib/generators/seams/admin/admin_generator.rb +852 -0
  24. data/lib/generators/seams/admin/templates/README.md.tt +266 -0
  25. data/lib/generators/seams/admin/templates/app/controllers/admin/accounts_controller.rb.tt +16 -0
  26. data/lib/generators/seams/admin/templates/app/controllers/admin/accounts_memberships_controller.rb.tt +16 -0
  27. data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +282 -0
  28. data/lib/generators/seams/admin/templates/app/controllers/admin/identities_controller.rb.tt +26 -0
  29. data/lib/generators/seams/admin/templates/app/controllers/admin/invitations_controller.rb.tt +14 -0
  30. data/lib/generators/seams/admin/templates/app/controllers/admin/invoices_controller.rb.tt +14 -0
  31. data/lib/generators/seams/admin/templates/app/controllers/admin/lifetime_passes_controller.rb.tt +14 -0
  32. data/lib/generators/seams/admin/templates/app/controllers/admin/notification_preferences_controller.rb.tt +15 -0
  33. data/lib/generators/seams/admin/templates/app/controllers/admin/notifications_controller.rb.tt +18 -0
  34. data/lib/generators/seams/admin/templates/app/controllers/admin/plans_controller.rb.tt +14 -0
  35. data/lib/generators/seams/admin/templates/app/controllers/admin/subscriptions_controller.rb.tt +14 -0
  36. data/lib/generators/seams/admin/templates/app/controllers/admin/teams_controller.rb.tt +14 -0
  37. data/lib/generators/seams/admin/templates/app/controllers/admin/teams_memberships_controller.rb.tt +16 -0
  38. data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +50 -0
  39. data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +58 -0
  40. data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +48 -0
  41. data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +51 -0
  42. data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +67 -0
  43. data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +65 -0
  44. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +58 -0
  45. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +43 -0
  46. data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +72 -0
  47. data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +59 -0
  48. data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +39 -0
  49. data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +43 -0
  50. data/lib/generators/seams/admin/templates/app/policies/admin/platform/account_policy.rb.tt +10 -0
  51. data/lib/generators/seams/admin/templates/app/policies/admin/platform/accounts_membership_policy.rb.tt +10 -0
  52. data/lib/generators/seams/admin/templates/app/policies/admin/platform/application_policy.rb.tt +85 -0
  53. data/lib/generators/seams/admin/templates/app/policies/admin/platform/identity_policy.rb.tt +18 -0
  54. data/lib/generators/seams/admin/templates/app/policies/admin/platform/invitation_policy.rb.tt +9 -0
  55. data/lib/generators/seams/admin/templates/app/policies/admin/platform/invoice_policy.rb.tt +9 -0
  56. data/lib/generators/seams/admin/templates/app/policies/admin/platform/lifetime_pass_policy.rb.tt +9 -0
  57. data/lib/generators/seams/admin/templates/app/policies/admin/platform/notification_policy.rb.tt +9 -0
  58. data/lib/generators/seams/admin/templates/app/policies/admin/platform/notification_preference_policy.rb.tt +9 -0
  59. data/lib/generators/seams/admin/templates/app/policies/admin/platform/plan_policy.rb.tt +11 -0
  60. data/lib/generators/seams/admin/templates/app/policies/admin/platform/subscription_policy.rb.tt +9 -0
  61. data/lib/generators/seams/admin/templates/app/policies/admin/platform/team_policy.rb.tt +9 -0
  62. data/lib/generators/seams/admin/templates/app/policies/admin/platform/teams_membership_policy.rb.tt +9 -0
  63. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +33 -0
  64. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +24 -0
  65. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +169 -0
  66. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +67 -0
  67. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +24 -0
  68. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +21 -0
  69. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +21 -0
  70. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +25 -0
  71. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +23 -0
  72. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +47 -0
  73. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +22 -0
  74. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +28 -0
  75. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +24 -0
  76. data/lib/generators/seams/admin/templates/config/routes.rb.tt +38 -0
  77. data/lib/generators/seams/admin/templates/lib/admin.rb.tt +36 -0
  78. data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +66 -0
  79. data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +90 -0
  80. data/lib/generators/seams/admin/templates/lib/context.rb.tt +44 -0
  81. data/lib/generators/seams/admin/templates/lib/engine.rb.tt +68 -0
  82. data/lib/generators/seams/admin/templates/spec/factories/admin.rb.tt +10 -0
  83. data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +604 -0
  84. data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +157 -0
  85. data/lib/generators/seams/auth/add_oauth_provider/templates/adapter.rb.tt +95 -0
  86. data/lib/generators/seams/auth/add_oauth_provider/templates/adapter_spec.rb.tt +58 -0
  87. data/lib/generators/seams/auth/auth_generator.rb +311 -0
  88. data/lib/generators/seams/auth/templates/README.md.tt +289 -0
  89. data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +80 -0
  90. data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +44 -0
  91. data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +34 -0
  92. data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +49 -0
  93. data/lib/generators/seams/auth/templates/app/jobs/application_job.rb.tt +7 -0
  94. data/lib/generators/seams/auth/templates/app/jobs/cleanup_expired_sessions_job.rb.tt +30 -0
  95. data/lib/generators/seams/auth/templates/app/mailers/passwords_mailer.rb.tt +15 -0
  96. data/lib/generators/seams/auth/templates/app/models/api_token.rb.tt +62 -0
  97. data/lib/generators/seams/auth/templates/app/models/application_record.rb.tt +7 -0
  98. data/lib/generators/seams/auth/templates/app/models/current.rb.tt +15 -0
  99. data/lib/generators/seams/auth/templates/app/models/identity.rb.tt +74 -0
  100. data/lib/generators/seams/auth/templates/app/models/oauth/provider.rb.tt +48 -0
  101. data/lib/generators/seams/auth/templates/app/models/session.rb.tt +28 -0
  102. data/lib/generators/seams/auth/templates/app/services/authenticate_identity.rb.tt +31 -0
  103. data/lib/generators/seams/auth/templates/app/services/generate_api_token.rb.tt +35 -0
  104. data/lib/generators/seams/auth/templates/app/services/oauth/authenticator.rb.tt +94 -0
  105. data/lib/generators/seams/auth/templates/app/services/register_identity.rb.tt +57 -0
  106. data/lib/generators/seams/auth/templates/app/services/reset_password.rb.tt +41 -0
  107. data/lib/generators/seams/auth/templates/app/services/revoke_api_token.rb.tt +38 -0
  108. data/lib/generators/seams/auth/templates/app/views/password_resets/edit.html.erb.tt +12 -0
  109. data/lib/generators/seams/auth/templates/app/views/password_resets/new.html.erb.tt +11 -0
  110. data/lib/generators/seams/auth/templates/app/views/passwords_mailer/reset_email.html.erb.tt +7 -0
  111. data/lib/generators/seams/auth/templates/app/views/registrations/new.html.erb.tt +26 -0
  112. data/lib/generators/seams/auth/templates/app/views/sessions/_oauth_buttons.html.erb.tt +18 -0
  113. data/lib/generators/seams/auth/templates/app/views/sessions/new.html.erb.tt +17 -0
  114. data/lib/generators/seams/auth/templates/config/routes.rb.tt +21 -0
  115. data/lib/generators/seams/auth/templates/db/migrate/create_auth_api_tokens.rb.tt +26 -0
  116. data/lib/generators/seams/auth/templates/db/migrate/create_auth_identities.rb.tt +29 -0
  117. data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +35 -0
  118. data/lib/generators/seams/auth/templates/db/migrate/create_auth_sessions.rb.tt +19 -0
  119. data/lib/generators/seams/auth/templates/lib/auth.rb.tt +39 -0
  120. data/lib/generators/seams/auth/templates/lib/concerns/api_authenticatable.rb.tt +58 -0
  121. data/lib/generators/seams/auth/templates/lib/concerns/authenticatable.rb.tt +32 -0
  122. data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +60 -0
  123. data/lib/generators/seams/auth/templates/lib/configuration.rb.tt +45 -0
  124. data/lib/generators/seams/auth/templates/lib/engine.rb.tt +46 -0
  125. data/lib/generators/seams/auth/templates/lib/oauth/abstract.rb.tt +87 -0
  126. data/lib/generators/seams/auth/templates/lib/oauth/github.rb.tt +112 -0
  127. data/lib/generators/seams/auth/templates/lib/oauth/google.rb.tt +78 -0
  128. data/lib/generators/seams/auth/templates/lib/tasks/auth_pii.rake.tt +68 -0
  129. data/lib/generators/seams/auth/templates/spec/factories/auth.rb.tt +38 -0
  130. data/lib/generators/seams/auth/templates/spec/mailers/passwords_mailer_spec.rb.tt +37 -0
  131. data/lib/generators/seams/auth/templates/spec/models/api_token_spec.rb.tt +84 -0
  132. data/lib/generators/seams/auth/templates/spec/models/identity_spec.rb.tt +56 -0
  133. data/lib/generators/seams/auth/templates/spec/models/oauth/provider_spec.rb.tt +64 -0
  134. data/lib/generators/seams/auth/templates/spec/models/session_spec.rb.tt +34 -0
  135. data/lib/generators/seams/auth/templates/spec/runtime/boot_spec.rb.tt +30 -0
  136. data/lib/generators/seams/auth/templates/spec/runtime/event_payload_spec.rb.tt +29 -0
  137. data/lib/generators/seams/auth/templates/spec/runtime/login_flow_spec.rb.tt +45 -0
  138. data/lib/generators/seams/billing/billing_generator.rb +476 -0
  139. data/lib/generators/seams/billing/templates/README.md.tt +355 -0
  140. data/lib/generators/seams/billing/templates/app/controllers/admin/lifetime_passes_controller.rb.tt +84 -0
  141. data/lib/generators/seams/billing/templates/app/controllers/checkout_controller.rb.tt +92 -0
  142. data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +63 -0
  143. data/lib/generators/seams/billing/templates/app/controllers/plans_controller.rb.tt +14 -0
  144. data/lib/generators/seams/billing/templates/app/controllers/portal_controller.rb.tt +45 -0
  145. data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +119 -0
  146. data/lib/generators/seams/billing/templates/app/controllers/webhooks_controller.rb.tt +98 -0
  147. data/lib/generators/seams/billing/templates/app/helpers/currency_helper.rb.tt +44 -0
  148. data/lib/generators/seams/billing/templates/app/jobs/application_job.rb.tt +6 -0
  149. data/lib/generators/seams/billing/templates/app/jobs/cancel_subscription_job.rb.tt +39 -0
  150. data/lib/generators/seams/billing/templates/app/jobs/start_subscription_job.rb.tt +32 -0
  151. data/lib/generators/seams/billing/templates/app/jobs/webhooks/process_event_job.rb.tt +37 -0
  152. data/lib/generators/seams/billing/templates/app/models/application_record.rb.tt +7 -0
  153. data/lib/generators/seams/billing/templates/app/models/invoice.rb.tt +35 -0
  154. data/lib/generators/seams/billing/templates/app/models/lifetime_pass.rb.tt +60 -0
  155. data/lib/generators/seams/billing/templates/app/models/plan.rb.tt +95 -0
  156. data/lib/generators/seams/billing/templates/app/models/subscription.rb.tt +31 -0
  157. data/lib/generators/seams/billing/templates/app/models/webhook_event.rb.tt +13 -0
  158. data/lib/generators/seams/billing/templates/app/services/checkout_session_service.rb.tt +25 -0
  159. data/lib/generators/seams/billing/templates/app/services/customers/find_or_create_service.rb.tt +73 -0
  160. data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +50 -0
  161. data/lib/generators/seams/billing/templates/app/services/lifetime/create_lifetime_session_service.rb.tt +82 -0
  162. data/lib/generators/seams/billing/templates/app/services/lifetime/create_pass_from_checkout_service.rb.tt +88 -0
  163. data/lib/generators/seams/billing/templates/app/services/lifetime/grant_pass_service.rb.tt +80 -0
  164. data/lib/generators/seams/billing/templates/app/services/lifetime/revoke_pass_service.rb.tt +59 -0
  165. data/lib/generators/seams/billing/templates/app/services/portal_session_service.rb.tt +23 -0
  166. data/lib/generators/seams/billing/templates/app/services/service_result.rb.tt +38 -0
  167. data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +67 -0
  168. data/lib/generators/seams/billing/templates/app/services/subscriptions/cancel_service.rb.tt +42 -0
  169. data/lib/generators/seams/billing/templates/app/services/subscriptions/change_plan_service.rb.tt +48 -0
  170. data/lib/generators/seams/billing/templates/app/services/subscriptions/reactivate_service.rb.tt +28 -0
  171. data/lib/generators/seams/billing/templates/app/services/webhooks/event_router.rb.tt +54 -0
  172. data/lib/generators/seams/billing/templates/app/services/webhooks/handler.rb.tt +93 -0
  173. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/charge_refunded_handler.rb.tt +18 -0
  174. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/checkout_session_completed_handler.rb.tt +58 -0
  175. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_created_handler.rb.tt +16 -0
  176. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_finalized_handler.rb.tt +14 -0
  177. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +80 -0
  178. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_paid_handler.rb.tt +12 -0
  179. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_payment_failed_handler.rb.tt +12 -0
  180. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_voided_handler.rb.tt +12 -0
  181. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/payment_failed_handler.rb.tt +15 -0
  182. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/payment_succeeded_handler.rb.tt +19 -0
  183. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_created_handler.rb.tt +11 -0
  184. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_deleted_handler.rb.tt +15 -0
  185. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +92 -0
  186. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_trial_will_end_handler.rb.tt +15 -0
  187. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_updated_handler.rb.tt +11 -0
  188. data/lib/generators/seams/billing/templates/app/views/admin/lifetime_passes/index.html.erb.tt +36 -0
  189. data/lib/generators/seams/billing/templates/app/views/admin/lifetime_passes/new.html.erb.tt +37 -0
  190. data/lib/generators/seams/billing/templates/app/views/checkout/success.html.erb.tt +5 -0
  191. data/lib/generators/seams/billing/templates/app/views/invoices/index.html.erb.tt +22 -0
  192. data/lib/generators/seams/billing/templates/app/views/invoices/show.html.erb.tt +14 -0
  193. data/lib/generators/seams/billing/templates/app/views/plans/index.html.erb.tt +51 -0
  194. data/lib/generators/seams/billing/templates/app/views/subscriptions/index.html.erb.tt +16 -0
  195. data/lib/generators/seams/billing/templates/app/views/subscriptions/show.html.erb.tt +25 -0
  196. data/lib/generators/seams/billing/templates/config/routes.rb.tt +39 -0
  197. data/lib/generators/seams/billing/templates/db/migrate/create_billing_invoices.rb.tt +32 -0
  198. data/lib/generators/seams/billing/templates/db/migrate/create_billing_lifetime_passes.rb.tt +43 -0
  199. data/lib/generators/seams/billing/templates/db/migrate/create_billing_plans.rb.tt +31 -0
  200. data/lib/generators/seams/billing/templates/db/migrate/create_billing_subscriptions.rb.tt +33 -0
  201. data/lib/generators/seams/billing/templates/db/migrate/create_billing_webhook_events.rb.tt +24 -0
  202. data/lib/generators/seams/billing/templates/lib/billing.rb.tt +34 -0
  203. data/lib/generators/seams/billing/templates/lib/concerns/billable.rb.tt +100 -0
  204. data/lib/generators/seams/billing/templates/lib/configuration.rb.tt +52 -0
  205. data/lib/generators/seams/billing/templates/lib/engine.rb.tt +72 -0
  206. data/lib/generators/seams/billing/templates/lib/gateways/abstract.rb.tt +65 -0
  207. data/lib/generators/seams/billing/templates/lib/gateways/adyen.rb.tt +16 -0
  208. data/lib/generators/seams/billing/templates/lib/gateways/paddle.rb.tt +22 -0
  209. data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +155 -0
  210. data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +101 -0
  211. data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +43 -0
  212. data/lib/generators/seams/billing/templates/lib/tasks/billing_check.rake.tt +34 -0
  213. data/lib/generators/seams/billing/templates/spec/factories/billing.rb.tt +65 -0
  214. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +19 -0
  215. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +17 -0
  216. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +25 -0
  217. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +17 -0
  218. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +17 -0
  219. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +28 -0
  220. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +18 -0
  221. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +18 -0
  222. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +19 -0
  223. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +20 -0
  224. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +18 -0
  225. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +21 -0
  226. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +17 -0
  227. data/lib/generators/seams/billing/templates/spec/gateways/contract_spec.rb.tt +11 -0
  228. data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +53 -0
  229. data/lib/generators/seams/billing/templates/spec/models/plan_spec.rb.tt +81 -0
  230. data/lib/generators/seams/billing/templates/spec/models/subscription_spec.rb.tt +43 -0
  231. data/lib/generators/seams/billing/templates/spec/runtime/boot_spec.rb.tt +38 -0
  232. data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +382 -0
  233. data/lib/generators/seams/billing/templates/spec/support/shared_examples/a_billing_gateway.rb.tt +100 -0
  234. data/lib/generators/seams/billing/templates/spec/support/stripe_helpers.rb.tt +59 -0
  235. data/lib/generators/seams/core/core_generator.rb +191 -0
  236. data/lib/generators/seams/core/templates/README.md.tt +45 -0
  237. data/lib/generators/seams/core/templates/app/controllers/concerns/has_current_attributes.rb.tt +71 -0
  238. data/lib/generators/seams/core/templates/app/models/application_record.rb.tt +7 -0
  239. data/lib/generators/seams/core/templates/app/models/audit_log.rb.tt +19 -0
  240. data/lib/generators/seams/core/templates/app/models/concerns/auditable.rb.tt +64 -0
  241. data/lib/generators/seams/core/templates/app/models/concerns/sluggable.rb.tt +53 -0
  242. data/lib/generators/seams/core/templates/app/models/concerns/soft_deletable.rb.tt +37 -0
  243. data/lib/generators/seams/core/templates/app/models/concerns/tenant_scoped.rb.tt +39 -0
  244. data/lib/generators/seams/core/templates/app/models/current.rb.tt +16 -0
  245. data/lib/generators/seams/core/templates/app/services/event_publisher.rb.tt +23 -0
  246. data/lib/generators/seams/core/templates/app/validators/email_format_validator.rb.tt +21 -0
  247. data/lib/generators/seams/core/templates/db/migrate/create_core_audit_logs.rb.tt +29 -0
  248. data/lib/generators/seams/core/templates/lib/core.rb.tt +8 -0
  249. data/lib/generators/seams/core/templates/lib/engine.rb.tt +28 -0
  250. data/lib/generators/seams/core/templates/spec/concerns/auditable_spec.rb.tt +39 -0
  251. data/lib/generators/seams/core/templates/spec/concerns/sluggable_spec.rb.tt +29 -0
  252. data/lib/generators/seams/core/templates/spec/models/audit_log_spec.rb.tt +22 -0
  253. data/lib/generators/seams/core/templates/spec/runtime/boot_spec.rb.tt +25 -0
  254. data/lib/generators/seams/core/templates/spec/validators/email_format_validator_spec.rb.tt +29 -0
  255. data/lib/generators/seams/engine/engine_generator.rb +165 -0
  256. data/lib/generators/seams/engine/templates/Gemfile.tt +19 -0
  257. data/lib/generators/seams/engine/templates/LICENSE.tt +21 -0
  258. data/lib/generators/seams/engine/templates/README.md.tt +40 -0
  259. data/lib/generators/seams/engine/templates/Rakefile.tt +14 -0
  260. data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
  261. data/lib/generators/seams/engine/templates/app/application_record.rb.tt +16 -0
  262. data/lib/generators/seams/engine/templates/config/locales/en.yml.tt +14 -0
  263. data/lib/generators/seams/engine/templates/config/routes.rb.tt +4 -0
  264. data/lib/generators/seams/engine/templates/gemspec.tt +20 -0
  265. data/lib/generators/seams/engine/templates/host_initializer.rb.tt +13 -0
  266. data/lib/generators/seams/engine/templates/lib/engine.rb.tt +27 -0
  267. data/lib/generators/seams/engine/templates/lib/root.rb.tt +7 -0
  268. data/lib/generators/seams/engine/templates/lib/version.rb.tt +5 -0
  269. data/lib/generators/seams/engine/templates/rubocop.yml.tt +55 -0
  270. data/lib/generators/seams/engine/templates/spec/example_spec.rb.tt +16 -0
  271. data/lib/generators/seams/engine/templates/spec/spec_helper.rb.tt +23 -0
  272. data/lib/generators/seams/install/install_generator.rb +211 -0
  273. data/lib/generators/seams/install/templates/Dockerfile.tt +52 -0
  274. data/lib/generators/seams/install/templates/Procfile.tt +14 -0
  275. data/lib/generators/seams/install/templates/bin_seams.tt +107 -0
  276. data/lib/generators/seams/install/templates/ci.yml.tt +123 -0
  277. data/lib/generators/seams/install/templates/deploy.yml.tt +63 -0
  278. data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +86 -0
  279. data/lib/generators/seams/install/templates/docker-entrypoint.tt +27 -0
  280. data/lib/generators/seams/install/templates/rubocop.yml.tt +33 -0
  281. data/lib/generators/seams/install/templates/ruby-version.tt +1 -0
  282. data/lib/generators/seams/install/templates/script/collate_coverage.rb.tt +33 -0
  283. data/lib/generators/seams/install/templates/script/run_affected_tests.sh.tt +64 -0
  284. data/lib/generators/seams/install/templates/seams.rake.tt +65 -0
  285. data/lib/generators/seams/install/templates/seams.rb.tt +9 -0
  286. data/lib/generators/seams/install/templates/seams_engines.rb.tt +15 -0
  287. data/lib/generators/seams/notifications/notifications_generator.rb +395 -0
  288. data/lib/generators/seams/notifications/templates/README.md.tt +269 -0
  289. data/lib/generators/seams/notifications/templates/app/channels/notification_channel.rb.tt +36 -0
  290. data/lib/generators/seams/notifications/templates/app/controllers/notifications_controller.rb.tt +58 -0
  291. data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +54 -0
  292. data/lib/generators/seams/notifications/templates/app/javascript/controllers/notification_bell_controller.js.tt +34 -0
  293. data/lib/generators/seams/notifications/templates/app/jobs/application_job.rb.tt +6 -0
  294. data/lib/generators/seams/notifications/templates/app/jobs/create_notification_job.rb.tt +31 -0
  295. data/lib/generators/seams/notifications/templates/app/jobs/send_due_notifications_job.rb.tt +22 -0
  296. data/lib/generators/seams/notifications/templates/app/jobs/send_notification_job.rb.tt +13 -0
  297. data/lib/generators/seams/notifications/templates/app/mailers/application_mailer.rb.tt +12 -0
  298. data/lib/generators/seams/notifications/templates/app/mailers/notification_mailer.rb.tt +23 -0
  299. data/lib/generators/seams/notifications/templates/app/models/application_record.rb.tt +7 -0
  300. data/lib/generators/seams/notifications/templates/app/models/delivery.rb.tt +13 -0
  301. data/lib/generators/seams/notifications/templates/app/models/notification.rb.tt +218 -0
  302. data/lib/generators/seams/notifications/templates/app/models/notification_preference.rb.tt +29 -0
  303. data/lib/generators/seams/notifications/templates/app/models/strategies/email.rb.tt +38 -0
  304. data/lib/generators/seams/notifications/templates/app/models/strategies/in_app.rb.tt +26 -0
  305. data/lib/generators/seams/notifications/templates/app/models/strategies/sms.rb.tt +33 -0
  306. data/lib/generators/seams/notifications/templates/app/subscribers/auth_subscriber.rb.tt +71 -0
  307. data/lib/generators/seams/notifications/templates/app/subscribers/billing_subscriber.rb.tt +127 -0
  308. data/lib/generators/seams/notifications/templates/app/views/layouts/notifications/mailer.html.erb.tt +22 -0
  309. data/lib/generators/seams/notifications/templates/app/views/layouts/notifications/mailer.text.erb.tt +4 -0
  310. data/lib/generators/seams/notifications/templates/app/views/notifications/_bell.html.erb.tt +15 -0
  311. data/lib/generators/seams/notifications/templates/app/views/notifications/index.html.erb.tt +15 -0
  312. data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_failed.html.erb.tt +4 -0
  313. data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_failed.text.erb.tt +4 -0
  314. data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_paid.html.erb.tt +3 -0
  315. data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_paid.text.erb.tt +3 -0
  316. data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_granted.html.erb.tt +5 -0
  317. data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_granted.text.erb.tt +5 -0
  318. data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_purchased.html.erb.tt +5 -0
  319. data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_purchased.text.erb.tt +5 -0
  320. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_canceled.html.erb.tt +4 -0
  321. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_canceled.text.erb.tt +4 -0
  322. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_started.html.erb.tt +4 -0
  323. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_started.text.erb.tt +5 -0
  324. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_updated.html.erb.tt +3 -0
  325. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_updated.text.erb.tt +3 -0
  326. data/lib/generators/seams/notifications/templates/app/views/templates/default.html.erb.tt +10 -0
  327. data/lib/generators/seams/notifications/templates/app/views/templates/default.text.erb.tt +11 -0
  328. data/lib/generators/seams/notifications/templates/app/views/templates/welcome.html.erb.tt +6 -0
  329. data/lib/generators/seams/notifications/templates/app/views/templates/welcome.text.erb.tt +6 -0
  330. data/lib/generators/seams/notifications/templates/config/initializers/notifications.rb.tt +58 -0
  331. data/lib/generators/seams/notifications/templates/config/routes.rb.tt +17 -0
  332. data/lib/generators/seams/notifications/templates/db/migrate/create_notification_deliveries.rb.tt +16 -0
  333. data/lib/generators/seams/notifications/templates/db/migrate/create_notification_preferences.rb.tt +25 -0
  334. data/lib/generators/seams/notifications/templates/db/migrate/create_notifications.rb.tt +35 -0
  335. data/lib/generators/seams/notifications/templates/lib/adapters/abstract.rb.tt +20 -0
  336. data/lib/generators/seams/notifications/templates/lib/adapters/action_mailer.rb.tt +17 -0
  337. data/lib/generators/seams/notifications/templates/lib/adapters/null_sms.rb.tt +23 -0
  338. data/lib/generators/seams/notifications/templates/lib/concerns/notifiable.rb.tt +135 -0
  339. data/lib/generators/seams/notifications/templates/lib/configuration.rb.tt +24 -0
  340. data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +35 -0
  341. data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +75 -0
  342. data/lib/generators/seams/notifications/templates/lib/type_registry.rb.tt +74 -0
  343. data/lib/generators/seams/notifications/templates/spec/factories/notifications.rb.tt +53 -0
  344. data/lib/generators/seams/notifications/templates/spec/models/delivery_spec.rb.tt +28 -0
  345. data/lib/generators/seams/notifications/templates/spec/models/notification_preference_spec.rb.tt +46 -0
  346. data/lib/generators/seams/notifications/templates/spec/models/notification_spec.rb.tt +60 -0
  347. data/lib/generators/seams/notifications/templates/spec/runtime/bell_broadcast_spec.rb.tt +59 -0
  348. data/lib/generators/seams/notifications/templates/spec/runtime/billing_subscriber_skip_spec.rb.tt +87 -0
  349. data/lib/generators/seams/notifications/templates/spec/runtime/boot_spec.rb.tt +39 -0
  350. data/lib/generators/seams/notifications/templates/spec/runtime/schedule_round_trip_spec.rb.tt +55 -0
  351. data/lib/generators/seams/remove/remove_generator.rb +259 -0
  352. data/lib/generators/seams/teams/teams_generator.rb +298 -0
  353. data/lib/generators/seams/teams/templates/README.md.tt +88 -0
  354. data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +102 -0
  355. data/lib/generators/seams/teams/templates/app/controllers/memberships_controller.rb.tt +54 -0
  356. data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +68 -0
  357. data/lib/generators/seams/teams/templates/app/jobs/application_job.rb.tt +6 -0
  358. data/lib/generators/seams/teams/templates/app/mailers/invitation_mailer.rb.tt +34 -0
  359. data/lib/generators/seams/teams/templates/app/models/application_record.rb.tt +7 -0
  360. data/lib/generators/seams/teams/templates/app/models/current.rb.tt +30 -0
  361. data/lib/generators/seams/teams/templates/app/models/invitation.rb.tt +36 -0
  362. data/lib/generators/seams/teams/templates/app/models/membership.rb.tt +36 -0
  363. data/lib/generators/seams/teams/templates/app/models/team.rb.tt +32 -0
  364. data/lib/generators/seams/teams/templates/app/subscribers/invitation_subscriber.rb.tt +36 -0
  365. data/lib/generators/seams/teams/templates/app/views/invitation_mailer/invite.text.erb.tt +8 -0
  366. data/lib/generators/seams/teams/templates/app/views/invitations/index.html.erb.tt +44 -0
  367. data/lib/generators/seams/teams/templates/app/views/memberships/index.html.erb.tt +32 -0
  368. data/lib/generators/seams/teams/templates/app/views/teams/edit.html.erb.tt +28 -0
  369. data/lib/generators/seams/teams/templates/app/views/teams/index.html.erb.tt +15 -0
  370. data/lib/generators/seams/teams/templates/app/views/teams/new.html.erb.tt +24 -0
  371. data/lib/generators/seams/teams/templates/app/views/teams/show.html.erb.tt +17 -0
  372. data/lib/generators/seams/teams/templates/config/routes.rb.tt +19 -0
  373. data/lib/generators/seams/teams/templates/db/migrate/create_team_invitations.rb.tt +24 -0
  374. data/lib/generators/seams/teams/templates/db/migrate/create_team_memberships.rb.tt +25 -0
  375. data/lib/generators/seams/teams/templates/db/migrate/create_teams.rb.tt +18 -0
  376. data/lib/generators/seams/teams/templates/lib/concerns/account_scoped.rb.tt +79 -0
  377. data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +55 -0
  378. data/lib/generators/seams/teams/templates/lib/configuration.rb.tt +45 -0
  379. data/lib/generators/seams/teams/templates/lib/engine.rb.tt +51 -0
  380. data/lib/generators/seams/teams/templates/lib/teams.rb.tt +22 -0
  381. data/lib/generators/seams/teams/templates/spec/factories/teams.rb.tt +47 -0
  382. data/lib/generators/seams/teams/templates/spec/models/invitation_spec.rb.tt +25 -0
  383. data/lib/generators/seams/teams/templates/spec/models/membership_spec.rb.tt +29 -0
  384. data/lib/generators/seams/teams/templates/spec/models/team_spec.rb.tt +23 -0
  385. data/lib/generators/seams/teams/templates/spec/runtime/boot_spec.rb.tt +32 -0
  386. data/lib/seams/cli/list.rb +111 -0
  387. data/lib/seams/cli/quality.rb +99 -0
  388. data/lib/seams/cli/resolve.rb +276 -0
  389. data/lib/seams/cli/test_changed.rb +116 -0
  390. data/lib/seams/cli.rb +48 -0
  391. data/lib/seams/configuration.rb +19 -0
  392. data/lib/seams/cops/known_queue_names.rb +42 -0
  393. data/lib/seams/cops/migration_comments.rb +68 -0
  394. data/lib/seams/cops/no_cross_engine_dependency.rb +58 -0
  395. data/lib/seams/cops/no_cross_engine_model_access.rb +153 -0
  396. data/lib/seams/cops.rb +18 -0
  397. data/lib/seams/event_registry.rb +49 -0
  398. data/lib/seams/events/adapter.rb +24 -0
  399. data/lib/seams/events/adapters/active_support.rb +31 -0
  400. data/lib/seams/events/publisher.rb +178 -0
  401. data/lib/seams/events.rb +39 -0
  402. data/lib/seams/generators/dummy_app_writer.rb +424 -0
  403. data/lib/seams/generators/eject_aware.rb +102 -0
  404. data/lib/seams/generators/follow_up_generator.rb +148 -0
  405. data/lib/seams/generators/host_injector.rb +124 -0
  406. data/lib/seams/generators/sibling_rubocop_writer.rb +77 -0
  407. data/lib/seams/generators/splicer.rb +217 -0
  408. data/lib/seams/observability/adapter.rb +33 -0
  409. data/lib/seams/observability/adapters/rails_logger.rb +59 -0
  410. data/lib/seams/observability.rb +34 -0
  411. data/lib/seams/runtime.rb +23 -0
  412. data/lib/seams/version.rb +5 -0
  413. data/lib/seams.rb +23 -0
  414. metadata +493 -0
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notifications
4
+ # Represents a scheduled notification belonging to an +owner+.
5
+ #
6
+ # Concrete delivery types are STI subclasses under
7
+ # +Notifications::Strategies+ — +InApp+, +Email+, +Sms+ — each
8
+ # implementing its own +#dispatch!+. They share this base's schema,
9
+ # scheduling, and audit logic.
10
+ #
11
+ # Scheduling is delegated to ice_cube. The schedule is persisted as
12
+ # a json hash via +schedule_data+ and exposed through
13
+ # +schedule+/+schedule=+. +next_delivery_at+ is an indexed cache so
14
+ # the recurring sweeper hits one query.
15
+ class Notification < ApplicationRecord
16
+ self.table_name = "notifications"
17
+
18
+ belongs_to :owner, polymorphic: true
19
+ has_many :deliveries, class_name: "Notifications::Delivery",
20
+ dependent: :destroy, inverse_of: :notification
21
+
22
+ # Concrete STI subclasses permitted for creation. Anything else
23
+ # fails validation rather than booting an unknown subclass.
24
+ PERMITTED_TYPES = [
25
+ "Notifications::Strategies::InApp",
26
+ "Notifications::Strategies::Email",
27
+ "Notifications::Strategies::Sms"
28
+ ].freeze
29
+
30
+ FREQUENCIES = %w[once daily weekly monthly].freeze
31
+
32
+ # Templates resolve to file reads via ERB. Lock the value down so a
33
+ # row can't reach outside the templates dir or evaluate arbitrary
34
+ # files. Allowed: lowercase letters, digits, underscores, dashes,
35
+ # forward slashes (so subdirs like "billing/invoice_paid" work).
36
+ # No leading slash, no `..`, no consecutive slashes.
37
+ TEMPLATE_NAME_PATTERN = %r{\A[a-z0-9_\-]+(?:/[a-z0-9_\-]+)*\z}
38
+
39
+ validates :type, inclusion: { in: PERMITTED_TYPES }
40
+ validates :template, presence: true,
41
+ format: { with: TEMPLATE_NAME_PATTERN, message: "must be a slash-separated lowercase identifier" }
42
+
43
+ scope :due, -> { where(next_delivery_at: ..Time.current) }
44
+ scope :pending, -> { where.not(next_delivery_at: nil) }
45
+ scope :completed, -> { where(next_delivery_at: nil) }
46
+ scope :unread, -> { where(read_at: nil) }
47
+ scope :recent, -> { order(created_at: :desc) }
48
+
49
+ # Accept short type names ("Email", "Sms", "InApp") in API params
50
+ # and normalise to fully-qualified class names. Falls back to the
51
+ # base class so the inclusion validation returns 422 instead of
52
+ # raising a 500.
53
+ def self.find_sti_class(type_name)
54
+ super(expand_type(type_name))
55
+ rescue ::ActiveRecord::SubclassNotFound
56
+ self
57
+ end
58
+
59
+ def self.expand_type(type_name)
60
+ PERMITTED_TYPES.find { |t| t.demodulize == type_name } || type_name
61
+ end
62
+
63
+ def type=(value)
64
+ super(self.class.expand_type(value.to_s))
65
+ end
66
+
67
+ # Deserialises +schedule_data+ into an IceCube::Schedule instance.
68
+ def schedule
69
+ return nil if schedule_data.blank?
70
+
71
+ IceCube::Schedule.from_hash(schedule_data)
72
+ end
73
+
74
+ # Assigns an IceCube::Schedule, persisting it as json and setting
75
+ # +next_delivery_at+ to the schedule's first occurrence.
76
+ def schedule=(sched)
77
+ self.schedule_data = sched&.to_hash
78
+ self.next_delivery_at = sched&.first
79
+ end
80
+
81
+ # Form-friendly schedule constructor. Accepts:
82
+ # { starts_at:, frequency: once|daily|weekly|monthly,
83
+ # interval: 1, count: 5, until: "2026-12-31" }
84
+ # so host code never has to import ice_cube.
85
+ def schedule_config=(config)
86
+ return if config.blank?
87
+
88
+ sched = IceCube::Schedule.new(Time.zone.parse(config[:starts_at].to_s))
89
+ rule = build_recurrence_rule(config)
90
+ sched.add_recurrence_rule(rule) if rule
91
+ self.schedule = sched
92
+ end
93
+
94
+ # Renders the ERB template named by +template+ in the requested
95
+ # +format+ (`:text` (default) or `:html`). Looks first in the host's
96
+ # app/views/notifications/templates/<name>.<format>.erb, then falls
97
+ # back to the engine's bundled templates. Hosts override by
98
+ # dropping a file. Raises Notifications::Error if no template
99
+ # exists at the requested format — callers that want to probe
100
+ # without raising should use #template_exists?(format:).
101
+ def rendered_content(format: :text)
102
+ path = find_template_path(format: format) ||
103
+ raise(Notifications::Error,
104
+ "Template #{template.inspect} (#{format}) not found under any allowed root")
105
+
106
+ ERB.new(File.read(path)).result_with_hash(notification: self)
107
+ end
108
+
109
+ def template_exists?(format: :text)
110
+ !find_template_path(format: format).nil?
111
+ end
112
+
113
+ def due?
114
+ next_delivery_at.present? && next_delivery_at <= Time.current
115
+ end
116
+
117
+ def read?
118
+ read_at.present?
119
+ end
120
+
121
+ def mark_as_read!
122
+ update!(read_at: Time.current) unless read?
123
+ end
124
+
125
+ def send_async
126
+ Notifications::SendNotificationJob.perform_later(id)
127
+ end
128
+
129
+ # Synchronous delivery. Publishes lifecycle events OUTSIDE the
130
+ # transaction so subscribers don't run inside the AR transaction
131
+ # (a slow subscriber would hold table locks; a raise would roll
132
+ # back the delivery write).
133
+ # No-ops if not yet due (defensive against duplicate enqueue).
134
+ def send!
135
+ return unless due?
136
+
137
+ Seams::Events::Publisher.publish(
138
+ "notification.queued.notifications",
139
+ id: id, type: type, owner_type: owner_type, owner_id: owner_id
140
+ )
141
+
142
+ dispatch!
143
+ self.class.transaction do
144
+ deliveries.create!(sent_at: Time.current)
145
+ advance!
146
+ end
147
+
148
+ Seams::Events::Publisher.publish(
149
+ "notification.delivered.notifications",
150
+ id: id, type: type, owner_type: owner_type, owner_id: owner_id
151
+ )
152
+ rescue StandardError => e
153
+ Seams::Events::Publisher.publish(
154
+ "notification.failed.notifications", id: id, type: type, error: "#{e.class}: #{e.message}"
155
+ )
156
+ raise
157
+ end
158
+
159
+ # Advances +next_delivery_at+ to the next ice_cube occurrence
160
+ # after now. nil when no further occurrences exist (one-shots
161
+ # become "completed").
162
+ def advance!
163
+ update!(next_delivery_at: schedule&.next_occurrence)
164
+ end
165
+
166
+ FREQUENCY_BUILDERS = {
167
+ "daily" => ->(interval) { IceCube::Rule.daily(interval) },
168
+ "weekly" => ->(interval) { IceCube::Rule.weekly(interval) },
169
+ "monthly" => ->(interval) { IceCube::Rule.monthly(interval) }
170
+ }.freeze
171
+ private_constant :FREQUENCY_BUILDERS
172
+
173
+ private
174
+
175
+ def build_recurrence_rule(config)
176
+ builder = FREQUENCY_BUILDERS[config[:frequency].to_s]
177
+ return unless builder
178
+
179
+ interval = [config[:interval].to_i, 1].max
180
+ apply_rule_constraints(builder.call(interval), config)
181
+ end
182
+
183
+ def apply_rule_constraints(rule, config)
184
+ rule = rule.count(config[:count].to_i) if config[:count].present?
185
+ rule = rule.until(Time.zone.parse(config[:until].to_s)) if config[:until].present?
186
+ rule
187
+ end
188
+
189
+ # Each strategy implements its own dispatch.
190
+ def dispatch!
191
+ raise NotImplementedError, "#{self.class} must implement #dispatch!"
192
+ end
193
+
194
+ PERMITTED_FORMATS = %i[text html].freeze
195
+
196
+ def find_template_path(format: :text)
197
+ raise ArgumentError, "format must be :text or :html" unless PERMITTED_FORMATS.include?(format)
198
+
199
+ # Realpath check — even though TEMPLATE_NAME_PATTERN already
200
+ # rejects "..", a defence-in-depth `realpath` confirms the
201
+ # resolved file lives under one of the two whitelisted roots.
202
+ host_root = Rails.root.join("app/views/notifications/templates")
203
+ engine_root = Notifications::Engine.root.join("app/views/notifications/templates")
204
+
205
+ [host_root, engine_root].each do |root|
206
+ candidate = root.join("#{template}.#{format}.erb")
207
+ next unless candidate.exist?
208
+
209
+ resolved = candidate.realpath
210
+ return resolved if resolved.to_s.start_with?(root.realpath.to_s)
211
+ end
212
+
213
+ # No template at this format. Caller decides whether to fall back
214
+ # (e.g. mailer trying :html then :text) or raise.
215
+ nil
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notifications
4
+ # Per-Identity channel + notification-type toggle. Absence of a row
5
+ # means "use defaults" (everything enabled). Presence with
6
+ # enabled: false means the Identity opted out.
7
+ #
8
+ # Keyed by `identity_id` (not the polymorphic Notification owner):
9
+ # channel preferences belong with the human, not with whatever
10
+ # other model a Notification happens to be addressed at (an
11
+ # Account, a Membership, a host-defined model).
12
+ class NotificationPreference < ApplicationRecord
13
+ self.table_name = "notification_preferences"
14
+
15
+ # Mirrors the keys of Notifications::Notifiable::STRATEGY_CLASSES
16
+ # but kept as plain strings here because the column stores strings.
17
+ CHANNELS = %w[in_app email sms].freeze
18
+
19
+ validates :identity_id, :channel, presence: true
20
+ validates :channel, inclusion: { in: CHANNELS }
21
+ validates :identity_id, uniqueness: { scope: %i[channel notification_type] }
22
+
23
+ def self.enabled?(identity_id:, channel:, notification_type: nil)
24
+ pref = find_by(identity_id: identity_id, channel: channel, notification_type: notification_type) ||
25
+ find_by(identity_id: identity_id, channel: channel, notification_type: nil)
26
+ pref ? pref.enabled : true
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notifications
4
+ module Strategies
5
+ # Email notification. +#dispatch!+ delegates to the configured
6
+ # email adapter, which ultimately renders the +template+ ERB
7
+ # against the owner and posts to the gateway (ActionMailer by
8
+ # default; swap with +Notifications.configuration.email_adapter+).
9
+ #
10
+ # The polymorphic +owner+ supplies the recipient address. We try
11
+ # +#email_address+ first (Rails 8's `has_secure_password` convention,
12
+ # used by some host User models), then +#email+ (Auth::Identity and
13
+ # most host User models). If the owner includes
14
+ # +Notifications::Notifiable+, +#email_notification_recipient+ wins.
15
+ # Hosts whose owner exposes a different attribute should override
16
+ # +#recipient+ on the strategy or set +recipient+ explicitly when
17
+ # creating the Notification.
18
+ class Email < Notification
19
+ def recipient
20
+ super.presence || resolve_email_from_owner
21
+ end
22
+
23
+ private
24
+
25
+ def dispatch!
26
+ Notifications.email_adapter.deliver(notification: self)
27
+ end
28
+
29
+ def resolve_email_from_owner
30
+ return nil unless owner
31
+
32
+ owner.try(:email_notification_recipient) ||
33
+ owner.try(:email_address) ||
34
+ owner.try(:email)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notifications
4
+ module Strategies
5
+ # In-app notification. The +Notification+ row IS the delivery —
6
+ # the bell view renders pending rows for the recipient and
7
+ # +#mark_as_read!+ flips +read_at+. +#dispatch!+ broadcasts on
8
+ # the per-recipient ActionCable channel so connected clients
9
+ # update without polling. No-op when ActionCable isn't booted.
10
+ class InApp < Notification
11
+ def recipient
12
+ super.presence || owner&.id&.to_s
13
+ end
14
+
15
+ private
16
+
17
+ def dispatch!
18
+ return unless defined?(Notifications::NotificationChannel) && defined?(::ActionCable)
19
+
20
+ Notifications::NotificationChannel.broadcast_to(
21
+ owner, id: id, template: template, body: rendered_content
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notifications
4
+ module Strategies
5
+ # SMS notification. +#dispatch!+ delegates to the configured SMS
6
+ # adapter (NullSms by default — logs and drops; swap with
7
+ # +Notifications.configuration.sms_adapter+).
8
+ #
9
+ # The polymorphic +owner+ supplies the recipient phone number.
10
+ # If the owner includes +Notifications::Notifiable+,
11
+ # +#sms_notification_recipient+ wins; otherwise we fall back to
12
+ # +#phone+. Hosts whose owner exposes a different attribute
13
+ # should override +#recipient+ on the strategy or set +recipient+
14
+ # explicitly when creating the Notification.
15
+ class Sms < Notification
16
+ def recipient
17
+ super.presence || resolve_phone_from_owner
18
+ end
19
+
20
+ private
21
+
22
+ def dispatch!
23
+ Notifications.sms_adapter.deliver(notification: self)
24
+ end
25
+
26
+ def resolve_phone_from_owner
27
+ return nil unless owner
28
+
29
+ owner.try(:sms_notification_recipient) || owner.try(:phone)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notifications
4
+ # Consumes Auth events. On +identity.signed_up.auth+:
5
+ # 1. Resolves the recipient by `payload[:identity_id]`. The owner
6
+ # of the welcome notification is the Auth::Identity itself
7
+ # (the human who just signed up).
8
+ # 2. Enqueues Notifications::CreateNotificationJob — which creates
9
+ # InApp + (preferences-permitting) Email rows out-of-band.
10
+ #
11
+ # Notifications are NEVER created inline inside the publisher's
12
+ # thread (see Seams::Events::Publisher docstring); the job is the
13
+ # boundary.
14
+ #
15
+ # Hosts that want a different owner (an Account, a Membership, or
16
+ # a host-defined User) should override this subscriber — copy this
17
+ # file into their host engine, change `OWNER_CLASS_NAME` and the
18
+ # resolution rule, and re-attach. The Notification table accepts
19
+ # any polymorphic owner; the choice is purely about which model
20
+ # the welcome notification "belongs to" in the bell partial / index.
21
+ #
22
+ # +.attach!+ is idempotent across Rails autoreload via
23
+ # +Seams::Events::Publisher.attach_class+ — registering the class by
24
+ # NAME (a String) rather than capturing a closure means each dispatch
25
+ # re-resolves the constant, so edits to +handle_signed_up+ are picked
26
+ # up without a server restart.
27
+ class AuthSubscriber
28
+ SUBSCRIBER_KEY = :notifications_auth_subscriber
29
+ OWNER_CLASS_NAME = "Auth::Identity"
30
+
31
+ class << self
32
+ def attach!
33
+ Seams::Events::Publisher.attach_class(
34
+ SUBSCRIBER_KEY,
35
+ "identity.signed_up.auth",
36
+ class_name: "Notifications::AuthSubscriber",
37
+ method_name: :handle_signed_up
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def handle_signed_up(payload)
44
+ identity_id = payload[:identity_id]
45
+ return unless identity_id
46
+
47
+ Notifications::CreateNotificationJob.perform_later(
48
+ owner_class: OWNER_CLASS_NAME,
49
+ owner_id: identity_id,
50
+ template: "welcome",
51
+ strategy: "in_app"
52
+ )
53
+
54
+ return unless email_enabled?(identity_id)
55
+
56
+ Notifications::CreateNotificationJob.perform_later(
57
+ owner_class: OWNER_CLASS_NAME,
58
+ owner_id: identity_id,
59
+ template: "welcome",
60
+ strategy: "email"
61
+ )
62
+ end
63
+
64
+ def email_enabled?(identity_id)
65
+ Notifications::NotificationPreference.enabled?(
66
+ identity_id: identity_id, channel: "email", notification_type: "welcome"
67
+ )
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notifications
4
+ # Consumes Billing events and enqueues a Notifications::CreateNotificationJob
5
+ # (which creates the InApp Notification out-of-band — never inline,
6
+ # see Seams::Events::Publisher docstring).
7
+ #
8
+ # Every subscribed event publishes the same canonical Wave-9 payload
9
+ # shape:
10
+ # { gateway:, livemode:, account_id:, customer_ref:, ref:, object_id:, object: }
11
+ #
12
+ # `account_id` is the UUID of the Accounts::Account (the billing
13
+ # tenant) the event belongs to. Notifications attaches the resulting
14
+ # Notification row to that Account directly — the recipient model is
15
+ # whatever class the host configured as the billing tenant
16
+ # (`Billing.configuration.billable_class`, default
17
+ # "Accounts::Account"). One human Identity may belong to many
18
+ # Accounts; per-Account billing notifications belong with the tenant,
19
+ # not with the human.
20
+ #
21
+ # +.attach!+ is idempotent across Rails autoreload via
22
+ # +Seams::Events::Publisher.attach_class+ — each event registers its
23
+ # subscriber by class NAME (a String), so dispatch re-resolves the
24
+ # constant on every call. Edits to a +handle_*+ method take effect
25
+ # without a server restart.
26
+ class BillingSubscriber
27
+ SUBSCRIBER_KEY = :notifications_billing_subscriber
28
+ SUBSCRIBER_CLASS_NAME = "Notifications::BillingSubscriber"
29
+
30
+ # (event_name => [handler_method, template]). Handler methods are
31
+ # one-line wrappers around #enqueue — kept separate (rather than a
32
+ # single block-capturing handler) so each registration goes through
33
+ # +attach_class+ and survives Rails autoreload.
34
+ EVENT_HANDLERS = {
35
+ "subscription.created.billing" => [:handle_subscription_created, "billing/subscription_started"],
36
+ "subscription.updated.billing" => [:handle_subscription_updated, "billing/subscription_updated"],
37
+ "subscription.canceled.billing" => [:handle_subscription_canceled, "billing/subscription_canceled"],
38
+ "invoice.paid.billing" => [:handle_invoice_paid, "billing/invoice_paid"],
39
+ "invoice.failed.billing" => [:handle_invoice_failed, "billing/invoice_failed"],
40
+ # Lifetime Deal events (issue #2 section 3A.LTD)
41
+ "lifetime.granted.billing" => [:handle_lifetime_granted, "billing/lifetime_granted"],
42
+ "lifetime.purchased.billing" => [:handle_lifetime_purchased, "billing/lifetime_purchased"]
43
+ }.freeze
44
+
45
+ class << self
46
+ def attach!
47
+ EVENT_HANDLERS.each do |event, (method_name, _template)|
48
+ Seams::Events::Publisher.attach_class(
49
+ SUBSCRIBER_KEY,
50
+ event,
51
+ class_name: SUBSCRIBER_CLASS_NAME,
52
+ method_name: method_name
53
+ )
54
+ end
55
+ end
56
+
57
+ # Owner class the billing subscriber attaches the Notification row
58
+ # to. Reads from `Billing.configuration.billable_class` so hosts
59
+ # with a custom tenant model (a host-defined `Workspace`, etc.) get
60
+ # the right `owner_type` without overriding this subscriber. Falls
61
+ # back to the canonical `"Accounts::Account"` if billing isn't
62
+ # configured.
63
+ def owner_class_name
64
+ return "Accounts::Account" unless defined?(Billing) && Billing.respond_to?(:configuration)
65
+
66
+ Billing.configuration.billable_class.to_s.presence || "Accounts::Account"
67
+ end
68
+
69
+ private
70
+
71
+ def handle_subscription_created(payload)
72
+ enqueue(payload, EVENT_HANDLERS.fetch("subscription.created.billing").last)
73
+ end
74
+
75
+ def handle_subscription_updated(payload)
76
+ enqueue(payload, EVENT_HANDLERS.fetch("subscription.updated.billing").last)
77
+ end
78
+
79
+ def handle_subscription_canceled(payload)
80
+ enqueue(payload, EVENT_HANDLERS.fetch("subscription.canceled.billing").last)
81
+ end
82
+
83
+ def handle_invoice_paid(payload)
84
+ enqueue(payload, EVENT_HANDLERS.fetch("invoice.paid.billing").last)
85
+ end
86
+
87
+ def handle_invoice_failed(payload)
88
+ enqueue(payload, EVENT_HANDLERS.fetch("invoice.failed.billing").last)
89
+ end
90
+
91
+ def handle_lifetime_granted(payload)
92
+ enqueue(payload, EVENT_HANDLERS.fetch("lifetime.granted.billing").last)
93
+ end
94
+
95
+ def handle_lifetime_purchased(payload)
96
+ enqueue(payload, EVENT_HANDLERS.fetch("lifetime.purchased.billing").last)
97
+ end
98
+
99
+ # Wave 9: every canonical billing payload carries `account_id:`
100
+ # (the UUID of the Accounts::Account that owns the billing row)
101
+ # right next to `customer_ref:`. We address the notification at
102
+ # the tenant directly — no host-User lookup needed.
103
+ def enqueue(payload, template)
104
+ account_id = payload[:account_id]
105
+ if account_id.nil? || account_id.to_s.empty?
106
+ # Visible signal — a silent miss here looks identical to "no
107
+ # recipient matched", which makes "billing notifications never
108
+ # fire" hard to debug. Log loudly once per call.
109
+ Seams::Observability.adapter.warn(
110
+ "notifications.billing_subscriber.skip",
111
+ engine: "notifications",
112
+ reason: "billing event missing account_id",
113
+ customer_ref: payload[:customer_ref]
114
+ )
115
+ return
116
+ end
117
+
118
+ Notifications::CreateNotificationJob.perform_later(
119
+ owner_class: owner_class_name,
120
+ owner_id: account_id,
121
+ template: template,
122
+ strategy: "in_app"
123
+ )
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title><%%= @notification&.template.to_s.titleize %></title>
8
+ <style>
9
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #222; line-height: 1.5; max-width: 560px; margin: 24px auto; padding: 0 16px; }
10
+ p { margin: 0 0 12px; }
11
+ .meta { color: #888; font-size: 12px; border-top: 1px solid #eee; padding-top: 12px; margin-top: 24px; }
12
+ a { color: #1a73e8; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <%%= yield %>
17
+
18
+ <p class="meta">
19
+ Sent via Seams Notifications.
20
+ </p>
21
+ </body>
22
+ </html>
@@ -0,0 +1,4 @@
1
+ <%%= yield %>
2
+
3
+ ---
4
+ Sent via Seams Notifications.
@@ -0,0 +1,15 @@
1
+ <%%# Notification bell partial. Render in the host layout: %>
2
+ <%%# <%%= render "notifications/bell", recipient: current_user %> %>
3
+ <%%
4
+ count = recipient&.notifications&.where(type: "Notifications::Strategies::InApp")&.unread&.count || 0
5
+ %>
6
+
7
+ <a href="<%%= notifications.notifications_path %>"
8
+ class="notifications-bell"
9
+ data-controller="notification-bell"
10
+ data-notification-bell-count-value="<%%= count %>">
11
+ <span class="notifications-bell-icon">🔔</span>
12
+ <%% if count.positive? %>
13
+ <span class="notifications-bell-count" data-notification-bell-target="count"><%%= count %></span>
14
+ <%% end %>
15
+ </a>
@@ -0,0 +1,15 @@
1
+ <h1>Notifications</h1>
2
+
3
+ <%% if @notifications.unread.exists? %>
4
+ <%%= button_to "Mark all as read", mark_all_as_read_notifications_path, method: :patch %>
5
+ <%% end %>
6
+
7
+ <ul>
8
+ <%% @notifications.each do |n| %>
9
+ <li class="<%%= 'unread' unless n.read? %>">
10
+ <strong><%%= n.template.to_s.titleize %></strong>
11
+ <p><%%= n.rendered_content %></p>
12
+ <%%= time_tag n.created_at %>
13
+ </li>
14
+ <%% end %>
15
+ </ul>
@@ -0,0 +1,4 @@
1
+ <%%# Default billing/invoice_failed HTML. Override at %>
2
+ <%%# app/views/notifications/templates/billing/invoice_failed.html.erb. %>
3
+ <p>We couldn't process your latest invoice. Please update your payment method
4
+ to avoid an interruption in service.</p>
@@ -0,0 +1,4 @@
1
+ <%%# Default billing/invoice_failed template. Override at %>
2
+ <%%# app/views/notifications/templates/billing/invoice_failed.erb. %>
3
+ We couldn't process your latest invoice. Please update your payment method
4
+ to avoid an interruption in service.
@@ -0,0 +1,3 @@
1
+ <%%# Default billing/invoice_paid HTML. Override at %>
2
+ <%%# app/views/notifications/templates/billing/invoice_paid.html.erb. %>
3
+ <p>Your invoice has been paid. Thank you.</p>
@@ -0,0 +1,3 @@
1
+ <%%# Default billing/invoice_paid template. Override at %>
2
+ <%%# app/views/notifications/templates/billing/invoice_paid.erb. %>
3
+ Your invoice has been paid. Thank you.
@@ -0,0 +1,5 @@
1
+ <%%# Default billing/lifetime_granted HTML — fires when an admin %>
2
+ <%%# privately grants a Lifetime Deal (no Stripe charge). Override at %>
3
+ <%%# app/views/notifications/templates/billing/lifetime_granted.html.erb. %>
4
+ <p>A lifetime pass has been granted to your account. You have permanent
5
+ access to this product — no further payment required.</p>
@@ -0,0 +1,5 @@
1
+ <%%# Default billing/lifetime_granted template — fires when an admin %>
2
+ <%%# privately grants a Lifetime Deal (no Stripe charge). Override at %>
3
+ <%%# app/views/notifications/templates/billing/lifetime_granted.erb. %>
4
+ A lifetime pass has been granted to your account. You have permanent
5
+ access to this product — no further payment required.
@@ -0,0 +1,5 @@
1
+ <%%# Default billing/lifetime_purchased HTML — fires when a customer %>
2
+ <%%# pays for a Lifetime Deal via Stripe Checkout. Override at %>
3
+ <%%# app/views/notifications/templates/billing/lifetime_purchased.html.erb. %>
4
+ <p>Thank you for your lifetime purchase. You have permanent access to this
5
+ product — no recurring billing, ever.</p>
@@ -0,0 +1,5 @@
1
+ <%%# Default billing/lifetime_purchased template — fires when a customer %>
2
+ <%%# pays for a Lifetime Deal via Stripe Checkout. Override at %>
3
+ <%%# app/views/notifications/templates/billing/lifetime_purchased.erb. %>
4
+ Thank you for your lifetime purchase. You have permanent access to this
5
+ product — no recurring billing, ever.
@@ -0,0 +1,4 @@
1
+ <%%# Default billing/subscription_canceled HTML. Override at %>
2
+ <%%# app/views/notifications/templates/billing/subscription_canceled.html.erb. %>
3
+ <p>Your subscription has been canceled. You'll continue to have access through
4
+ the end of your current billing period.</p>
@@ -0,0 +1,4 @@
1
+ <%%# Default billing/subscription_canceled template. Override at %>
2
+ <%%# app/views/notifications/templates/billing/subscription_canceled.erb. %>
3
+ Your subscription has been canceled. You'll continue to have access through
4
+ the end of your current billing period.