plutonium 0.50.0 → 0.51.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,655 @@
1
+ ---
2
+ name: plutonium-tenancy
3
+ description: Use BEFORE any multi-tenant work — scoping a model to a tenant, writing relation_scope, configuring portal entity strategies, setting up parent/child nested resources, or wiring user invitations. The single source for entity scoping, nested resources, and invites.
4
+ ---
5
+
6
+ # Plutonium Tenancy — Entity Scoping, Nested Resources, Invites
7
+
8
+ Three closely-coupled concerns:
9
+
10
+ 1. **Entity scoping** — every record belongs to a tenant; queries are filtered automatically.
11
+ 2. **Nested resources** — parent/child URLs; parent scoping takes precedence over entity scoping.
12
+ 3. **Invites** — onboarding users into a tenant's membership.
13
+
14
+ Cross-references back to [[plutonium-resource]] (models, definitions) and [[plutonium-behavior]] (policies, controllers).
15
+
16
+ ## 🚨 Critical (read first)
17
+
18
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!`. Always call `default_relation_scope(relation)` explicitly — not `super`.
19
+ - **Always declare an association path from model to entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, Plutonium raises. Fix the **model**, not the policy.
20
+ - **Parent scoping beats entity scoping.** When a parent is present (nested resource), `default_relation_scope` scopes via the parent, NOT via `entity_scope`. Don't double-scope.
21
+ - **One level of nesting only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
22
+ - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
23
+ - **Invite email must match the accepting user's email.** Security feature. Don't disable `enforce_email?` lightly.
24
+ - **Use generators.** `pu:saas:setup`, `pu:pkg:portal --scope=Entity`, `pu:res:scaffold`, `pu:invites:install`, `pu:invites:invitable`. Hand-wiring is how leaks happen.
25
+
26
+ ---
27
+
28
+ # Part 1 — Entity Scoping
29
+
30
+ Built on three cooperating pieces:
31
+
32
+ | Piece | Role |
33
+ |---|---|
34
+ | **Portal** | Declares the entity class and how to resolve it (`scope_to_entity Organization, strategy: :path`). |
35
+ | **Policy** | `default_relation_scope(relation)` calls `relation.associated_with(entity_scope)` on every collection query. Enforced via `verify_default_relation_scope_applied!`. |
36
+ | **Model** | `associated_with(entity)` resolves via custom scope, direct association, or `has_one :through`. |
37
+
38
+ ## `associated_with` resolution order
39
+
40
+ `Model.associated_with(entity)` tries, in order:
41
+
42
+ 1. **Custom scope** `associated_with_<entity_name>` — highest priority, full SQL control.
43
+ 2. **Direct `belongs_to` to entity class** — `WHERE <entity>_id = ?`, most efficient.
44
+ 3. **`has_one` / `has_one :through` to entity class** — JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
45
+ 4. **Reverse `has_many` from entity** — JOIN required, logs a warning (less efficient).
46
+
47
+ If none apply: `Could not resolve the association between 'Model' and 'Entity'`. Fix on the **model** — either declare an association path (`belongs_to`, `has_one :through`) OR define a custom `associated_with_<entity>` scope. Never work around this by overriding `relation_scope` in the policy.
48
+
49
+ ## Three model shapes
50
+
51
+ Pick the lightest that fits.
52
+
53
+ ### Shape 1: Direct child (`belongs_to` the entity)
54
+
55
+ ```ruby
56
+ class Organization < ResourceRecord
57
+ has_many :projects
58
+ end
59
+
60
+ class Project < ResourceRecord
61
+ belongs_to :organization
62
+ end
63
+
64
+ Project.associated_with(org)
65
+ # => Project.where(organization: org)
66
+ ```
67
+
68
+ Auto-detected. No extra work.
69
+
70
+ ### Shape 2: Join table (membership)
71
+
72
+ ```ruby
73
+ class User < ResourceRecord
74
+ has_many :memberships
75
+ has_many :organizations, through: :memberships
76
+ end
77
+
78
+ class Membership < ResourceRecord
79
+ belongs_to :user
80
+ belongs_to :organization # auto-detected
81
+ end
82
+
83
+ Membership.associated_with(org)
84
+ # => Membership.where(organization: org)
85
+ ```
86
+
87
+ If `Membership` is itself a parent and the scoped target is two hops away, add `has_one :through`:
88
+
89
+ ```ruby
90
+ class ProjectMember < ResourceRecord
91
+ belongs_to :project
92
+ belongs_to :user
93
+ has_one :organization, through: :project # enables auto-scoping
94
+ end
95
+ ```
96
+
97
+ ### Shape 3: Grandchild (multi-hop via `has_one :through`)
98
+
99
+ ```ruby
100
+ class Project < ResourceRecord
101
+ belongs_to :organization
102
+ has_many :tasks
103
+ end
104
+
105
+ class Task < ResourceRecord
106
+ belongs_to :project
107
+ has_one :organization, through: :project # critical
108
+ end
109
+
110
+ class Comment < ResourceRecord
111
+ belongs_to :task
112
+ has_one :project, through: :task
113
+ has_one :organization, through: :project # multi-hop chain
114
+ end
115
+ ```
116
+
117
+ `Task.associated_with(org)` and `Comment.associated_with(org)` both auto-resolve.
118
+
119
+ ### When to fall back to a custom scope
120
+
121
+ ```ruby
122
+ class Comment < ResourceRecord
123
+ scope :associated_with_organization, ->(org) do
124
+ joins(task: :project).where(projects: {organization_id: org.id})
125
+ end
126
+ end
127
+ ```
128
+
129
+ Use when:
130
+ - The path is polymorphic.
131
+ - Conditional logic is needed.
132
+ - You want explicit SQL for performance.
133
+
134
+ Picked up BEFORE association detection.
135
+
136
+ ## `relation_scope` — safe overrides
137
+
138
+ `default_relation_scope(relation)` does two things:
139
+
140
+ 1. If a **parent** is present (nested resource), scopes via the parent association.
141
+ 2. Otherwise, applies `relation.associated_with(entity_scope)`.
142
+
143
+ ### Correct
144
+
145
+ ```ruby
146
+ # ✅ Best: don't override — the inherited scope already does it.
147
+
148
+ # ✅ Extra filters on top
149
+ relation_scope do |relation|
150
+ default_relation_scope(relation).where(archived: false)
151
+ end
152
+
153
+ # ✅ Role-based
154
+ relation_scope do |relation|
155
+ relation = default_relation_scope(relation)
156
+ user.admin? ? relation : relation.where(author: user)
157
+ end
158
+ ```
159
+
160
+ ### Wrong
161
+
162
+ ```ruby
163
+ # ❌ Manually filtering by entity — bypasses default_relation_scope
164
+ relation_scope { |r| r.where(organization: current_scoped_entity) }
165
+
166
+ # ❌ Manual joins — same problem
167
+ relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
168
+
169
+ # ❌ Missing default_relation_scope entirely — raises at runtime
170
+ relation_scope { |r| r.where(published: true) }
171
+ ```
172
+
173
+ **Do not use `super`** from inside `relation_scope`. Call `default_relation_scope(relation)` explicitly — `super` semantics depend on how ActionPolicy's DSL registered the scope.
174
+
175
+ ### Intentionally skipping
176
+
177
+ Rare. Before reaching for this, consider a separate, unscoped portal.
178
+
179
+ ```ruby
180
+ relation_scope do |relation|
181
+ skip_default_relation_scope!
182
+ relation
183
+ end
184
+ ```
185
+
186
+ ## Portal entity strategies
187
+
188
+ ### Path strategy (most common)
189
+
190
+ ```ruby
191
+ module AdminPortal
192
+ class Engine < Rails::Engine
193
+ include Plutonium::Portal::Engine
194
+
195
+ config.after_initialize do
196
+ scope_to_entity Organization, strategy: :path
197
+ end
198
+ end
199
+ end
200
+ ```
201
+
202
+ Routes become `/organizations/:organization_id/posts`. Portal extracts `params[:organization_id]` and loads the entity automatically.
203
+
204
+ ### Custom strategy (subdomain, session, etc.)
205
+
206
+ ```ruby
207
+ scope_to_entity Organization, strategy: :current_organization
208
+
209
+ module AdminPortal::Concerns::Controller
210
+ extend ActiveSupport::Concern
211
+ include Plutonium::Portal::Controller
212
+
213
+ private
214
+
215
+ def current_organization
216
+ @current_organization ||= Organization.find_by!(subdomain: request.subdomain)
217
+ end
218
+ end
219
+ ```
220
+
221
+ The strategy symbol must match a method name on the controller.
222
+
223
+ ### Accessing the scoped entity
224
+
225
+ ```ruby
226
+ # Controller
227
+ current_scoped_entity
228
+ scoped_to_entity?
229
+
230
+ # Policy
231
+ entity_scope
232
+ ```
233
+
234
+ ## Gotchas
235
+
236
+ - **Multiple associations to the same entity class.** E.g. `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises — override `scoped_entity_association` on the controller to pick one (`def scoped_entity_association = :home_team`).
237
+ - **`param_key` differs from association name.** Fine — Plutonium matches by **class**, not param key. `scope_to_entity Competition::Team, param_key: :team` works with `belongs_to :competition_team`.
238
+ - **Forgetting compound uniqueness.** `validates :code, uniqueness: true` leaks across tenants. Use `uniqueness: {scope: :organization_id}`.
239
+ - **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly. Never leave a `where` bypass in code.
240
+
241
+ ---
242
+
243
+ # Part 2 — Nested Resources
244
+
245
+ Plutonium auto-generates nested routes from `has_many` / `has_one` associations on a registered parent. **One level only** — no grandparent → parent → child chains.
246
+
247
+ ## Setup
248
+
249
+ ```bash
250
+ rails g pu:res:scaffold Company name:string --dest=main_app
251
+ rails g pu:res:scaffold Property company:belongs_to name:string --dest=main_app
252
+ rails g pu:res:conn Company Property --dest=admin_portal
253
+ ```
254
+
255
+ Then register both in the portal routes:
256
+
257
+ ```ruby
258
+ register_resource ::Company
259
+ register_resource ::Property # has belongs_to :company
260
+ register_resource ::CompanyProfile # has_one :company_profile on Company
261
+ ```
262
+
263
+ ## Generated routes
264
+
265
+ Plutonium prefixes nested routes with `nested_` to avoid conflicts with the top-level routes:
266
+
267
+ | Route | Purpose |
268
+ |---|---|
269
+ | `/companies/:company_id/nested_properties` | has_many index |
270
+ | `/companies/:company_id/nested_properties/new` | new |
271
+ | `/companies/:company_id/nested_properties/:id` | show |
272
+ | `/companies/:company_id/nested_company_profile` | has_one show (no `:id`) |
273
+ | `/companies/:company_id/nested_company_profile/new` | has_one new |
274
+
275
+ For `has_one`: index redirects to show (or new if no record exists); only one record per parent.
276
+
277
+ ## Automatic behavior in nested routes
278
+
279
+ When the controller is hit through a nested route:
280
+
281
+ 1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
282
+ 2. **Scopes queries** via parent association (e.g. `parent.properties` for `has_many`, `where(foreign_key => parent.id)` for `has_one`).
283
+ 3. **Assigns parent** on create (injected into `resource_params`).
284
+ 4. **Hides parent field** in forms (already determined by URL).
285
+
286
+ You don't need to add hidden parent fields in forms or filter queries manually.
287
+
288
+ ## Controller methods
289
+
290
+ ```ruby
291
+ current_parent # Parent record
292
+ current_nested_association # :properties
293
+ parent_route_param # :company_id
294
+ parent_input_param # :company
295
+ ```
296
+
297
+ ## Parent vs entity scoping
298
+
299
+ When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, not `entity_scope`. The parent was already authorized and entity-scoped during its own authorization — double-scoping isn't needed.
300
+
301
+ ```ruby
302
+ # In the child policy — just call default_relation_scope, it handles both cases
303
+ relation_scope do |relation|
304
+ default_relation_scope(relation) # uses parent when present, entity_scope otherwise
305
+ end
306
+ ```
307
+
308
+ ## URL generation
309
+
310
+ ```ruby
311
+ # Collection
312
+ resource_url_for(Property, parent: company)
313
+ # => /companies/123/nested_properties
314
+
315
+ # Record
316
+ resource_url_for(property, parent: company)
317
+ # => /companies/123/nested_properties/456
318
+
319
+ # Form
320
+ resource_url_for(Property, action: :new, parent: company)
321
+ resource_url_for(property, action: :edit, parent: company)
322
+
323
+ # has_one
324
+ resource_url_for(CompanyProfile, action: :new, parent: company)
325
+ # => /companies/123/nested_company_profile/new
326
+
327
+ # Interactions
328
+ resource_url_for(property, parent: company, interaction: :archive)
329
+ resource_url_for(Property, parent: company, interaction: :import)
330
+ resource_url_for(Property, parent: company, interaction: :bulk_delete, ids: [1, 2])
331
+
332
+ # Cross-package
333
+ resource_url_for(property, parent: company, package: CustomerPortal)
334
+ ```
335
+
336
+ ## Authorization context
337
+
338
+ The child policy receives the parent:
339
+
340
+ ```ruby
341
+ class PropertyPolicy < ResourcePolicy
342
+ # parent => the Company instance
343
+ # parent_association => :properties
344
+
345
+ def create?
346
+ parent.present? && user.member_of?(parent)
347
+ end
348
+ end
349
+ ```
350
+
351
+ ## Presentation hooks
352
+
353
+ ```ruby
354
+ class PropertiesController < ResourceController
355
+ private
356
+
357
+ def present_parent? = true # show parent in displays (default: false)
358
+ def submit_parent? = false # allow changing in forms (defaults to present_parent?)
359
+ end
360
+ ```
361
+
362
+ Conditional pattern — show parent only when accessed standalone:
363
+
364
+ ```ruby
365
+ def present_parent?
366
+ current_parent.nil?
367
+ end
368
+ ```
369
+
370
+ ## Custom parent resolution
371
+
372
+ ```ruby
373
+ def current_parent
374
+ @current_parent ||= Company.friendly.find(params[:company_id])
375
+ end
376
+ ```
377
+
378
+ ## Custom nested routes
379
+
380
+ ```ruby
381
+ register_resource ::Property do
382
+ member do
383
+ get :analytics, as: :analytics # `as:` is REQUIRED for resource_url_for to work
384
+ post :archive, as: :archive
385
+ end
386
+ end
387
+ ```
388
+
389
+ Generates `/companies/:company_id/nested_properties/:id/analytics` etc.
390
+
391
+ ## Breadcrumbs
392
+
393
+ Auto-include parent: `Companies > Acme Corp > Properties > Property #123`.
394
+
395
+ ---
396
+
397
+ # Part 3 — Invites
398
+
399
+ A complete user-invitation system: token-based emails, secure acceptance, Rodauth integration, entity membership creation, and "invitable" hooks for app-specific behavior.
400
+
401
+ ## Prerequisites
402
+
403
+ User model + entity model + membership model. The fastest path:
404
+
405
+ ```bash
406
+ rails g pu:saas:setup --user Customer --entity Organization
407
+ ```
408
+
409
+ This creates all three plus the join table.
410
+
411
+ ## Install
412
+
413
+ ```bash
414
+ rails generate pu:invites:install
415
+ ```
416
+
417
+ ### Options
418
+
419
+ | Option | Default | Description |
420
+ |---|---|---|
421
+ | `--entity-model=NAME` | `Entity` | Entity model name |
422
+ | `--user-model=NAME` | `User` | User model name |
423
+ | `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name (omit for single-flow apps) |
424
+ | `--membership-model=NAME` | `EntityUser` | Membership join model |
425
+ | `--roles=ROLES` | `member,admin` | Comma-separated |
426
+ | `--rodauth=NAME` | `user` | Rodauth configuration for signup |
427
+ | `--enforce-domain` | `false` | Require invited email domain to match entity |
428
+
429
+ ### What gets created
430
+
431
+ ```
432
+ packages/invites/
433
+ ├── app/controllers/invites/
434
+ │ ├── user_invitations_controller.rb
435
+ │ └── welcome_controller.rb
436
+ ├── app/definitions/invites/user_invite_definition.rb
437
+ ├── app/interactions/invites/
438
+ │ ├── cancel_invite_interaction.rb
439
+ │ └── resend_invite_interaction.rb
440
+ ├── app/mailers/invites/user_invite_mailer.rb
441
+ ├── app/models/invites/user_invite.rb
442
+ ├── app/policies/invites/user_invite_policy.rb
443
+ └── app/views/invites/...
444
+
445
+ app/interactions/{entity,user}/invite_user_interaction.rb
446
+ db/migrate/TIMESTAMP_create_user_invites.rb
447
+ ```
448
+
449
+ Routes added:
450
+
451
+ ```ruby
452
+ get "welcome", to: "invites/welcome#index"
453
+ get "invitations/:token", to: "invites/user_invitations#show"
454
+ post "invitations/:token/accept", to: "invites/user_invitations#accept"
455
+ get "invitations/:token/signup", to: "invites/user_invitations#signup"
456
+ post "invitations/:token/signup", to: "invites/user_invitations#signup"
457
+ ```
458
+
459
+ ## Multiple invite flows in one app
460
+
461
+ Run `pu:invites:install` once per flow. Default class name derives as `<EntityModel><UserModel>Invite` — no literal `UserInvite` default. Single-flow apps don't need `--invite-model`.
462
+
463
+ ```bash
464
+ rails g pu:invites:install \
465
+ --entity-model=FunderOrganization --user-model=SpenderAccount \
466
+ --invite-model=FunderInvite
467
+
468
+ rails g pu:invites:install \
469
+ --entity-model=Project --user-model=Member \
470
+ --invite-model=ProjectInvite
471
+ ```
472
+
473
+ Each invocation creates an independent model (`Invites::FunderInvite`), controller (`Invites::FunderInvitationsController`), route (`/funder_invitations/:token`), and helper (`funder_invitation_path`). The shared `Invites::WelcomeController` accumulates each class into `invite_classes`; `pending_invite` checks all flows in priority order (first-match wins).
474
+
475
+ Model-level overrides for non-default association names:
476
+
477
+ ```ruby
478
+ def user_attribute = :spender_account # belongs_to :spender_account
479
+ def invite_entity_attribute = :funder_organization # belongs_to :funder_organization
480
+ ```
481
+
482
+ Controller-level (auto-generated, but shown for clarity):
483
+
484
+ ```ruby
485
+ # welcome_controller.rb
486
+ def invite_classes
487
+ [::Invites::FunderInvite, ::Invites::ProjectInvite]
488
+ end
489
+
490
+ # funder_invitations_controller.rb
491
+ def invitation_path_for(token)
492
+ funder_invitation_path(token: token)
493
+ end
494
+ ```
495
+
496
+ ## Invitables — app models notified on accept
497
+
498
+ An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
499
+
500
+ ```bash
501
+ rails generate pu:invites:invitable Tenant
502
+ rails generate pu:invites:invitable TeamMember --role=member
503
+ rails generate pu:invites:invitable Tenant --dest=my_package
504
+ ```
505
+
506
+ Then implement the callback:
507
+
508
+ ```ruby
509
+ class Tenant < ApplicationRecord
510
+ include Plutonium::Invites::Concerns::Invitable
511
+
512
+ belongs_to :entity
513
+ belongs_to :user, optional: true
514
+
515
+ def on_invite_accepted(user)
516
+ update!(user: user, status: :active)
517
+ end
518
+ end
519
+ ```
520
+
521
+ Without `on_invite_accepted`, the invitable never learns about the new user.
522
+
523
+ ## The flow
524
+
525
+ ### 1. Admin sends the invite
526
+
527
+ ```ruby
528
+ entity.invite_user(email: "user@example.com", role: :member)
529
+ tenant.invite_user(email: "user@example.com") # from invitable context
530
+ ```
531
+
532
+ ### 2. Email goes out
533
+
534
+ Token-based URL: `https://app.example.com/invitations/abc123...`
535
+
536
+ ### 3. User accepts
537
+
538
+ **Existing user:** clicks link → logs in (or already logged in) → email validated → membership created → invitable notified via `on_invite_accepted`.
539
+
540
+ **New user:** clicks link → "Create Account" → signs up with the invited email → membership created → invitable notified.
541
+
542
+ ### 4. Pending invite check
543
+
544
+ After login, users land on `/welcome` where pending invites are shown:
545
+
546
+ ```ruby
547
+ include Plutonium::Invites::PendingInviteCheck
548
+ ```
549
+
550
+ Rodauth wiring (required for redirect):
551
+
552
+ ```ruby
553
+ # app/rodauth/user_rodauth_plugin.rb
554
+ configure do
555
+ login_return_to_requested_location? true
556
+ login_redirect "/welcome"
557
+
558
+ after_login do
559
+ session[:after_welcome_redirect] = session.delete(:login_redirect)
560
+ end
561
+ end
562
+ ```
563
+
564
+ ## The UserInvite model
565
+
566
+ Generated as `Invites::<InviteModelName>`:
567
+
568
+ ```ruby
569
+ class Invites::UserInvite < Invites::ResourceRecord
570
+ include Plutonium::Invites::Concerns::InviteToken
571
+
572
+ belongs_to :entity
573
+ belongs_to :invited_by, polymorphic: true
574
+ belongs_to :user, optional: true
575
+ belongs_to :invitable, polymorphic: true, optional: true
576
+
577
+ enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
578
+ enum :role, member: 0, admin: 1
579
+ end
580
+ ```
581
+
582
+ Key methods:
583
+
584
+ ```ruby
585
+ invite = Invites::UserInvite.find_for_acceptance(token)
586
+ invite.accept_for_user!(current_user)
587
+ invite.resend!
588
+ invite.cancel!
589
+ ```
590
+
591
+ ## Customization
592
+
593
+ ### Custom email templates
594
+
595
+ Override views in your package:
596
+
597
+ ```erb
598
+ <%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
599
+ <h1>Welcome to <%= @invite.entity.name %>!</h1>
600
+ <p><%= @invite.invited_by.email %> has invited you.</p>
601
+ <p><%= link_to "Accept", @invitation_url %></p>
602
+ ```
603
+
604
+ ### Custom validation
605
+
606
+ Extend the model:
607
+
608
+ ```ruby
609
+ class Invites::UserInvite < Invites::ResourceRecord
610
+ validate :email_not_already_member
611
+
612
+ private
613
+
614
+ def email_not_already_member
615
+ existing = membership_model.joins(:user)
616
+ .where(entity: entity, users: {email: email}).exists?
617
+ errors.add(:email, "is already a member") if existing
618
+ end
619
+ end
620
+ ```
621
+
622
+ ### Domain enforcement / custom roles
623
+
624
+ ```bash
625
+ rails g pu:invites:install --enforce-domain
626
+ rails g pu:invites:install --roles=viewer,editor,admin,owner
627
+ ```
628
+
629
+ ## Portal connection
630
+
631
+ ```ruby
632
+ module CustomerPortal
633
+ class Engine < Rails::Engine
634
+ include Plutonium::Portal::Engine
635
+ register_package Invites::Engine
636
+ end
637
+ end
638
+ ```
639
+
640
+ Invites are entity-scoped automatically: `Invites::UserInvite belongs_to :entity` → `associated_with` resolves directly → admins see only invites for their org.
641
+
642
+ ## Common issues
643
+
644
+ - **"Invite not found"** — token expired (default 1 week), invite cancelled, or no longer `pending`.
645
+ - **Email mismatch** — `enforce_email?` is on by default. The accepting user's email must match the invited email. Override `def enforce_email? = false` only if you fully understand the security trade-off.
646
+ - **Rodauth redirect after login** — make sure `login_redirect "/welcome"` is set in the rodauth plugin.
647
+
648
+ ---
649
+
650
+ ## Related skills
651
+
652
+ - [[plutonium-resource]] — model declarations (`belongs_to`, `has_one :through`, custom scopes), `permitted_associations` for show-page tabs.
653
+ - [[plutonium-behavior]] — `relation_scope` syntax, policy authorization context, controller presentation hooks.
654
+ - [[plutonium-app]] — portal setup, `scope_to_entity`, mounting engines.
655
+ - [[plutonium-auth]] — Rodauth signup flow for invite acceptance.
@@ -260,9 +260,10 @@ Output path: `test/integration/<portal>_portal/<resource_underscored>_test.rb`.
260
260
  - **Nested resources need `parent: :foo`** in the DSL AND a real parent record from `parent_record!`. Without both, path interpolation fails.
261
261
  - **`PortalAccess` doesn't use `resource_tests_for`** — use `portal_access_for` instead. Mixing them on the same class is undefined behavior.
262
262
 
263
- ## See also
263
+ ## Related skills
264
264
 
265
- - `plutonium-policy`write the policy this concern verifies
266
- - `plutonium-definition` — definition props the smoke test introspects
267
- - `plutonium-portal`portal mounting and entity strategies that drive auth/scoping
268
- - `plutonium-auth`Rodauth setup behind the default login flow
265
+ - [[plutonium-behavior]]policies (verified by `ResourcePolicy`), interactions (asserted by `ResourceInteraction`)
266
+ - [[plutonium-resource]] — definition props the smoke test introspects (`field`, `input`, `display`, `column`, `scope`, `filter`, `sort`, `action`)
267
+ - [[plutonium-tenancy]]`relation_scope`, parent scoping, nested resources (matched by `NestedResource`)
268
+ - [[plutonium-app]]portal mounting and entity strategies that drive auth/scoping
269
+ - [[plutonium-auth]] — Rodauth setup behind the default login flow