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,393 @@
1
+ # Invites
2
+
3
+ Token-based email invitations for multi-tenant onboarding. Integrates with Rodauth signup, creates entity memberships on acceptance, and supports "invitable" hooks for app-specific behavior.
4
+
5
+ ## 🚨 Critical
6
+
7
+ - **Invite email must match the accepting user's email.** Security feature — don't disable `enforce_email?` lightly.
8
+ - **Entity scoping applies to invites** — invites are automatically filtered to the current entity (their model has `belongs_to :entity`).
9
+ - **Invitables must implement `on_invite_accepted`.** Without it, the invitable never learns about the new user.
10
+ - **A single app can have multiple invite flows** — run `pu:invites:install` once per flow with different `--entity-model` / `--user-model` / `--invite-model`.
11
+
12
+ ## Prerequisites
13
+
14
+ Before installing invites, you need:
15
+
16
+ 1. A Rodauth user model
17
+ 2. An entity model (Organization, Company, Team, …)
18
+ 3. A membership model linking users to entities
19
+
20
+ The fastest path is `pu:saas:setup` — it creates all three plus the SaaS portal, profile, welcome flow, and invites in one shot:
21
+
22
+ ```bash
23
+ rails g pu:saas:setup --user Customer --entity Organization
24
+ ```
25
+
26
+ ## Install (standalone)
27
+
28
+ ```bash
29
+ rails generate pu:invites:install
30
+ ```
31
+
32
+ ### Options
33
+
34
+ | Option | Default | Description |
35
+ |---|---|---|
36
+ | `--entity-model=NAME` | `Entity` | Entity model name |
37
+ | `--user-model=NAME` | `User` | User model name |
38
+ | `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name (omit for single-flow apps) |
39
+ | `--membership-model=NAME` | `EntityUser` | Membership join model |
40
+ | `--roles` | `member,admin` | Comma-separated roles |
41
+ | `--rodauth=NAME` | `user` | Rodauth configuration for signup |
42
+ | `--enforce-domain` | `false` | Require invited email domain to match entity domain |
43
+
44
+ Example with custom models:
45
+
46
+ ```bash
47
+ rails g pu:invites:install \
48
+ --entity-model=Organization \
49
+ --user-model=Customer \
50
+ --membership-model=OrganizationMember \
51
+ --roles=member,manager,admin
52
+ ```
53
+
54
+ After install:
55
+
56
+ ```bash
57
+ rails db:migrate
58
+ ```
59
+
60
+ ## What gets created
61
+
62
+ ```
63
+ packages/invites/
64
+ ├── app/
65
+ │ ├── controllers/invites/
66
+ │ │ ├── user_invitations_controller.rb
67
+ │ │ └── welcome_controller.rb
68
+ │ ├── definitions/invites/user_invite_definition.rb
69
+ │ ├── interactions/invites/
70
+ │ │ ├── cancel_invite_interaction.rb
71
+ │ │ └── resend_invite_interaction.rb
72
+ │ ├── mailers/invites/user_invite_mailer.rb
73
+ │ ├── models/invites/user_invite.rb
74
+ │ ├── policies/invites/user_invite_policy.rb
75
+ │ └── views/invites/...
76
+
77
+ app/interactions/{entity,user}/invite_user_interaction.rb
78
+ db/migrate/TIMESTAMP_create_user_invites.rb
79
+ ```
80
+
81
+ Routes added:
82
+
83
+ ```ruby
84
+ get "welcome", to: "invites/welcome#index"
85
+ get "invitations/:token", to: "invites/user_invitations#show"
86
+ post "invitations/:token/accept", to: "invites/user_invitations#accept"
87
+ get "invitations/:token/signup", to: "invites/user_invitations#signup"
88
+ post "invitations/:token/signup", to: "invites/user_invitations#signup"
89
+ ```
90
+
91
+ ## Connect to a portal
92
+
93
+ ```ruby
94
+ # packages/customer_portal/lib/engine.rb
95
+ module CustomerPortal
96
+ class Engine < Rails::Engine
97
+ include Plutonium::Portal::Engine
98
+
99
+ register_package Invites::Engine
100
+ end
101
+ end
102
+ ```
103
+
104
+ Invites are entity-scoped automatically: `Invites::UserInvite belongs_to :entity` → `associated_with` resolves directly → admins only see invites for their org.
105
+
106
+ ## The flow
107
+
108
+ ### 1. Admin sends the invite
109
+
110
+ ```ruby
111
+ # From entity context
112
+ entity.invite_user(email: "user@example.com", role: :member)
113
+
114
+ # From invitable context
115
+ tenant.invite_user(email: "user@example.com")
116
+ ```
117
+
118
+ ### 2. Email goes out
119
+
120
+ Token-based URL:
121
+
122
+ ```
123
+ Subject: You've been invited to join Acme Corp
124
+
125
+ Click here: https://app.example.com/invitations/abc123...
126
+ ```
127
+
128
+ ### 3. User accepts
129
+
130
+ **Existing user:**
131
+
132
+ 1. Clicks the invite link.
133
+ 2. Logs in (or is already logged in).
134
+ 3. System validates email matches.
135
+ 4. Membership created; invitable notified via `on_invite_accepted`.
136
+
137
+ **New user:**
138
+
139
+ 1. Clicks the invite link.
140
+ 2. Clicks "Create Account".
141
+ 3. Signs up with the invited email.
142
+ 4. System validates email matches.
143
+ 5. Membership created; invitable notified.
144
+
145
+ ### 4. Pending invite check
146
+
147
+ After login, users land on `/welcome` where pending invites are shown:
148
+
149
+ ```ruby
150
+ include Plutonium::Invites::PendingInviteCheck
151
+ ```
152
+
153
+ Rodauth wiring (required for the redirect):
154
+
155
+ ```ruby
156
+ # app/rodauth/user_rodauth_plugin.rb
157
+ configure do
158
+ login_return_to_requested_location? true
159
+ login_redirect "/welcome"
160
+
161
+ after_login do
162
+ session[:after_welcome_redirect] = session.delete(:login_redirect)
163
+ end
164
+ end
165
+ ```
166
+
167
+ ## Invitables — app models notified on accept
168
+
169
+ An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
170
+
171
+ ```bash
172
+ rails g pu:invites:invitable Tenant
173
+ rails g pu:invites:invitable TeamMember --role=member
174
+ rails g pu:invites:invitable Tenant --dest=my_package
175
+ ```
176
+
177
+ | Option | Default | Description |
178
+ |---|---|---|
179
+ | `--role=ROLE` | `member` | Role to assign on acceptance |
180
+ | `--user-model=NAME` | `User` | User model |
181
+ | `--membership-model=NAME` | `EntityUser` | Membership join model |
182
+ | `--dest=PACKAGE` | `main_app` | Destination package |
183
+ | `--[no-]email-templates` | `true` | Generate custom email templates |
184
+
185
+ Implement the callback on the invitable:
186
+
187
+ ```ruby
188
+ class Tenant < ApplicationRecord
189
+ include Plutonium::Invites::Concerns::Invitable
190
+
191
+ belongs_to :entity
192
+ belongs_to :user, optional: true
193
+
194
+ def on_invite_accepted(user)
195
+ update!(user: user, status: :active)
196
+ end
197
+ end
198
+ ```
199
+
200
+ ::: warning Without `on_invite_accepted`
201
+ The invitable never learns about the new user — the invite is consumed but your app doesn't update its state.
202
+ :::
203
+
204
+ ## Multiple invite flows
205
+
206
+ A single app can have several independent invite flows side-by-side (e.g. one for inviting customers to organizations, another for inviting funders to projects). Run `pu:invites:install` once per flow.
207
+
208
+ **Default name derivation:** when `--invite-model` is omitted, the class is `<EntityModel><UserModel>Invite`. So with the defaults (`--entity-model=Organization --user-model=User`) the generated class is `Invites::OrganizationUserInvite` — there is no literal `UserInvite` default. Single-flow apps don't need `--invite-model`.
209
+
210
+ ```bash
211
+ rails g pu:invites:install \
212
+ --entity-model=FunderOrganization \
213
+ --user-model=SpenderAccount \
214
+ --invite-model=FunderInvite
215
+
216
+ rails g pu:invites:install \
217
+ --entity-model=Project \
218
+ --user-model=Member \
219
+ --invite-model=ProjectInvite
220
+ ```
221
+
222
+ Each invocation creates an independent flow: model `Invites::FunderInvite` on `funder_invites`, controller `Invites::FunderInvitationsController` on `/funder_invitations/:token`, helper `funder_invitation_path`, etc.
223
+
224
+ The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array, so `pending_invite` checks all flows in priority order (first-match wins).
225
+
226
+ ### Model-level overrides for non-default associations
227
+
228
+ ```ruby
229
+ def user_attribute = :spender_account # belongs_to :spender_account instead of :user
230
+ def invite_entity_attribute = :funder_organization # belongs_to :funder_organization instead of :entity
231
+ ```
232
+
233
+ ### Controller-level overrides (auto-generated)
234
+
235
+ ```ruby
236
+ # packages/invites/app/controllers/invites/welcome_controller.rb
237
+ def invite_classes
238
+ [::Invites::FunderInvite, ::Invites::ProjectInvite]
239
+ end
240
+
241
+ # packages/invites/app/controllers/invites/funder_invitations_controller.rb
242
+ def invitation_path_for(token)
243
+ funder_invitation_path(token: token)
244
+ end
245
+ ```
246
+
247
+ ## The UserInvite model
248
+
249
+ Generated as `Invites::<InviteModelName>`:
250
+
251
+ ```ruby
252
+ class Invites::UserInvite < Invites::ResourceRecord
253
+ include Plutonium::Invites::Concerns::InviteToken
254
+
255
+ belongs_to :entity
256
+ belongs_to :invited_by, polymorphic: true
257
+ belongs_to :user, optional: true
258
+ belongs_to :invitable, polymorphic: true, optional: true
259
+
260
+ enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
261
+ enum :role, member: 0, admin: 1
262
+ end
263
+ ```
264
+
265
+ Key methods:
266
+
267
+ ```ruby
268
+ invite = Invites::UserInvite.find_for_acceptance(token)
269
+ invite.accept_for_user!(current_user)
270
+ invite.resend!
271
+ invite.cancel!
272
+ ```
273
+
274
+ ## Customization
275
+
276
+ ### Custom email templates
277
+
278
+ Override views in your package:
279
+
280
+ ```erb
281
+ <%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
282
+ <h1>Welcome to <%= @invite.entity.name %>!</h1>
283
+ <p><%= @invite.invited_by.email %> has invited you.</p>
284
+ <p><%= link_to "Accept", @invitation_url %></p>
285
+ ```
286
+
287
+ ### Per-invitable templates
288
+
289
+ When you generate an invitable with `--email-templates`, you get per-invitable mailer views — useful for differentiating "Join as a team member" from "Join as a project collaborator".
290
+
291
+ ### Custom validation
292
+
293
+ Extend the invite model:
294
+
295
+ ```ruby
296
+ class Invites::UserInvite < Invites::ResourceRecord
297
+ validate :email_not_already_member
298
+
299
+ private
300
+
301
+ def email_not_already_member
302
+ existing = membership_model.joins(:user)
303
+ .where(entity: entity, users: {email: email})
304
+ .exists?
305
+ errors.add(:email, "is already a member") if existing
306
+ end
307
+ end
308
+ ```
309
+
310
+ ### Domain enforcement
311
+
312
+ ```bash
313
+ rails g pu:invites:install --enforce-domain
314
+ ```
315
+
316
+ Requires the invited email's domain to match the entity's domain.
317
+
318
+ ### Custom roles
319
+
320
+ ```bash
321
+ rails g pu:invites:install --roles=viewer,editor,admin,owner
322
+ ```
323
+
324
+ ### Custom expiration
325
+
326
+ Override on the model:
327
+
328
+ ```ruby
329
+ class Invites::UserInvite < Invites::ResourceRecord
330
+ TOKEN_EXPIRATION = 30.days # default is 1 week
331
+
332
+ def expired?
333
+ created_at < TOKEN_EXPIRATION.ago
334
+ end
335
+ end
336
+ ```
337
+
338
+ ## Managing invitations
339
+
340
+ ### Resend
341
+
342
+ ```ruby
343
+ invite.resend! # generates new token + sends email
344
+ ```
345
+
346
+ ### Cancel
347
+
348
+ ```ruby
349
+ invite.cancel! # transitions to :cancelled state
350
+ ```
351
+
352
+ ### View pending
353
+
354
+ ```ruby
355
+ entity.user_invites.pending
356
+ ```
357
+
358
+ ## Security
359
+
360
+ ### Token security
361
+
362
+ Tokens use `SecureRandom.urlsafe_base64(32)` — 256 bits, URL-safe. Stored hashed in the DB; raw token shown only at creation (in the email).
363
+
364
+ ### Email validation
365
+
366
+ `enforce_email?` is `true` by default. The accepting user's email must match the invited email — prevents account hijacking via invite forwarding.
367
+
368
+ To allow any email (NOT recommended):
369
+
370
+ ```ruby
371
+ def enforce_email? = false
372
+ ```
373
+
374
+ ### Rate limiting
375
+
376
+ Use Rack::Attack or similar to throttle:
377
+
378
+ - Invite creation per admin
379
+ - Invitation acceptance attempts per IP
380
+
381
+ ## Common issues
382
+
383
+ - **"Invitation not found or expired"** — token expired (default 1 week), invite cancelled, or no longer in `pending` state.
384
+ - **Email mismatch error** — the accepting user's email doesn't match the invited email. `enforce_email?` is enforcing the match (this is intentional security).
385
+ - **Rodauth redirect after login doesn't go to `/welcome`** — check the `login_redirect "/welcome"` line in the rodauth plugin's `configure` block.
386
+ - **`on_invite_accepted` not called** — ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
387
+
388
+ ## Related
389
+
390
+ - [Entity scoping](./entity-scoping) — how invites are filtered to the current entity
391
+ - [Auth](/reference/auth/) — Rodauth account configuration
392
+ - [Behavior › Interactions](/reference/behavior/interactions) — `cancel_invite_interaction`, `resend_invite_interaction`
393
+ - [Guides › User invites](/guides/user-invites) — task-oriented walkthrough
@@ -0,0 +1,267 @@
1
+ # Nested Resources
2
+
3
+ Plutonium auto-generates nested routes from `has_many` and `has_one` associations on a registered parent. No manual route wiring — `belongs_to` on the child plus `register_resource` for both is enough.
4
+
5
+ ## 🚨 Critical
6
+
7
+ - **One level only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
8
+ - **Parent scoping beats entity scoping.** When a parent is present, `default_relation_scope` scopes via the parent, NOT via `entity_scope`. Don't double-scope.
9
+ - **Named custom routes.** When adding member/collection routes on a nested resource, always pass `as:` — otherwise `resource_url_for` will fail.
10
+ - **The parent is authorized for `:read?`** before `current_parent` returns. The child policy receives the parent in its context.
11
+
12
+ ## Setup
13
+
14
+ ```bash
15
+ rails g pu:res:scaffold Company name:string --dest=main_app
16
+ rails g pu:res:scaffold Property company:belongs_to name:string --dest=main_app
17
+ rails g pu:res:conn Company Property --dest=admin_portal
18
+ ```
19
+
20
+ Then register both in the portal:
21
+
22
+ ```ruby
23
+ # packages/admin_portal/config/routes.rb
24
+ register_resource ::Company
25
+ register_resource ::Property # has belongs_to :company
26
+ register_resource ::CompanyProfile # has_one :company_profile on Company
27
+ ```
28
+
29
+ ## Generated routes
30
+
31
+ Plutonium prefixes nested routes with `nested_` so they don't conflict with the top-level routes:
32
+
33
+ | Route | Purpose |
34
+ |---|---|
35
+ | `/companies/:company_id/nested_properties` | `has_many` index |
36
+ | `/companies/:company_id/nested_properties/new` | new |
37
+ | `/companies/:company_id/nested_properties/:id` | show |
38
+ | `/companies/:company_id/nested_company_profile` | `has_one` show (no `:id`) |
39
+ | `/companies/:company_id/nested_company_profile/new` | `has_one` new |
40
+
41
+ For `has_one`:
42
+
43
+ - Routes are singular (no `:id` param).
44
+ - Index redirects to show (or new if no record exists).
45
+ - Only one record can exist per parent.
46
+ - Forms don't show the parent field (determined by URL).
47
+
48
+ ## Automatic behavior on nested routes
49
+
50
+ When the controller is hit via a nested route, Plutonium automatically:
51
+
52
+ 1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
53
+ 2. **Scopes queries** via the parent association:
54
+ - `has_many` → `parent.send(parent_association)` (e.g. `company.properties`)
55
+ - `has_one` → `relation.where(foreign_key => parent.id)` with limit
56
+ 3. **Assigns the parent** on create (injected into `resource_params`).
57
+ 4. **Hides the parent field** in forms and displays (already determined by URL).
58
+
59
+ You don't add hidden parent fields or filter queries manually.
60
+
61
+ ## Controller methods
62
+
63
+ ```ruby
64
+ current_parent # parent record (e.g. Company instance)
65
+ current_nested_association # association name (e.g. :properties)
66
+ parent_route_param # URL param (e.g. :company_id)
67
+ parent_input_param # form param / association name (e.g. :company)
68
+ ```
69
+
70
+ ## Parent vs entity scoping
71
+
72
+ 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 is redundant.
73
+
74
+ In the child's policy, just call `default_relation_scope` — it handles both cases:
75
+
76
+ ```ruby
77
+ class PropertyPolicy < ResourcePolicy
78
+ relation_scope do |relation|
79
+ default_relation_scope(relation) # parent when present, entity_scope otherwise
80
+ end
81
+ end
82
+ ```
83
+
84
+ For composite filtering on top of the default:
85
+
86
+ ```ruby
87
+ relation_scope do |relation|
88
+ default_relation_scope(relation).where(archived: false)
89
+ end
90
+ ```
91
+
92
+ ## URL generation
93
+
94
+ `resource_url_for(...)` with the `parent:` option:
95
+
96
+ ```ruby
97
+ # Child collection (has_many)
98
+ resource_url_for(Property, parent: company)
99
+ # => /companies/123/nested_properties
100
+
101
+ # Child record
102
+ resource_url_for(property, parent: company)
103
+ # => /companies/123/nested_properties/456
104
+
105
+ # New child
106
+ resource_url_for(Property, action: :new, parent: company)
107
+ # => /companies/123/nested_properties/new
108
+
109
+ # Edit child
110
+ resource_url_for(property, action: :edit, parent: company)
111
+ # => /companies/123/nested_properties/456/edit
112
+
113
+ # Singular (has_one)
114
+ resource_url_for(company_profile, parent: company)
115
+ # => /companies/123/nested_company_profile
116
+
117
+ resource_url_for(CompanyProfile, action: :new, parent: company)
118
+ # => /companies/123/nested_company_profile/new
119
+
120
+ # Interactions compose with parent
121
+ resource_url_for(property, parent: company, interaction: :archive)
122
+ resource_url_for(Property, parent: company, interaction: :import)
123
+ resource_url_for(Property, parent: company, interaction: :bulk_delete, ids: [1, 2])
124
+ ```
125
+
126
+ ### Cross-package URLs
127
+
128
+ ```ruby
129
+ # From AdminPortal, generate URL to a CustomerPortal resource
130
+ resource_url_for(property, parent: company, package: CustomerPortal)
131
+ ```
132
+
133
+ ## Authorization context
134
+
135
+ The child policy receives the parent automatically:
136
+
137
+ ```ruby
138
+ class PropertyPolicy < ResourcePolicy
139
+ # parent => the Company instance
140
+ # parent_association => :properties
141
+
142
+ def create?
143
+ parent.present? && user.member_of?(parent)
144
+ end
145
+
146
+ def read?
147
+ parent.present? && record.company == parent
148
+ end
149
+ end
150
+ ```
151
+
152
+ The parent is authorized for `:read?` before `current_parent` returns — children inherit the parent's access requirements.
153
+
154
+ ## Parameter handling
155
+
156
+ The parent is injected into `resource_params` automatically:
157
+
158
+ ```ruby
159
+ # When creating a property under /companies/123/nested_properties
160
+ resource_params
161
+ # => { name: "...", company: <Company:123>, company_id: 123 }
162
+ ```
163
+
164
+ No hidden parent fields needed in forms.
165
+
166
+ ## Presentation hooks
167
+
168
+ Control whether the parent field appears in views/forms:
169
+
170
+ ```ruby
171
+ class PropertiesController < ::ResourceController
172
+ private
173
+
174
+ def present_parent? = true # show on displays (default: false)
175
+ def submit_parent? = false # include in forms (defaults to present_parent?)
176
+ end
177
+ ```
178
+
179
+ Conditional — show parent only when accessed standalone:
180
+
181
+ ```ruby
182
+ def present_parent?
183
+ current_parent.nil?
184
+ end
185
+ ```
186
+
187
+ ## Custom parent resolution
188
+
189
+ Override `current_parent` for non-default lookup:
190
+
191
+ ```ruby
192
+ class PropertiesController < ::ResourceController
193
+ private
194
+
195
+ def current_parent
196
+ @current_parent ||= Company.friendly.find(params[:company_id])
197
+ end
198
+ end
199
+ ```
200
+
201
+ ## Custom routes on nested resources
202
+
203
+ ```ruby
204
+ register_resource ::Property do
205
+ member do
206
+ get :analytics, as: :analytics
207
+ post :archive, as: :archive
208
+ end
209
+ collection do
210
+ get :report, as: :report
211
+ end
212
+ end
213
+ ```
214
+
215
+ Generates `/companies/:company_id/nested_properties/:id/analytics`, etc.
216
+
217
+ ::: warning Always pass `as:`
218
+ Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails — there's no named route to look up.
219
+ :::
220
+
221
+ ## Compound uniqueness
222
+
223
+ Scope uniqueness to the parent FK:
224
+
225
+ ```ruby
226
+ class Property < ResourceRecord
227
+ belongs_to :company
228
+ validates :code, uniqueness: {scope: :company_id}
229
+ end
230
+ ```
231
+
232
+ Without the scope, the same code in different companies would collide.
233
+
234
+ ## Custom association scope (for complex relationships)
235
+
236
+ When the parent path isn't a direct `belongs_to`, define a custom scope on the child:
237
+
238
+ ```ruby
239
+ class Property < ResourceRecord
240
+ scope :associated_with_organization, ->(org) {
241
+ joins(:company).where(companies: {organization_id: org.id})
242
+ }
243
+ end
244
+ ```
245
+
246
+ Useful when the child is nested under a grandparent-style entity. See [Entity scoping › Three model shapes](./entity-scoping#three-model-shapes).
247
+
248
+ ## Breadcrumbs
249
+
250
+ Auto-include the parent: `Companies > Acme Corp > Properties > Property #123`.
251
+
252
+ ## Nesting limitations
253
+
254
+ Plutonium supports **one level of nesting**:
255
+
256
+ - ✅ `/companies/:company_id/nested_properties` (parent → child)
257
+ - ❌ `/companies/:company_id/nested_properties/:property_id/nested_units` (grandparent → parent → child)
258
+
259
+ For deeper hierarchies, use top-level routes plus association tabs on the show page (see [Behavior › Policy › Association permissions](/reference/behavior/policies#association-permissions) and [Resource › Definition › Custom page classes](/reference/resource/definition#custom-page-classes)).
260
+
261
+ ## Related
262
+
263
+ - [Entity scoping](./entity-scoping) — what happens when no parent is present
264
+ - [Invites](./invites) — membership-based onboarding
265
+ - [Behavior › Policy](/reference/behavior/policies) — `relation_scope`, parent context
266
+ - [Behavior › Controllers](/reference/behavior/controllers) — `current_parent`, presentation hooks
267
+ - [App › Portals](/reference/app/portals) — `register_resource` and custom member/collection routes