Repository navigation
Email notifications: group membership (B1-B7) - #129
Merged
Merged
Conversation
New owner-only update action allowing the group owner to change a member's role between :member and :organizer. Promoting to :owner is explicitly rejected — use Group.:transfer_ownership for that. Owners also cannot change their own role. Lays the groundwork for B4 (group role changed) email notifications.
New owner-only update action that swaps the owner_id and reconciles GroupMember roles in the same transaction: - Old owner is demoted to :organizer. - New owner is promoted to :owner, joining the group if they were not already a member. Adds an internal :set_role action on GroupMember (always forbidden via policy; transfer_ownership calls it with `authorize?: false`) so the :change_role validation can keep rejecting promotions to :owner from public callers. Lays the groundwork for B7 (group ownership transferred) email notifications.
Wire the existing group_member_removed trigger to GroupMember.:remove_member via an after_action change. Recipient is the removed user; sender is transactional, so it ignores preferences and ships no unsubscribe footer. The change short-circuits when the actor is the user being removed (defensive — self-leaves should go through :leave_group). Includes the GroupMemberRemoved sender, sender unit tests, and an integration test that drives the action through Oban.
Wire group_member_joined to GroupMember.:join_group via an after_action change. Recipients are the owner and every organizer of the joined group, deduped, with the joining actor excluded. Activity category, so preferences apply and the email carries the standard unsubscribe footer. Adds a shared Notifications.Footer helper for unsubscribe + settings markup so future Activity senders can reuse it.
Wire group_member_added to GroupMember.:add_member via an after_action change. Per spec, fires only when the group is private — additions to public groups skip the email since users join those themselves. Skips the notification when the actor is the user being added. AddOwnerAsMember now propagates the actor on its self-add :add_member call so the owner doesn't get a B2 email when creating their own group.
Wire group_role_changed to GroupMember.:change_role via an after_action change. The sender resolves the previous and new roles, the group, and the recipient, then dispatches asynchronously. Skips delivery when the new role equals the prior role (no-op update).
Wire group_ownership_transferred to Group.:transfer_ownership. The
TransferOwnership change now enqueues two emails after the role swap
commits — one to the previous owner ("you transferred…") and one to
the new owner ("you're the new owner…"). Each variant is selected by
the "role" key in the payload.
NotifyAdded now skips B2 for :owner-role additions so the new owner
doesn't get a stray "you were added" email when transfer_ownership
auto-joins them to a private group.
Wire group_archived to Group.:destroy. The NotifyArchived change captures the group's member user_ids in before_action (because the GroupMember rows cascade-delete with the group) and fans out notifications in after_action once the destroy commits. The actor (the user destroying the group) is excluded from the recipients. Promotes :destroy to a primary explicit action so the change can hang off it. A future :archive (soft-delete) action will reuse the same sender — see #128. Also flattens a nested case in NotifyJoined to satisfy credo --strict.
Each new B-series sender introduced earlier in the branch had its own private defp escape/1. Main has since landed Senders.HtmlEscape.escape/1 as the canonical helper for HTML-escaping payload-supplied strings in sender modules. Drop the private helpers and call HtmlEscape.escape/1 directly to match the convention documented in docs/notifications.md § Sender conventions.
mwoods79
force-pushed
the
feat/group-membership-notifications
branch
from
April 30, 2026 19:33
c24073f to
fddc5cf
Compare
Contributor
Author
|
closes :#117 |
1 of 2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements all of issue #117 — six group-membership lifecycle emails (B1–B7, B5 intentionally skipped per spec) plus the two new resource actions they hang off of.
Each B-trigger gets its own commit with a sender, sender unit tests, an after_action change module, and an integration test that drives the full action → Oban → mailer path.
group_member_joinedGroupMember.:join_groupgroup_member_addedGroupMember.:add_membergroup_member_removedGroupMember.:remove_membergroup_role_changedGroupMember.:change_role(new)group_archivedGroup.:destroygroup_ownership_transferredGroup.:transfer_ownership(new)New resource actions
GroupMember.:change_role— owner-only, accepts:member/:organizer.:owneris rejected (use transfer_ownership). Owners can't change their own role.Group.:transfer_ownership— owner-only, swapsowner_idand reconciles GroupMember roles atomically (old owner → :organizer, new owner → :owner, auto-joining if not already a member).GroupMember.:set_role— internal-only escape hatch used by transfer_ownership; always forbidden by policy, callers must passauthorize?: false.Notes on the
Ash.Notifierhint in the issueThe issue suggested
Ash.Notifier. I usedafter_actionchanges instead because the Ash docs themselves (notifiers.html#when-you-really-need-an-event-to-happen) explicitly recommend committing Oban jobs in-transaction for events that must happen — which is whatafter_actiondoes andAsh.Notifierdoes not (it fires after the transaction closes). Also matches the existingUser.change_passwordpattern.Out of scope
:destroy. A soft-delete:archiveaction is tracked separately in Group archive (soft delete) action #128.docs/notifications.md.Test plan
Oban.drain_queue/1+Swoosh.TestAssertions:change_role,:transfer_ownership,:set_rolepolicy)mix precommit: 883 tests, credo --strict clean, formatter clean)/dev/mailbox: trigger each action and confirm the email renders correctly