All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m27s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m25s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
Make a "major" (golf/tennis/CS2) scored once on its canonical tournament and fan out to every linked sports_season window and league. Fan-out & completion (app/services/sync-tournament-results.ts): - syncTournamentResults now marks each synced window's event complete (gated by markComplete), recalculates affected leagues, and counts recalc failures so a stale league can't hide behind a "completed" badge - syncMajorFromPrimaryEvent promotes a primary window's derived results to canonical tournament_results (deleting rows for dropped placements) and fans out to siblings; fanOutMajorIfPrimary guards on the primary - placement removals now propagate (stale rows reset to filler) Primary-event model (scoring_events.is_primary, migration 0122): - getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent / setPrimaryEvent; event creation auto-seeds a primary for bracket majors; "Make primary" button on the tournament page - per-window event/bracket/cs2 pages are read-only for non-primary linked events (not-participating stays editable) Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG): - bracket-scored qualifying major via the existing bracket pipeline - simulator conditions in-progress EV on the real bracket (honoring completed matches, walkover for withdrawals), QP derived from config, round structure read from the template; CS2 + tennis share resolveStructureSource Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile of existing majors (link orphans, designate primary, promote canonical, sync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
550 lines
19 KiB
TypeScript
550 lines
19 KiB
TypeScript
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId";
|
|
import { logger } from "~/lib/logger";
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
|
import { findParticipantsBySportsSeasonId, createParticipant } from "~/models/season-participant";
|
|
import {
|
|
getScoringEventById,
|
|
completeScoringEvent,
|
|
updateScoringEvent,
|
|
isReadOnlySibling,
|
|
} from "~/models/scoring-event";
|
|
import {
|
|
getEventResults,
|
|
createEventResult,
|
|
createEventResultsBulk,
|
|
updateEventResult,
|
|
deleteEventResult,
|
|
getEventResultById,
|
|
getNotParticipatingResults,
|
|
type CreateEventResultData,
|
|
type UpdateEventResultData,
|
|
} from "~/models/event-result";
|
|
import { findParticipantsWithoutResults } from "./admin.sports-seasons.$id.events.$eventId.helpers";
|
|
import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
|
import {
|
|
upsertParticipantSeasonResult,
|
|
getSeasonResults,
|
|
} from "~/models/participant-season-result";
|
|
import { processSeasonStandings, processQualifyingEvent } from "~/models/scoring-calculator";
|
|
import { getQPStandings, getQPConfig } from "~/models/qualifying-points";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq } from "drizzle-orm";
|
|
import { upsertTournamentResult } from "~/models/tournament-result";
|
|
import { syncTournamentResults } from "~/services/sync-tournament-results";
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
const sportsSeason = await findSportsSeasonById(params.id);
|
|
|
|
if (!sportsSeason) {
|
|
throw new Response("Sports season not found", { status: 404 });
|
|
}
|
|
|
|
const event = await getScoringEventById(params.eventId);
|
|
|
|
if (!event) {
|
|
throw new Response("Event not found", { status: 404 });
|
|
}
|
|
|
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
|
const results = await getEventResults(params.eventId);
|
|
const participantResults = await findParticipantResultsBySportsSeasonId(params.id);
|
|
|
|
// For final_standings events, also get season results
|
|
let seasonResults = null;
|
|
if (event.eventType === "final_standings") {
|
|
seasonResults = await getSeasonResults(params.id);
|
|
}
|
|
|
|
// For qualifying events, get QP config
|
|
let qpConfig = null;
|
|
if (event.isQualifyingEvent) {
|
|
qpConfig = await getQPConfig(params.id);
|
|
}
|
|
|
|
// For qualifying sports seasons, get QP standings
|
|
let qpStandings = null;
|
|
if (sportsSeason.scoringPattern === "qualifying_points") {
|
|
qpStandings = await getQPStandings(params.id);
|
|
}
|
|
|
|
// For qualifying major_tournament events, get not-participating participant IDs
|
|
const notParticipatingIds: Set<string> =
|
|
event.isQualifyingEvent && event.eventType === "major_tournament"
|
|
? new Set(
|
|
(await getNotParticipatingResults(params.eventId)).map(
|
|
(r) => r.seasonParticipantId
|
|
)
|
|
)
|
|
: new Set();
|
|
|
|
// A tournament-linked event that is NOT the primary is a read-only mirror:
|
|
// scoring happens once on the canonical tournament (or its primary window) and
|
|
// fans out here. Surface this so the UI can point the admin to the right place.
|
|
return {
|
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
|
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
|
},
|
|
event,
|
|
participants,
|
|
results,
|
|
participantResults,
|
|
seasonResults,
|
|
qpConfig,
|
|
qpStandings,
|
|
notParticipatingIds,
|
|
isReadOnlySibling: isReadOnlySibling(event),
|
|
canonicalTournamentId: event.tournamentId,
|
|
};
|
|
}
|
|
|
|
// Scoring intents that mutate this window's results directly. For a read-only
|
|
// sibling (tournament-linked, non-primary) these are rejected — score on the
|
|
// canonical tournament page instead, and the result fans out automatically.
|
|
//
|
|
// mark/unmark-not-participating are deliberately NOT here: they are per-window
|
|
// simulator inputs (exclude a participant from this window's EV draws), have no
|
|
// canonical-tournament equivalent, and must stay editable on every window.
|
|
const SCORING_INTENTS = new Set([
|
|
"process-qp",
|
|
"complete",
|
|
"uncomplete",
|
|
"add-result",
|
|
"update-result",
|
|
"delete-result",
|
|
"update-standings",
|
|
"batch-add-results",
|
|
]);
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
// Enforce single-source scoring: reject direct scoring on a read-only sibling.
|
|
if (typeof intent === "string" && SCORING_INTENTS.has(intent)) {
|
|
const ev = await getScoringEventById(params.eventId);
|
|
if (ev && isReadOnlySibling(ev)) {
|
|
return {
|
|
error:
|
|
"This event belongs to a shared major. Score it once on the tournament page (Admin → Tournaments) and it fans out to every window automatically.",
|
|
canonicalTournamentId: ev.tournamentId,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "mark-qualifying") {
|
|
try {
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) {
|
|
return { error: "Event not found" };
|
|
}
|
|
|
|
// Update the event to mark it as a qualifying event
|
|
const db = database();
|
|
await db.update(schema.scoringEvents)
|
|
.set({ isQualifyingEvent: true })
|
|
.where(eq(schema.scoringEvents.id, params.eventId));
|
|
|
|
return { success: "Event marked as qualifying event!" };
|
|
} catch (error) {
|
|
logger.error("Error marking event as qualifying:", error);
|
|
return { error: "Failed to mark event as qualifying" };
|
|
}
|
|
}
|
|
|
|
if (intent === "process-qp") {
|
|
try {
|
|
await processQualifyingEvent(params.eventId);
|
|
return { success: "Qualifying points processed and awarded!" };
|
|
} catch (error) {
|
|
logger.error("Error processing qualifying points:", error);
|
|
return { error: error instanceof Error ? error.message : "Failed to process qualifying points" };
|
|
}
|
|
}
|
|
|
|
if (intent === "complete") {
|
|
try {
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) {
|
|
return { error: "Event not found" };
|
|
}
|
|
|
|
await completeScoringEvent(params.eventId);
|
|
|
|
// If this is a final_standings event, process season standings
|
|
if (event.eventType === "final_standings") {
|
|
await processSeasonStandings(params.id);
|
|
return { success: "Event completed and fantasy placements assigned to top 8!" };
|
|
}
|
|
|
|
// If this is a qualifying event, process qualifying points then fill 0-QP rows
|
|
if (event.isQualifyingEvent) {
|
|
await processQualifyingEvent(params.eventId);
|
|
|
|
// Write 0-QP rows for all season participants who have no result for this event.
|
|
// This marks them as "competed, earned nothing" so simulations skip this event.
|
|
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
|
const existingResults = await getEventResults(params.eventId);
|
|
const existingIds = new Set(existingResults.map((r) => r.seasonParticipantId));
|
|
const missingIds = findParticipantsWithoutResults(
|
|
allParticipants.map((p) => p.id),
|
|
existingIds
|
|
);
|
|
|
|
if (missingIds.length > 0) {
|
|
await createEventResultsBulk(
|
|
missingIds.map((participantId) => ({
|
|
scoringEventId: params.eventId,
|
|
participantId,
|
|
qualifyingPointsAwarded: 0,
|
|
}))
|
|
);
|
|
}
|
|
|
|
return { success: "Event completed and qualifying points awarded!" };
|
|
}
|
|
|
|
return { success: "Event marked as completed" };
|
|
} catch (error) {
|
|
logger.error("Error completing event:", error);
|
|
return { error: "Failed to complete event" };
|
|
}
|
|
}
|
|
|
|
if (intent === "uncomplete") {
|
|
try {
|
|
await updateScoringEvent(params.eventId, { isComplete: false });
|
|
return { success: "Event marked as not updated" };
|
|
} catch (error) {
|
|
logger.error("Error uncompleting event:", error);
|
|
return { error: "Failed to update event" };
|
|
}
|
|
}
|
|
|
|
if (intent === "update-event") {
|
|
const name = formData.get("name");
|
|
const eventStartsAtRaw = formData.get("eventStartsAt");
|
|
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
return { error: "Event name is required" };
|
|
}
|
|
|
|
// eventStartsAt is a UTC ISO string from the client; empty string means "clear it"
|
|
const eventStartsAt: Date | null | undefined =
|
|
typeof eventStartsAtRaw === "string"
|
|
? eventStartsAtRaw
|
|
? new Date(eventStartsAtRaw)
|
|
: null
|
|
: undefined;
|
|
|
|
// Derive eventDate from eventStartsAt; null when cleared
|
|
const eventDate: Date | null | undefined =
|
|
eventStartsAt !== undefined
|
|
? eventStartsAt
|
|
? new Date(eventStartsAt.toISOString().split("T")[0])
|
|
: null
|
|
: undefined;
|
|
|
|
try {
|
|
await updateScoringEvent(params.eventId, {
|
|
name: name.trim(),
|
|
eventDate,
|
|
eventStartsAt,
|
|
});
|
|
return { success: "Event updated" };
|
|
} catch (error) {
|
|
logger.error("Error updating event:", error);
|
|
return { error: "Failed to update event" };
|
|
}
|
|
}
|
|
|
|
if (intent === "add-result") {
|
|
const participantId = formData.get("participantId");
|
|
const placement = formData.get("placement");
|
|
|
|
if (typeof participantId !== "string" || !participantId) {
|
|
return { error: "Participant is required" };
|
|
}
|
|
|
|
if (typeof placement !== "string" || !placement) {
|
|
return { error: "Placement is required" };
|
|
}
|
|
|
|
const placementNum = parseInt(placement, 10);
|
|
if (isNaN(placementNum) || placementNum < 1 || placementNum > 100) {
|
|
return { error: "Placement must be between 1 and 100" };
|
|
}
|
|
|
|
const resultData: CreateEventResultData = {
|
|
scoringEventId: params.eventId,
|
|
participantId,
|
|
placement: placementNum,
|
|
};
|
|
|
|
try {
|
|
await createEventResult(resultData);
|
|
return { success: "Result added successfully" };
|
|
} catch (error) {
|
|
logger.error("Error adding result:", error);
|
|
return { error: "Failed to add result" };
|
|
}
|
|
}
|
|
|
|
if (intent === "update-result") {
|
|
const resultId = formData.get("resultId");
|
|
const placement = formData.get("placement");
|
|
|
|
if (typeof resultId !== "string" || !resultId) {
|
|
return { error: "Result ID is required" };
|
|
}
|
|
|
|
if (typeof placement !== "string" || !placement) {
|
|
return { error: "Placement is required" };
|
|
}
|
|
|
|
const placementNum = parseInt(placement, 10);
|
|
if (isNaN(placementNum) || placementNum < 1 || placementNum > 100) {
|
|
return { error: "Placement must be between 1 and 100" };
|
|
}
|
|
|
|
const updateData: UpdateEventResultData = {
|
|
placement: placementNum,
|
|
};
|
|
|
|
try {
|
|
await updateEventResult(resultId, updateData);
|
|
return { success: "Result updated successfully" };
|
|
} catch (error) {
|
|
logger.error("Error updating result:", error);
|
|
return { error: "Failed to update result" };
|
|
}
|
|
}
|
|
|
|
if (intent === "delete-result") {
|
|
const resultId = formData.get("resultId");
|
|
|
|
if (typeof resultId !== "string" || !resultId) {
|
|
return { error: "Result ID is required" };
|
|
}
|
|
|
|
try {
|
|
await deleteEventResult(resultId);
|
|
return { success: "Result deleted successfully" };
|
|
} catch (error) {
|
|
logger.error("Error deleting result:", error);
|
|
return { error: "Failed to delete result" };
|
|
}
|
|
}
|
|
|
|
if (intent === "update-standings") {
|
|
// Bulk update season standings for final_standings events
|
|
try {
|
|
// Parse all form fields and collect participants with points
|
|
const participantPoints: Array<{ participantId: string; points: number }> = [];
|
|
|
|
for (const [key, value] of formData.entries()) {
|
|
if (key.startsWith("points-")) {
|
|
const participantId = key.replace("points-", "");
|
|
const points = value ? parseFloat(value as string) : 0;
|
|
|
|
if (points > 0) {
|
|
participantPoints.push({ participantId, points });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by points descending to determine positions
|
|
participantPoints.sort((a, b) => b.points - a.points);
|
|
|
|
// Assign positions based on sorted order and update
|
|
for (let i = 0; i < participantPoints.length; i++) {
|
|
const { participantId, points } = participantPoints[i];
|
|
const position = i + 1; // Position is 1-based
|
|
|
|
await upsertParticipantSeasonResult(
|
|
{
|
|
participantId,
|
|
sportsSeasonId: params.id,
|
|
currentPoints: points,
|
|
currentPosition: position,
|
|
}
|
|
);
|
|
}
|
|
|
|
// Also update participants with 0 or no points (they don't have a position)
|
|
for (const [key, value] of formData.entries()) {
|
|
if (key.startsWith("points-")) {
|
|
const participantId = key.replace("points-", "");
|
|
const points = value ? parseFloat(value as string) : 0;
|
|
|
|
if (points === 0) {
|
|
await upsertParticipantSeasonResult(
|
|
{
|
|
participantId,
|
|
sportsSeasonId: params.id,
|
|
currentPoints: 0,
|
|
currentPosition: undefined,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: `Updated standings for ${participantPoints.length} participants (positions auto-calculated from points)` };
|
|
} catch (error) {
|
|
logger.error("Error updating season standings:", error);
|
|
return { error: "Failed to update season standings" };
|
|
}
|
|
}
|
|
|
|
if (intent === "batch-add-results") {
|
|
const resultsJson = formData.get("results");
|
|
if (typeof resultsJson !== "string" || !resultsJson) {
|
|
return { error: "Results data is required" };
|
|
}
|
|
|
|
let incoming: { participantId: string; placement: number }[];
|
|
try {
|
|
incoming = JSON.parse(resultsJson);
|
|
} catch {
|
|
return { error: "Invalid results format" };
|
|
}
|
|
|
|
if (!Array.isArray(incoming) || incoming.length === 0) {
|
|
return { error: "No results provided" };
|
|
}
|
|
|
|
// Validate that all participant IDs belong to this sports season
|
|
const allSeasonParticipants = await findParticipantsBySportsSeasonId(params.id);
|
|
const validParticipantIds = new Set(allSeasonParticipants.map((p) => p.id));
|
|
const invalidEntries = incoming.filter((r) => !validParticipantIds.has(r.participantId));
|
|
if (invalidEntries.length > 0) {
|
|
return { error: "Some participant IDs do not belong to this sports season" };
|
|
}
|
|
|
|
try {
|
|
// If this scoring event is linked to a canonical tournament, write
|
|
// results to the canonical layer and fan out via syncTournamentResults.
|
|
// This keeps sibling windows (if any) in sync automatically.
|
|
const scoringEvent = await database().query.scoringEvents.findFirst({
|
|
where: eq(schema.scoringEvents.id, params.eventId),
|
|
});
|
|
if (!scoringEvent) {
|
|
return { error: "Scoring event not found" };
|
|
}
|
|
|
|
// Qualifying events always have a canonical tournament link (check
|
|
// constraint). Write results canonically and fan out.
|
|
if (!scoringEvent.tournamentId) {
|
|
return {
|
|
error:
|
|
"This scoring event has no canonical tournament link — cannot save batch results. Contact a developer.",
|
|
};
|
|
}
|
|
|
|
const spRows = allSeasonParticipants.filter((sp) =>
|
|
incoming.some((r) => r.participantId === sp.id),
|
|
);
|
|
const missingCanonical = spRows.filter((sp) => !sp.participantId);
|
|
if (missingCanonical.length > 0) {
|
|
return {
|
|
error: `Some season participants are not linked to canonical participants: ${missingCanonical.map((sp) => sp.name).join(", ")}`,
|
|
};
|
|
}
|
|
|
|
for (const row of incoming) {
|
|
const sp = spRows.find((s) => s.id === row.participantId);
|
|
if (!sp?.participantId) continue;
|
|
await upsertTournamentResult({
|
|
tournamentId: scoringEvent.tournamentId,
|
|
participantId: sp.participantId,
|
|
placement: row.placement,
|
|
});
|
|
}
|
|
|
|
const syncReport = await syncTournamentResults(scoringEvent.tournamentId);
|
|
return {
|
|
success: `${incoming.length} result${incoming.length === 1 ? "" : "s"} saved canonically. Synced to ${syncReport.windowsSynced} window${syncReport.windowsSynced === 1 ? "" : "s"}${syncReport.windowsFailed > 0 ? ` (${syncReport.windowsFailed} failed)` : ""}.`,
|
|
syncReport,
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error saving batch results:", error);
|
|
return { error: "Failed to save results" };
|
|
}
|
|
}
|
|
|
|
if (intent === "mark-not-participating") {
|
|
const participantId = formData.get("participantId");
|
|
|
|
if (typeof participantId !== "string" || !participantId) {
|
|
return { error: "Participant ID is required" };
|
|
}
|
|
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) return { error: "Event not found" };
|
|
if (event.isComplete) return { error: "Cannot mark not-participating on a completed event" };
|
|
|
|
try {
|
|
await createEventResult({
|
|
scoringEventId: params.eventId,
|
|
participantId,
|
|
notParticipating: true,
|
|
});
|
|
return { success: "Participant marked as not participating" };
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes("event_results_event_participant_unique")) {
|
|
return { error: "This participant is already marked for this event" };
|
|
}
|
|
logger.error("Error marking not-participating:", error);
|
|
return { error: "Failed to mark participant as not participating" };
|
|
}
|
|
}
|
|
|
|
if (intent === "unmark-not-participating") {
|
|
const resultId = formData.get("resultId");
|
|
|
|
if (typeof resultId !== "string" || !resultId) {
|
|
return { error: "Result ID is required" };
|
|
}
|
|
|
|
const result = await getEventResultById(resultId);
|
|
if (!result) return { error: "Result not found" };
|
|
if (result.scoringEvent.id !== params.eventId) return { error: "Result does not belong to this event" };
|
|
if (!result.notParticipating) return { error: "Cannot remove a result that is not a not-participating marker" };
|
|
|
|
try {
|
|
await deleteEventResult(resultId);
|
|
return { success: "Participant re-added to event" };
|
|
} catch (error) {
|
|
logger.error("Error unmarking not-participating:", error);
|
|
return { error: "Failed to re-add participant to event" };
|
|
}
|
|
}
|
|
|
|
if (intent === "create-participant") {
|
|
const name = formData.get("name");
|
|
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
return { error: "Participant name is required" };
|
|
}
|
|
|
|
try {
|
|
const participant = await createParticipant({
|
|
name: name.trim(),
|
|
sportsSeasonId: params.id,
|
|
});
|
|
return {
|
|
success: `Participant "${participant.name}" created.`,
|
|
newParticipant: { id: participant.id, name: participant.name },
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error creating participant:", error);
|
|
if (error instanceof Error && error.message.includes("participants_sports_season_name_unique")) {
|
|
return { error: `A participant named "${name.trim()}" already exists in this sports season.` };
|
|
}
|
|
return { error: "Failed to create participant" };
|
|
}
|
|
}
|
|
|
|
return { error: "Invalid action" };
|
|
}
|