Skip to content

Event Email Reminders — CEO Plan Review

Branch: feature/event-email-reminders Review date: 2026-04-04 Mode: HOLD SCOPE (verifying existing work, identifying gaps)


Context

Admins want to schedule automated reminder emails to event registrants (e.g., "1 day before" or "2 hours before") to increase attendance. The feature builds on Raklet's existing email infrastructure (TemplateV2, scheduled WebJob queue, Cosmos DB, email tracking). The branch was developed with Cursor + gstack, but QA revealed three symptoms: (1) the reminder template doesn't appear in the event edit UI, (2) saving an event with reminders is blocked, (3) no reminder emails are delivered.


System Audit Summary

  • 86 files changed, ~6,000 lines net
  • Key new files: EventEmailReminderSchedulerService.cs, CosmosDbEventReminderSentService.cs, EventReminderSentDoc.cs, EventEmailReminderValidation.cs, event-email-reminder-helpers.js
  • Infrastructure reuse: TemplateV2 email system, CosmosDb ticket payment queries, existing TriggerScheduleEmail WebJob queue
  • Existing tests: EventEmailReminderValidationTests.cs (132 lines), event-email-reminder-helpers.spec.js (54 lines)
  • Prior handoff doc: docs/events/event-email-reminders-handoff.md — PR #13552 open, manual QA item still unchecked

Critical Bug: Wrong Recipient Filter (P0)

Current filter in EventEmailReminderSchedulerService.cs line 104:

x.Status == PaymentStatus.Completed.ToString()

Problem: This excludes at-door / manager-manually-added registrants. AtTheDoorController.cs:182 creates TicketPaymentDoc with Status = NotCompleted. They only become Completed after checking in at the door. A person pre-registered for a free event via at-door flow, or manually added by the admin, would be silently excluded from reminders even though they have a confirmed registration.

Statuses in the wild for event registrations: - Completed — paid online, free online (AutomatedBuyFreeEventTickets sets Completed), post-checkin at-door - NotCompleted — pre-registered at-door / manually added, before check-in - Error — failed payment — should NOT receive reminder

Fix: EventEmailReminderSchedulerService.cs, DispatchOneReminderAsync:

// Before
&& x.Status == PaymentStatus.Completed.ToString()

// After — include all valid registrations; exclude only Error (failed payments)
&& x.Status != PaymentStatus.Error.ToString()

Files: Services/EmailServices/EventEmailReminderSchedulerService.cs line 104


Root Cause Analysis — The Three QA Failures

Bug 1 (Root): Template not visible in event edit UI

Flow: loadReminderTemplates()EmailService.getTemplateEmails('', 0, 100, false, true)GET /organisations/:id/emails/templates?includeSystemTemplates=false&forEventReminders=true

What the API does: 1. Base query: all StatusNg=Template, TypeNg=TemplateV2 records 2. !includeSystemTemplates block: removes the event reminder template from results (treated as a system template) 3. Fallback block (forEventReminders=true): looks up "Event-EventEmailReminderTemplateId" from Cosmos org settings → re-adds the template

The gap: Step 3 requires the org to have "Event-EventEmailReminderTemplateId" stored in Cosmos settings. The test org was created before this branch was deployed, so NewOrganisationGenericService never ran the template creation code for it. The Cosmos key doesn't exist. The fallback finds nothing. The dropdown is empty.

Fix: Run the backfill for the test org. - Script: .\scripts\dev\backfill-email-templates-for-org.ps1 -OrgId <your-test-org-id> - Or: use ConsoleTestApp Program.cs (already has backfill tooling, see recent commits) - Or: create a fresh org after deploying this branch (new orgs get the template automatically via NewOrganisationGenericService)


Bug 2 (Downstream): Cannot save event

Cause: The template dropdown is empty (Bug 1). User adds a reminder row, can't select a template, frontend validation fires:

// event-upsert.controller.js ~line 650
if (!row.templateId) {
    $scope.Data.FormErrors.EmailReminders = 'Select an email template for each reminder';
    hasErrors = true;
}

Save is blocked. This is a downstream symptom of Bug 1. Fix Bug 1 and this resolves.

Secondary UX gap: When Data.EmailTemplateList is empty, the <select> dropdown renders with no options and no explanation. User has no way to know why. Should show: "No reminder templates found." with a link to Messages > Templates.


Bug 3 (Downstream): No reminders created

Cause: Downstream of Bug 2. EventDoc.EmailReminders is never populated because save fails. The scheduler (EventEmailReminderSchedulerService.ProcessDueRemindersForScheduleWindowAsync) correctly reads ev.EmailReminders, but if that list is null/empty, it skips the event. Nothing to process.

The scheduler wiring itself is correct: - TriggerScheduleEmail (queue: send-schedule-email) → ProcessDueRemindersForScheduleWindowAsync - Pagination with continuation tokens ✓ - Idempotency via deterministic Cosmos ID ({eventId}_{offsetMinutes}_{paymentId}) ✓


