feat: add draft pick, queue, and timer models with database schema updates

This commit is contained in:
Chris Parsons 2025-10-16 00:32:48 -07:00
parent 6e19a23e35
commit 469a85a0f8
9 changed files with 1896 additions and 0 deletions

65
app/models/draft-pick.ts Normal file
View file

@ -0,0 +1,65 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
export async function createDraftPick(data: {
seasonId: string;
teamId: string;
participantId: string;
pickNumber: number;
round: number;
pickInRound: number;
pickedByUserId: string;
pickedByType: "owner" | "commissioner" | "auto";
timeUsed: number;
}) {
const db = database();
const [pick] = await db.insert(schema.draftPicks).values(data).returning();
return pick;
}
export async function getDraftPicks(seasonId: string) {
const db = database();
return await db
.select()
.from(schema.draftPicks)
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(schema.draftPicks.pickNumber);
}
export async function getDraftPickByNumber(seasonId: string, pickNumber: number) {
const db = database();
const [pick] = await db
.select()
.from(schema.draftPicks)
.where(
and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.pickNumber, pickNumber)
)
);
return pick;
}
export async function getTeamDraftPicks(teamId: string) {
const db = database();
return await db
.select()
.from(schema.draftPicks)
.where(eq(schema.draftPicks.teamId, teamId))
.orderBy(schema.draftPicks.pickNumber);
}
export async function isParticipantDrafted(seasonId: string, participantId: string) {
const db = database();
const [pick] = await db
.select()
.from(schema.draftPicks)
.where(
and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.participantId, participantId)
)
);
return !!pick;
}

76
app/models/draft-queue.ts Normal file
View file

@ -0,0 +1,76 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, asc } from "drizzle-orm";
export async function addToQueue(data: {
seasonId: string;
teamId: string;
participantId: string;
queuePosition: number;
}) {
const db = database();
const [item] = await db.insert(schema.draftQueue).values(data).returning();
return item;
}
export async function removeFromQueue(queueId: string) {
const db = database();
await db.delete(schema.draftQueue).where(eq(schema.draftQueue.id, queueId));
}
export async function getTeamQueue(teamId: string) {
const db = database();
return await db
.select()
.from(schema.draftQueue)
.where(eq(schema.draftQueue.teamId, teamId))
.orderBy(asc(schema.draftQueue.queuePosition));
}
export async function clearTeamQueue(teamId: string) {
const db = database();
await db.delete(schema.draftQueue).where(eq(schema.draftQueue.teamId, teamId));
}
export async function reorderQueue(teamId: string, participantIds: string[]) {
// Delete existing queue
await clearTeamQueue(teamId);
// Get season ID from first queue item (or we need to pass it)
// For now, we'll need to pass seasonId
return participantIds;
}
export async function reorderQueueWithSeason(
seasonId: string,
teamId: string,
participantIds: string[]
) {
// Delete existing queue
await clearTeamQueue(teamId);
// Insert new queue in order
const items = participantIds.map((participantId, index) => ({
seasonId,
teamId,
participantId,
queuePosition: index + 1,
}));
if (items.length > 0) {
const db = database();
await db.insert(schema.draftQueue).values(items);
}
}
export async function removeParticipantFromQueue(teamId: string, participantId: string) {
const db = database();
await db
.delete(schema.draftQueue)
.where(
and(
eq(schema.draftQueue.teamId, teamId),
eq(schema.draftQueue.participantId, participantId)
)
);
}

46
app/models/draft-timer.ts Normal file
View file

@ -0,0 +1,46 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
export async function initializeDraftTimers(seasonId: string, teams: { id: string }[], initialTime: number) {
const timerData = teams.map((team) => ({
seasonId,
teamId: team.id,
timeRemaining: initialTime,
}));
const db = database();
await db.insert(schema.draftTimers).values(timerData);
}
export async function getTeamTimer(teamId: string) {
const db = database();
const [timer] = await db
.select()
.from(schema.draftTimers)
.where(eq(schema.draftTimers.teamId, teamId));
return timer;
}
export async function getSeasonTimers(seasonId: string) {
const db = database();
return await db
.select()
.from(schema.draftTimers)
.where(eq(schema.draftTimers.seasonId, seasonId));
}
export async function updateTeamTimer(teamId: string, timeRemaining: number) {
const db = database();
const [timer] = await db
.update(schema.draftTimers)
.set({ timeRemaining, updatedAt: new Date() })
.where(eq(schema.draftTimers.teamId, teamId))
.returning();
return timer;
}
export async function deleteSeasonTimers(seasonId: string) {
const db = database();
await db.delete(schema.draftTimers).where(eq(schema.draftTimers.seasonId, seasonId));
}

