From 0413103bc3ece69fe6f170a858ce18c49b5f1ee6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 01:51:46 +0000 Subject: [PATCH 1/2] feat: list all workouts in submission window open email When multiple workouts share the same submission window, send a single email listing all workouts instead of separate emails per workout. Groups events by (competitionId, submissionOpensAt) and consolidates them into one notification with all workout names and descriptions. https://claude.ai/code/session_01NPjNVihbNaLUbBNU4ZETri --- .../react-email/submission-window-opens.tsx | 82 ++++-- .../server/notifications/submission-window.ts | 242 ++++++++++++------ .../notifications/submission-window.test.ts | 60 ++++- 3 files changed, 274 insertions(+), 110 deletions(-) diff --git a/apps/wodsmith-start/src/react-email/submission-window-opens.tsx b/apps/wodsmith-start/src/react-email/submission-window-opens.tsx index b939ba5ec..e2bbe470d 100644 --- a/apps/wodsmith-start/src/react-email/submission-window-opens.tsx +++ b/apps/wodsmith-start/src/react-email/submission-window-opens.tsx @@ -3,6 +3,7 @@ import { Container, Head, Heading, + Hr, Html, Link, Section, @@ -10,12 +11,20 @@ import { } from "@react-email/components" import { SITE_DOMAIN } from "@/constants" +export interface WorkoutInfo { + name: string + description?: string +} + export interface SubmissionWindowOpensProps { athleteName?: string competitionName?: string competitionSlug?: string + /** @deprecated Use `workouts` array instead */ workoutName?: string + /** @deprecated Use `workouts` array instead */ workoutDescription?: string + workouts?: WorkoutInfo[] submissionClosesAt?: string timezone?: string } @@ -24,13 +33,23 @@ export const SubmissionWindowOpensEmail = ({ athleteName = "Athlete", competitionName = "Competition", competitionSlug = "competition", - workoutName = "Workout 1", + workoutName, workoutDescription, + workouts: workoutsProp, submissionClosesAt, timezone = "UTC", }: SubmissionWindowOpensProps) => { const competitionUrl = `https://${SITE_DOMAIN}/compete/${competitionSlug}` + // Support both legacy single-workout and new multi-workout props + const workouts: WorkoutInfo[] = + workoutsProp && workoutsProp.length > 0 + ? workoutsProp + : [{ name: workoutName || "Workout 1", description: workoutDescription }] + + const isSingleWorkout = workouts.length === 1 + const workoutLabel = isSingleWorkout ? "workout" : "workouts" + return ( @@ -39,24 +58,41 @@ export const SubmissionWindowOpensEmail = ({ Submission Window Now Open! Hi {athleteName}, - The submission window for {workoutName} in{" "} - {competitionName} is now open. You can submit your - score! + The submission window for{" "} + {isSingleWorkout ? ( + <> + {workouts[0].name} in{" "} + + ) : ( + <> + {workouts.length} {workoutLabel} in{" "} + + )} + {competitionName} is now open. You can submit your{" "} + {isSingleWorkout ? "score" : "scores"}!
- Event Details - - Competition: {competitionName} + + {isSingleWorkout ? "Event Details" : "Events Now Open"} - Event: {workoutName} + Competition: {competitionName} - {workoutDescription && ( - - Description: {workoutDescription} - - )} + {workouts.map((workout, index) => ( +
+ {!isSingleWorkout && index > 0 &&
} + + {isSingleWorkout ? "Event:" : `Event ${index + 1}:`}{" "} + {workout.name} + + {workout.description && ( + + Description: {workout.description} + + )} +
+ ))} {submissionClosesAt && ( Submit By: {submissionClosesAt} ({timezone}) @@ -66,13 +102,15 @@ export const SubmissionWindowOpensEmail = ({
- Submit Your Score + Submit Your {isSingleWorkout ? "Score" : "Scores"}
- Make sure to complete the workout and submit your score before the - window closes. Good luck! + Make sure to complete the{" "} + {isSingleWorkout ? "workout" : "workouts"} and submit your{" "} + {isSingleWorkout ? "score" : "scores"} before the window closes. + Good luck! @@ -88,8 +126,11 @@ SubmissionWindowOpensEmail.PreviewProps = { athleteName: "John Smith", competitionName: "CrossFit Open 2025", competitionSlug: "crossfit-open-2025", - workoutName: "25.1", - workoutDescription: "15-12-9 Thrusters and Bar Muscle-ups", + workouts: [ + { name: "25.1", description: "15-12-9 Thrusters and Bar Muscle-ups" }, + { name: "25.2", description: "5 Rounds: 10 Deadlifts, 20 Double-unders" }, + { name: "25.3" }, + ], submissionClosesAt: "Monday, March 17, 2025 at 5:00 PM", timezone: "America/Denver", } as SubmissionWindowOpensProps @@ -154,6 +195,11 @@ const detailRow = { margin: "8px 0", } +const divider = { + borderColor: "#e2e8f0", + margin: "12px 0", +} + const buttonContainer = { textAlign: "center" as const, margin: "30px 0", diff --git a/apps/wodsmith-start/src/server/notifications/submission-window.ts b/apps/wodsmith-start/src/server/notifications/submission-window.ts index d6bc60f15..52c3511a7 100644 --- a/apps/wodsmith-start/src/server/notifications/submission-window.ts +++ b/apps/wodsmith-start/src/server/notifications/submission-window.ts @@ -24,7 +24,10 @@ import { } from "@/db/schema" import { logError, logInfo } from "@/lib/logging/posthog-otel-logger" import { SubmissionWindowClosedEmail } from "@/react-email/submission-window-closed" -import { SubmissionWindowOpensEmail } from "@/react-email/submission-window-opens" +import { + SubmissionWindowOpensEmail, + type WorkoutInfo, +} from "@/react-email/submission-window-opens" import { SubmissionWindowReminderEmail } from "@/react-email/submission-window-reminder" import { sendEmail } from "@/utils/email" @@ -206,17 +209,17 @@ async function hasUserSubmittedScore(params: { // ============================================================================ /** - * Send a "submission window opens" notification to an athlete + * Send a "submission window opens" notification to an athlete. + * Supports multiple workouts in a single email when events share the same window. */ export async function sendWindowOpensNotification(params: { userId: string registrationId: string competitionId: string - competitionEventId: string + competitionEventIds: string[] competitionName: string competitionSlug: string - workoutName: string - workoutDescription?: string + workouts: WorkoutInfo[] submissionClosesAt?: string timezone: string }): Promise { @@ -224,11 +227,10 @@ export async function sendWindowOpensNotification(params: { userId, registrationId, competitionId, - competitionEventId, + competitionEventIds, competitionName, competitionSlug, - workoutName, - workoutDescription, + workouts, submissionClosesAt, timezone, } = params @@ -241,23 +243,30 @@ export async function sendWindowOpensNotification(params: { if (!user?.email) { logError({ message: "[Submission Notification] Cannot send window opens - no email", - attributes: { userId, registrationId, competitionEventId }, + attributes: { userId, registrationId, competitionEventIds }, }) return false } - // Reserve the notification slot first (prevents race conditions) - const reserved = await reserveNotification({ - competitionId, - competitionEventId, - registrationId, - userId, - type: SUBMISSION_WINDOW_NOTIFICATION_TYPES.WINDOW_OPENS, - sentToEmail: user.email, - }) + // Reserve notification slots for all events in this window group. + // If at least one reservation succeeds, we need to send the email. + const reservedEventIds: string[] = [] + for (const eventId of competitionEventIds) { + const reserved = await reserveNotification({ + competitionId, + competitionEventId: eventId, + registrationId, + userId, + type: SUBMISSION_WINDOW_NOTIFICATION_TYPES.WINDOW_OPENS, + sentToEmail: user.email, + }) + if (reserved) { + reservedEventIds.push(eventId) + } + } - if (!reserved) { - // Already sent by another process + if (reservedEventIds.length === 0) { + // All events already notified by another process return false } @@ -267,15 +276,16 @@ export async function sendWindowOpensNotification(params: { ? formatDateTimeForDisplay(submissionClosesAt, timezone) : undefined + const workoutNames = workouts.map((w) => w.name).join(", ") + await sendEmail({ to: user.email, - subject: `Submission Window Open: ${workoutName} - ${competitionName}`, + subject: `Submission Window Open: ${workoutNames} - ${competitionName}`, template: SubmissionWindowOpensEmail({ athleteName, competitionName, competitionSlug, - workoutName, - workoutDescription, + workouts, submissionClosesAt: formattedCloseTime, timezone, }), @@ -287,25 +297,28 @@ export async function sendWindowOpensNotification(params: { attributes: { userId, registrationId, - competitionEventId, + competitionEventIds, competitionName, - workoutName, + workoutNames, + workoutCount: workouts.length, }, }) return true } catch (err) { - // Email failed - delete reservation to allow retry on next cron run - await deleteNotificationReservation({ - competitionEventId, - registrationId, - type: SUBMISSION_WINDOW_NOTIFICATION_TYPES.WINDOW_OPENS, - }) + // Email failed - delete reservations to allow retry on next cron run + for (const eventId of reservedEventIds) { + await deleteNotificationReservation({ + competitionEventId: eventId, + registrationId, + type: SUBMISSION_WINDOW_NOTIFICATION_TYPES.WINDOW_OPENS, + }) + } logError({ message: "[Submission Notification] Failed to send window opens notification", error: err, - attributes: { userId, registrationId, competitionEventId }, + attributes: { userId, registrationId, competitionEventIds }, }) return false } @@ -607,11 +620,38 @@ export async function processSubmissionWindowNotifications(): Promise() + for (const { event, competition, workout } of events) { if (!event.submissionOpensAt) continue // Normalize datetime strings to UTC-aware ISO format - // Handles both SQLite format ("2026-01-27 00:36:37") and ISO with timezone const opensAt = new Date(normalizeToUtcDatetime(event.submissionOpensAt)) const closesAt = event.submissionClosesAt ? new Date(normalizeToUtcDatetime(event.submissionClosesAt)) @@ -619,59 +659,63 @@ export async function processSubmissionWindowNotifications(): Promise fifteenMinutesAgo) { - const sent = await sendWindowOpensNotification({ - ...baseParams, + // 1. Window just opened (within last 15 minutes) — collect for grouped email + if (opensAt <= now && opensAt > fifteenMinutesAgo) { + const groupKey = `${competition.id}::${event.submissionOpensAt}` + const existing = windowOpenGroups.get(groupKey) + if (existing) { + existing.eventIds.push(event.id) + existing.workouts.push({ + name: workout.name, + description: workout.description || undefined, + }) + } else { + windowOpenGroups.set(groupKey, { + competitionId: competition.id, + competitionName: competition.name, + competitionSlug: competition.slug, + timezone, submissionClosesAt: event.submissionClosesAt || undefined, + eventIds: [event.id], + workouts: [ + { + name: workout.name, + description: workout.description || undefined, + }, + ], }) - if (sent) result.windowOpens++ } + } + + // Per-event notifications for closing/closed (these remain per-event) + if (closesAt) { + const registrations = await db + .select({ + registration: competitionRegistrationsTable, + user: userTable, + }) + .from(competitionRegistrationsTable) + .innerJoin( + userTable, + eq(competitionRegistrationsTable.userId, userTable.id), + ) + .where(eq(competitionRegistrationsTable.eventId, competition.id)) + + for (const { registration, user } of registrations) { + if (!user.email) continue + + const baseParams = { + userId: user.id, + registrationId: registration.id, + competitionId: competition.id, + competitionEventId: event.id, + competitionName: competition.name, + competitionSlug: competition.slug, + workoutName: workout.name, + workoutDescription: workout.description || undefined, + timezone, + } - // Only process closing notifications if we have a close time - if (closesAt) { // 2. Window closes in ~24 hours (23h45m - 24h window) if ( closesAt <= twentyFourHoursFromNow && @@ -716,7 +760,6 @@ export async function processSubmissionWindowNotifications(): Promise fifteenMinutesAgo) { - // Check if user has actually submitted a score for this event const hasSubmitted = await hasUserSubmittedScore({ userId: user.id, competitionEventId: event.id, @@ -732,6 +775,39 @@ export async function processSubmissionWindowNotifications(): Promise { userId: "user_123", registrationId: "reg_123", competitionId: "comp_123", - competitionEventId: "event_123", + competitionEventIds: ["event_123"], competitionName: "Test Competition", competitionSlug: "test-comp", - workoutName: "Test Workout", + workouts: [{ name: "Test Workout" }], timezone: "America/Denver", }) @@ -104,7 +104,49 @@ describe("Submission Window Notifications", () => { ) }) - it("does not send notification when reservation fails (already sent)", async () => { + it("sends single email with multiple workouts when events share a window", async () => { + // Arrange - user exists with email + const mockUser = { id: "user_123", email: "athlete@example.com", firstName: "John" } + mockDb.registerTable("userTable") + mockDb.registerTable("submissionWindowNotificationsTable") + mockDb.setMockSingleValue(mockUser) + + // Both event reservations succeed (first time sending) + const notifFindFirst = mockDb.query.submissionWindowNotificationsTable.findFirst as any + notifFindFirst.mockResolvedValueOnce(null) // event_1 reservation succeeds + notifFindFirst.mockResolvedValueOnce(null) // event_2 reservation succeeds + + const { sendWindowOpensNotification } = await import( + "@/server/notifications/submission-window" + ) + + // Act + const result = await sendWindowOpensNotification({ + userId: "user_123", + registrationId: "reg_123", + competitionId: "comp_123", + competitionEventIds: ["event_1", "event_2"], + competitionName: "Test Competition", + competitionSlug: "test-comp", + workouts: [ + { name: "Workout 1", description: "10 Thrusters" }, + { name: "Workout 2", description: "20 Pull-ups" }, + ], + timezone: "America/Denver", + }) + + // Assert - only one email sent, containing both workout names + expect(result).toBe(true) + expect(mockSendEmail).toHaveBeenCalledTimes(1) + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: "athlete@example.com", + subject: "Submission Window Open: Workout 1, Workout 2 - Test Competition", + }), + ) + }) + + it("does not send notification when all reservations fail (already sent)", async () => { // Arrange - user exists with email const mockUser = { id: "user_123", email: "athlete@example.com", firstName: "John" } mockDb.registerTable("userTable") @@ -125,10 +167,10 @@ describe("Submission Window Notifications", () => { userId: "user_123", registrationId: "reg_123", competitionId: "comp_123", - competitionEventId: "event_123", + competitionEventIds: ["event_123"], competitionName: "Test Competition", competitionSlug: "test-comp", - workoutName: "Test Workout", + workouts: [{ name: "Test Workout" }], timezone: "America/Denver", }) @@ -164,10 +206,10 @@ describe("Submission Window Notifications", () => { userId: "user_123", registrationId: "reg_123", competitionId: "comp_123", - competitionEventId: "event_123", + competitionEventIds: ["event_123"], competitionName: "Test Competition", competitionSlug: "test-comp", - workoutName: "Test Workout", + workouts: [{ name: "Test Workout" }], timezone: "America/Denver", }) @@ -194,10 +236,10 @@ describe("Submission Window Notifications", () => { userId: "user_123", registrationId: "reg_123", competitionId: "comp_123", - competitionEventId: "event_123", + competitionEventIds: ["event_123"], competitionName: "Test Competition", competitionSlug: "test-comp", - workoutName: "Test Workout", + workouts: [{ name: "Test Workout" }], timezone: "America/Denver", }) From 7c439ce93776b375f6d8475073c80407e8ca3804 Mon Sep 17 00:00:00 2001 From: Ian Jones Date: Sat, 11 Apr 2026 14:14:58 -0600 Subject: [PATCH 2/2] fix: atomic reservation for grouped window-open emails and include closesAt in group key Wrap grouped notification reservations in a db.transaction() so either all events are reserved or none are, preventing duplicate emails from concurrent cron runs. Also include submissionClosesAt in the grouping key so events with different close times aren't merged into one email with the wrong deadline. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/notifications/submission-window.ts | 59 ++++++++++++++----- .../notifications/submission-window.test.ts | 7 +-- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/apps/wodsmith-start/src/server/notifications/submission-window.ts b/apps/wodsmith-start/src/server/notifications/submission-window.ts index 52c3511a7..78ce263d2 100644 --- a/apps/wodsmith-start/src/server/notifications/submission-window.ts +++ b/apps/wodsmith-start/src/server/notifications/submission-window.ts @@ -248,22 +248,49 @@ export async function sendWindowOpensNotification(params: { return false } - // Reserve notification slots for all events in this window group. - // If at least one reservation succeeds, we need to send the email. - const reservedEventIds: string[] = [] - for (const eventId of competitionEventIds) { - const reserved = await reserveNotification({ - competitionId, - competitionEventId: eventId, - registrationId, - userId, - type: SUBMISSION_WINDOW_NOTIFICATION_TYPES.WINDOW_OPENS, - sentToEmail: user.email, - }) - if (reserved) { - reservedEventIds.push(eventId) + // Reserve notification slots for all events in this window group atomically. + // Uses a transaction so either all reservations succeed or none do, + // preventing duplicate grouped emails from concurrent cron runs. + const reservedEventIds = await db.transaction(async (tx) => { + const reserved: string[] = [] + for (const eventId of competitionEventIds) { + const [existing] = await tx + .select({ id: submissionWindowNotificationsTable.id }) + .from(submissionWindowNotificationsTable) + .where( + and( + eq( + submissionWindowNotificationsTable.competitionEventId, + eventId, + ), + eq( + submissionWindowNotificationsTable.registrationId, + registrationId, + ), + eq( + submissionWindowNotificationsTable.type, + SUBMISSION_WINDOW_NOTIFICATION_TYPES.WINDOW_OPENS, + ), + ), + ) + + if (existing) { + // This event was already reserved — another process owns this group + return [] + } + + await tx.insert(submissionWindowNotificationsTable).values({ + competitionId, + competitionEventId: eventId, + registrationId, + userId, + type: SUBMISSION_WINDOW_NOTIFICATION_TYPES.WINDOW_OPENS, + sentToEmail: user.email, + }) + reserved.push(eventId) } - } + return reserved + }) if (reservedEventIds.length === 0) { // All events already notified by another process @@ -661,7 +688,7 @@ export async function processSubmissionWindowNotifications(): Promise fifteenMinutesAgo) { - const groupKey = `${competition.id}::${event.submissionOpensAt}` + const groupKey = `${competition.id}::${event.submissionOpensAt}::${event.submissionClosesAt ?? ""}` const existing = windowOpenGroups.get(groupKey) if (existing) { existing.eventIds.push(event.id) diff --git a/apps/wodsmith-start/test/server/notifications/submission-window.test.ts b/apps/wodsmith-start/test/server/notifications/submission-window.test.ts index 2c11e1c3c..8d5d83d65 100644 --- a/apps/wodsmith-start/test/server/notifications/submission-window.test.ts +++ b/apps/wodsmith-start/test/server/notifications/submission-window.test.ts @@ -153,10 +153,9 @@ describe("Submission Window Notifications", () => { mockDb.registerTable("submissionWindowNotificationsTable") mockDb.setMockSingleValue(mockUser) - // reserveNotification checks submissionWindowNotificationsTable.findFirst() - // Return existing notification = already sent = reservation fails - const notifFindFirst = mockDb.query.submissionWindowNotificationsTable.findFirst as any - notifFindFirst.mockResolvedValueOnce({ id: "existing_notif" }) + // The atomic transaction uses select().from().where() to check for existing reservations. + // Set mockReturnValue so the select chain returns an existing record. + mockDb.setMockReturnValue([{ id: "existing_notif" }]) const { sendWindowOpensNotification } = await import( "@/server/notifications/submission-window"