Additional Code Gaps (beyond the QA failures)

P1 — TryClaimSendAsync not wrapped in try-catch

// EventEmailReminderSchedulerService.cs, DispatchOneReminderAsync
bool claimed = await sentService.TryClaimSendAsync(...);  // ← no try-catch
if (!claimed) continue;

try {
    await eventEmailService.SendEventEmailReminderAsync(...);  // ← has try-catch
} catch (Exception ex) { ... }

If Cosmos throws a transient error (503, 429, timeout) during TryClaimSendAsync, the exception bubbles through DispatchOneReminderAsync and up to the foreach (var ev in feed.Resource) loop in ProcessDueRemindersForScheduleWindowAsync. This aborts reminder processing for all remaining events in the batch. Only SendEventEmailReminderAsync is protected.

Fix:

bool claimed;
try {
    claimed = await sentService.TryClaimSendAsync(...);
} catch (Exception ex) {
    WebJobUtil.LogException(log, ex, nameof(EventEmailReminderSchedulerService), $"claim-failed eventId={ev.Id} paymentId={payment.Id}");
    continue;
}
if (!claimed) continue;

P2 — No startEpoch > 0 guard in scheduler

// EventEmailReminderSchedulerService.cs
long startEpoch = ev.StartDateTimeUtc > 0 ? ev.StartDateTimeUtc : ev.StartDateTime;
// No check: if startEpoch == 0, sendEpoch = 0 - offset*60 = negative epoch

A corrupt or legacy event doc with StartDateTime=0 would compute sendEpoch = -offset*60, which is year ~1969. The window check sendEpoch < windowStartEpoch would exclude it, so no actual bad send would happen — but the guard clarifies intent and prevents any future code from using a zero epoch.

Fix: Add if (startEpoch <= 0) continue; after the epoch assignment.

P3 — Template ownership not validated in backend

EventEmailReminderValidation.ValidateAndNormalize checks TemplateId != Guid.Empty but never verifies the template belongs to the org. A crafted API request could reference another org's template ID.

Fix: In V2EventsController after validation, verify each TemplateId exists in the org's Cosmos email store. (Low risk in practice since the template ID comes from the org's own UI dropdown, but worth adding for defense.)

P4 — No empty-list UX in reminder dropdown

When Data.EmailTemplateList is empty, the template <select> renders silently with no options. User has no feedback.

Fix in event-upsert.html:

<option value="" disabled ng-if="!Data.EmailTemplateList.length">
  No reminder template found — set one up in Messages &gt; Templates
</option>

P5 — Recurring events: silent feature gap

The reminder section is hidden for recurring events:

ng-if="!Data.IsRecurringEvent && !(Data.IsRecurring && !Data.IsEditMode)"
No tooltip or message explains why. User who opens a recurring event sees the section disappear without explanation.

Fix: Replace the ng-if with ng-show + a disabled state with tooltip: "Email reminders are not yet supported for recurring event series." Or keep ng-if but add a visible callout above the section for recurring events.


Architecture Diagram

ADMIN SETS UP REMINDER
  Admin → event-upsert UI (AngularJS)
    → loadReminderTemplates()
    → GET /emails/templates?forEventReminders=true
    → API reads Cosmos org setting "Event-EventEmailReminderTemplateId"
    → returns template list to dropdown
  Admin selects template + offset (e.g. 1440 min = 1 day)
  Admin saves event
    → PUT /organisations/:id/events/:eventId
    → EventEmailReminderValidation.ValidateAndNormalize()
    → eventDoc.EmailReminders = [{TemplateId, OffsetMinutesBeforeStart}]
    → saved to Cosmos EventDoc

SCHEDULER RUNS (every minute, queue: send-schedule-email)
  TriggerScheduleEmail (WebJobs.Secondary)
    → ProcessDueRemindersForScheduleWindowAsync(startUtc, endUtc)
    → Query Cosmos EventDocs (paginated, 50/page)
      filter: published, not archived, not recurring parent,
              StartDateTime in [window, window + 120 days]
    → for each event with EmailReminders:
        for each reminder row:
          sendEpoch = startEpoch - offset*60
          if sendEpoch not in window → skip
          → Query completed ticket payments (paginated, 100/page)
          → for each payment with email:
              TryClaimSendAsync(orgId, eventId, offset, paymentId)
              → Cosmos create EventReminderSentDoc (id = deterministic)
              → if 409 Conflict → skip (already sent)
              → if success → SendEventEmailReminderAsync()
                  → TemplateV2 email via primary-rakletemail-send
                  → tracked (opens, clicks, sends)

