Skip to content

Fix invite status never updating when invitations are sent via email blast - #1577

Open
masiarek wants to merge 1 commit into
Equal-Vote:mainfrom
masiarek:fix/invite-status
Open

masiarek wants to merge 1 commit into
Equal-Vote:mainfrom
masiarek:fix/invite-status

Conversation

@masiarek

Copy link
Copy Markdown
Collaborator

Description

The voter roll's per-voter "Email invite status" can report failures that didn't happen. The field the admin UI reads is email_data.inviteResponse — rendered by the invite_status column in packages/frontend/src/components/EnhancedTable.tsx (lines 102–118: undefined → "Not Sent", inviteResponse[0].statusCode >= 400 → "Failed", else "Sent") and by the per-voter view in packages/frontend/src/components/Election/Admin/EditElectionRoll.tsx (lines ~168–191).

That field was only ever written by the legacy sendInvites / sendInvite/:voter_id endpoints — sendInvitation() in packages/backend/src/Controllers/Election/sendInvitesController.ts (previously lines 137–149) — which no current UI calls. Invitations actually go out through the email-blast endpoint (sendEmails, handled by handleSendEmailEvent in packages/backend/src/Controllers/Election/sendEmailController.ts), which appended a history entry but never wrote email_data.inviteResponse. So after an admin sent invitations via the blast tool, every roll row still read "Invite not sent" — an admin checking mid-election sees failures that didn't happen and may re-send to everyone (and the legacy endpoint's already-invited filter also couldn't see the blast, so a stray call to it would re-invite everyone too).

The fix

  • Shared helper instead of a second write path. The legacy path's recording logic is extracted into recordInviteResponse() (exported from sendInvitesController.ts) and called from handleSendEmailEvent for each voter the blast actually targeted — writing the exact shape the legacy path writes, so the frontend status rendering needs no change.

  • Only invitation-type blasts update the status. SendEmailDialog already makes the admin pick a template — "Invitation Template" or "Empty Template" — but the choice was only used to prefill the editable subject/body and was never sent to the backend. It now rides along as an optional template?: 'invite' | 'blank' field on email_request_data (a type the frontend already imports from the backend controller, so it propagates with no duplication).

    Why gate on the template rather than marking every blast: the column is defined by both existing surfaces as "was this voter successfully sent their invitation" — the legacy path writes it only when sending the Invites template (the email carrying the voter's unique voting link), and the frontend renders it under the heading "Email Invites". Marking a generic "blank" update or a reminder-to-voters blast as an invite would introduce the reverse bug: reporting "Sent" for a voter who was never actually sent an invitation (the blank template's body is free-form and need not contain __VOTE_BUTTON__). Blasts sent before this change have no template field and are treated the same as blank — no status change, which was the previous behavior.

  • Test blasts unchanged: target: 'test' already skips all roll updates and still does.

  • The SendGrid email_events ledger (webhook-driven, shown by EmailEventsList) is untouched — the blast path already inserts its sent event; this change only adds the roll-status write that was missing.

  • The mocked EmailService (packages/backend/src/Services/Email/__mocks__/EmailService.ts) now returns SendGrid-shaped responses ([[ClientResponse, body], …], matching how sendInvitesController reads emailResponse[0][0].statusCode) so tests can assert on the recorded status. Nothing else reads sendEmails's return value.

Tests

New packages/backend/src/test/sendEmailInviteStatus.test.ts, following the house supertest/TestHelper/MockEventQueue pattern:

  1. An invite-template blast to target: 'all' marks every targeted roll with inviteResponse[0].statusCode < 400 (renders as "Sent").
  2. A blank-template blast is recorded in roll history but leaves inviteResponse unset (still "Not Sent").

Ran locally: full backend suite 25 suites / 177 tests passed (includes the 3 new tests); npx tsc --noEmit clean in both packages/backend and packages/frontend.

Screenshots / Videos (frontend only)

The only frontend change is threading the already-displayed template choice into the request payload (SendEmailDialog.tsx, ViewElectionRolls.tsx, EditElectionRoll.tsx) — no visual change. The status column itself renders exactly as before; it just becomes truthful after an invite blast.

Related Issues

First bullet of the "Bugs noticed while writing" list in #1556:

The roll's "Email invite status" column can report failures that didn't happen — only updated by legacy endpoints with no current UI consumer, so invitations sent via the email-blast tool leave every row reading "Invite not sent."

🤖 Generated with Claude Code

…blast

The voter roll's per-voter "Email invite status" (email_data.inviteResponse,
read by EnhancedTable's invite_status column and EditElectionRoll) was only
written by the legacy sendInvites / sendInvite/:voter_id endpoints, which no
current UI calls. Invitations actually go out through the email-blast
endpoint (sendEmails), which never wrote the field - so after an admin sent
invitations, every roll row still read "Invite not sent", and an admin
checking mid-election saw failures that didn't happen.

Fix:
- Extract the legacy path's inviteResponse-recording logic into a shared
  recordInviteResponse() helper (sendInvitesController.ts) and call it from
  handleSendEmailEvent for each voter the blast actually targeted, writing
  the exact shape the legacy path writes so the frontend needs no change.
- Only blasts started from the "invite" template update the status. The
  template choice already existed in SendEmailDialog but was never sent to
  the backend; it now rides along as an optional `template` field on
  email_request_data. A "blank" update/reminder blast deliberately does NOT
  flip "Not Sent" to "Sent" - that would be the reverse bug (reporting an
  invite that wasn't sent).
- Test blasts (target: 'test') are unchanged: they already skip all roll
  updates.
- The mock EmailService now returns SendGrid-shaped responses
  ([[ClientResponse, body], ...]) so tests can assert on statusCode the way
  production code reads it.
- New backend test (sendEmailInviteStatus.test.ts): an invite-template blast
  marks every targeted roll as invited (statusCode < 400); a blank blast
  leaves inviteResponse unset.

One of the bugs listed in Equal-Vote#1556.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 19, 2026

Copy link
Copy Markdown

Deploy Preview for bettervoting ready!

Name Link
🔨 Latest commit 8eb3056
🔍 Latest deploy log https://app.netlify.com/projects/bettervoting/deploys/6a861bafc1200b00088d2b47
😎 Deploy Preview https://deploy-preview-1577--bettervoting.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@masiarek, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b45ac1ae-10a0-436e-966e-79171090a0c7

📥 Commits

Reviewing files that changed from the base of the PR and between 454a38a and 8eb3056.

📒 Files selected for processing (7)
  • packages/backend/src/Controllers/Election/sendEmailController.ts
  • packages/backend/src/Controllers/Election/sendInvitesController.ts
  • packages/backend/src/Services/Email/__mocks__/EmailService.ts
  • packages/backend/src/test/sendEmailInviteStatus.test.ts
  • packages/frontend/src/components/Election/Admin/EditElectionRoll.tsx
  • packages/frontend/src/components/Election/Admin/SendEmailDialog.tsx
  • packages/frontend/src/components/Election/Admin/ViewElectionRolls.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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