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 = 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" }; }