8000 Email notifications: group membership (B1-B7) by mwoods79 · Pull Request #129 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

Skip to content

Email notifications: group membership (B1-B7) - #129

Merged
mwoods79 merged 9 commits into
mainfrom
feat/group-membership-notifications
Apr 30, 2026
Merged

mwoods79 merged 9 commits into
mainfrom
feat/group-membership-notifications

Conversation

@mwoods79
Copy link
Copy Markdown
Contributor

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.

Trigger Action Category Recipient
B1 group_member_joined GroupMember.:join_group Activity owner + organizers (deduped, joiner excluded)
B2 group_member_added GroupMember.:add_member Activity added user (private groups only)
B3 group_member_removed GroupMember.:remove_member Transactional removed user
B4 group_role_changed GroupMember.:change_role (new) Activity the user
B6 group_archived Group.:destroy Transactional every member at delete time
B7 group_ownership_transferred Group.:transfer_ownership (new) Transactional old + new owner

New resource actions

  • GroupMember.:change_role — owner-only, accepts :member/:organizer. :owner is rejected (use transfer_ownership). Owners can't change their own role.
  • Group.:transfer_ownership — owner-only, swaps owner_id and 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 pass authorize?: false.

Notes on the Ash.Notifier hint in the issue

The issue suggested Ash.Notifier. I used after_action changes 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 what after_action does and Ash.Notifier does not (it fires after the transaction closes). Also matches the existing User.change_password pattern.

Out of scope

  • B6 currently fires on hard :destroy. A soft-delete :archive action is tracked separately in Group archive (soft delete) action #128.
  • Daily cap on B1 for viral public groups — explicitly v2 per docs/notifications.md.

Test plan

  • All 6 sender unit tests (subject/body/escape/footer presence)
  • Integration tests per trigger using Oban.drain_queue/1 + Swoosh.TestAssertions
  • New action tests (:change_role, :transfer_ownership, :set_role policy)
  • No regressions in existing GroupMember/Group tests (107 total, all green)
  • Full suite green (mix precommit: 883 tests, credo --strict clean, formatter clean)
  • Manual smoke in /dev/mailbox: trigger each action and confirm the email renders correctly

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
mwoods79 force-pushed the feat/group-membership-notifications branch from c24073f to fddc5cf Compare April 30, 2026 19:33
@mwoods79
mwoods79 merged commit d938b2e into main Apr 30, 2026
1 check passed
@mwoods79
mwoods79 deleted the feat/group-membership-notifications branch April 30, 2026 19:37
@mwoods79
Copy link
Copy Markdown
Contributor Author

closes :#117

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0