Repository navigation
feat(notifications): RSVP emails (E1, E2, E3) — closes #116 - #132
Merged
Merged
Conversation
Confirms an RSVP to the rsvper themselves, with an .ics attachment so they can save the huddl to their calendar. Mirrors the D1/D2 reminder sender pattern (re-reads the huddl row at send time to build the live .ics). The duplicate-RSVP path in Huddl.Changes.Rsvp returns the changeset unchanged when an attendee row already exists. To avoid emailing on those no-op updates, the change now sets a one-off :rsvp_created context flag only on the actual create path, and the notify-change skips when the flag is absent. Capacity rejection already short-circuits via a before_action error, so no email fires in that case either. E1 (organizer fanout) and E2 (cancellation fanout) ship in subsequent commits.
When a user RSVPs to a huddl, every owner and organizer of the group gets an "X RSVPd to Y" email. The recipient set is deduped and excludes the actor: a self-RSVPing organizer is not their own audience. Mirrors the B1 NotifyJoined fanout pattern but reads owner+organizer membership from the huddl's group rather than directly from a GroupMember create. Reuses the :rsvp_created context flag from the E3 commit so duplicate RSVPs and capacity-rejected RSVPs don't fire E1 either. The orchestrator's "raises when sender not implemented" test moved from :rsvp_received to :weekly_digest, since the former is now implemented and the latter (F-series) remains stubbed for v2.
When a user cancels their RSVP, every owner and organizer of the huddl's group gets an "X cancelled their RSVP to Y" email. The recipient set is deduped and excludes the actor. Per the spec there is no E4 confirmation to the cancelling user themselves. Mirrors the E1 NotifyRsvpReceived shape exactly. The CancelRsvp change now sets a :rsvp_cancelled context flag on the actual destroy path (parallel to E1's :rsvp_created) so a cancel against a nonexistent RSVP — which is a no-op the action still allows — does not fire.
Both NotifyRsvpReceived and NotifyRsvpCancelled had nearly identical private helpers for resolving the owner/organizer recipient set and looking up the rsvper's display name. Move them into RecipientHelpers where rsvp_user_ids/2 and actor_id/1 already live. - Add RecipientHelpers.group_organizer_user_ids/2 (owners + organizers, deduped, optional :exclude actor). - Add RecipientHelpers.user_display_name/2 (looks up display_name with a caller-supplied fallback). - Drop the duplicated private helpers from both Notify* modules. No behavior change; -44 / +39, mix precommit clean.
- Lift `deliver_each/3` from `NotifyMeaningfulUpdate` into `RecipientHelpers` so all three E-series notify changes (`NotifyRsvpReceived`, `NotifyRsvpCancelled`, `NotifyRsvpConfirmation`) use the same helper instead of duplicating the per-user `Ash.get(User, id) → Notifications.deliver_async` loop. - Pull the actor struct directly from `cs.context[:private][:actor]` in the rsvp notify changes. The `Rsvp`/`CancelRsvp` changes already error if no actor is present, so the prior `is_nil(actor_id)` cond-branch was unreachable, and reading display_name off the loaded actor avoids an extra `Ash.get` round-trip. - Drop the now-unused `user_display_name/2` helper. - Drop `starts_at_iso` from the E1/E2 payload — the senders never read it.
User-controlled fields like display_name, Group.name, and Huddl.title allow CR/LF — on a poorly-behaved transport that can corrupt or inject email headers when interpolated into Subject. Add a small `HeaderSafe` helper that strips control characters and collapses whitespace, and apply it to every sender that interpolates user-controlled data into its subject line. Senders with constant subjects (account_role_changed, email_changed, password_changed) are unaffected.
Hardcoded `assert %{success: N} = Oban.drain_queue(...)` is brittle:
the count breaks the test as soon as anything else attaches an email
to the same action, even though the per-recipient `assert_email_sent`
calls below still cover the actual contract. Replace the success-count
assertion with `%{failure: 0}` so we still catch sender exceptions
without locking in the exact number of jobs.
`RsvpReceivedTest` and `RsvpCancelledTest` both built the same payload fixture inline. Lift it into `Huddlz.Test.Helpers.RsvpFanoutPayload` so the two test files only carry the parts that actually differ (subject string, fallback subject). Drops the dead `starts_at_iso` key the helper used to set — neither E1 nor E2 reads it.
…lpers Four notify modules still hand-rolled `for user_id <- ids; Ash.get(User, ...)` fanout loops that the new `RecipientHelpers.deliver_each/3` was extracted to subsume: - `Huddl.Changes.NotifyCancelled` (C3) - `Huddl.Changes.NotifyNewInGroup` (C1) - `Huddl.Changes.SendReminder` (D1/D2) - `GroupMember.Changes.NotifyJoined` (B1) `NotifyJoined` additionally hand-rolled the same owner+organizer query that `RecipientHelpers.group_organizer_user_ids/2` now provides; collapsed both helpers into the shared module. No behavior change. Drops the unused `Accounts.User` / `Notifications` aliases from each module.
The "Per-recipient assertions below cover what was sent. Drain asserts no jobs failed." comment restated what the next 5 lines already showed. Drop.
This was referenced May 1, 2026
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
Ships the E-series RSVP email notifications, completing the trigger set from
docs/notifications.md§ E.rsvp_confirmation— confirmation email to the user who RSVPd, with an.icscalendar attachment. Mirrors the D1/D2 reminder sender pattern (re-reads the huddl row at send time so the.icsis live).rsvp_received— fanout to the group's owner + organizers when someone RSVPs. Deduped, actor excluded.rsvp_cancelled— same fanout when someone cancels their RSVP. Per spec, no E4 confirmation to the cancelling user.Three small atomic commits, one trigger each, each
mix precommitclean.Implementation notes
Huddl.:rsvpandHuddl.:cancel_rsvp(lib/huddlz/communities/huddl.ex:289-302), matching the establishedNotifyCancelled/NotifyMeaningfulUpdate/NotifyJoinedpattern. The issue'sAsh.Notifier on HuddlAttendeeframing predates that pattern.RsvpandCancelRsvpchanges return the changeset unchanged on the duplicate-RSVP / no-op-cancel paths, which would otherwise trigger spurious emails. Each now sets a one-offcs.context[:rsvp_created]/:rsvp_cancelledflag only on the actual create/destroy path; the notify-changes skip when the flag is absent. Capacity rejection already short-circuits via abefore_actionerror, so no extra guard is needed there.:rsvp_receivedto:weekly_digest, since the former is now implemented and the latter (F-series digest) remains stubbed for v2.Closes #116.
Test plan
mix precommitclean on every commit (1014 tests, credo clean).icsattachment for E3)test/huddlz/notifications/rsvp_notifications_test.exscover:.icsattachment/dev/mailboxonce the branch is reviewed