BACKFILL (one-time, existing orgs)
  backfill-email-templates-for-org.ps1
  OR ConsoleTestApp → NewOrganisationGenericService.AddDefaultEmailTemplatesForOldOrganizations()
  → CreateTemplateV2(EmailTemplateKeys.EventEmailReminder, ...)
  → stores Cosmos setting "Event-EventEmailReminderTemplateId" = {guid}

Error & Rescue Map

Codepath Failure Rescued? User/System sees
TryClaimSendAsync Cosmos 409 (already sent) ✓ (returns false) Silent skip
TryClaimSendAsync Cosmos 503/429/timeout GAP Aborts entire event batch
SendEventEmailReminderAsync Any exception ✓ (try-catch + log) Logged, next payment continues
GetTemplates (API) Cosmos setting missing N/A (returns empty list) UI shows empty dropdown — UX gap
ValidateAndNormalize null input ✓ (returns success) Event saves without reminders
ValidateAndNormalize bad row ✓ (returns 400) API returns error message
Event save (frontend) empty templateId ✓ (frontend blocks save) Growl error shown

CRITICAL GAP: TryClaimSendAsync needs try-catch. One flaky Cosmos call can silence an entire scheduler run.


What Already Exists (Reused)

Component How used
TemplateV2 email system Template creation, rendering, sending
TriggerScheduleEmail WebJob queue Scheduler trigger (same window as BulkV2)
CosmosDbTicketPaymentService Fetch completed ticket buyers per event
NewOrganisationGenericService Template creation for new + old orgs
Email tracking (opens/clicks/sends) Automatically applied via existing send path
EventEmailReminderValidation Shared between API and unit tests

Test Coverage Gaps

What Type needed Exists?
ValidateAndNormalize happy + error paths Unit ✓ (132 lines)
event-email-reminder-helpers.js Karma unit ✓ (54 lines)
TryClaimSendAsync happy path Integration
TryClaimSendAsync Cosmos exception handling Unit
ProcessDueRemindersForScheduleWindowAsync end-to-end Integration
UI: empty template dropdown shows fallback message E2E
UI: reminder row added → save → reminders appear on reload E2E
Backend: templateId belonging to another org rejected Unit

Fixes Required Before PR Merge

Fix 1 (P0 — QA unblock): Backfill test org

.\scripts\dev\backfill-email-templates-for-org.ps1 -OrgId <test-org-id>
Not a code change. Run once on dev/test environment.

Fix 2 (P1 — Scheduler safety): Wrap TryClaimSendAsync in try-catch

File: Services/EmailServices/EventEmailReminderSchedulerService.cs ~line 120 in DispatchOneReminderAsync — wrap the TryClaimSendAsync call.

Fix 3 (P1 — Correctness): Add startEpoch > 0 guard

File: Services/EmailServices/EventEmailReminderSchedulerService.cs After long startEpoch = ... assignment, add if (startEpoch <= 0) continue;

Fix 4 (P2 — UX): Empty template dropdown fallback message

File: Raklet.Backend/Content/scripts/core/manager/events/templates/event-upsert.html Add disabled <option> when Data.EmailTemplateList is empty.

Fix 5 (P2 — UX): Recurring event callout

File: Raklet.Backend/Content/scripts/core/manager/events/templates/event-upsert.html Add visible message for recurring events explaining reminders aren't supported in v1.


NOT in Scope (v1)

  • Reminders for recurring event series (explicitly deferred per code comment)
  • Post-event follow-up emails (mentioned as future by product)
  • Template ownership validation on backend (low risk, defer)
  • Integration/E2E tests for scheduler (defer to follow-up)
  • Per-reminder analytics dashboard in manager UI

Dream State Delta

CURRENT STATE                     THIS PLAN                        12-MONTH IDEAL
No automated reminders     --->   Reminder emails before event     Reminder + follow-up emails
Manual outreach only               TemplateV2, tracked              Configurable sequences
No attendance nudging              Cosmos idempotency               A/B subject line testing
                                   Same infra as membership         Per-ticket-tier targeting

Deployment Notes

  • Cosmos container for EventReminderSentDoc must exist before deploy (see PR description)
  • Backfill for existing orgs: run backfill-email-templates-for-org.ps1 or queue via ConsoleTestApp
  • WebJob: Raklet.WebJobs.Secondary already has the hook — no new queue needed
  • Zero-downtime: new EmailReminders field on EventDoc is nullable, backward compatible

GSTACK REVIEW REPORT

Review Trigger Why Runs Status Findings
CEO Review /plan-ceo-review Scope & strategy 1 issues_open mode: HOLD_SCOPE, 1 critical gap
Codex Review /codex review Independent 2nd opinion 0
Eng Review /plan-eng-review Architecture & tests (required) 1 issues_open 7 issues, 2 critical gaps
Design Review /plan-design-review UI/UX gaps 0

UNRESOLVED: 0 decisions pending VERDICT: CEO + ENG reviewed — issues_open. Fix 7 items before merge. eng review required.