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
TriggerScheduleEmailWebJob 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 > Templates
</option>
P5 — Recurring events: silent feature gap¶
The reminder section is hidden for recurring events:
ng-if="!Data.IsRecurringEvent && !(Data.IsRecurring && !Data.IsEditMode)"
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>
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
EventReminderSentDocmust exist before deploy (see PR description) - Backfill for existing orgs: run
backfill-email-templates-for-org.ps1or queue via ConsoleTestApp - WebJob:
Raklet.WebJobs.Secondaryalready has the hook — no new queue needed - Zero-downtime: new
EmailRemindersfield onEventDocis 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.