* Add notParticipating flag to allow excluding withdrawn participants from qualifying-points simulators Adds a `not_participating` boolean column to `event_results` so admins can mark a participant as not competing in a specific upcoming major (e.g. Alcaraz withdrawing from Wimbledon due to injury). The golf, tennis, and CS2 major simulators now query this flag for incomplete events and exclude those participants from the event's draw/field, redistributing probability weight to the remaining field. Admin UI for qualifying major_tournament events gains a "Not Participating" card to mark/unmark withdrawals before the event runs. https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 * Address code review feedback on not-participating flag Security: unmark-not-participating now validates the result exists, belongs to this event, and is actually a DNP row before deleting. mark-not-participating now returns a user-friendly error on duplicate-key constraint violations. Code quality: extract shared getExcludedByEventMap() utility to event-result model, eliminating the duplicated 20-line exclusion-loading block that was copy-pasted into all three simulators. Fix hasParticipantResult() to exclude notParticipating rows so it correctly reflects actual competition participation. Remove optional chaining on the non-optional notParticipatingIds field in the admin UI. Fix misleading empty-state message. Tests: replace the misleading first tennis DNP test (which never used the activeIds variable it created) with a test that explicitly validates the fallback behaviour when too few players remain after exclusion. Add three CS2 DNP tests covering the excluded-team-gets-zero-QP path, the redistribution of wins, and per-event pool independence. https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 * Fix lint errors: replace non-null assertions in CS2 DNP test https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 --------- Co-authored-by: Claude <noreply@anthropic.com>
309 lines
7.6 KiB
TypeScript
309 lines
7.6 KiB
TypeScript
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq, and, inArray } from "drizzle-orm";
|
|
|
|
export interface CreateEventResultData {
|
|
scoringEventId: string;
|
|
participantId: string;
|
|
placement?: number;
|
|
qualifyingPointsAwarded?: number;
|
|
eliminated?: boolean;
|
|
rawScore?: number;
|
|
notParticipating?: boolean;
|
|
}
|
|
|
|
export interface UpdateEventResultData {
|
|
placement?: number;
|
|
qualifyingPointsAwarded?: number;
|
|
eliminated?: boolean;
|
|
rawScore?: number;
|
|
notParticipating?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Create an event result for a participant
|
|
*/
|
|
export async function createEventResult(
|
|
data: CreateEventResultData,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
const [result] = await db
|
|
.insert(schema.eventResults)
|
|
.values({
|
|
scoringEventId: data.scoringEventId,
|
|
seasonParticipantId: data.participantId,
|
|
placement: data.placement,
|
|
qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(),
|
|
eliminated: data.eliminated,
|
|
rawScore: data.rawScore?.toString(),
|
|
notParticipating: data.notParticipating,
|
|
})
|
|
.returning();
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Create multiple event results in bulk
|
|
* Useful for entering all results for a tournament at once
|
|
*/
|
|
export async function createEventResultsBulk(
|
|
results: CreateEventResultData[],
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
const inserted = await db
|
|
.insert(schema.eventResults)
|
|
.values(
|
|
results.map((r) => ({
|
|
scoringEventId: r.scoringEventId,
|
|
seasonParticipantId: r.participantId,
|
|
placement: r.placement,
|
|
qualifyingPointsAwarded: r.qualifyingPointsAwarded?.toString(),
|
|
eliminated: r.eliminated,
|
|
rawScore: r.rawScore?.toString(),
|
|
notParticipating: r.notParticipating,
|
|
}))
|
|
)
|
|
.returning();
|
|
|
|
return inserted;
|
|
}
|
|
|
|
/**
|
|
* Get an event result by ID
|
|
*/
|
|
export async function getEventResultById(
|
|
resultId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return await db.query.eventResults.findFirst({
|
|
where: eq(schema.eventResults.id, resultId),
|
|
with: {
|
|
seasonParticipant: {
|
|
with: {
|
|
sportsSeason: {
|
|
with: {
|
|
sport: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
scoringEvent: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all results for a scoring event
|
|
*/
|
|
export async function getEventResults(
|
|
eventId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return await db.query.eventResults.findMany({
|
|
where: eq(schema.eventResults.scoringEventId, eventId),
|
|
orderBy: schema.eventResults.placement,
|
|
with: {
|
|
seasonParticipant: {
|
|
with: {
|
|
sportsSeason: {
|
|
with: {
|
|
sport: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all event results for a participant across all events
|
|
*/
|
|
export async function getParticipantEventResults(
|
|
participantId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
return await db.query.eventResults.findMany({
|
|
where: eq(schema.eventResults.seasonParticipantId, participantId),
|
|
with: {
|
|
scoringEvent: true,
|
|
},
|
|
orderBy: schema.eventResults.createdAt,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update an event result
|
|
*/
|
|
export async function updateEventResult(
|
|
resultId: string,
|
|
data: UpdateEventResultData,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
const [updated] = await db
|
|
.update(schema.eventResults)
|
|
.set({
|
|
placement: data.placement,
|
|
qualifyingPointsAwarded: data.qualifyingPointsAwarded?.toString(),
|
|
eliminated: data.eliminated,
|
|
rawScore: data.rawScore?.toString(),
|
|
notParticipating: data.notParticipating,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schema.eventResults.id, resultId))
|
|
.returning();
|
|
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Delete an event result
|
|
*/
|
|
export async function deleteEventResult(
|
|
resultId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
await db.delete(schema.eventResults).where(eq(schema.eventResults.id, resultId));
|
|
}
|
|
|
|
/**
|
|
* Delete all results for a scoring event
|
|
*/
|
|
export async function deleteEventResults(
|
|
eventId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
await db
|
|
.delete(schema.eventResults)
|
|
.where(eq(schema.eventResults.scoringEventId, eventId));
|
|
}
|
|
|
|
/**
|
|
* Check if a participant has a real result (placement/QP) for a specific event.
|
|
* Returns false for not-participating rows, which are exclusion markers, not results.
|
|
*/
|
|
export async function hasParticipantResult(
|
|
eventId: string,
|
|
participantId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
): Promise<boolean> {
|
|
const db = providedDb || database();
|
|
|
|
const result = await db.query.eventResults.findFirst({
|
|
where: and(
|
|
eq(schema.eventResults.scoringEventId, eventId),
|
|
eq(schema.eventResults.seasonParticipantId, participantId),
|
|
eq(schema.eventResults.notParticipating, false)
|
|
),
|
|
});
|
|
|
|
return !!result;
|
|
}
|
|
|
|
/**
|
|
* Build a map of eventId → Set<participantId> for all not-participating rows
|
|
* across a list of scoring events. Used by qualifying-points simulators to
|
|
* exclude withdrawn participants from each event's draw/pool in a single query.
|
|
*/
|
|
export async function getExcludedByEventMap(
|
|
eventIds: string[],
|
|
providedDb?: ReturnType<typeof database>
|
|
): Promise<Map<string, Set<string>>> {
|
|
if (eventIds.length === 0) return new Map();
|
|
|
|
const db = providedDb || database();
|
|
|
|
const rows = await db
|
|
.select({
|
|
scoringEventId: schema.eventResults.scoringEventId,
|
|
seasonParticipantId: schema.eventResults.seasonParticipantId,
|
|
})
|
|
.from(schema.eventResults)
|
|
.where(
|
|
and(
|
|
inArray(schema.eventResults.scoringEventId, eventIds),
|
|
eq(schema.eventResults.notParticipating, true)
|
|
)
|
|
);
|
|
|
|
const map = new Map<string, Set<string>>();
|
|
for (const r of rows) {
|
|
let set = map.get(r.scoringEventId);
|
|
if (!set) {
|
|
set = new Set();
|
|
map.set(r.scoringEventId, set);
|
|
}
|
|
set.add(r.seasonParticipantId);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Get all not-participating records for a scoring event.
|
|
* Used by simulators to exclude withdrawn participants from future event draws.
|
|
*/
|
|
export async function getNotParticipatingResults(
|
|
eventId: string,
|
|
providedDb?: ReturnType<typeof database>
|
|
): Promise<{ seasonParticipantId: string }[]> {
|
|
const db = providedDb || database();
|
|
|
|
return await db
|
|
.select({ seasonParticipantId: schema.eventResults.seasonParticipantId })
|
|
.from(schema.eventResults)
|
|
.where(
|
|
and(
|
|
eq(schema.eventResults.scoringEventId, eventId),
|
|
eq(schema.eventResults.notParticipating, true)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get results for multiple participants in a specific event
|
|
* Useful for checking drafted participants' performance
|
|
*/
|
|
export async function getEventResultsForParticipants(
|
|
eventId: string,
|
|
participantIds: string[],
|
|
providedDb?: ReturnType<typeof database>
|
|
) {
|
|
const db = providedDb || database();
|
|
|
|
if (participantIds.length === 0) return [];
|
|
|
|
return await db.query.eventResults.findMany({
|
|
where: and(
|
|
eq(schema.eventResults.scoringEventId, eventId),
|
|
inArray(schema.eventResults.seasonParticipantId, participantIds)
|
|
),
|
|
with: {
|
|
seasonParticipant: {
|
|
with: {
|
|
sportsSeason: {
|
|
with: {
|
|
sport: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|