160
app/models/draft-utils.ts Normal file
View file

@ -0,0 +1,160 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, notInArray, desc } from "drizzle-orm";
import { getTeamQueue } from "./draft-queue";
import { isParticipantDrafted } from "./draft-pick";
/**
* Auto-pick for a team when their timer runs out
* 1. Check queue - pick first item if available
* 2. If queue empty, pick highest EV participant not drafted
*/
export async function autoPickForTeam(seasonId: string, teamId: string) {
// Check queue first
const queue = await getTeamQueue(teamId);
if (queue.length > 0) {
// Pick first item from queue
const firstInQueue = queue[0];
// Verify participant is not already drafted
const isDrafted = await isParticipantDrafted(seasonId, firstInQueue.participantId);
if (!isDrafted) {
return firstInQueue.participantId;
}
// If first item was drafted, try next items in queue
for (const item of queue.slice(1)) {
const isDrafted = await isParticipantDrafted(seasonId, item.participantId);
if (!isDrafted) {
return item.participantId;
}
}
}
// Queue is empty or all queued players drafted - pick highest EV available
return await getTopAvailableParticipant(seasonId);
}
/**
* Get the highest EV participant that hasn't been drafted yet
*/
export async function getTopAvailableParticipant(seasonId: string) {
const db = database();
// Get all drafted participant IDs
const draftedPicks = await db
.select({ participantId: schema.draftPicks.participantId })
.from(schema.draftPicks)
.where(eq(schema.draftPicks.seasonId, seasonId));
const draftedIds = draftedPicks.map((p: { participantId: string }) => p.participantId);
// Get all participants from season sports
const seasonSportsData = await db
.select({ sportsSeasonId: schema.seasonSports.sportsSeasonId })
.from(schema.seasonSports)
.where(eq(schema.seasonSports.seasonId, seasonId));
const sportsSeasonIds = seasonSportsData.map((s: { sportsSeasonId: string }) => s.sportsSeasonId);
if (sportsSeasonIds.length === 0) {
return null;
}
// Get top available participant by EV
let query = db
.select()
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]))
.orderBy(desc(schema.participants.expectedValue), schema.participants.name);
// Filter out drafted participants if any exist
if (draftedIds.length > 0) {
query = db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]),
notInArray(schema.participants.id, draftedIds)
)
)
.orderBy(desc(schema.participants.expectedValue), schema.participants.name);
}
// Handle multiple sports seasons
if (sportsSeasonIds.length > 1) {
// For simplicity, we'll query all and sort in memory
// In production, might want to optimize this
const allParticipants = [];
for (const sportsSeasonId of sportsSeasonIds) {
let participantQuery = db
.select()
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (draftedIds.length > 0) {
participantQuery = db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
notInArray(schema.participants.id, draftedIds)
)
);
}
const seasonParticipants = await participantQuery;
allParticipants.push(...seasonParticipants);
}
// Sort by EV desc, then name
allParticipants.sort((a, b) => {
if (b.expectedValue !== a.expectedValue) {
return b.expectedValue - a.expectedValue;
}
return a.name.localeCompare(b.name);
});
return allParticipants[0]?.id || null;
}
const [topParticipant] = await query;
return topParticipant?.id || null;
}
/**
* Calculate the current pick based on draft order and round
*/
export function calculatePickInfo(
pickNumber: number,
teamCount: number
): { round: number; pickInRound: number; teamIndex: number } {
const round = Math.ceil(pickNumber / teamCount);
const pickInRound = ((pickNumber - 1) % teamCount) + 1;
// Snake draft: odd rounds go forward, even rounds go backward
const isOddRound = round % 2 === 1;
const teamIndex = isOddRound ? pickInRound - 1 : teamCount - pickInRound;
return { round, pickInRound, teamIndex };
}
/**
* Get the team ID for a given pick number based on draft order
*/
export function getTeamForPick(
pickNumber: number,
draftOrder: { teamId: string; draftOrder: number }[]
): string | null {
const sortedOrder = [...draftOrder].sort((a, b) => a.draftOrder - b.draftOrder);
const teamCount = sortedOrder.length;
if (teamCount === 0) return null;
const { teamIndex } = calculatePickInfo(pickNumber, teamCount);
return sortedOrder[teamIndex]?.teamId || null;
}

View file

@ -12,3 +12,7 @@ export * from "./season-template-sport";
export * from "./season-sport"; export * from "./season-sport";
export * from "./participant-result"; export * from "./participant-result";
export * from "./draft-slot"; export * from "./draft-slot";
export * from "./draft-pick";
export * from "./draft-queue";
export * from "./draft-timer";
export * from "./draft-utils";

View file

