Skip to content

refactor: make member_email_deliveries a typed log - #2832

Merged
mroderick merged 7 commits into
masterfrom
refactor/type-member-email-deliveries
Aug 31, 2026
Merged

mroderick merged 7 commits into
masterfrom
refactor/type-member-email-deliveries

Conversation

@mroderick

@mroderick mroderick commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

member_email_deliveries records which member received an email but not which email it was, and the three-month chaser's "already emailed" exclusion only works because it is the sole writer of the table — any other mailer action that starts logging would silently change chaser behavior.

Change

  • Type each row: new email_type column, populated with the mailer action that sent the email (email_type: action_name in the EmailDelivery concern). The migration backfills existing rows with 'chaser' (all 439 are chasers) and then drops the column default, so every writer must set the type explicitly
  • Integrity in the database: unique index on (member_id, email_type) — a member can only ever have one chaser row, enforced by Postgres rather than by query convention. Built :concurrently in its own migration so the column DDL stays atomic and reversible
  • Scoped exclusion: the chaser now excludes members via email_type: 'chaser' rows only — identical behavior today (it is the only writer), and immune to whatever else gets logged later
  • Replay-tolerant writes: find_or_create_by! so a retried delivery job can't raise on the unique index after the email has already gone out (a create! would fail the job and cause a re-delivery loop); content columns are written on first creation only

No behavior change for anything that exists today: the only logging mailer action is the chaser, and its eligibility outcome is identical under scoped vs blanket exclusion.

Testing

  • Chaser logging spec now asserts the logged row's email_type, and a new spec proves a replayed delivery logs only one row
  • Chaser service spec proves a member whose only logged delivery is of another type remains chaser-eligible, while chaser-typed rows still exclude

Built on #2829 + #2830 (both merged).

@mroderick
mroderick force-pushed the refactor/type-member-email-deliveries branch from d322e0e to 0507893 Compare August 31, 2026 11:09
Rows recorded which member received an email but not which email it
was, and the chaser's "already emailed" exclusion only works because
it is the sole writer of the table.

Type each row with the mailer action that sent it (email_type; 'chaser'
for all existing rows), enforce one row per (member, email_type) with a
unique index, and scope the chaser's exclusion to its own rows so its
behavior is unchanged regardless of what else gets logged. Log writes
are replay-tolerant: a retried delivery job no longer raises after the
email has already gone out.
@mroderick
mroderick force-pushed the refactor/type-member-email-deliveries branch from 0507893 to 89b9986 Compare August 31, 2026 11:21
@mroderick
mroderick marked this pull request as ready for review August 31, 2026 12:00
@mroderick
mroderick requested a review from olleolleolle August 31, 2026 12:00

@olleolleolle olleolleolle left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

Comment thread app/mailers/concerns/email_delivery.rb Resolved

it 'logs one row per member even if the delivery is performed twice' do
expect do
described_class.with(member: member).chaser.deliver_now

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
described_class.with(member: member).chaser.deliver_now
described_class.with(member:).chaser.deliver_now

Comment thread spec/mailers/member_mailer_spec.rb Resolved
mroderick and others added 5 commits August 31, 2026 14:31
Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com>
Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com>
Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com>
Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com>
Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com>
Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com>
@mroderick

Copy link
Copy Markdown
Collaborator Author

Thank you for your review @olleolleolle. I've created #2833 to enforce the use of the shorthand style, so you don't need to suggest it on every review going forwards.

@mroderick
mroderick enabled auto-merge August 31, 2026 12:56
@mroderick
mroderick merged commit 170a940 into master Aug 31, 2026
9 checks passed
@mroderick
mroderick deleted the refactor/type-member-email-deliveries branch August 31, 2026 12:59
mroderick added a commit that referenced this pull request Sep 10, 2026
…apter

Members who sign up but never subscribe to a chapter get at most two
emails, recorded in member_email_deliveries (typed log from #2832):

1. Nudge 7 days after signup to members created in the last 30 days
   who have no subscription and are not banned.
2. One follow-up a month after the nudge if they still have none.

Stage eligibility derives from the typed log rows; delivery-confirmed
writes via the EmailDelivery concern (email_type signup_nudge /
signup_nudge_followup) make a lost send retry within the window while
the (member_id, email_type) unique index prevents duplicates. Members
who subscribe exit the sequence at any point.

Daily entry point: rake chaser:signup_nudges (add to Heroku Scheduler
after merge). Both emails currently use the copy from Kimberley's
first-email draft; dedicated follow-up copy is pending (issue #2384).
mroderick added a commit that referenced this pull request Oct 8, 2026
Three latent traps from schema drift since the tool was written, plus a
detector guard:

- member_email_deliveries gained a UNIQUE (member_id, email_type) index
  (PR #2832). The blind update_all(member_id:) raised mid-run on a type
  collision; a colliding delivery now stays on the renamed duplicate
  instead of destroying log history.
- subscriptions gained discarded_at and a partial unique index. The
  existence check now uses kept rows only, so a discarded original
  subscription lets the duplicate's active subscription move instead of
  being destroyed.
- merge_invitations deduped by event only; the (member_id, event_id,
  role) unique index permits moves for differing roles, so the check is
  role-aware now.
- firstname_uid_surname_matches degenerated to a first-name-only match
  when the original's surname was an empty string; the predicate now
  requires a non-blank surname.

Task-level and unit specs cover each behaviour, written red first.
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.

3 participants