plutonium 0.49.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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-invites/SKILL.md +41 -0
- data/CHANGELOG.md +15 -0
- data/app/assets/plutonium.js +35 -0
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +24 -24
- data/app/assets/plutonium.min.js.map +3 -3
- data/app/views/plutonium/_flash.html.erb +1 -1
- data/docs/guides/user-invites.md +64 -0
- data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md +1487 -0
- data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/invites/install_generator.rb +136 -35
- data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
- data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
- data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
- data/lib/plutonium/invites/concerns/invite_token.rb +11 -3
- data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
- data/lib/plutonium/invites/controller.rb +14 -1
- data/lib/plutonium/invites/pending_invite_check.rb +37 -28
- data/lib/plutonium/resource/controllers/interactive_actions.rb +13 -9
- data/lib/plutonium/resource/policy.rb +23 -8
- data/lib/plutonium/ui/layout/sidebar.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/js/controllers/flatpickr_controller.js +23 -0
- data/src/js/controllers/sidebar_controller.js +28 -1
- metadata +5 -3
|
@@ -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.
|