plutonium 0.48.0 → 0.49.1

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-invites/SKILL.md +41 -0
  3. data/CHANGELOG.md +38 -0
  4. data/app/assets/plutonium.js +73 -25
  5. data/app/assets/plutonium.js.map +3 -3
  6. data/app/assets/plutonium.min.js +29 -29
  7. data/app/assets/plutonium.min.js.map +3 -3
  8. data/app/views/plutonium/_flash.html.erb +1 -1
  9. data/config/initializers/pagy.rb +1 -1
  10. data/docs/guides/user-invites.md +64 -0
  11. data/docs/public/templates/plutonium.rb +3 -0
  12. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md +1487 -0
  13. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -0
  14. data/gemfiles/rails_7.gemfile.lock +27 -1
  15. data/gemfiles/rails_8.0.gemfile.lock +27 -1
  16. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  17. data/lib/generators/pu/gem/actual_db_schema/actual_db_schema_generator.rb +24 -0
  18. data/lib/generators/pu/invites/install_generator.rb +136 -35
  19. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
  20. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
  21. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
  22. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
  23. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
  24. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
  25. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
  26. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
  27. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
  28. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
  29. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
  30. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
  31. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
  32. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
  33. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
  34. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +9 -3
  35. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +6 -3
  36. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +18 -0
  37. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
  38. data/lib/plutonium/core/controller.rb +10 -3
  39. data/lib/plutonium/engine.rb +1 -1
  40. data/lib/plutonium/invites/concerns/invite_token.rb +11 -3
  41. data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
  42. data/lib/plutonium/invites/controller.rb +14 -1
  43. data/lib/plutonium/invites/pending_invite_check.rb +37 -28
  44. data/lib/plutonium/resource/controllers/interactive_actions.rb +13 -9
  45. data/lib/plutonium/resource/policy.rb +23 -8
  46. data/lib/plutonium/rodauth/controller_methods.rb +5 -1
  47. data/lib/plutonium/ui/color_mode_selector.rb +7 -18
  48. data/lib/plutonium/ui/layout/rodauth_layout.rb +6 -0
  49. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  50. data/lib/plutonium/ui/table/components/pagy_info.rb +1 -1
  51. data/lib/plutonium/version.rb +1 -1
  52. data/package.json +1 -1
  53. data/plutonium.gemspec +16 -0
  54. data/src/js/controllers/color_mode_controller.js +41 -34
  55. data/src/js/controllers/flatpickr_controller.js +23 -0
  56. data/src/js/controllers/sidebar_controller.js +28 -1
  57. metadata +19 -2
