plutonium 0.39.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-connect-resource/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-controller/SKILL.md +5 -9
  4. data/.claude/skills/plutonium-definition-query/SKILL.md +10 -2
  5. data/.claude/skills/plutonium-installation/SKILL.md +9 -7
  6. data/.claude/skills/plutonium-invites/SKILL.md +363 -0
  7. data/.claude/skills/plutonium-package/SKILL.md +2 -1
  8. data/.claude/skills/plutonium-portal/SKILL.md +30 -16
  9. data/.claude/skills/plutonium-rodauth/SKILL.md +111 -18
  10. data/CHANGELOG.md +48 -0
  11. data/app/assets/plutonium.css +1 -1
  12. data/config/initializers/sqlite_alias.rb +8 -8
  13. data/docs/.vitepress/config.ts +1 -0
  14. data/docs/getting-started/tutorial/07-author-portal.md +1 -0
  15. data/docs/getting-started/tutorial/08-customizing-ui.md +5 -2
  16. data/docs/guides/adding-resources.md +10 -0
  17. data/docs/guides/authentication.md +15 -8
  18. data/docs/guides/creating-packages.md +13 -8
  19. data/docs/guides/index.md +2 -0
  20. data/docs/guides/search-filtering.md +8 -3
  21. data/docs/guides/user-invites.md +497 -0
  22. data/docs/public/templates/base.rb +5 -1
  23. data/docs/public/templates/lite.rb +42 -0
  24. data/docs/public/templates/pluton8.rb +7 -2
  25. data/docs/reference/controller/index.md +12 -7
  26. data/docs/reference/definition/query.md +12 -3
  27. data/docs/reference/generators/index.md +70 -10
  28. data/docs/reference/portal/index.md +22 -11
  29. data/gemfiles/rails_7.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  31. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  32. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +31 -0
  33. data/lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt +58 -0
  34. data/lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake +6 -1
  35. data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -0
  36. data/lib/generators/pu/invites/USAGE +27 -0
  37. data/lib/generators/pu/invites/install_generator.rb +364 -0
  38. data/lib/generators/pu/invites/invitable/USAGE +31 -0
  39. data/lib/generators/pu/invites/invitable_generator.rb +143 -0
  40. data/lib/generators/pu/invites/templates/INSTRUCTIONS +22 -0
  41. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +24 -0
  42. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +26 -0
  43. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +47 -0
  44. data/lib/generators/pu/invites/templates/invitable/invitation.html.erb.tt +45 -0
  45. data/lib/generators/pu/invites/templates/invitable/invitation.text.erb.tt +15 -0
  46. data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +33 -0
  47. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +77 -0
  48. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +68 -0
  49. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +23 -0
  50. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +7 -0
  51. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/resend_invite_interaction.rb.tt +7 -0
  52. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +34 -0
  53. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +41 -0
  54. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +33 -0
  55. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/error.html.erb.tt +24 -0
  56. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +40 -0
  57. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +39 -0
  58. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +49 -0
  59. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +45 -0
  60. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +15 -0
  61. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +23 -0
  62. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +33 -0
  63. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +23 -2
  64. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +130 -0
  65. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +72 -0
  66. data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -2
  67. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +7 -1
  68. data/lib/generators/pu/lite/litestream/litestream_generator.rb +105 -0
  69. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +88 -0
  70. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +14 -0
  71. data/lib/generators/pu/lite/setup/setup_generator.rb +54 -0
  72. data/lib/generators/pu/lite/solid_cable/solid_cable_generator.rb +65 -0
  73. data/lib/generators/pu/lite/solid_cache/solid_cache_generator.rb +66 -0
  74. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +61 -0
  75. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +107 -0
  76. data/lib/generators/pu/pkg/portal/USAGE +8 -2
  77. data/lib/generators/pu/pkg/portal/portal_generator.rb +11 -1
  78. data/lib/generators/pu/pkg/portal/templates/app/controllers/concerns/controller.rb.tt +2 -0
  79. data/lib/generators/pu/pkg/portal/templates/app/controllers/plutonium_controller.rb.tt +1 -0
  80. data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +7 -0
  81. data/lib/generators/pu/pkg/portal/templates/lib/engine.rb.tt +3 -0
  82. data/lib/generators/pu/res/conn/USAGE +5 -0
  83. data/lib/generators/pu/res/conn/conn_generator.rb +30 -4
  84. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +6 -3
  85. data/lib/generators/pu/res/scaffold/templates/policy.rb.tt +6 -6
  86. data/lib/generators/pu/rodauth/account_generator.rb +36 -11
  87. data/lib/generators/pu/rodauth/admin_generator.rb +55 -0
  88. data/lib/generators/pu/rodauth/install_generator.rb +1 -8
  89. data/lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt +25 -0
  90. data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +6 -2
  91. data/lib/generators/pu/saas/USAGE +22 -0
  92. data/lib/generators/pu/saas/entity/USAGE +19 -0
  93. data/lib/generators/pu/saas/entity_generator.rb +55 -0
  94. data/lib/generators/pu/saas/membership/USAGE +25 -0
  95. data/lib/generators/pu/saas/membership_generator.rb +165 -0
  96. data/lib/generators/pu/saas/setup/USAGE +27 -0
  97. data/lib/generators/pu/saas/setup_generator.rb +98 -0
  98. data/lib/generators/pu/saas/user/USAGE +21 -0
  99. data/lib/generators/pu/saas/user_generator.rb +66 -0
  100. data/lib/plutonium/core/controller.rb +9 -5
  101. data/lib/plutonium/definition/base.rb +3 -1
  102. data/lib/plutonium/definition/scoping.rb +20 -0
  103. data/lib/plutonium/invites/concerns/cancel_invite.rb +44 -0
  104. data/lib/plutonium/invites/concerns/invitable.rb +98 -0
  105. data/lib/plutonium/invites/concerns/invite_token.rb +186 -0
  106. data/lib/plutonium/invites/concerns/invite_user.rb +147 -0
  107. data/lib/plutonium/invites/concerns/resend_invite.rb +66 -0
  108. data/lib/plutonium/invites/controller.rb +226 -0
  109. data/lib/plutonium/invites/pending_invite_check.rb +76 -0
  110. data/lib/plutonium/invites.rb +6 -0
  111. data/lib/plutonium/resource/controllers/queryable.rb +4 -0
  112. data/lib/plutonium/resource/query_object.rb +3 -5
  113. data/lib/plutonium/version.rb +1 -1
  114. data/package.json +1 -1
  115. metadata +64 -7
  116. data/lib/generators/pu/res/entity/entity_generator.rb +0 -158
  117. data/lib/generators/pu/rodauth/customer_generator.rb +0 -101
  118. data/public/plutonium-assets/plutonium-logo-original.png +0 -0
  119. data/public/plutonium-assets/plutonium-logo-white.png +0 -0
  120. data/public/plutonium-assets/plutonium-logo.png +0 -0
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Invites
5
+ module Concerns
6
+ # Invitable allows any model to trigger invites and be notified on acceptance.
7
+ #
8
+ # This pattern is useful when you have a profile or record that needs to
9
+ # invite a user and then connect itself to that user after acceptance.
10
+ #
11
+ # @example TenantProfile that invites users
12
+ # class TenantProfile < ApplicationRecord
13
+ # include Plutonium::Resource::Record
14
+ # include Plutonium::Invites::Concerns::Invitable
15
+ #
16
+ # belongs_to :entity
17
+ # belongs_to :user, optional: true
18
+ #
19
+ # def on_invite_accepted(user)
20
+ # update!(user: user)
21
+ # end
22
+ # end
23
+ #
24
+ # @example Creating an invite from an invitable
25
+ # tenant_profile.create_invite!(
26
+ # email: tenant_profile.email,
27
+ # entity: tenant_profile.entity,
28
+ # invited_by: current_user,
29
+ # role: :member,
30
+ # email_template: "tenant"
31
+ # )
32
+ #
33
+ module Invitable
34
+ extend ActiveSupport::Concern
35
+
36
+ included do
37
+ # Association to the pending user invite for this record.
38
+ # Scoped to pending only - cancelled/expired/accepted invites are kept for audit.
39
+ has_one :user_invite, -> { pending }, class_name: "Invites::UserInvite", as: :invitable
40
+ end
41
+
42
+ # Create an invite for this invitable.
43
+ #
44
+ # If there's already a pending invite for this invitable, it will be
45
+ # destroyed and replaced with a new one.
46
+ #
47
+ # @param email [String] the email address to invite
48
+ # @param entity [Object] the entity to join
49
+ # @param invited_by [Object] the user creating the invite
50
+ # @param role [Symbol, String] the role to assign (default: nil, uses model default)
51
+ # @param email_template [String, nil] optional template type for email customization
52
+ # @return [Object] the created invite record
53
+ def create_invite!(email:, entity:, invited_by:, role: nil, email_template: nil)
54
+ # Cancel any existing pending invite first (association is already scoped to pending)
55
+ user_invite&.cancelled!
56
+
57
+ attrs = {
58
+ email: email,
59
+ entity: entity,
60
+ invited_by: invited_by,
61
+ email_template: email_template
62
+ }
63
+ attrs[:role] = role if role.present?
64
+
65
+ create_user_invite!(attrs)
66
+ end
67
+
68
+ # Check if there's an active pending invite.
69
+ #
70
+ # @return [Boolean] true if there's a pending invite
71
+ def has_pending_invite?
72
+ user_invite.present?
73
+ end
74
+
75
+ # Check if this invitable can receive an invitation.
76
+ #
77
+ # Override this method to customize the logic. The default implementation
78
+ # returns true if no user is attached and no pending invite exists.
79
+ #
80
+ # @return [Boolean] true if invitation can be sent
81
+ def can_invite_user?
82
+ !user.present? && !has_pending_invite?
83
+ end
84
+
85
+ # Called when the invited user accepts and joins the entity.
86
+ #
87
+ # Override this method in your model to handle the acceptance,
88
+ # typically to connect the invitable to the user.
89
+ #
90
+ # @param user [Object] the user who accepted the invite
91
+ # @raise [NotImplementedError] if not overridden
92
+ def on_invite_accepted(user)
93
+ raise NotImplementedError, "#{self.class.name} must implement #on_invite_accepted(user)"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Invites
5
+ module Concerns
6
+ # InviteToken provides core invite functionality for models.
7
+ #
8
+ # This concern handles:
9
+ # - Token generation and validation
10
+ # - State machine (pending, accepted, expired, cancelled)
11
+ # - Email constraint validation
12
+ # - Invite acceptance flow
13
+ #
14
+ # @example Basic usage
15
+ # class UserInvite < ApplicationRecord
16
+ # include Plutonium::Resource::Record
17
+ # include Plutonium::Invites::Concerns::InviteToken
18
+ #
19
+ # belongs_to :entity
20
+ # belongs_to :invited_by, polymorphic: true
21
+ # belongs_to :user, optional: true
22
+ # belongs_to :invitable, polymorphic: true, optional: true
23
+ #
24
+ # enum :role, member: 0, admin: 1
25
+ #
26
+ # def invitation_mailer
27
+ # UserInviteMailer
28
+ # end
29
+ #
30
+ # def create_membership_for(user)
31
+ # EntityMembership.create!(entity: entity, user: user, role: role)
32
+ # end
33
+ # end
34
+ #
35
+ module InviteToken
36
+ extend ActiveSupport::Concern
37
+
38
+ included do
39
+ # State machine for invite lifecycle
40
+ enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
41
+
42
+ # Callbacks
43
+ before_validation :set_token_defaults, on: :create
44
+ after_create :send_invitation_email
45
+
46
+ # Core validations
47
+ validates :email, presence: true
48
+ validates :token, presence: true
49
+ validates :state, presence: true
50
+ end
51
+
52
+ class_methods do
53
+ # Find a valid invitation for acceptance.
54
+ #
55
+ # Returns nil if the token is invalid, expired, or already accepted.
56
+ # Automatically marks expired invites as expired.
57
+ #
58
+ # @param token [String] the invitation token
59
+ # @return [Object, nil] the invite record or nil
60
+ def find_for_acceptance(token)
61
+ return nil if token.blank?
62
+
63
+ invite = find_by(token: token)
64
+ return nil unless invite
65
+
66
+ # Check if invitation is expired
67
+ if invite.expires_at && invite.expires_at < Time.current
68
+ invite.expired! if invite.pending?
69
+ return nil
70
+ end
71
+
72
+ # Only pending invites can be accepted
73
+ return nil unless invite.pending?
74
+
75
+ invite
76
+ end
77
+ end
78
+
79
+ # Override in subclass to enforce email domain matching.
80
+ #
81
+ # @return [String, nil] the domain to enforce, or nil to skip domain check
82
+ def enforce_domain
83
+ nil
84
+ end
85
+
86
+ # Override in subclass to require exact email match.
87
+ #
88
+ # @return [Boolean] true to require exact email match
89
+ def enforce_email?
90
+ true
91
+ end
92
+
93
+ # Validate email constraints against the accepting user's email.
94
+ #
95
+ # @param user_email [String] the email of the user accepting the invite
96
+ # @raise [ActiveRecord::RecordInvalid] if constraints are violated
97
+ def validate_email_constraints!(user_email)
98
+ if enforce_email? && user_email.downcase != email.downcase
99
+ errors.add(:base, "This invitation is for #{email}. You must use an account with that email address.")
100
+ raise ActiveRecord::RecordInvalid.new(self)
101
+ end
102
+
103
+ if (required_domain = enforce_domain)
104
+ user_domain = extract_domain(user_email)
105
+
106
+ if user_domain != required_domain
107
+ errors.add(:base, "This invitation requires an email from the #{required_domain} domain.")
108
+ raise ActiveRecord::RecordInvalid.new(self)
109
+ end
110
+ end
111
+ end
112
+
113
+ # Accept the invitation for a user.
114
+ #
115
+ # This method:
116
+ # 1. Validates email constraints
117
+ # 2. Marks the invite as accepted
118
+ # 3. Creates the entity membership
119
+ # 4. Notifies the invitable (if present)
120
+ #
121
+ # @param user [Object] the user accepting the invitation
122
+ # @raise [ActiveRecord::RecordInvalid] if acceptance fails
123
+ def accept_for_user!(user)
124
+ validate_email_constraints!(user.email)
125
+
126
+ transaction do
127
+ update!(
128
+ state: :accepted,
129
+ accepted_at: Time.current,
130
+ user: user
131
+ )
132
+
133
+ create_membership_for(user)
134
+ notify_invitable(user)
135
+ end
136
+ end
137
+
138
+ # Override this method to specify the mailer class.
139
+ #
140
+ # @return [Class] the mailer class for sending invitation emails
141
+ # @raise [NotImplementedError] if not overridden
142
+ def invitation_mailer
143
+ raise NotImplementedError, "#{self.class}#invitation_mailer must be implemented to return the mailer class"
144
+ end
145
+
146
+ # Override this method to create the entity membership.
147
+ #
148
+ # @param user [Object] the user who accepted the invitation
149
+ # @raise [NotImplementedError] if not overridden
150
+ def create_membership_for(user)
151
+ raise NotImplementedError, "#{self.class}#create_membership_for must be implemented to create the membership record"
152
+ end
153
+
154
+ # Alias method for the entity association.
155
+ # Override if your entity association has a different name.
156
+ #
157
+ # @return [Object] the entity record
158
+ def entity
159
+ raise NotImplementedError, "#{self.class}#entity must be implemented or an entity association must exist"
160
+ end
161
+
162
+ private
163
+
164
+ def extract_domain(email)
165
+ return nil unless email&.include?("@")
166
+ email.split("@").last&.downcase
167
+ end
168
+
169
+ def set_token_defaults
170
+ self.token ||= SecureRandom.urlsafe_base64(32)
171
+ self.expires_at ||= 1.week.from_now
172
+ end
173
+
174
+ def send_invitation_email
175
+ invitation_mailer.invitation(self).deliver_later
176
+ end
177
+
178
+ def notify_invitable(user)
179
+ return unless invitable_id.present?
180
+
181
+ invitable.on_invite_accepted(user)
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Invites
5
+ module Concerns
6
+ # InviteUser provides the core logic for inviting users to an entity.
7
+ #
8
+ # Include this in your InviteUserInteraction and implement the required methods.
9
+ #
10
+ # @example Basic usage with polymorphic entity
11
+ # class Organization::InviteUserInteraction < Plutonium::Resource::Interaction
12
+ # include Plutonium::Invites::Concerns::InviteUser
13
+ #
14
+ # def invite_class
15
+ # Invites::UserInvite
16
+ # end
17
+ #
18
+ # def membership_class
19
+ # OrganizationMembership
20
+ # end
21
+ # end
22
+ #
23
+ module InviteUser
24
+ extend ActiveSupport::Concern
25
+
26
+ included do
27
+ presents label: "Invite User", icon: Phlex::TablerIcons::Mail
28
+
29
+ attribute :resource
30
+ attribute :email
31
+
32
+ validates :email, presence: true
33
+ validate :role_is_present
34
+ validate :user_not_already_member
35
+ validate :no_pending_invitation
36
+ end
37
+
38
+ def execute
39
+ attrs = {
40
+ entity: entity,
41
+ email: email,
42
+ role: role,
43
+ invited_by: current_user,
44
+ **additional_invite_attributes
45
+ }
46
+ attrs[:invitable] = invitable if invitable.present?
47
+
48
+ invite_class.create!(attrs)
49
+
50
+ succeed(resource).with_message(success_message)
51
+ rescue ActiveRecord::RecordInvalid => e
52
+ failed(e.record.errors)
53
+ end
54
+
55
+ private
56
+
57
+ # Override to specify the invite model class
58
+ # @return [Class]
59
+ def invite_class
60
+ Invites::UserInvite
61
+ end
62
+
63
+ # Override to specify the membership model class
64
+ # @return [Class]
65
+ def membership_class
66
+ EntityMembership
67
+ end
68
+
69
+ # Override to specify the user model class
70
+ # @return [Class]
71
+ def user_class
72
+ User
73
+ end
74
+
75
+ # Override to specify how to get the entity from the resource.
76
+ # By default assumes resource IS the entity.
77
+ # @return [Object]
78
+ def entity
79
+ resource
80
+ end
81
+
82
+ # Override to specify the invitable (model that triggered the invite).
83
+ # By default returns nil (no invitable).
84
+ # @return [Object, nil]
85
+ def invitable
86
+ nil
87
+ end
88
+
89
+ # Override to specify the role to assign
90
+ # @return [Symbol, String]
91
+ def role
92
+ raise NotImplementedError, "#{self.class}#role must return the role to assign"
93
+ end
94
+
95
+ # Override to add additional attributes when creating the invite
96
+ # @return [Hash]
97
+ def additional_invite_attributes
98
+ {}
99
+ end
100
+
101
+ # Override to customize success message
102
+ def success_message
103
+ "Invitation sent to #{email}"
104
+ end
105
+
106
+ # Override to specify the entity association name on membership
107
+ # @return [Symbol]
108
+ def membership_entity_attribute
109
+ entity.class.name.underscore.to_sym
110
+ end
111
+
112
+ def role_is_present
113
+ errors.add(:role, :blank) if role.blank?
114
+ end
115
+
116
+ def user_not_already_member
117
+ return unless email.present? && entity.present?
118
+
119
+ existing_user = user_class.find_by(email: email)
120
+ return unless existing_user
121
+
122
+ membership = membership_class.find_by(
123
+ membership_entity_attribute => entity,
124
+ user_association => existing_user
125
+ )
126
+ errors.add(:email, "is already a member") if membership
127
+ end
128
+
129
+ def no_pending_invitation
130
+ return unless email.present? && entity.present?
131
+
132
+ pending = invite_class.find_by(
133
+ entity: entity,
134
+ email: email,
135
+ state: :pending
136
+ )
137
+ errors.add(:email, "already has a pending invitation") if pending
138
+ end
139
+
140
+ # Override if user association has a different name
141
+ def user_association
142
+ :user
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Invites
5
+ module Concerns
6
+ # ResendInvite provides the core logic for resending invitations.
7
+ #
8
+ # Include this in your ResendInviteInteraction to get the default behavior,
9
+ # then override methods as needed.
10
+ #
11
+ # @example Basic usage
12
+ # class ResendInviteInteraction < Plutonium::Resource::Interaction
13
+ # include Plutonium::Invites::Concerns::ResendInvite
14
+ # end
15
+ #
16
+ # @example With custom expiry
17
+ # class ResendInviteInteraction < Plutonium::Resource::Interaction
18
+ # include Plutonium::Invites::Concerns::ResendInvite
19
+ #
20
+ # def new_expiry
21
+ # 2.weeks.from_now
22
+ # end
23
+ # end
24
+ #
25
+ module ResendInvite
26
+ extend ActiveSupport::Concern
27
+
28
+ included do
29
+ presents label: "Resend Invitation", icon: Phlex::TablerIcons::MailForward
30
+
31
+ attribute :resource
32
+ end
33
+
34
+ def execute
35
+ unless resource.pending?
36
+ return failed("Can only resend pending invitations")
37
+ end
38
+
39
+ resource.update!(expires_at: new_expiry)
40
+ send_invitation_email
41
+
42
+ succeed(resource).with_message(success_message)
43
+ rescue => error
44
+ failed("Failed to resend: #{error.message}")
45
+ end
46
+
47
+ private
48
+
49
+ # Override to customize expiry duration
50
+ def new_expiry
51
+ 1.week.from_now
52
+ end
53
+
54
+ # Override to customize email sending
55
+ def send_invitation_email
56
+ resource.invitation_mailer.invitation(resource).deliver_later
57
+ end
58
+
59
+ # Override to customize success message
60
+ def success_message
61
+ "Invitation resent to #{resource.email}"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end