brackt/app/models/scoring-event.ts

353 lines
10 KiB
TypeScript
Raw Normal View History

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, desc, asc, gte, lte } from "drizzle-orm";
import { recalculateAffectedLeagues } from "./scoring-calculator";
import { recalculateParticipantQP } from "./qualifying-points";
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
export function getEventTypeLabel(eventType: string): string {
switch (eventType) {
case "playoff_game": return "Bracket";
case "major_tournament": return "Major Tournament";
case "final_standings": return "Final Standings";
case "schedule_event": return "Non-Scoring";
default: return eventType;
}
}
export interface CreateScoringEventData {
sportsSeasonId: string;
name: string;
eventDate?: Date;
eventType: EventType;
isQualifyingEvent?: boolean;
// Template system fields (Phase 2.6)
bracketTemplateId?: string;
scoringStartsAtRound?: string;
}
export interface UpdateScoringEventData {
name?: string;
eventDate?: Date;
isComplete?: boolean;
completedAt?: Date;
bracketTemplateId?: string;
scoringStartsAtRound?: string;
}
/**
* Create a new scoring event
*/
export async function createScoringEvent(
data: CreateScoringEventData,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const [event] = await db
.insert(schema.scoringEvents)
.values({
sportsSeasonId: data.sportsSeasonId,
name: data.name,
eventDate: data.eventDate ? data.eventDate.toISOString().split('T')[0] : undefined,
eventType: data.eventType,
isQualifyingEvent: data.isQualifyingEvent || false,
bracketTemplateId: data.bracketTemplateId,
scoringStartsAtRound: data.scoringStartsAtRound,
isComplete: false,
})
.returning();
return event;
}
/**
* Get a scoring event by ID
*/
export async function getScoringEventById(
eventId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
}
/**
* Get all scoring events for a sports season
*/
export async function getScoringEventsForSportsSeason(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)],
with: {
eventResults: {
with: {
participant: true,
},
},
},
});
}
/**
* Get incomplete scoring events for a sports season
*/
export async function getIncompleteScoringEvents(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, false)
),
orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)],
});
}
/**
* Get qualifying events for a sports season
*/
export async function getQualifyingEvents(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isQualifyingEvent, true)
),
orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)],
});
}
/**
* Update a scoring event
*/
export async function updateScoringEvent(
eventId: string,
data: UpdateScoringEventData,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const updateData: any = { updatedAt: new Date() };
if (data.name !== undefined) updateData.name = data.name;
if (data.eventDate !== undefined) {
updateData.eventDate = data.eventDate.toISOString().split('T')[0];
}
if (data.isComplete !== undefined) updateData.isComplete = data.isComplete;
if (data.completedAt !== undefined) updateData.completedAt = data.completedAt;
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
const [updated] = await db
.update(schema.scoringEvents)
.set(updateData)
.where(eq(schema.scoringEvents.id, eventId))
.returning();
return updated;
}
/**
* Mark a scoring event as complete
*/
export async function completeScoringEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await updateScoringEvent(
eventId,
{
isComplete: true,
completedAt: new Date(),
},
db
);
}
/**
* Delete a scoring event.
* For bracket events (playoff_game), also clears all participant results for the
* sports season (placements are stored there with no FK back to the event) and
* recalculates league standings.
*/
export async function deleteScoringEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
if (!event) return;
// For qualifying events: capture affected participant IDs before the cascade deletes
// eventResults (which is how we know who had QP awarded from this event).
let affectedParticipantIds: string[] = [];
let wasQPProcessed = false;
if (event.isQualifyingEvent) {
const results = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId),
});
affectedParticipantIds = results.map((r) => r.participantId);
wasQPProcessed = results.some(
(r) => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0
);
}
await db.transaction(async (tx) => {
if (event.eventType === "playoff_game") {
await tx
.delete(schema.participantResults)
.where(eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId));
}
// Cascades: playoffMatches, eventResults, tournamentGroups/Members
await tx.delete(schema.scoringEvents).where(eq(schema.scoringEvents.id, eventId));
});
if (event.eventType === "playoff_game") {
await recalculateAffectedLeagues(event.sportsSeasonId, db);
}
if (event.isQualifyingEvent && affectedParticipantIds.length > 0) {
// eventResults have been cascade-deleted; recalculate QP totals from remaining events
for (const participantId of affectedParticipantIds) {
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
// Decrement majorsCompleted if this event had already been processed
if (wasQPProcessed) {
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
});
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
await db
.update(schema.sportsSeasons)
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
}
await recalculateAffectedLeagues(event.sportsSeasonId, db);
}
}
/**
* Check if all events for a sports season are complete
*/
export async function areAllEventsComplete(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<boolean> {
const db = providedDb || database();
const incompleteEvents = await getIncompleteScoringEvents(sportsSeasonId, db);
return incompleteEvents.length === 0;
}
/**
* Get upcoming (not yet complete) scoring events for a sports season,
* ordered by eventDate ascending (soonest first).
* Events without a date are included last.
*/
export async function getUpcomingScoringEvents(
sportsSeasonId: string,
limit = 5,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, false)
),
orderBy: [asc(schema.scoringEvents.eventDate), asc(schema.scoringEvents.createdAt)],
limit,
});
}
/**
* Get the single next upcoming event for a sports season (for card display).
*/
export async function getNextScoringEvent(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const upcoming = await getUpcomingScoringEvents(sportsSeasonId, 1, providedDb);
return upcoming[0] || null;
}
/**
* Get recently completed scoring events for a sports season,
* ordered by completedAt descending (most recent first).
*/
export async function getRecentCompletedEvents(
sportsSeasonId: string,
limit = 3,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, true)
),
orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.eventDate)],
limit,
});
}
/**
* Bulk create scoring events for a sports season.
* Returns created events in order.
*/
export async function bulkCreateScoringEvents(
sportsSeasonId: string,
events: Array<{ name: string; eventDate?: Date; eventType: EventType; isQualifyingEvent?: boolean }>,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
if (events.length === 0) return [];
const rows = events.map((e) => ({
sportsSeasonId,
name: e.name,
eventDate: e.eventDate ? e.eventDate.toISOString().split("T")[0] : undefined,
eventType: e.eventType,
isQualifyingEvent: e.isQualifyingEvent ?? false,
isComplete: false,
}));
return db.insert(schema.scoringEvents).values(rows).returning();
}