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..78ce263d2 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,57 @@ 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 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 (!reserved) { - // Already sent by another process + if (reservedEventIds.length === 0) { + // All events already notified by another process return false } @@ -267,15 +303,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 +324,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 +647,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 +686,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}::${event.submissionClosesAt ?? ""}` + 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 +787,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 +802,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,17 +104,58 @@ 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) - // reserveNotification checks submissionWindowNotificationsTable.findFirst() - // Return existing notification = already sent = reservation fails + // Both event reservations succeed (first time sending) const notifFindFirst = mockDb.query.submissionWindowNotificationsTable.findFirst as any - notifFindFirst.mockResolvedValueOnce({ id: "existing_notif" }) + 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") + mockDb.registerTable("submissionWindowNotificationsTable") + mockDb.setMockSingleValue(mockUser) + + // 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" @@ -125,10 +166,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 +205,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 +235,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", })