@@ -0,0 +1,1487 @@
1
+ # Multi-Invite-Model Support Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make `pu:invites:install` parameterizable on `--invite_model` so a host app can run it multiple times for different entity types and get fully independent invite flows side-by-side, and extend `PendingInviteCheck` to look across all invite classes.
6
+
7
+ **Architecture:** Add a single `--invite_model=NAME` generator option (default `UserInvite`). Derive every per-flow name (model, table, controller, mailer, policy, definition, route helpers, file paths) from that name. Templates take ERB-resolved class/path names; generator renames destination paths via interpolation. Concerns gain hooks: `invite_classes` (Array) on `PendingInviteCheck`, overridable `invitation_path_for(token)` on `Controller`, and auto `append_view_path` so generated controllers don't need manual wiring. Welcome controller is generated once on first invocation; subsequent invocations inject the new class into its `invite_classes` array.
8
+
9
+ **Tech Stack:** Ruby on Rails 8.x generators (Thor), ActiveSupport::Concern, Plutonium gem, Phlex, ActionPolicy, Rodauth (optional), Standard Ruby (linter), Minitest.
10
+
11
+ **User Verification:** NO — no user verification required.
12
+
13
+ **Non-goals (called out, not implemented):**
14
+ - Migrating prior installs to renamed schemas (host app upgrades manually).
15
+ - Cookie-key namespacing — pending-invite cookie remains `:pending_invitation`; first matching class wins on lookup.
16
+ - A shared `/invitations/:token` route across flows; each flow owns `/<invitations_path>/:token`.
17
+
18
+ ---
19
+
20
+ ## Existing Groundwork (already shipped in commit `3279be0`)
21
+
22
+ These are **not** tasks here — they're the baseline this plan builds on:
23
+ - `Plutonium::Invites::Concerns::InviteUser#invite_entity_attribute` hook (default `:entity`).
24
+ - `Plutonium::Invites::Concerns::InviteToken#user_attribute` hook (default `:user`).
25
+ - Policy template uses `entity_association_name` instead of `:entity` in `permitted_attributes_for_*`.
26
+ - Model template `create_membership_for` uses `user_association_name`.
27
+ - Model template overrides `user_attribute` (when `user_table != "user"`) and `invite_entity_attribute` (when `entity_association_name != "entity"`).
28
+ - Layout templates fall back to `javascript_include_tag` when `Importmap` is undefined.
29
+
30
+ ---
31
+
32
+ ## File Structure
33
+
34
+ ### Concerns (`lib/plutonium/invites/`)
35
+ - `controller.rb` — auto `append_view_path` on include; replace hardcoded `invitation_path(token: ...)` with overridable `invitation_path_for(token)`; default reads route helper from `invitation_path_helper` (default `:invitation`).
36
+ - `pending_invite_check.rb` — `invite_classes` returning `Array<Class>`; `invite_class` kept as a backward-compat shim that returns `[invite_class]`; lookup iterates classes and returns first valid pending invite; auto `append_view_path` on include.
37
+
38
+ ### Generator (`lib/generators/pu/invites/`)
39
+ - `install_generator.rb` — add `--invite_model` option (default `UserInvite`); add naming helpers (`invite_model`, `invite_underscore`, `invite_table`, `invitations_controller_class`, `invitations_path`, `invite_route_prefix`); make file-creation steps use derived destination paths; make welcome controller generation one-shot; make route addition scoped + idempotent; on second invocation, inject new invite class into existing welcome controller's `invite_classes`.
40
+
41
+ ### Templates (`lib/generators/pu/invites/templates/`)
42
+ All renamed via interpolation in `template "..."` calls so each invocation writes to its own paths:
43
+ - `packages/invites/app/models/invites/<%= invite_underscore %>.rb`
44
+ - `packages/invites/app/policies/invites/<%= invite_underscore %>_policy.rb`
45
+ - `packages/invites/app/definitions/invites/<%= invite_underscore %>_definition.rb`
46
+ - `packages/invites/app/mailers/invites/<%= invite_underscore %>_mailer.rb`
47
+ - `packages/invites/app/controllers/invites/<%= invitations_path %>_controller.rb`
48
+ - `packages/invites/app/views/invites/<%= invite_underscore %>_mailer/invitation.{html,text}.erb`
49
+ - `packages/invites/app/views/invites/<%= invitations_path %>/{landing,show,signup,error}.html.erb`
50
+ - `db/migrate/create_<%= invite_table %>.rb`
51
+
52
+ Class names inside templates: `Invites::<InviteModel>`, `Invites::<InviteModel>Mailer`, `Invites::<InviteModel>Policy`, `Invites::<InviteModel>Definition`, `Invites::<InvitationsControllerClass>`. URL helpers: `<%= invite_route_prefix %>_invitation_path`, etc.
53
+
54
+ Welcome layout (`packages/invites/app/views/layouts/invites/invitation.html.erb`) and welcome controller (`packages/invites/app/controllers/invites/welcome_controller.rb`) are **generated once** (on first invocation only) and remain unparameterized.
55
+
56
+ ### Tests
57
+ - `test/generators/invites_install_generator_test.rb` — extend with cases for custom `--invite_model` and a dual-invocation scenario.
58
+ - `test/plutonium/invites/pending_invite_check_test.rb` — **new** unit test for multi-class support.
59
+ - `test/plutonium/invites/controller_test.rb` — **new** unit test for `invitation_path_for` override and view-path inclusion.
60
+
61
+ ### Docs
62
+ - `.claude/skills/plutonium-invites/SKILL.md` — add "Multiple invite flows in one app" section.
63
+ - `docs/guides/user-invites.md` — add section.
64
+
65
+ ---
66
+
67
+ ## Task 1: PendingInviteCheck — multi-class support
68
+
69
+ **Goal:** Make `PendingInviteCheck` look across an Array of invite classes for the first valid pending invite, while preserving backward compatibility with the existing single-class `invite_class` override.
70
+
71
+ **Files:**
72
+ - Modify: `lib/plutonium/invites/pending_invite_check.rb`
73
+ - Create: `test/plutonium/invites/pending_invite_check_test.rb`
74
+
75
+ **Acceptance Criteria:**
76
+ - [ ] Public method `pending_invite` returns the first invite found across all classes returned by `invite_classes`, or `nil`.
77
+ - [ ] Public method `redirect_to_pending_invite!` redirects to `invitation_path(token: token)` if a valid invite is found in any class.
78
+ - [ ] Hosts can override either `invite_class` (single, returning a Class) — backward-compat — or `invite_classes` (returning `Array<Class>`).
79
+ - [ ] Default `invite_classes` wraps `invite_class` in an Array.
80
+ - [ ] If neither is overridden, calling `pending_invite` raises `NotImplementedError` with a helpful message.
81
+
82
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/pending_invite_check_test.rb` → 4 tests pass.
83
+
84
+ **Steps:**
85
+
86
+ - [ ] **Step 1: Write the failing test**
87
+
88
+ Create `test/plutonium/invites/pending_invite_check_test.rb`:
89
+
90
+ ```ruby
91
+ # frozen_string_literal: true
92
+
93
+ require "test_helper"
94
+
95
+ class Plutonium::Invites::PendingInviteCheckTest < Minitest::Test
96
+ # Stub invite class that emulates `find_for_acceptance`.
97
+ class StubInvite
98
+ @valid_tokens = {}
99
+
100
+ class << self
101
+ attr_accessor :valid_tokens
102
+
103
+ def find_for_acceptance(token)
104
+ valid_tokens[token]
105
+ end
106
+ end
107
+ end
108
+
109
+ class OtherInvite < StubInvite
110
+ @valid_tokens = {}
111
+ end
112
+
113
+ # Bare host that includes the concern.
114
+ class Host
115
+ include Plutonium::Invites::PendingInviteCheck
116
+
117
+ attr_accessor :_invite_classes
118
+
119
+ def cookies = @cookies ||= {encrypted: {}}
120
+ # The concern reads cookies.encrypted[:pending_invitation]; minimal stub:
121
+ def cookies = @cookies ||= Class.new {
122
+ def encrypted = @encrypted ||= {}
123
+ def delete(_key) end
124
+ }.new
125
+
126
+ def invite_classes
127
+ _invite_classes
128
+ end
129
+ end
130
+
131
+ def setup
132
+ StubInvite.valid_tokens = {}
133
+ OtherInvite.valid_tokens = {}
134
+ end
135
+
136
+ def test_finds_invite_in_first_class
137
+ invite = Object.new
138
+ StubInvite.valid_tokens["t1"] = invite
139
+
140
+ host = Host.new
141
+ host._invite_classes = [StubInvite, OtherInvite]
142
+ host.cookies.encrypted[:pending_invitation] = "t1"
143
+
144
+ assert_equal invite, host.send(:pending_invite)
145
+ end
146
+
147
+ def test_finds_invite_in_second_class_when_first_misses
148
+ invite = Object.new
149
+ OtherInvite.valid_tokens["t2"] = invite
150
+
151
+ host = Host.new
152
+ host._invite_classes = [StubInvite, OtherInvite]
153
+ host.cookies.encrypted[:pending_invitation] = "t2"
154
+
155
+ assert_equal invite, host.send(:pending_invite)
156
+ end
157
+
158
+ def test_returns_nil_when_no_class_finds
159
+ host = Host.new
160
+ host._invite_classes = [StubInvite, OtherInvite]
161
+ host.cookies.encrypted[:pending_invitation] = "missing"
162
+
163
+ assert_nil host.send(:pending_invite)
164
+ end
165
+
166
+ def test_invite_class_singular_override_still_works
167
+ invite = Object.new
168
+ StubInvite.valid_tokens["t3"] = invite
169
+
170
+ legacy_host = Class.new {
171
+ include Plutonium::Invites::PendingInviteCheck
172
+
173
+ def cookies = @cookies ||= Class.new {
174
+ def encrypted = @encrypted ||= {pending_invitation: "t3"}
175
+ def delete(_key) end
176
+ }.new
177
+
178
+ def invite_class
179
+ StubInvite
180
+ end
181
+ }.new
182
+
183
+ assert_equal invite, legacy_host.send(:pending_invite)
184
+ end
185
+ end
186
+ ```
187
+
188
+ - [ ] **Step 2: Run test to verify it fails**
189
+
190
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/pending_invite_check_test.rb`
191
+ Expected: tests fail because the concern still raises `NotImplementedError` from `invite_class` and has no `invite_classes` method.
192
+
193
+ - [ ] **Step 3: Implement the multi-class support**
194
+
195
+ Modify `lib/plutonium/invites/pending_invite_check.rb`:
196
+
197
+ ```ruby
198
+ # frozen_string_literal: true
199
+
200
+ module Plutonium
201
+ module Invites
202
+ # PendingInviteCheck provides post-login invitation handling.
203
+ #
204
+ # Include this in a controller that users land on after login
205
+ # (e.g., WelcomeController, DashboardController) to check for
206
+ # pending invitations stored in cookies.
207
+ #
208
+ # Hosts may override either `invite_classes` (preferred — returns
209
+ # an Array of invite classes to check, in priority order) or
210
+ # `invite_class` (single class, kept for backward compatibility).
211
+ #
212
+ # @example Single invite class
213
+ # def invite_class
214
+ # ::Invites::UserInvite
215
+ # end
216
+ #
217
+ # @example Multiple invite classes
218
+ # def invite_classes
219
+ # [::Invites::FunderInvite, ::Invites::SpenderInvite]
220
+ # end
221
+ module PendingInviteCheck
222
+ extend ActiveSupport::Concern
223
+
224
+ private
225
+
226
+ # Check for a pending invitation and redirect if found.
227
+ def redirect_to_pending_invite!
228
+ token = cookies.encrypted[:pending_invitation]
229
+ return false unless token
230
+
231
+ if find_pending_invite(token)
232
+ redirect_to invitation_path(token: token)
233
+ true
234
+ else
235
+ cookies.delete(:pending_invitation)
236
+ false
237
+ end
238
+ end
239
+
240
+ # Returns the pending invite if one exists across any invite_classes.
241
+ def pending_invite
242
+ token = cookies.encrypted[:pending_invitation]
243
+ return nil unless token
244
+
245
+ invite = find_pending_invite(token)
246
+ unless invite
247
+ cookies.delete(:pending_invitation)
248
+ return nil
249
+ end
250
+
251
+ invite
252
+ end
253
+
254
+ # Override to specify multiple invite model classes (preferred).
255
+ # Defaults to `[invite_class]` for backward compatibility.
256
+ # @return [Array<Class>]
257
+ def invite_classes
258
+ [invite_class]
259
+ end
260
+
261
+ # Override to specify a single invite model class. Maintained for
262
+ # backward compatibility; prefer `invite_classes` for multi-flow apps.
263
+ # @return [Class]
264
+ def invite_class
265
+ raise NotImplementedError,
266
+ "#{self.class}#invite_class or #invite_classes must return the invite model class(es)"
267
+ end
268
+
269
+ def find_pending_invite(token)
270
+ invite_classes.each do |klass|
271
+ invite = klass.find_for_acceptance(token)
272
+ return invite if invite
273
+ end
274
+ nil
275
+ end
276
+ end
277
+ end
278
+ end
279
+ ```
280
+
281
+ - [ ] **Step 4: Run test to verify it passes**
282
+
283
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/pending_invite_check_test.rb`
284
+ Expected: 4 tests, 4 assertions, 0 failures.
285
+
286
+ - [ ] **Step 5: Lint**
287
+
288
+ Run: `bundle exec standardrb lib/plutonium/invites/pending_invite_check.rb test/plutonium/invites/pending_invite_check_test.rb`
289
+ Expected: no violations.
290
+
291
+ - [ ] **Step 6: Commit**
292
+
293
+ ```bash
294
+ git add lib/plutonium/invites/pending_invite_check.rb test/plutonium/invites/pending_invite_check_test.rb
295
+ git commit -m "feat(invites): support multiple invite classes in PendingInviteCheck"
296
+ ```
297
+
298
+ ---
299
+
300
+ ## Task 2: Auto append_view_path in invites concerns + qualify flash partial
301
+
302
+ **Goal:** Generated invite controllers should resolve Plutonium's shared partials (e.g., `plutonium/flash`) without each one calling `prepend_view_path` manually. Two parts:
303
+
304
+ 1. Auto-append Plutonium's `app/views` on include of `Plutonium::Invites::Controller` and `Plutonium::Invites::PendingInviteCheck`, mirroring the pattern in `Plutonium::Core::Controller` and `Plutonium::Rodauth::ControllerMethods`.
305
+ 2. Fix `app/views/plutonium/_flash.html.erb` — currently does `render "flash_toasts"` (unprefixed), which Rails resolves relative to the calling controller's path. Outside Plutonium's portal context (e.g., the invitations controller), that resolution fails. Qualify it as `render "plutonium/flash_toasts"` so the inner partial is always found relative to the source.
306
+
307
+ **Files:**
308
+ - Modify: `lib/plutonium/invites/controller.rb`
309
+ - Modify: `lib/plutonium/invites/pending_invite_check.rb`
310
+ - Modify: `app/views/plutonium/_flash.html.erb`
311
+ - Create: `test/plutonium/invites/controller_test.rb`
312
+
313
+ **Acceptance Criteria:**
314
+ - [ ] Including `Plutonium::Invites::Controller` in an `ActionController::Base` subclass adds Plutonium's `app/views` to its lookup paths.
315
+ - [ ] Including `Plutonium::Invites::PendingInviteCheck` in an `ActionController::Base` subclass adds Plutonium's `app/views` to its lookup paths.
316
+ - [ ] The path is added with `append_view_path` (not `prepend_view_path`) so host app templates win on conflict.
317
+ - [ ] `app/views/plutonium/_flash.html.erb` calls `render "plutonium/flash_toasts"` (qualified path) so it works from any controller that has Plutonium's `app/views` on its lookup path.
318
+
319
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/controller_test.rb` → tests pass.
320
+
321
+ **Steps:**
322
+
323
+ - [ ] **Step 1: Write the failing test**
324
+
325
+ Create `test/plutonium/invites/controller_test.rb`:
326
+
327
+ ```ruby
328
+ # frozen_string_literal: true
329
+
330
+ require "test_helper"
331
+
332
+ class Plutonium::Invites::ControllerTest < Minitest::Test
333
+ Plutonium_root_views = File.expand_path("app/views", Plutonium.root)
334
+
335
+ def test_controller_concern_appends_plutonium_views
336
+ klass = Class.new(ActionController::Base) do
337
+ include Plutonium::Invites::Controller
338
+ end
339
+
340
+ paths = klass.view_paths.map { |p| p.to_s.chomp("/") }
341
+ assert_includes paths, Plutonium_root_views.chomp("/")
342
+ end
343
+
344
+ def test_pending_invite_check_concern_appends_plutonium_views
345
+ klass = Class.new(ActionController::Base) do
346
+ include Plutonium::Invites::PendingInviteCheck
347
+ end
348
+
349
+ paths = klass.view_paths.map { |p| p.to_s.chomp("/") }
350
+ assert_includes paths, Plutonium_root_views.chomp("/")
351
+ end
352
+ end
353
+ ```
354
+
355
+ - [ ] **Step 2: Run test to verify it fails**
356
+
357
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/controller_test.rb`
358
+ Expected: both tests fail; the Plutonium views path is not in lookup paths.
359
+
360
+ - [ ] **Step 3: Wire the path append in `Plutonium::Invites::Controller`**
361
+
362
+ Edit `lib/plutonium/invites/controller.rb`, replace the `included do ... end` block:
363
+
364
+ ```ruby
365
+ included do
366
+ append_view_path File.expand_path("app/views", Plutonium.root)
367
+ helper_method :current_user if respond_to?(:helper_method)
368
+ end
369
+ ```
370
+
371
+ - [ ] **Step 4: Wire the path append in `Plutonium::Invites::PendingInviteCheck`**
372
+
373
+ Edit `lib/plutonium/invites/pending_invite_check.rb`, add `included do` block right after `extend ActiveSupport::Concern`:
374
+
375
+ ```ruby
376
+ extend ActiveSupport::Concern
377
+
378
+ included do
379
+ append_view_path File.expand_path("app/views", Plutonium.root)
380
+ end
381
+
382
+ private
383
+ ```
384
+
385
+ - [ ] **Step 5: Run test to verify it passes**
386
+
387
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/controller_test.rb`
388
+ Expected: 2 tests, 2 assertions, 0 failures.
389
+
390
+ - [ ] **Step 6: Qualify the flash partial path**
391
+
392
+ Edit `app/views/plutonium/_flash.html.erb`. Replace:
393
+
394
+ ```erb
395
+ <%= render "flash_toasts" %>
396
+ ```
397
+
398
+ with:
399
+
400
+ ```erb
401
+ <%= render "plutonium/flash_toasts" %>
402
+ ```
403
+
404
+ This makes the `plutonium/flash` partial usable from any controller that has Plutonium's `app/views` on its lookup path — Rails would otherwise resolve `flash_toasts` relative to the calling controller's path (e.g. `invites/_flash_toasts.html.erb`), which doesn't exist.
405
+
406
+ - [ ] **Step 7: Run the full invites test set + system tests to confirm no regressions**
407
+
408
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/pending_invite_check_test.rb`
409
+ Expected: still 4 tests, 4 assertions, 0 failures (Task 1 stays green).
410
+
411
+ If the test suite has system tests that render flash messages in resource pages, run those too to confirm the qualified path doesn't break the existing portal flow:
412
+
413
+ Run: `bundle exec appraisal rails-8.1 rake test 2>&1 | tail -20`
414
+ Expected: any pre-existing flash-rendering tests still pass.
415
+
416
+ - [ ] **Step 8: Commit**
417
+
418
+ ```bash
419
+ git add lib/plutonium/invites/controller.rb lib/plutonium/invites/pending_invite_check.rb app/views/plutonium/_flash.html.erb test/plutonium/invites/controller_test.rb
420
+ git commit -m "feat(invites): auto-append Plutonium views and qualify flash partial path"
421
+ ```
422
+
423
+ ---
424
+
425
+ ## Task 3: Plutonium::Invites::Controller — overridable invitation_path
426
+
427
+ **Goal:** Replace the two hardcoded `invitation_path(...)` references in `Plutonium::Invites::Controller` with an overridable `invitation_path_for(token)` so renamed routes (e.g., `funder_invitation_path`) can be plugged in by generated subclasses.
428
+
429
+ **Files:**
430
+ - Modify: `lib/plutonium/invites/controller.rb`
431
+ - Modify: `test/plutonium/invites/controller_test.rb`
432
+
433
+ **Acceptance Criteria:**
434
+ - [ ] Concern exposes `invitation_path_for(token)` (private). Default implementation calls `invitation_path(token: token)` (preserves current behavior for single-flow apps).
435
+ - [ ] Internal callers (`accept` failure redirect; signup back-link not in concern, but the cookie-based flow) call `invitation_path_for(token)` instead of `invitation_path(token: token)`.
436
+ - [ ] Subclass override of `invitation_path_for` is honored.
437
+
438
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/controller_test.rb` → 3 tests pass.
439
+
440
+ **Steps:**
441
+
442
+ - [ ] **Step 1: Add a test that asserts the override is honored**
443
+
444
+ Append to `test/plutonium/invites/controller_test.rb`:
445
+
446
+ ```ruby
447
+ def test_invitation_path_for_is_overridable
448
+ klass = Class.new(ActionController::Base) do
449
+ include Plutonium::Invites::Controller
450
+
451
+ def invitation_path_for(token)
452
+ "/funder_invitations/#{token}"
453
+ end
454
+ end
455
+
456
+ instance = klass.new
457
+ assert_equal "/funder_invitations/abc", instance.send(:invitation_path_for, "abc")
458
+ end
459
+ ```
460
+
461
+ - [ ] **Step 2: Run test to verify it fails**
462
+
463
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/controller_test.rb`
464
+ Expected: new test fails with `NoMethodError: undefined method 'invitation_path_for'`.
465
+
466
+ - [ ] **Step 3: Add the helper and route the call site through it**
467
+
468
+ In `lib/plutonium/invites/controller.rb`:
469
+
470
+ Replace this block in `accept`:
471
+
472
+ ```ruby
473
+ unless current_user
474
+ redirect_to invitation_path(token: params[:token]),
475
+ alert: "Please sign in to accept this invitation"
476
+ return
477
+ end
478
+ ```
479
+
480
+ with:
481
+
482
+ ```ruby
483
+ unless current_user
484
+ redirect_to invitation_path_for(params[:token]),
485
+ alert: "Please sign in to accept this invitation"
486
+ return
487
+ end
488
+ ```
489
+
490
+ Then add this private method (next to `invite_class`):
491
+
492
+ ```ruby
493
+ # Override to customize the invitation URL helper.
494
+ # Default uses Rails' `invitation_path(token:)` helper, which is what
495
+ # `pu:invites:install` generates for single-flow apps. Multi-flow apps
496
+ # whose generator scoped the route as `<prefix>_invitation_path` should
497
+ # override this.
498
+ #
499
+ # @param token [String] the invitation token
500
+ # @return [String] the URL path
501
+ def invitation_path_for(token)
502
+ invitation_path(token: token)
503
+ end
504
+ ```
505
+
506
+ - [ ] **Step 4: Run test to verify it passes**
507
+
508
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/controller_test.rb`
509
+ Expected: 3 tests, 3 assertions, 0 failures.
510
+
511
+ - [ ] **Step 5: Lint**
512
+
513
+ Run: `bundle exec standardrb lib/plutonium/invites/controller.rb test/plutonium/invites/controller_test.rb`
514
+ Expected: no violations.
515
+
516
+ - [ ] **Step 6: Commit**
517
+
518
+ ```bash
519
+ git add lib/plutonium/invites/controller.rb test/plutonium/invites/controller_test.rb
520
+ git commit -m "feat(invites): make invitation_path_for overridable"
521
+ ```
522
+
523
+ ---
524
+
525
+ ## Task 4: install_generator — `--invite_model` option + naming helpers
526
+
527
+ **Goal:** Introduce the `--invite_model` CLI option and the private naming helpers that subsequent tasks will consume. No template renaming yet — this task only adds the option, helpers, and verifies they compute correctly. Default value `UserInvite` keeps existing behavior intact.
528
+
529
+ **Files:**
530
+ - Modify: `lib/generators/pu/invites/install_generator.rb`
531
+ - Modify: `test/generators/invites_install_generator_test.rb`
532
+
533
+ **Acceptance Criteria:**
534
+ - [ ] New `class_option :invite_model, type: :string, default: "UserInvite"` accepted.
535
+ - [ ] Private helpers added (each returns a String):
536
+ - [ ] `invite_model` — `"UserInvite"`, `"FunderInvite"`
537
+ - [ ] `invite_underscore` — `"user_invite"`, `"funder_invite"`
538
+ - [ ] `invite_table` — `"user_invites"`, `"funder_invites"`
539
+ - [ ] `invitations_controller_class` — `"UserInvitationsController"`, `"FunderInvitationsController"`
540
+ - [ ] `invitations_path` — `"user_invitations"`, `"funder_invitations"` (URL segment + controller filename)
541
+ - [ ] `invite_route_prefix` — `"user"`, `"funder"` (route helper prefix)
542
+ - [ ] Existing tests still pass (default `UserInvite` produces all the prior assertions).
543
+
544
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb` → all tests pass (existing + 1 new helper test).
545
+
546
+ **Steps:**
547
+
548
+ - [ ] **Step 1: Write the failing helper test**
549
+
550
+ Append to `test/generators/invites_install_generator_test.rb` (after the existing `default_args` definition):
551
+
552
+ ```ruby
553
+ test "naming helpers derive correctly for default invite_model" do
554
+ generator = Pu::Invites::InstallGenerator.new(default_args, [])
555
+ assert_equal "UserInvite", generator.send(:invite_model)
556
+ assert_equal "user_invite", generator.send(:invite_underscore)
557
+ assert_equal "user_invites", generator.send(:invite_table)
558
+ assert_equal "UserInvitationsController", generator.send(:invitations_controller_class)
559
+ assert_equal "user_invitations", generator.send(:invitations_path)
560
+ assert_equal "user", generator.send(:invite_route_prefix)
561
+ end
562
+
563
+ test "naming helpers derive correctly for custom invite_model" do
564
+ generator = Pu::Invites::InstallGenerator.new(
565
+ default_args + ["--invite-model=FunderInvite"], []
566
+ )
567
+ assert_equal "FunderInvite", generator.send(:invite_model)
568
+ assert_equal "funder_invite", generator.send(:invite_underscore)
569
+ assert_equal "funder_invites", generator.send(:invite_table)
570
+ assert_equal "FunderInvitationsController", generator.send(:invitations_controller_class)
571
+ assert_equal "funder_invitations", generator.send(:invitations_path)
572
+ assert_equal "funder", generator.send(:invite_route_prefix)
573
+ end
574
+ ```
575
+
576
+ - [ ] **Step 2: Run test to verify it fails**
577
+
578
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n "/naming helpers/"`
579
+ Expected: 2 tests fail with `NoMethodError` for `invite_model` etc.
580
+
581
+ - [ ] **Step 3: Add option + helpers to install_generator**
582
+
583
+ In `lib/generators/pu/invites/install_generator.rb`:
584
+
585
+ Add new `class_option` next to the others (after `class_option :user_model`):
586
+
587
+ ```ruby
588
+ class_option :invite_model, type: :string, default: "UserInvite",
589
+ desc: "The invite model class name (e.g., FunderInvite for Invites::FunderInvite). Run multiple times with different values for separate flows."
590
+ ```
591
+
592
+ Add to the `private` section (next to `entity_model`):
593
+
594
+ ```ruby
595
+ def invite_model
596
+ options[:invite_model].camelize
597
+ end
598
+
599
+ def invite_underscore
600
+ invite_model.underscore
601
+ end
602
+
603
+ def invite_table
604
+ invite_model.tableize
605
+ end
606
+
607
+ # e.g. UserInvite -> UserInvitationsController, FunderInvite -> FunderInvitationsController.
608
+ # If the input ends in "Invite", swap to "Invitations"; else append "Invitations".
609
+ def invitations_controller_class
610
+ base = invite_model.sub(/Invite\z/, "")
611
+ "#{base}InvitationsController"
612
+ end
613
+
614
+ def invitations_path
615
+ invitations_controller_class.sub(/Controller\z/, "").underscore
616
+ end
617
+
618
+ # Route helper prefix: "user" for UserInvite, "funder" for FunderInvite.
619
+ def invite_route_prefix
620
+ invite_model.sub(/Invite\z/, "").underscore.presence || "invite"
621
+ end
622
+ ```
623
+
624
+ - [ ] **Step 4: Run helper tests to verify pass**
625
+
626
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n "/naming helpers/"`
627
+ Expected: 2 tests pass.
628
+
629
+ - [ ] **Step 5: Run full generator test suite to confirm no regressions**
630
+
631
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb`
632
+ Expected: all existing + 2 new tests pass; assertions count grows by ~14.
633
+
634
+ - [ ] **Step 6: Commit**
635
+
636
+ ```bash
637
+ git add lib/generators/pu/invites/install_generator.rb test/generators/invites_install_generator_test.rb
638
+ git commit -m "feat(invites/generator): add --invite_model option and naming helpers"
639
+ ```
640
+
641
+ ---
642
+
643
+ ## Task 5: Parameterize all templates by invite model name
644
+
645
+ **Goal:** Rename every per-flow template's destination path and replace every hardcoded `UserInvite` / `user_invite` / `UserInvitations` reference inside templates with ERB-resolved derived names. Welcome controller + invitation layout remain global (one-shot, unchanged in this task).
646
+
647
+ **Files:**
648
+ - Modify: `lib/generators/pu/invites/install_generator.rb` — destination paths in `template "..."` calls.
649
+ - Modify: `lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt` — class name only (filename handled in generator step below).
650
+ - Modify: `lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt`
651
+ - Modify: `lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt`
652
+ - Modify: `lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt`
653
+ - Modify: `lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt`
654
+ - Modify: `lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt`
655
+ - Modify: `lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/{invitation.html,invitation.text}.erb.tt`
656
+ - Modify: `lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/{landing,show,signup,error}.html.erb.tt`
657
+ - Modify: `lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt`
658
+ - Modify: `lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt`
659
+ - Modify: `test/generators/invites_install_generator_test.rb` — add coverage for custom `--invite_model`.
660
+
661
+ **Acceptance Criteria:**
662
+ - [ ] Default `--invite_model=UserInvite` produces identical file paths and content to today (no regressions in existing tests).
663
+ - [ ] Generated controller's `sign_in_user` calls `rodauth.login_session("signup")` instead of `rodauth.login("signup")` (so post-accept redirect isn't pre-empted).
664
+ - [ ] `--invite_model=FunderInvite` produces:
665
+ - Migration `db/migrate/<timestamp>_create_funder_invites.rb` with `create_table :funder_invites`.
666
+ - Model `packages/invites/app/models/invites/funder_invite.rb` with `class FunderInvite < Invites::ResourceRecord`.
667
+ - Policy `packages/invites/app/policies/invites/funder_invite_policy.rb` with `class FunderInvitePolicy`.
668
+ - Definition `packages/invites/app/definitions/invites/funder_invite_definition.rb`.
669
+ - Mailer `packages/invites/app/mailers/invites/funder_invite_mailer.rb` with `class FunderInviteMailer`.
670
+ - Controller `packages/invites/app/controllers/invites/funder_invitations_controller.rb` with `class FunderInvitationsController`.
671
+ - Mailer views under `packages/invites/app/views/invites/funder_invite_mailer/`.
672
+ - Controller views under `packages/invites/app/views/invites/funder_invitations/`.
673
+ - [ ] Inside generated controller: `invite_class` returns `::Invites::FunderInvite`; `invitation_path_for` overrides default to call `funder_invitation_path(token: token)`.
674
+ - [ ] Mailer reads `invitation_url(token: ...)` via the route helper named `<invite_route_prefix>_invitation_url`.
675
+ - [ ] All view path helpers in templates use the prefixed route helper name.
676
+
677
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb` → all existing + new custom-invite-model tests pass.
678
+
679
+ **Steps:**
680
+
681
+ - [ ] **Step 1: Write a failing test for `--invite_model=FunderInvite` outputs**
682
+
683
+ Add to `test/generators/invites_install_generator_test.rb`:
684
+
685
+ ```ruby
686
+ test "generates funder invite model with custom invite_model" do
687
+ run_generator default_args + ["--invite-model=FunderInvite"]
688
+
689
+ assert_migration "db/migrate/create_funder_invites.rb" do |content|
690
+ assert_match(/create_table :funder_invites/, content)
691
+ end
692
+
693
+ assert_file "packages/invites/app/models/invites/funder_invite.rb" do |content|
694
+ assert_match(/class FunderInvite < Invites::ResourceRecord/, content)
695
+ assert_match(/include Plutonium::Invites::Concerns::InviteToken/, content)
696
+ assert_match(/Invites::FunderInviteMailer/, content)
697
+ end
698
+
699
+ assert_file "packages/invites/app/policies/invites/funder_invite_policy.rb" do |content|
700
+ assert_match(/class FunderInvitePolicy/, content)
701
+ end
702
+
703
+ assert_file "packages/invites/app/definitions/invites/funder_invite_definition.rb" do |content|
704
+ assert_match(/class FunderInviteDefinition/, content)
705
+ assert_match(/Invites::ResendInviteInteraction/, content)
706
+ end
707
+
708
+ assert_file "packages/invites/app/mailers/invites/funder_invite_mailer.rb" do |content|
709
+ assert_match(/class FunderInviteMailer < ApplicationMailer/, content)
710
+ assert_match(/funder_invitation_url\(token:/, content)
711
+ end
712
+
713
+ assert_file "packages/invites/app/controllers/invites/funder_invitations_controller.rb" do |content|
714
+ assert_match(/class FunderInvitationsController < ApplicationController/, content)
715
+ assert_match(/::Invites::FunderInvite/, content)
716
+ assert_match(/funder_invitation_path\(token: token\)/, content)
717
+ end
718
+
719
+ assert_file "packages/invites/app/views/invites/funder_invitations/landing.html.erb"
720
+ assert_file "packages/invites/app/views/invites/funder_invitations/show.html.erb"
721
+ assert_file "packages/invites/app/views/invites/funder_invitations/signup.html.erb"
722
+ assert_file "packages/invites/app/views/invites/funder_invitations/error.html.erb"
723
+ assert_file "packages/invites/app/views/invites/funder_invite_mailer/invitation.html.erb"
724
+ assert_file "packages/invites/app/views/invites/funder_invite_mailer/invitation.text.erb"
725
+ end
726
+ ```
727
+
728
+ - [ ] **Step 2: Run the test to verify it fails**
729
+
730
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n test_generates_funder_invite_model_with_custom_invite_model`
731
+ Expected: file/migration assertions fail because all files still write to `user_invite*` paths and class names are still `UserInvite*`.
732
+
733
+ - [ ] **Step 3: Update destination paths in install_generator**
734
+
735
+ In `lib/generators/pu/invites/install_generator.rb`, edit each `def create_*` step that uses `template`. Replace destination paths with interpolated ones.
736
+
737
+ ```ruby
738
+ def create_user_invites_migration
739
+ migration_template "db/migrate/create_user_invites.rb",
740
+ "db/migrate/create_#{invite_table}.rb"
741
+ end
742
+
743
+ def create_model
744
+ template "packages/invites/app/models/invites/user_invite.rb",
745
+ "packages/invites/app/models/invites/#{invite_underscore}.rb"
746
+ end
747
+
748
+ def create_mailer
749
+ template "packages/invites/app/mailers/invites/user_invite_mailer.rb",
750
+ "packages/invites/app/mailers/invites/#{invite_underscore}_mailer.rb"
751
+
752
+ template "packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb",
753
+ "packages/invites/app/views/invites/#{invite_underscore}_mailer/invitation.html.erb"
754
+
755
+ template "packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb",
756
+ "packages/invites/app/views/invites/#{invite_underscore}_mailer/invitation.text.erb"
757
+ end
758
+
759
+ def create_controllers
760
+ template "packages/invites/app/controllers/invites/user_invitations_controller.rb",
761
+ "packages/invites/app/controllers/invites/#{invitations_path}_controller.rb"
762
+
763
+ # Welcome controller is a one-shot — only generate if it doesn't exist yet.
764
+ welcome_path = "packages/invites/app/controllers/invites/welcome_controller.rb"
765
+ unless File.exist?(Rails.root.join(welcome_path))
766
+ template "packages/invites/app/controllers/invites/welcome_controller.rb",
767
+ welcome_path
768
+ end
769
+ end
770
+
771
+ def create_views
772
+ %w[landing show signup error].each do |view|
773
+ template "packages/invites/app/views/invites/user_invitations/#{view}.html.erb",
774
+ "packages/invites/app/views/invites/#{invitations_path}/#{view}.html.erb"
775
+ end
776
+
777
+ # Welcome view is a one-shot too.
778
+ pending_path = "packages/invites/app/views/invites/welcome/pending_invitation.html.erb"
779
+ unless File.exist?(Rails.root.join(pending_path))
780
+ template "packages/invites/app/views/invites/welcome/pending_invitation.html.erb",
781
+ pending_path
782
+ end
783
+
784
+ layout_path = "packages/invites/app/views/layouts/invites/invitation.html.erb"
785
+ unless File.exist?(Rails.root.join(layout_path))
786
+ template "packages/invites/app/views/layouts/invites/invitation.html.erb",
787
+ layout_path
788
+ end
789
+ end
790
+
791
+ def create_definition
792
+ template "packages/invites/app/definitions/invites/user_invite_definition.rb",
793
+ "packages/invites/app/definitions/invites/#{invite_underscore}_definition.rb"
794
+ end
795
+
796
+ def create_policy
797
+ template "packages/invites/app/policies/invites/user_invite_policy.rb",
798
+ "packages/invites/app/policies/invites/#{invite_underscore}_policy.rb"
799
+ end
800
+ ```
801
+
802
+ - [ ] **Step 4: Update template content — model**
803
+
804
+ Edit `lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt`. Replace:
805
+
806
+ ```erb
807
+ module Invites
808
+ class UserInvite < Invites::ResourceRecord
809
+ ...
810
+ def invitation_mailer
811
+ Invites::UserInviteMailer
812
+ end
813
+ ```
814
+
815
+ with:
816
+
817
+ ```erb
818
+ module Invites
819
+ class <%= invite_model %> < Invites::ResourceRecord
820
+ ...
821
+ def invitation_mailer
822
+ Invites::<%= invite_model %>Mailer
823
+ end
824
+ ```
825
+
826
+ - [ ] **Step 5: Update template content — policy**
827
+
828
+ Edit `lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt`. Replace:
829
+
830
+ ```erb
831
+ module Invites
832
+ class UserInvitePolicy < Invites::ResourcePolicy
833
+ ```
834
+
835
+ with:
836
+
837
+ ```erb
838
+ module Invites
839
+ class <%= invite_model %>Policy < Invites::ResourcePolicy
840
+ ```
841
+
842
+ - [ ] **Step 6: Update template content — definition**
843
+
844
+ Edit `lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt`. Replace:
845
+
846
+ ```erb
847
+ module Invites
848
+ class UserInviteDefinition < Invites::ResourceDefinition
849
+ ```
850
+
851
+ with:
852
+
853
+ ```erb
854
+ module Invites
855
+ class <%= invite_model %>Definition < Invites::ResourceDefinition
856
+ ```
857
+
858
+ - [ ] **Step 7: Update template content — mailer**
859
+
860
+ Edit `lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt`. Replace:
861
+
862
+ ```erb
863
+ module Invites
864
+ class UserInviteMailer < ApplicationMailer
865
+ ...
866
+ def invitation(user_invite)
867
+ @user_invite = user_invite
868
+ @invitation_url = invitation_url(token: user_invite.token)
869
+ ```
870
+
871
+ with:
872
+
873
+ ```erb
874
+ module Invites
875
+ class <%= invite_model %>Mailer < ApplicationMailer
876
+ ...
877
+ def invitation(invite)
878
+ @invite = invite
879
+ @invitation_url = <%= invite_route_prefix %>_invitation_url(token: invite.token)
880
+ ```
881
+
882
+ Also rename `@user_invite` → `@invite` further in the file (subject, template_name lookups). Update mailer-view templates accordingly:
883
+
884
+ In `packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt` and `.text.erb.tt`, replace every `@user_invite` with `@invite`. (Single-flow apps see no behavioral change; this just unifies naming.)
885
+
886
+ - [ ] **Step 8: Update template content — controller**
887
+
888
+ Edit `lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt`. Replace:
889
+
890
+ ```erb
891
+ module Invites
892
+ class UserInvitationsController < ApplicationController
893
+ ```
894
+
895
+ with:
896
+
897
+ ```erb
898
+ module Invites
899
+ class <%= invitations_controller_class %> < ApplicationController
900
+ ```
901
+
902
+ Replace:
903
+
904
+ ```erb
905
+ def invite_class
906
+ ::Invites::UserInvite
907
+ end
908
+ ```
909
+
910
+ with:
911
+
912
+ ```erb
913
+ def invite_class
914
+ ::Invites::<%= invite_model %>
915
+ end
916
+
917
+ def invitation_path_for(token)
918
+ <%= invite_route_prefix %>_invitation_path(token: token)
919
+ end
920
+ ```
921
+
922
+ Drop the now-unnecessary `prepend_view_path Invites::Engine.root.join("app/views")` line (the engine's view path resolves automatically once the engine is mounted; Plutonium views are now appended by the concern from Task 2).
923
+
924
+ Also fix the post-signup sign-in flow. Replace:
925
+
926
+ ```erb
927
+ def sign_in_user(user)
928
+ rodauth.account_from_login(user.email)
929
+ rodauth.login("signup")
930
+ end
931
+ ```
932
+
933
+ with:
934
+
935
+ ```erb
936
+ def sign_in_user(user)
937
+ rodauth.account_from_login(user.email)
938
+ # login_session just persists the session; `login` would redirect to
939
+ # rodauth.login_redirect and short-circuit our post-accept redirect.
940
+ rodauth.login_session("signup")
941
+ end
942
+ ```
943
+
944
+ Reason: `rodauth.login` issues a redirect to `rodauth.login_redirect`, which short-circuits the controller's `redirect_to after_accept_path` and breaks the post-acceptance flow. `login_session` only persists the session.
945
+
946
+ - [ ] **Step 9: Update view templates — replace hardcoded route helpers**
947
+
948
+ In `packages/invites/app/views/invites/user_invitations/{landing,show,signup,error}.html.erb.tt`:
949
+
950
+ - Replace `invitation_signup_path(token: ...)` with `<%= invite_route_prefix %>_invitation_signup_path(token: ...)`.
951
+ - Replace `invitation_path(token: ...)` with `<%= invite_route_prefix %>_invitation_path(token: ...)`.
952
+ - Replace `accept_invitation_path(token: ...)` with `accept_<%= invite_route_prefix %>_invitation_path(token: ...)`.
953
+
954
+ Where the helper name is interpolated, double-escape ERB so the host file calls Rails' helper, e.g.:
955
+
956
+ ```erb
957
+ <%%= link_to "Create Account", <%= invite_route_prefix %>_invitation_signup_path(token: params[:token]),
958
+ ```
959
+
960
+ - [ ] **Step 10: Update interaction templates**
961
+
962
+ Both `lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt` and `user_invite_user_interaction.rb.tt` reference `Invites::UserInvite.roles.keys`. Replace with:
963
+
964
+ ```erb
965
+ input :role, as: :select, choices: Invites::<%= invite_model %>.roles.keys.excluding("owner")
966
+ ```
967
+
968
+ - [ ] **Step 11: Update entity association injection**
969
+
970
+ In install_generator's `add_entity_association` step, the line currently injects:
971
+
972
+ ```ruby
973
+ " has_many :user_invites, class_name: \"Invites::UserInvite\", dependent: :destroy\n"
974
+ ```
975
+
976
+ Make it interpolated and use a per-invite name:
977
+
978
+ ```ruby
979
+ " has_many :#{invite_table}, class_name: \"Invites::#{invite_model}\", dependent: :destroy\n"
980
+ ```
981
+
982
+ (For `UserInvite` this still emits `:user_invites`; for `FunderInvite` it emits `:funder_invites`.)
983
+
984
+ - [ ] **Step 12: Run the full generator test**
985
+
986
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb`
987
+ Expected:
988
+ - All existing default-`UserInvite` tests still pass.
989
+ - New `funder invite model` test passes.
990
+
991
+ - [ ] **Step 13: Commit**
992
+
993
+ ```bash
994
+ git add lib/generators/pu/invites lib/plutonium/invites test/generators
995
+ git commit -m "feat(invites/generator): parameterize templates on invite_model"
996
+ ```
997
+
998
+ ---
999
+
1000
+ ## Task 6: Scope routes per invite type (idempotent re-run)
1001
+
1002
+ **Goal:** `add_routes` in install_generator should append a route block scoped to the current `--invite_model` (URL prefix + helper prefix), and skip any block that's already present, so a second invocation with a different `--invite_model` adds new routes without disturbing existing ones.
1003
+
1004
+ **Files:**
1005
+ - Modify: `lib/generators/pu/invites/install_generator.rb` — `add_routes` method.
1006
+ - Modify: `test/generators/invites_install_generator_test.rb` — add a route-content assertion test.
1007
+
1008
+ **Acceptance Criteria:**
1009
+ - [ ] After `pu:invites:install --invite_model=UserInvite`, `config/routes.rb` contains:
1010
+
1011
+ ```ruby
1012
+ scope module: :invites do
1013
+ get "user_invitations/welcome", to: "welcome#index", as: :invites_welcome_check
1014
+ delete "user_invitations/welcome", to: "welcome#skip", as: :invites_welcome_skip
1015
+ get "user_invitations/:token", to: "user_invitations#show", as: :user_invitation
1016
+ post "user_invitations/:token/accept", to: "user_invitations#accept", as: :accept_user_invitation
1017
+ get "user_invitations/:token/signup", to: "user_invitations#signup", as: :user_invitation_signup
1018
+ post "user_invitations/:token/signup", to: "user_invitations#signup"
1019
+ end
1020
+ ```
1021
+ (welcome routes are global — generated once, see step below.)
1022
+ - [ ] After a second run with `--invite_model=FunderInvite`, the same file contains an additional block with `funder_invitations`/`funder_invitation` helpers, and the `welcome` block is **not** duplicated.
1023
+ - [ ] Re-running the same `--invite_model` is idempotent (skips with `say_status :skip`).
1024
+
1025
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n "/scopes routes/"` → passes.
1026
+
1027
+ **Steps:**
1028
+
1029
+ - [ ] **Step 1: Add the route-scoping test**
1030
+
1031
+ Append to `test/generators/invites_install_generator_test.rb`:
1032
+
1033
+ ```ruby
1034
+ test "scopes routes per invite_model" do
1035
+ run_generator default_args
1036
+
1037
+ assert_file "config/routes.rb" do |content|
1038
+ assert_match(/get "user_invitations\/:token", to: "user_invitations#show", as: :user_invitation/, content)
1039
+ assert_match(/post "user_invitations\/:token\/accept", to: "user_invitations#accept", as: :accept_user_invitation/, content)
1040
+ assert_match(/get "user_invitations\/welcome".*as: :invites_welcome_check/, content)
1041
+ end
1042
+ end
1043
+
1044
+ test "second invocation adds funder routes without duplicating welcome" do
1045
+ run_generator default_args
1046
+ run_generator default_args + ["--invite-model=FunderInvite"]
1047
+
1048
+ assert_file "config/routes.rb" do |content|
1049
+ assert_match(/as: :user_invitation/, content)
1050
+ assert_match(/as: :funder_invitation/, content)
1051
+ # Welcome route block appears exactly once
1052
+ welcome_count = content.scan(/as: :invites_welcome_check/).size
1053
+ assert_equal 1, welcome_count, "expected exactly one welcome route, got #{welcome_count}"
1054
+ end
1055
+ end
1056
+ ```
1057
+
1058
+ - [ ] **Step 2: Run tests to verify they fail**
1059
+
1060
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n "/scopes routes|second invocation/"`
1061
+ Expected: tests fail because routes still use the un-prefixed `:invitation` helper and the `add_routes` method doesn't scope or guard.
1062
+
1063
+ - [ ] **Step 3: Update `add_routes` to scope and guard per invite type**
1064
+
1065
+ Replace the body of `add_routes` in `lib/generators/pu/invites/install_generator.rb`:
1066
+
1067
+ ```ruby
1068
+ def add_routes
1069
+ routes_content = File.read(Rails.root.join("config/routes.rb"))
1070
+ marker = "# Invitation routes for #{invite_model}"
1071
+
1072
+ if routes_content.include?(marker)
1073
+ say_status :skip, "Invitation routes for #{invite_model} already present", :yellow
1074
+ else
1075
+ welcome_present = routes_content.include?("# Invitation welcome routes")
1076
+
1077
+ welcome_block = if welcome_present
1078
+ ""
1079
+ else
1080
+ <<-RUBY
1081
+
1082
+ # Invitation welcome routes (shared across all invite flows)
1083
+ scope module: :invites do
1084
+ get "invitations/welcome", to: "welcome#index", as: :invites_welcome_check
1085
+ delete "invitations/welcome", to: "welcome#skip", as: :invites_welcome_skip
1086
+ end
1087
+ RUBY
1088
+ end
1089
+
1090
+ flow_block = <<-RUBY
1091
+
1092
+ #{marker}
1093
+ scope module: :invites do
1094
+ get "#{invitations_path}/:token", to: "#{invitations_path}#show", as: :#{invite_route_prefix}_invitation
1095
+ post "#{invitations_path}/:token/accept", to: "#{invitations_path}#accept", as: :accept_#{invite_route_prefix}_invitation
1096
+ get "#{invitations_path}/:token/signup", to: "#{invitations_path}#signup", as: :#{invite_route_prefix}_invitation_signup
1097
+ post "#{invitations_path}/:token/signup", to: "#{invitations_path}#signup"
1098
+ end
1099
+ RUBY
1100
+
1101
+ inject_into_file "config/routes.rb",
1102
+ welcome_block + flow_block,
1103
+ before: /^end\s*\z/
1104
+ end
1105
+
1106
+ # If no main WelcomeController exists, add /welcome route pointing to
1107
+ # Invites::WelcomeController so Rodauth's login_redirect "/welcome" works.
1108
+ unless File.exist?(Rails.root.join("app/controllers/welcome_controller.rb")) ||
1109
+ routes_content.include?(%(get "welcome", to: "invites/welcome#index"))
1110
+ welcome_route = <<-RUBY
1111
+
1112
+ # Welcome route (handled by invites package — replace with pu:saas:welcome for full onboarding)
1113
+ get "welcome", to: "invites/welcome#index"
1114
+ RUBY
1115
+
1116
+ inject_into_file "config/routes.rb",
1117
+ welcome_route,
1118
+ before: /^\s*# Invitation welcome routes|^\s*# Invitation routes for/
1119
+ end
1120
+ end
1121
+ ```
1122
+
1123
+ - [ ] **Step 4: Run tests to verify pass**
1124
+
1125
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n "/scopes routes|second invocation/"`
1126
+ Expected: 2 tests, 4+ assertions, 0 failures.
1127
+
1128
+ - [ ] **Step 5: Run full generator test suite to confirm no regressions**
1129
+
1130
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb`
1131
+ Expected: all tests pass.
1132
+
1133
+ - [ ] **Step 6: Commit**
1134
+
1135
+ ```bash
1136
+ git add lib/generators/pu/invites/install_generator.rb test/generators/invites_install_generator_test.rb
1137
+ git commit -m "feat(invites/generator): scope routes per invite_model with idempotent re-run"
1138
+ ```
1139
+
1140
+ ---
1141
+
1142
+ ## Task 7: Welcome controller — multi-class integration on re-run
1143
+
1144
+ **Goal:** Make the welcome controller honor multiple invite classes. On first invocation, generate a welcome controller that calls `invite_classes` returning a single-element array. On subsequent invocations, inject the new class into the existing array.
1145
+
1146
+ **Files:**
1147
+ - Modify: `lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt` — switch to `invite_classes` returning Array.
1148
+ - Modify: `lib/generators/pu/invites/install_generator.rb` — add `add_welcome_invite_class` step that inserts the new class on second+ invocation.
1149
+ - Modify: `lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt` — switch generated user-invitations controller to also expose `invite_classes`? No — single-flow controller still uses `invite_class`. Welcome is the multi-class point.
1150
+ - Modify: `test/generators/invites_install_generator_test.rb` — add re-run test.
1151
+
1152
+ **Acceptance Criteria:**
1153
+ - [ ] First invocation generates welcome controller with `def invite_classes; [::Invites::UserInvite]; end`.
1154
+ - [ ] Second invocation with `--invite_model=FunderInvite` mutates that to `[::Invites::UserInvite, ::Invites::FunderInvite]`.
1155
+ - [ ] Third invocation with same model is a no-op (idempotent).
1156
+ - [ ] Existing `WelcomeController` integration (`integrate_with_welcome_controller`) still updates host's `app/controllers/welcome_controller.rb` with `invite_classes`.
1157
+
1158
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n "/welcome.*multi/"` → passes.
1159
+
1160
+ **Steps:**
1161
+
1162
+ - [ ] **Step 1: Update the welcome_controller template**
1163
+
1164
+ Edit `lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt`. Replace:
1165
+
1166
+ ```ruby
1167
+ def invite_class
1168
+ ::Invites::UserInvite
1169
+ end
1170
+ ```
1171
+
1172
+ with:
1173
+
1174
+ ```ruby
1175
+ def invite_classes
1176
+ [::Invites::<%= invite_model %>]
1177
+ end
1178
+ ```
1179
+
1180
+ - [ ] **Step 2: Add a re-run test**
1181
+
1182
+ Append to `test/generators/invites_install_generator_test.rb`:
1183
+
1184
+ ```ruby
1185
+ test "welcome controller invite_classes accumulates across invocations" do
1186
+ run_generator default_args
1187
+ run_generator default_args + ["--invite-model=FunderInvite"]
1188
+
1189
+ assert_file "packages/invites/app/controllers/invites/welcome_controller.rb" do |content|
1190
+ assert_match(/def invite_classes/, content)
1191
+ assert_match(/::Invites::UserInvite/, content)
1192
+ assert_match(/::Invites::FunderInvite/, content)
1193
+ # Order matters for first-match semantics; both should appear in the same array literal.
1194
+ assert_match(/\[\s*::Invites::UserInvite\s*,\s*::Invites::FunderInvite\s*\]/m, content)
1195
+ end
1196
+ end
1197
+
1198
+ test "welcome controller invite_classes injection is idempotent" do
1199
+ run_generator default_args
1200
+ run_generator default_args # second run, same invite_model
1201
+ run_generator default_args # third run, same invite_model
1202
+
1203
+ assert_file "packages/invites/app/controllers/invites/welcome_controller.rb" do |content|
1204
+ assert_equal 1, content.scan(/::Invites::UserInvite/).size
1205
+ end
1206
+ end
1207
+ ```
1208
+
1209
+ - [ ] **Step 3: Run tests to verify they fail**
1210
+
1211
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n "/welcome.*invite_classes/"`
1212
+ Expected: tests fail — welcome controller is regenerated and overwritten on re-run, not accumulated.
1213
+
1214
+ - [ ] **Step 4: Add the `add_welcome_invite_class` step in install_generator**
1215
+
1216
+ In `lib/generators/pu/invites/install_generator.rb`, add this step after `create_controllers` and before `create_views`:
1217
+
1218
+ ```ruby
1219
+ def add_welcome_invite_class
1220
+ welcome_path = "packages/invites/app/controllers/invites/welcome_controller.rb"
1221
+ return unless File.exist?(Rails.root.join(welcome_path))
1222
+
1223
+ content = File.read(Rails.root.join(welcome_path))
1224
+ new_class = "::Invites::#{invite_model}"
1225
+
1226
+ # Already present? bail.
1227
+ return if content =~ /\b#{Regexp.escape(new_class)}\b/
1228
+
1229
+ # Find `def invite_classes` block; inject before the closing `]`.
1230
+ injection = content.sub(/(\bdef invite_classes\b.*?\[)([^\]]*)(\])/m) do
1231
+ before, list, after = Regexp.last_match[1], Regexp.last_match[2], Regexp.last_match[3]
1232
+ existing = list.strip
1233
+ new_list = existing.empty? ? new_class : "#{existing.chomp(",").strip}, #{new_class}"
1234
+ "#{before}#{new_list}#{after}"
1235
+ end
1236
+
1237
+ if injection != content
1238
+ File.write(Rails.root.join(welcome_path), injection)
1239
+ say_status :inject, "Added #{new_class} to welcome controller's invite_classes", :green
1240
+ end
1241
+ end
1242
+ ```
1243
+
1244
+ Then update `create_controllers` so the welcome controller is generated only when missing (already done in Task 5, step 3 — confirm it's in place).
1245
+
1246
+ - [ ] **Step 5: Update host welcome controller integration**
1247
+
1248
+ In `integrate_with_welcome_controller`, replace the existing `def invite_class` injection with multi-class equivalent:
1249
+
1250
+ ```ruby
1251
+ # Add invite_classes method if neither it nor invite_class is present
1252
+ unless file_content =~ /def invite_classes\b/ || file_content =~ /def invite_class\b/
1253
+ inject_into_file relative_path,
1254
+ "\n def invite_classes\n [::Invites::#{invite_model}]\n end\n",
1255
+ before: /^end\s*\z/
1256
+ else
1257
+ # Inject this invite_model into the existing invite_classes array if missing.
1258
+ host_content = File.read(Rails.root.join(relative_path))
1259
+ if host_content =~ /def invite_classes\b/ && host_content !~ /::Invites::#{invite_model}\b/
1260
+ updated = host_content.sub(/(\bdef invite_classes\b.*?\[)([^\]]*)(\])/m) do
1261
+ before, list, after = Regexp.last_match[1], Regexp.last_match[2], Regexp.last_match[3]
1262
+ existing = list.strip
1263
+ new_list = existing.empty? ? "::Invites::#{invite_model}" : "#{existing.chomp(",").strip}, ::Invites::#{invite_model}"
1264
+ "#{before}#{new_list}#{after}"
1265
+ end
1266
+ File.write(Rails.root.join(relative_path), updated)
1267
+ end
1268
+ end
1269
+ ```
1270
+
1271
+ - [ ] **Step 6: Run tests to verify pass**
1272
+
1273
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n "/welcome.*invite_classes|idempotent/"`
1274
+ Expected: both tests pass.
1275
+
1276
+ - [ ] **Step 7: Run full generator test suite**
1277
+
1278
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb`
1279
+ Expected: all tests pass.
1280
+
1281
+ - [ ] **Step 8: Commit**
1282
+
1283
+ ```bash
1284
+ git add lib/generators/pu/invites/install_generator.rb lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt test/generators/invites_install_generator_test.rb
1285
+ git commit -m "feat(invites/generator): accumulate invite_classes across invocations"
1286
+ ```
1287
+
1288
+ ---
1289
+
1290
+ ## Task 8: End-to-end dual-invocation generator test
1291
+
1292
+ **Goal:** A single test that runs the generator twice with two different `--invite_model` values and asserts that both flows live side-by-side without collisions.
1293
+
1294
+ **Files:**
1295
+ - Modify: `test/generators/invites_install_generator_test.rb`
1296
+
1297
+ **Acceptance Criteria:**
1298
+ - [ ] Two invocations succeed without raising.
1299
+ - [ ] Both migrations exist and target different tables.
1300
+ - [ ] Both models, policies, definitions, mailers, controllers exist.
1301
+ - [ ] Routes file contains both flows + a single welcome block.
1302
+ - [ ] Welcome controller `invite_classes` lists both classes.
1303
+ - [ ] Entity model has `has_many` for both invite tables.
1304
+
1305
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n test_dual_invocation_yields_independent_flows` → passes.
1306
+
1307
+ **Steps:**
1308
+
1309
+ - [ ] **Step 1: Write the test**
1310
+
1311
+ Append to `test/generators/invites_install_generator_test.rb`:
1312
+
1313
+ ```ruby
1314
+ test "dual invocation yields independent flows" do
1315
+ run_generator default_args # UserInvite (default)
1316
+ run_generator default_args + ["--invite-model=FunderInvite"]
1317
+
1318
+ # Both migrations exist, with distinct tables.
1319
+ assert_migration "db/migrate/create_user_invites.rb"
1320
+ assert_migration "db/migrate/create_funder_invites.rb"
1321
+
1322
+ # Both models, policies, definitions, mailers, controllers exist.
1323
+ assert_file "packages/invites/app/models/invites/user_invite.rb"
1324
+ assert_file "packages/invites/app/models/invites/funder_invite.rb"
1325
+ assert_file "packages/invites/app/policies/invites/user_invite_policy.rb"
1326
+ assert_file "packages/invites/app/policies/invites/funder_invite_policy.rb"
1327
+ assert_file "packages/invites/app/definitions/invites/user_invite_definition.rb"
1328
+ assert_file "packages/invites/app/definitions/invites/funder_invite_definition.rb"
1329
+ assert_file "packages/invites/app/mailers/invites/user_invite_mailer.rb"
1330
+ assert_file "packages/invites/app/mailers/invites/funder_invite_mailer.rb"
1331
+ assert_file "packages/invites/app/controllers/invites/user_invitations_controller.rb"
1332
+ assert_file "packages/invites/app/controllers/invites/funder_invitations_controller.rb"
1333
+
1334
+ # Routes contain both helpers + welcome appears exactly once.
1335
+ assert_file "config/routes.rb" do |content|
1336
+ assert_match(/as: :user_invitation\b/, content)
1337
+ assert_match(/as: :funder_invitation\b/, content)
1338
+ assert_equal 1, content.scan(/as: :invites_welcome_check\b/).size
1339
+ end
1340
+
1341
+ # Entity has has_many for both invite tables.
1342
+ assert_file "app/models/organization.rb" do |content|
1343
+ assert_match(/has_many :user_invites, class_name: "Invites::UserInvite"/, content)
1344
+ assert_match(/has_many :funder_invites, class_name: "Invites::FunderInvite"/, content)
1345
+ end
1346
+
1347
+ # Welcome controller invite_classes lists both.
1348
+ assert_file "packages/invites/app/controllers/invites/welcome_controller.rb" do |content|
1349
+ assert_match(/::Invites::UserInvite/, content)
1350
+ assert_match(/::Invites::FunderInvite/, content)
1351
+ end
1352
+ end
1353
+ ```
1354
+
1355
+ - [ ] **Step 2: Run the test**
1356
+
1357
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb -n test_dual_invocation_yields_independent_flows`
1358
+ Expected: test passes (Tasks 4–7 should have laid the foundation).
1359
+
1360
+ - [ ] **Step 3: Run the entire generator test file**
1361
+
1362
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb`
1363
+ Expected: all existing + all new tests pass; total run count grew from 48 → ~55.
1364
+
1365
+ - [ ] **Step 4: Commit**
1366
+
1367
+ ```bash
1368
+ git add test/generators/invites_install_generator_test.rb
1369
+ git commit -m "test(invites/generator): cover dual-invocation independent flows"
1370
+ ```
1371
+
1372
+ ---
1373
+
1374
+ ## Task 9: Update skill + guide docs
1375
+
1376
+ **Goal:** Document the new `--invite_model` option and multi-flow welcome integration in both the user-facing guide and the AI skill.
1377
+
1378
+ **Files:**
1379
+ - Modify: `.claude/skills/plutonium-invites/SKILL.md`
1380
+ - Modify: `docs/guides/user-invites.md`
1381
+
1382
+ **Acceptance Criteria:**
1383
+ - [ ] Skill includes a "Multiple invite flows" section with the two-invocation example.
1384
+ - [ ] Guide includes the same in user-facing prose, plus a note on how `pending_invite` traverses all classes.
1385
+ - [ ] Both mention the `invite_classes` Array hook and the `invitation_path_for` override.
1386
+
1387
+ **Verify:** `yarn docs:build` (in `docs/`) → no broken-link errors. (Optional: visually inspect the rendered guide.)
1388
+
1389
+ **Steps:**
1390
+
1391
+ - [ ] **Step 1: Read the current skill to find the right insertion point**
1392
+
1393
+ Run: `head -80 .claude/skills/plutonium-invites/SKILL.md`
1394
+
1395
+ Find the section listing options for `pu:invites:install`. Add a sub-section after it.
1396
+
1397
+ - [ ] **Step 2: Append the multi-flow section to the skill**
1398
+
1399
+ Append the following to `.claude/skills/plutonium-invites/SKILL.md` (under the install options or examples section):
1400
+
1401
+ ```markdown
1402
+ ## Multiple invite flows in one app
1403
+
1404
+ Run `pu:invites:install` once per entity type. Pass `--invite_model=<Class>` to scope every per-flow file, route, and class:
1405
+
1406
+ ```bash
1407
+ rails g pu:invites:install \
1408
+ --entity_model=FunderOrganization \
1409
+ --user_model=SpenderAccount \
1410
+ --invite_model=FunderInvite
1411
+
1412
+ rails g pu:invites:install \
1413
+ --entity_model=Project \
1414
+ --user_model=Member \
1415
+ --invite_model=ProjectInvite
1416
+ ```
1417
+
1418
+ Each invocation creates an independent flow: model `Invites::FunderInvite` on `funder_invites`, controller `Invites::FunderInvitationsController` on `/funder_invitations/:token`, helper `funder_invitation_path`, etc. 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).
1419
+
1420
+ Override hooks at the model level:
1421
+ - `def user_attribute; :spender_account; end` — when `belongs_to :spender_account` instead of `:user`.
1422
+ - `def invite_entity_attribute; :funder_organization; end` — when `belongs_to :funder_organization` instead of `:entity`.
1423
+
1424
+ Override hooks at the controller level (auto-generated by the install generator):
1425
+ - `def invite_classes; [::Invites::UserInvite, ::Invites::FunderInvite]; end` on `WelcomeController`.
1426
+ - `def invitation_path_for(token); funder_invitation_path(token: token); end` on each invitations controller.
1427
+ ```
1428
+
1429
+ - [ ] **Step 3: Append a similar section to the docs guide**
1430
+
1431
+ Append to `docs/guides/user-invites.md`:
1432
+
1433
+ ```markdown
1434
+ ## Multiple invite flows in one app
1435
+
1436
+ Some apps invite users to several distinct kinds of entity. Run `pu:invites:install` once per kind, passing `--invite_model` to scope class names, table names, and routes:
1437
+
1438
+ ```bash
1439
+ rails g pu:invites:install \
1440
+ --entity_model=FunderOrganization \
1441
+ --user_model=SpenderAccount \
1442
+ --invite_model=FunderInvite
1443
+ ```
1444
+
1445
+ Each invocation produces independent migrations, models, policies, definitions, mailers, controllers, view templates, and route helpers. The shared `Invites::WelcomeController` keeps a running list of invite classes; after-login checks consult all of them and use the first matching token.
1446
+
1447
+ If you need to plug a third-party invite class into the welcome flow, override `invite_classes` directly:
1448
+
1449
+ ```ruby
1450
+ class WelcomeController < ApplicationController
1451
+ include Plutonium::Invites::PendingInviteCheck
1452
+
1453
+ def invite_classes
1454
+ [::Invites::UserInvite, ::Invites::FunderInvite, ::Foreign::ApiInvite]
1455
+ end
1456
+ end
1457
+ ```
1458
+ ```
1459
+
1460
+ - [ ] **Step 4: Validate docs build**
1461
+
1462
+ Run: `cd docs && yarn docs:build`
1463
+ Expected: build completes; no broken links touching the user-invites guide.
1464
+
1465
+ - [ ] **Step 5: Commit**
1466
+
1467
+ ```bash
1468
+ git add .claude/skills/plutonium-invites/SKILL.md docs/guides/user-invites.md
1469
+ git commit -m "docs(invites): document multi-invite-model support"
1470
+ ```
1471
+
1472
+ ---
1473
+
1474
+ ## Final integration check
1475
+
1476
+ Run the complete test suite for both Rails 8.0 and 8.1 to confirm no global regressions:
1477
+
1478
+ ```bash
1479
+ bundle exec appraisal rails-8.0 ruby -Itest test/generators/invites_install_generator_test.rb
1480
+ bundle exec appraisal rails-8.0 ruby -Itest test/plutonium/invites/pending_invite_check_test.rb
1481
+ bundle exec appraisal rails-8.0 ruby -Itest test/plutonium/invites/controller_test.rb
1482
+ bundle exec appraisal rails-8.1 ruby -Itest test/generators/invites_install_generator_test.rb
1483
+ bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/pending_invite_check_test.rb
1484
+ bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/invites/controller_test.rb
1485
+ ```
1486
+
1487
+ All green → ready to push and tag a release.