8000 feat(notifications): huddl lifecycle and reminders (C1-C4, D1-D2) by mwoods79 · Pull Request #131 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

Skip to content

feat(notifications): huddl lifecycle and reminders (C1-C4, D1-D2) - #131

Merged
mwoods79 merged 5 commits into
mainfrom
feat/huddl-lifecycle-notifications
Apr 30, 2026
Merged

mwoods79 merged 5 commits into
mainfrom
feat/huddl-lifecycle-notifications

Conversation

@mwoods79
Copy link
Copy Markdown
Contributor

Closes #115.

Adds the six remaining huddl-related email triggers from docs/notifications.md:

ID Trigger Recipients Category
C1 New huddl created in a group All group members Activity
C2 Meaningful huddl edit Current RSVPs Activity
C3 Huddl cancelled / deleted Current RSVPs Transactional
C4 Recurring series modified (edit_type: "all") RSVPs of next upcoming instance only Activity
D1 24-hour reminder Current RSVPs at fire time Activity (with .ics)
D2 1-hour reminder Current RSVPs at fire time Activity (with .ics)

Summary

  • C3 wires huddl_cancelled to Huddl.:destroy via NotifyCancelled. Captures attendees + display fields in before_action (the rows cascade-delete with the huddl) and fans out in after_action. Promotes the previously-default :destroy to a primary explicit action. Mirrors the B-series NotifyArchived pattern exactly.

  • D1/D2 use AshOban cron triggers + two new sent-at columns on huddlz (reminder_24h_sent_at, reminder_1h_sent_at). Read actions filter on starts_at > now() and starts_at < from_now(N, :hour) and is_nil(reminder_*_sent_at) so the action is idempotent and rescheduled huddls are picked up automatically once ResetReminderStamps clears the column. Cancellation is free (destroyed huddls drop out of the read filter). The senders attach an .ics calendar event via the existing Notifications.ICS.event_for/1. The 1h sender leads with the virtual link prominently when one is set.

  • C1 wires huddl_new to Huddl.:create via NotifyNewInGroup. Skips when the create runs with no actor (system-driven path used by RecurrenceHelper for child instances of a series — those don't need a fresh "new huddl" email per occurrence).

  • C2 wires huddl_updated to Huddl.:update via NotifyMeaningfulUpdate. Detects changes to title, starts_at, ends_at, physical_location, or virtual_link; cosmetic edits are silent. Lifts a small Huddl.Changes.RecipientHelpers module out of inline code now that two changes need RSVP user-id lookup.

  • C4 layers the recurring-series branch into NotifyMeaningfulUpdate. After EditRecurringHuddlz regenerates the series, resolve the next-upcoming instance by huddl_template_id and fan out :huddl_series_updated to its RSVPs only.

The trigger atoms (:huddl_new, :huddl_updated, :huddl_cancelled, :huddl_series_updated, :huddl_reminder_24h, :huddl_reminder_1h) were already registered in Huddlz.Notifications.Triggers from the foundation work; this PR ships the senders and change modules at those module paths.

Commits

  1. feat(notifications): send C3 huddl_cancelled email
  2. feat(notifications): send D1/D2 huddl reminder emails on cron
  3. feat(notifications): send C1 huddl_new email
  4. feat(notifications): send C2 huddl_updated email
  5. feat(notifications): send C4 huddl_series_updated email

Each commit lands its trigger end-to-end (sender + change module + sender unit test + integration test section) with mix precommit clean.

Test plan

  • mix precommit clean (981 tests, credo clean)
  • Sender unit tests: greeting, to/from, subject, body, HTML escape, footer (activity) or no footer (transactional), .ics attached for D1/D2.
  • Integration tests: each trigger exercised end-to-end through Oban.drain_queue(:notifications) with assert_email_sent + actor exclusion + skip-when-no-meaningful-change.
  • Idempotency / reschedule: stamping a huddl drops it from the due_for_24h_reminder read filter; updating starts_at re-nils the column.
  • Manual /dev/mailbox walkthrough of all six flows.
  • CI green.

Wire huddl_cancelled to Huddl.:destroy. The NotifyCancelled change
captures attendee user_ids and the huddl's display fields in
before_action (the HuddlAttendee rows cascade-delete with the huddl
and the row itself disappears) and fans out notifications in
after_action once the destroy commits. The actor (typically the
owner or organizer cancelling) is excluded from the recipients.

Promotes :destroy to a primary explicit action so the change can
hang off it, replacing defaults [:destroy].

Also lays down test/huddlz/notifications/huddl_lifecycle_notifications_test.exs
as the integration-test home for the C-series and D-series triggers,
mirroring the shape of the existing group_membership_notifications_test.
Wire 24h and 1h reminders to the AshOban scheduler. Two new triggers
on Huddl run a candidate read every 2 minutes and dispatch a
per-record update action that stamps the corresponding sent-at
column and fans out emails to current RSVPs.

Schema:

- Add reminder_24h_sent_at, reminder_1h_sent_at columns on huddlz
  (utc_datetime_usec, nullable). Migration generated via
  mix ash.codegen.

Read actions due_for_24h_reminder / due_for_1h_reminder filter by
starts_at within the window AND is_nil(reminder_*_sent_at), so
already-sent huddlz drop out and the action is idempotent at the
filter level. Both use keyset pagination as required by AshOban.

Update actions send_24h_reminder / send_1h_reminder run the
SendReminder change with kind: :h24 or :h1. The change stamps the
column and enqueues DeliverWorker jobs per attendee — recipients
resolved at fire time so RSVPs added after the huddl was created
still get the reminder. Reminder dispatch policy authorizes
always() since the scheduler runs without an actor.

ResetReminderStamps attaches to :update and clears both columns
whenever starts_at changes. That is the entire reschedule story:
the cron picks the huddl up again at its new time. Cancellation
is automatic — destroyed huddlz drop out of the read filter.

Senders HuddlReminder24h and HuddlReminder1h are Activity (footer
included) and attach an .ics calendar file via
Notifications.ICS.event_for/1. Each sender re-fetches the huddl by
id from the payload — documented exception to the stateless-sender
rule, since the .ics requires the live struct and embedding binary
in JSONB is awkward. The 1h sender leads with the virtual link
prominently when one is set.
Wire huddl_new to Huddl.:create. The NotifyNewInGroup change runs in
after_action once the huddl row exists, reads every member of the
huddl's group, excludes the actor (the creator), and fans out a
:huddl_new notification per member. Recipients without an active
preference fall through to the registry default (true), so members
get emailed unless they opted out via /profile/notifications.

Sender HuddlNew is Activity (footer included), naming the group and
the huddl in the subject and linking to the huddl page.
Wire huddl_updated to Huddl.:update. NotifyMeaningfulUpdate detects
changes to title, starts_at, ends_at, physical_location, or
virtual_link in before_action; if none of those are in the changeset
the change is a no-op. Otherwise after_action fans out :huddl_updated
to current RSVPs (excluding the actor) with a "changed_fields" list
the sender humanizes for the body ("the start time", "the location",
etc.). Cosmetic edits (description, thumbnail, max_attendees, etc.)
do not trigger a notification.

Sender HuddlUpdated is Activity (footer included).

Lifts a small RecipientHelpers module out of inline change-module
code now that two changes (NotifyCancelled and NotifyMeaningfulUpdate)
need to read RSVP user_ids and pull the actor id off the changeset.
SendReminder also adopts rsvp_user_ids/1 for symmetry.
Layer the recurring-series branch into NotifyMeaningfulUpdate. When
the editor passes edit_type: "all", EditRecurringHuddlz regenerates
every future instance in the series; we then resolve the *next*
upcoming instance by huddl_template_id + smallest starts_at > now()
and fan out :huddl_series_updated to its RSVPs. Subsequent
instances rely on their own D1/D2 reminders to surface the new
schedule, per docs/notifications.md C4.

Sender HuddlSeriesUpdated is Activity (footer included), naming the
next instance and explicitly noting that later instances will be
covered by their own reminders.

Also tightens NotifyNewInGroup to skip C1 when there is no actor on
the changeset. RecurrenceHelper creates each child instance via a
system-driven Ash.create with no actor, so without this gate the
series-regeneration path would fan out a fresh "new huddl scheduled"
email for every regenerated occurrence — exactly what C4 was added
to avoid.
@mwoods79
mwoods79 merged commit d763c19 into main Apr 30, 2026
1 check passed
@mwoods79
mwoods79 deleted the feat/huddl-lifecycle-notifications branch April 30, 2026 21:49
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: huddl lifecycle and reminders (C1-C4, D1-D2)

1 participant

0