@ -39,6 +39,12 @@ export const scoringTypeEnum = pgEnum("scoring_type", [
"majors", "majors",
]); ]);
export const pickedByTypeEnum = pgEnum("picked_by_type", [
"owner",
"commissioner",
"auto",
]);
export const leagues = pgTable("leagues", { export const leagues = pgTable("leagues", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
@ -70,6 +76,11 @@ export const seasons = pgTable("seasons", {
draftRounds: integer("draft_rounds").notNull().default(20), draftRounds: integer("draft_rounds").notNull().default(20),
flexSpots: integer("flex_spots").notNull().default(0), flexSpots: integer("flex_spots").notNull().default(0),
draftDateTime: timestamp("draft_date_time"), draftDateTime: timestamp("draft_date_time"),
draftInitialTime: integer("draft_initial_time").notNull().default(120), // seconds
draftIncrementTime: integer("draft_increment_time").notNull().default(30), // seconds
currentPickNumber: integer("current_pick_number").default(1),
draftStartedAt: timestamp("draft_started_at"),
draftPaused: boolean("draft_paused").notNull().default(false),
inviteCode: varchar("invite_code", { length: 20 }).notNull().unique(), inviteCode: varchar("invite_code", { length: 20 }).notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
@ -100,6 +111,54 @@ export const draftSlots = pgTable("draft_slots", {
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });
export const draftPicks = pgTable("draft_picks", {
id: uuid("id").primaryKey().defaultRandom(),
seasonId: uuid("season_id")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
teamId: uuid("team_id")
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
pickNumber: integer("pick_number").notNull(),
round: integer("round").notNull(),
pickInRound: integer("pick_in_round").notNull(),
pickedByUserId: varchar("picked_by_user_id", { length: 255 }).notNull(), // Clerk user ID
pickedByType: pickedByTypeEnum("picked_by_type").notNull(),
timeUsed: integer("time_used").notNull().default(0), // seconds
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const draftQueue = pgTable("draft_queue", {
id: uuid("id").primaryKey().defaultRandom(),
seasonId: uuid("season_id")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
teamId: uuid("team_id")
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
queuePosition: integer("queue_position").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const draftTimers = pgTable("draft_timers", {
id: uuid("id").primaryKey().defaultRandom(),
seasonId: uuid("season_id")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
teamId: uuid("team_id")
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
timeRemaining: integer("time_remaining").notNull(), // seconds
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const commissioners = pgTable("commissioners", { export const commissioners = pgTable("commissioners", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
leagueId: uuid("league_id") leagueId: uuid("league_id")

View file

@ -0,0 +1,85 @@
CREATE TYPE "public"."picked_by_type" AS ENUM('owner', 'commissioner', 'auto');--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "draft_picks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"season_id" uuid NOT NULL,
"team_id" uuid NOT NULL,
"participant_id" uuid NOT NULL,
"pick_number" integer NOT NULL,
"round" integer NOT NULL,
"pick_in_round" integer NOT NULL,
"picked_by_user_id" varchar(255) NOT NULL,
"picked_by_type" "picked_by_type" NOT NULL,
"time_used" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "draft_queue" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"season_id" uuid NOT NULL,
"team_id" uuid NOT NULL,
"participant_id" uuid NOT NULL,
"queue_position" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "draft_timers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"season_id" uuid NOT NULL,
"team_id" uuid NOT NULL,
"time_remaining" integer NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "seasons" ADD COLUMN "draft_initial_time" integer DEFAULT 120 NOT NULL;--> statement-breakpoint
ALTER TABLE "seasons" ADD COLUMN "draft_increment_time" integer DEFAULT 30 NOT NULL;--> statement-breakpoint
ALTER TABLE "seasons" ADD COLUMN "current_pick_number" integer DEFAULT 1;--> statement-breakpoint
ALTER TABLE "seasons" ADD COLUMN "draft_started_at" timestamp;--> statement-breakpoint
ALTER TABLE "seasons" ADD COLUMN "draft_paused" boolean DEFAULT false NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_picks" ADD CONSTRAINT "draft_picks_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_picks" ADD CONSTRAINT "draft_picks_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_picks" ADD CONSTRAINT "draft_picks_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_queue" ADD CONSTRAINT "draft_queue_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_queue" ADD CONSTRAINT "draft_queue_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_queue" ADD CONSTRAINT "draft_queue_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_timers" ADD CONSTRAINT "draft_timers_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_timers" ADD CONSTRAINT "draft_timers_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load diff

View file

@ -120,6 +120,13 @@
"when": 1760590495868, "when": 1760590495868,
"tag": "0016_majestic_roulette", "tag": "0016_majestic_roulette",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1760599350154,
"tag": "0017_calm_zarda",
"breakpoints": true
} }
] ]
} }