Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/wodsmith-start/src/db/schemas/scores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ export const scoresTable = mysqlTable(

// What was scored
workoutId: varchar({ length: 255 }).notNull(),
competitionEventId: varchar({ length: 255 }), // NULL for personal logs
// References trackWorkoutsTable.id (NOT competitionEventsTable.id) despite
// the name. The column predates the event/workout split; all insert sites
// write the trackWorkoutId here. NULL for personal logs.
competitionEventId: varchar({ length: 255 }),
scheduledWorkoutInstanceId: varchar({ length: 255 }),

// Score classification
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ type AudienceFilterType =
| "volunteers"
| "volunteer_role"
| "pending_teammates"
| "missing_submissions"

const VOLUNTEER_ROLES = [
{ value: "judge", label: "Judge" },
Expand Down Expand Up @@ -236,7 +237,10 @@ function ComposeCard({

// Determine which questions to show based on audience type
const relevantQuestions = useMemo(() => {
const isAthleteAudience = filterType === "all" || filterType === "division"
const isAthleteAudience =
filterType === "all" ||
filterType === "division" ||
filterType === "missing_submissions"
const isVolunteerAudience =
filterType === "volunteers" || filterType === "volunteer_role"
const isPublic = filterType === "public"
Expand All @@ -261,9 +265,11 @@ function ComposeCard({
divisionId,
}
: { type: "pending_teammates" as const }
: {
type: filterType as "all" | "public" | "volunteers",
}
: filterType === "missing_submissions" && divisionId
? { type: "missing_submissions" as const, divisionId }
: {
type: filterType as "all" | "public" | "volunteers",
}

if (questionFilters.length > 0) {
return { ...base, questionFilters }
Expand All @@ -274,7 +280,8 @@ function ComposeCard({
// Auto-fetch recipient count when filter is complete
const filterReady =
(filterType !== "division" || !!divisionId) &&
(filterType !== "volunteer_role" || !!volunteerRole)
(filterType !== "volunteer_role" || !!volunteerRole) &&
(filterType !== "missing_submissions" || !!divisionId)

// Debounce the preview call
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
Expand Down Expand Up @@ -340,6 +347,11 @@ function ComposeCard({
return
}

if (filterType === "missing_submissions" && !divisionId) {
toast.error("Please select a division")
return
}

if (filterType === "volunteer_role" && !volunteerRole) {
toast.error("Please select a volunteer role")
return
Expand Down Expand Up @@ -421,6 +433,9 @@ function ComposeCard({
<SelectItem value="public">Everyone (Public)</SelectItem>
<SelectItem value="all">All Athletes</SelectItem>
<SelectItem value="division">Athletes by Division</SelectItem>
<SelectItem value="missing_submissions">
Athletes Missing Submissions (by Division)
</SelectItem>
<SelectItem value="volunteers">All Volunteers</SelectItem>
<SelectItem value="volunteer_role">
Volunteers by Role
Expand All @@ -432,7 +447,8 @@ function ComposeCard({
</Select>

{(filterType === "division" ||
filterType === "pending_teammates") && (
filterType === "pending_teammates" ||
filterType === "missing_submissions") && (
<Select
value={divisionId}
onValueChange={(v) => {
Expand Down
175 changes: 171 additions & 4 deletions apps/wodsmith-start/src/server-fns/broadcast-fns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ import {
competitionBroadcastsTable,
} from "@/db/schemas/broadcasts"
import {
competitionEventsTable,
competitionRegistrationAnswersTable,
competitionRegistrationQuestionsTable,
competitionRegistrationsTable,
competitionsTable,
REGISTRATION_STATUS,
volunteerRegistrationAnswersTable,
} from "@/db/schemas/competitions"
import { eventDivisionMappingsTable } from "@/db/schemas/event-division-mappings"
import { scoresTable } from "@/db/schemas/scores"
import {
INVITATION_STATUS,
SYSTEM_ROLES_ENUM,
Expand Down Expand Up @@ -71,6 +74,7 @@ export const audienceFilterSchema = z
"volunteers",
"volunteer_role",
"pending_teammates",
"missing_submissions",
]),
divisionId: z.string().optional(),
volunteerRole: z.string().optional(),
Expand Down Expand Up @@ -98,6 +102,14 @@ export const audienceFilterSchema = z
"Registration question filters are not supported for pending teammate invites (invitees have no registration answers)",
},
)
.refine(
(filter) =>
filter.type !== "missing_submissions" ||
(filter.divisionId && filter.divisionId.length > 0),
{
message: "Division ID is required when filtering by missing submissions",
},
)

const sendBroadcastInputSchema = z.object({
competitionId: z.string().min(1, "Competition ID is required"),
Expand Down Expand Up @@ -614,6 +626,125 @@ async function partitionQuestionFilters(
return { athleteFilters, volunteerFilters }
}

// ============================================================================
// Missing Submissions Filter
// ============================================================================

/**
* Return the set of trackWorkoutIds that athletes in `divisionId` are expected
* to have a score for. Honors event-division mappings — an event is required
* for the division when it's unmapped (applies to all) or explicitly mapped
* to this division. Sub-event mappings inherit from the parent event, matching
* the leaderboard rule used elsewhere in the app.
*/
async function getDivisionRequiredTrackWorkoutIds(params: {
competitionId: string
divisionId: string
}): Promise<string[]> {
const db = getDb()

const events = await db
.select({
trackWorkoutId: competitionEventsTable.trackWorkoutId,
})
.from(competitionEventsTable)
.where(eq(competitionEventsTable.competitionId, params.competitionId))

if (events.length === 0) return []

const mappings = await db
.select({
trackWorkoutId: eventDivisionMappingsTable.trackWorkoutId,
divisionId: eventDivisionMappingsTable.divisionId,
})
.from(eventDivisionMappingsTable)
.where(eq(eventDivisionMappingsTable.competitionId, params.competitionId))

if (mappings.length === 0) {
return events.map((e) => e.trackWorkoutId)
}

const mappedEventIds = new Set(mappings.map((m) => m.trackWorkoutId))
const mappedToThisDivision = new Set(
mappings
.filter((m) => m.divisionId === params.divisionId)
.map((m) => m.trackWorkoutId),
)

return events
.filter((e) =>

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Missing-submissions division filtering ignores parent-event mapping inheritance, so recipients can be incorrectly flagged as missing scores.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/wodsmith-start/src/server-fns/broadcast-fns.ts, line 675:

<comment>Missing-submissions division filtering ignores parent-event mapping inheritance, so recipients can be incorrectly flagged as missing scores.</comment>

<file context>
@@ -614,6 +626,125 @@ async function partitionQuestionFilters(
+  )
+
+  return events
+    .filter((e) =>
+      !mappedEventIds.has(e.trackWorkoutId)
+        ? true
</file context>
Fix with Cubic

!mappedEventIds.has(e.trackWorkoutId)
? true
: mappedToThisDivision.has(e.trackWorkoutId),
)
.map((e) => e.trackWorkoutId)
}

/**
* Filter athlete recipients down to those who have NOT submitted a score for
* every required event in the division.
*
* Pending-invite recipients (userId=null) have zero submissions by definition
* and are kept whenever any event is required.
*
* `scoresTable.competitionEventId` actually stores the `trackWorkoutId`
* (see comment on [[apps/wodsmith-start/src/db/schemas/workouts.ts#competitionEventId]]),
* which is why we match scores against the trackWorkoutId set directly.
*/
async function applyMissingSubmissionsFilter(
recipients: Recipient[],
params: { competitionId: string; divisionId: string },
): Promise<Recipient[]> {
if (recipients.length === 0) return recipients

const requiredTrackWorkoutIds = await getDivisionRequiredTrackWorkoutIds({
competitionId: params.competitionId,
divisionId: params.divisionId,
})

// No required events means every recipient is trivially "complete" — nobody
// qualifies as missing submissions.
if (requiredTrackWorkoutIds.length === 0) return []

const userIds = recipients
.map((r) => r.userId)
.filter((id): id is string => id !== null)

const submittedByUser = new Map<string, Set<string>>()
if (userIds.length > 0) {
const db = getDb()
const rows = await db
.select({
userId: scoresTable.userId,
competitionEventId: scoresTable.competitionEventId,
})
.from(scoresTable)
.where(
and(
inArray(scoresTable.userId, userIds),
inArray(scoresTable.competitionEventId, requiredTrackWorkoutIds),
),
)

for (const r of rows) {
if (!r.competitionEventId) continue
let set = submittedByUser.get(r.userId)
if (!set) {
set = new Set()
submittedByUser.set(r.userId, set)
}
set.add(r.competitionEventId)
}
}

const requiredCount = requiredTrackWorkoutIds.length
return recipients.filter((r) => {
if (!r.userId) return true // pending invites are always incomplete
const submitted = submittedByUser.get(r.userId)
return !submitted || submitted.size < requiredCount
})
}

// ============================================================================
// Organizer: Get Distinct Answer Values (for UI autocomplete)
// ============================================================================
Expand Down Expand Up @@ -822,18 +953,22 @@ export const sendBroadcastFn = createServerFn({ method: "POST" })
const includeAthletes =
filterType === "all" ||
filterType === "division" ||
filterType === "public"
filterType === "public" ||
filterType === "missing_submissions"
const includeVolunteers =
filterType === "public" ||
filterType === "volunteers" ||
filterType === "volunteer_role"
const isPendingTeammates = filterType === "pending_teammates"
const isMissingSubmissions = filterType === "missing_submissions"

if (includeAthletes || isPendingTeammates) {
const audienceRows = await fetchAthleteAudienceRows({
competitionId: data.competitionId,
divisionId:
filterType === "division" || isPendingTeammates
filterType === "division" ||
isPendingTeammates ||
isMissingSubmissions
? (data.audienceFilter?.divisionId ?? null)
: null,
})
Expand Down Expand Up @@ -935,6 +1070,21 @@ export const sendBroadcastFn = createServerFn({ method: "POST" })
volunteerRecipients = filteredVolunteers
}

// Apply missing-submissions filter last so question-filter team inheritance
// isn't broken: the question filter looks up captain/solo registrations to
// decide which teammates/invites pass, and narrowing by missing submissions
// first could strip the captain (who submitted everything) while leaving a
// teammate whose captain is no longer in the pool to inherit from.
if (isMissingSubmissions && data.audienceFilter?.divisionId) {
athleteRecipients = await applyMissingSubmissionsFilter(
athleteRecipients,
{
competitionId: data.competitionId,
divisionId: data.audienceFilter.divisionId,
},
)
}

// Drop pending-invite athlete rows whose email collides with a volunteer
// we're about to send to — volunteer has a real user account, so their
// row wins and prevents the same person getting two emails. Done after
Expand Down Expand Up @@ -1352,12 +1502,14 @@ export const previewAudienceFn = createServerFn({ method: "GET" })
const includeAthletes =
filterType === "all" ||
filterType === "division" ||
filterType === "public"
filterType === "public" ||
filterType === "missing_submissions"
const includeVolunteers =
filterType === "public" ||
filterType === "volunteers" ||
filterType === "volunteer_role"
const isPendingTeammates = filterType === "pending_teammates"
const isMissingSubmissions = filterType === "missing_submissions"

// Build the athlete-side recipient set via the shared helper so preview
// and send stay in lock-step on audience expansion + dedup rules.
Expand All @@ -1366,7 +1518,9 @@ export const previewAudienceFn = createServerFn({ method: "GET" })
const audienceRows = await fetchAthleteAudienceRows({
competitionId: data.competitionId,
divisionId:
filterType === "division" || isPendingTeammates
filterType === "division" ||
isPendingTeammates ||
isMissingSubmissions
? (data.audienceFilter?.divisionId ?? null)
: null,
})
Expand Down Expand Up @@ -1477,6 +1631,19 @@ export const previewAudienceFn = createServerFn({ method: "GET" })
: volunteerRecipients
}

// Apply missing-submissions filter after question filters so team
// inheritance in applyAthleteQuestionFilters still has the captain row
// available to resolve teammate matches (kept in lock-step with send).
if (isMissingSubmissions && data.audienceFilter?.divisionId) {
athleteRecipients = await applyMissingSubmissionsFilter(
athleteRecipients,
{
competitionId: data.competitionId,
divisionId: data.audienceFilter.divisionId,
},
)
}

return {
count: athleteRecipients.length + volunteerRecipients.length,
}
Expand Down
17 changes: 12 additions & 5 deletions apps/wodsmith-start/src/server/notifications/submission-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,17 +184,22 @@ async function deleteNotificationReservation(params: {
}

/**
* Check if a user has submitted a score for a competition event
* Check if a user has submitted a score for a competition event.
*
* NOTE: `scoresTable.competitionEventId` stores the `trackWorkoutId`, not
* `competitionEventsTable.id` — the column name is historical. Every insert
* site writes `data.trackWorkoutId` into this column, so we match on it here.
* Do not pass `competitionEventsTable.id`; the query will never match.
*/
async function hasUserSubmittedScore(params: {
userId: string
competitionEventId: string
trackWorkoutId: string
}): Promise<boolean> {
const db = getDb()
const score = await db.query.scoresTable.findFirst({
where: and(
eq(scoresTable.userId, params.userId),
eq(scoresTable.competitionEventId, params.competitionEventId),
eq(scoresTable.competitionEventId, params.trackWorkoutId),
),
columns: { id: true },
})
Expand Down Expand Up @@ -716,10 +721,12 @@ export async function processSubmissionWindowNotifications(): Promise<ProcessedN

// 5. Window just closed (within last 15 minutes)
if (closesAt <= now && closesAt > fifteenMinutesAgo) {
// Check if user has actually submitted a score for this event
// Check if user has actually submitted a score for this event.
// scoresTable.competitionEventId stores the trackWorkoutId, not
// competitionEventsTable.id — see hasUserSubmittedScore comment.
const hasSubmitted = await hasUserSubmittedScore({
userId: user.id,
competitionEventId: event.id,
trackWorkoutId: event.trackWorkoutId,
})

const sent = await sendWindowClosedNotification({
Expand Down
Loading
Loading