8000 feat(notifications): RSVP emails (E1, E2, E3) — closes #116 by mwoods79 · Pull Request #132 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

Skip to content

feat(notifications): RSVP emails (E1, E2, E3) — closes #116 - #132

Merged
mwoods79 merged 10 commits into
mainfrom
feat/huddl-rsvp-notifications
May 1, 2026
Merged

mwoods79 merged 10 commits into
mainfrom
feat/huddl-rsvp-notifications

Conversation

@mwoods79
Copy link
Copy Markdown
Contributor

Summary

Ships the E-series RSVP email notifications, completing the trigger set from docs/notifications.md § E.

  • E3 rsvp_confirmation — confirmation email to the user who RSVPd, with an .ics calendar attachment. Mirrors the D1/D2 reminder sender pattern (re-reads the huddl row at send time so the .ics is live).
  • E1 rsvp_received — fanout to the group's owner + organizers when someone RSVPs. Deduped, actor excluded.
  • E2 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 precommit clean.

Implementation notes

  • Notifications are wired as Ash change modules attached to Huddl.:rsvp and Huddl.:cancel_rsvp (lib/huddlz/communities/huddl.ex:289-302), matching the established NotifyCancelled / NotifyMeaningfulUpdate / NotifyJoined pattern. The issue's Ash.Notifier on HuddlAttendee framing predates that pattern.
  • The Rsvp and CancelRsvp changes return the changeset unchanged on the duplicate-RSVP / no-op-cancel paths, which would otherwise trigger spurious emails. Each now sets a one-off cs.context[:rsvp_created] / :rsvp_cancelled flag only on the actual create/destroy path; the notify-changes skip when the flag is absent. Capacity rejection already short-circuits via a before_action error, so no extra guard is needed there.
  • 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 digest) remains stubbed for v2.

Closes #116.

Test plan

  • mix precommit clean on every commit (1014 tests, credo clean)
  • Sender unit tests assert greeting, subject, body link, footer, HTML escaping, missing-payload fallback (and .ics attachment for E3)
  • Integration tests in test/huddlz/notifications/rsvp_notifications_test.exs cover:
    • E3 happy path with .ics attachment
    • E3 does not fire on duplicate RSVP
    • E3 does not fire on capacity-rejected RSVP
    • E1 fans out to owner + every organizer, deduped
    • E1: actor who is also an organizer is excluded but still receives E3
    • E1 does not fire when the actor is the only owner/organizer
    • E2 fans out to owner + every organizer
    • E2 does not fire when cancelling a nonexistent RSVP
    • E2: actor who is also an organizer is excluded
  • Tidewave check: registry resolves all three new sender modules
  • Manual smoke in /dev/mailbox once the branch is reviewed

mwoods79 added 10 commits April 30, 2026 19:18
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.
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.

Email notifications: RSVPs (E1-E3)

1 participant

0