- Add unique index on (sports_season_id, name) in participants table - findParticipantByName uses case-insensitive lower() comparison - Single add: check for existing name before insert, return clear error - Bulk add: load existing names once upfront (1 query vs N), dedup input case-insensitively, report skipped names in UI - Fix golf-skills and surface-elo routes which called createParticipant without any duplicate guard (would have thrown DB constraint errors) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3c4ed67946
commit
8f55d0d135
8 changed files with 4264 additions and 16 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { eq, inArray, count } from "drizzle-orm";
|
import { eq, inArray, count, and, sql } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
|
@ -36,6 +36,24 @@ export async function findParticipantById(id: string): Promise<Participant | und
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findParticipantByName(
|
||||||
|
sportsSeasonId: string,
|
||||||
|
name: string
|
||||||
|
): Promise<Participant | undefined> {
|
||||||
|
const db = database();
|
||||||
|
const results = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.participants)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.participants.sportsSeasonId, sportsSeasonId),
|
||||||
|
sql`lower(${schema.participants.name}) = lower(${name})`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
export async function findParticipantsBySportsSeasonId(sportsSeasonId: string): Promise<Participant[]> {
|
export async function findParticipantsBySportsSeasonId(sportsSeasonId: string): Promise<Participant[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
return await db.query.participants.findMany({
|
return await db.query.participants.findMany({
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Route } from './+types/admin.sports-seasons.$id.golf-skills';
|
||||||
|
|
||||||
import { logger } from '~/lib/logger';
|
import { logger } from '~/lib/logger';
|
||||||
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
|
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
|
||||||
import { findParticipantsBySportsSeasonId, createParticipant } from '~/models/participant';
|
import { findParticipantsBySportsSeasonId, findParticipantByName, createParticipant } from '~/models/participant';
|
||||||
import { batchUpsertParticipantEVs } from '~/models/participant-expected-value';
|
import { batchUpsertParticipantEVs } from '~/models/participant-expected-value';
|
||||||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||||
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
|
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
|
||||||
|
|
@ -89,6 +89,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return { intent: 'create-participant', success: false, message: 'Name is required' } satisfies ActionData;
|
return { intent: 'create-participant', success: false, message: 'Name is required' } satisfies ActionData;
|
||||||
}
|
}
|
||||||
|
const existing = await findParticipantByName(sportsSeasonId, name);
|
||||||
|
if (existing) {
|
||||||
|
return { intent: 'create-participant', success: false, message: `"${name}" already exists in this season.` } satisfies ActionData;
|
||||||
|
}
|
||||||
const participant = await createParticipant({ sportsSeasonId, name });
|
const participant = await createParticipant({ sportsSeasonId, name });
|
||||||
return {
|
return {
|
||||||
intent: 'create-participant',
|
intent: 'create-participant',
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ import type { Route } from "./+types/admin.sports-seasons.$id.participants";
|
||||||
|
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { findSportsSeasonById } from "~/models/sports-season";
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
import {
|
import {
|
||||||
findParticipantsBySportsSeasonId,
|
findParticipantsBySportsSeasonId,
|
||||||
|
findParticipantByName,
|
||||||
createParticipant,
|
createParticipant,
|
||||||
deleteParticipant
|
deleteParticipant,
|
||||||
} from "~/models/participant";
|
} from "~/models/participant";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
|
|
@ -64,22 +65,35 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// Bulk add participants
|
// Bulk add participants
|
||||||
if (intent === "bulk") {
|
if (intent === "bulk") {
|
||||||
const bulkNames = formData.get("bulkNames");
|
const bulkNames = formData.get("bulkNames");
|
||||||
|
|
||||||
if (typeof bulkNames !== "string" || !bulkNames.trim()) {
|
if (typeof bulkNames !== "string" || !bulkNames.trim()) {
|
||||||
return { error: "Please enter at least one participant name" };
|
return { error: "Please enter at least one participant name" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = bulkNames
|
// Dedup case-insensitively, preserving first occurrence's casing
|
||||||
.split("\n")
|
const seenKeys = new Map<string, string>();
|
||||||
.map(name => name.trim())
|
for (const raw of bulkNames.split("\n").map(n => n.trim()).filter(n => n.length > 0)) {
|
||||||
.filter(name => name.length > 0);
|
const key = raw.toLowerCase();
|
||||||
|
if (!seenKeys.has(key)) seenKeys.set(key, raw);
|
||||||
|
}
|
||||||
|
const names = [...seenKeys.values()];
|
||||||
|
|
||||||
if (names.length === 0) {
|
if (names.length === 0) {
|
||||||
return { error: "Please enter at least one participant name" };
|
return { error: "Please enter at least one participant name" };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const existingParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
const existingNames = new Set(existingParticipants.map(p => p.name.toLowerCase()));
|
||||||
|
|
||||||
|
const skipped: string[] = [];
|
||||||
|
let added = 0;
|
||||||
|
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
|
if (existingNames.has(name.toLowerCase())) {
|
||||||
|
skipped.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await createParticipant({
|
await createParticipant({
|
||||||
sportsSeasonId: params.id,
|
sportsSeasonId: params.id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -87,9 +101,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
externalId: null,
|
externalId: null,
|
||||||
expectedValue: "0",
|
expectedValue: "0",
|
||||||
});
|
});
|
||||||
|
added++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, count: names.length };
|
return { success: true, count: added, skipped };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating participants:", error);
|
logger.error("Error creating participants:", error);
|
||||||
return { error: "Failed to add participants. Please try again." };
|
return { error: "Failed to add participants. Please try again." };
|
||||||
|
|
@ -103,10 +118,16 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { error: "Participant name is required" };
|
return { error: "Participant name is required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const existing = await findParticipantByName(params.id, trimmedName);
|
||||||
|
if (existing) {
|
||||||
|
return { error: `"${trimmedName}" already exists in this season.` };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createParticipant({
|
await createParticipant({
|
||||||
sportsSeasonId: params.id,
|
sportsSeasonId: params.id,
|
||||||
name: name.trim(),
|
name: trimmedName,
|
||||||
shortName: null,
|
shortName: null,
|
||||||
externalId: null,
|
externalId: null,
|
||||||
expectedValue: "0",
|
expectedValue: "0",
|
||||||
|
|
@ -214,9 +235,14 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{actionData?.success && actionData?.count && (
|
{actionData?.success && actionData?.count !== undefined && (
|
||||||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||||||
{actionData.count} participant{actionData.count !== 1 ? 's' : ''} added successfully!
|
{actionData.count} participant{actionData.count !== 1 ? 's' : ''} added.
|
||||||
|
{actionData.skipped && actionData.skipped.length > 0 && (
|
||||||
|
<span className="block mt-1 text-yellow-400">
|
||||||
|
{actionData.skipped.length} skipped (already exist): {actionData.skipped.join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Route } from './+types/admin.sports-seasons.$id.surface-elo';
|
||||||
|
|
||||||
import { logger } from '~/lib/logger';
|
import { logger } from '~/lib/logger';
|
||||||
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
|
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
|
||||||
import { findParticipantsBySportsSeasonId, createParticipant } from '~/models/participant';
|
import { findParticipantsBySportsSeasonId, findParticipantByName, createParticipant } from '~/models/participant';
|
||||||
import {
|
import {
|
||||||
batchUpsertParticipantEVs,
|
batchUpsertParticipantEVs,
|
||||||
} from '~/models/participant-expected-value';
|
} from '~/models/participant-expected-value';
|
||||||
|
|
@ -85,6 +85,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return { intent: 'create-participant', success: false, message: 'Name is required' } satisfies ActionData;
|
return { intent: 'create-participant', success: false, message: 'Name is required' } satisfies ActionData;
|
||||||
}
|
}
|
||||||
|
const existing = await findParticipantByName(sportsSeasonId, name);
|
||||||
|
if (existing) {
|
||||||
|
return { intent: 'create-participant', success: false, message: `"${name}" already exists in this season.` } satisfies ActionData;
|
||||||
|
}
|
||||||
const participant = await createParticipant({ sportsSeasonId, name });
|
const participant = await createParticipant({ sportsSeasonId, name });
|
||||||
return {
|
return {
|
||||||
intent: 'create-participant',
|
intent: 'create-participant',
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,9 @@ export const participants = pgTable("participants", {
|
||||||
expectedValue: decimal("expected_value", { precision: 10, scale: 4 }).notNull().default("0"),
|
expectedValue: decimal("expected_value", { precision: 10, scale: 4 }).notNull().default("0"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
}, (table) => [
|
||||||
|
uniqueIndex("participants_sports_season_name_unique").on(table.sportsSeasonId, table.name),
|
||||||
|
]);
|
||||||
|
|
||||||
export const seasonTemplateSports = pgTable("season_template_sports", {
|
export const seasonTemplateSports = pgTable("season_template_sports", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
|
||||||
1
drizzle/0064_fuzzy_wolfsbane.sql
Normal file
1
drizzle/0064_fuzzy_wolfsbane.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "participants_sports_season_name_unique" ON "participants" USING btree ("sports_season_id","name");
|
||||||
4186
drizzle/meta/0064_snapshot.json
Normal file
4186
drizzle/meta/0064_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -449,6 +449,13 @@
|
||||||
"when": 1774504559479,
|
"when": 1774504559479,
|
||||||
"tag": "0063_bored_ultimo",
|
"tag": "0063_bored_ultimo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 64,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774598151282,
|
||||||
|
"tag": "0064_fuzzy_wolfsbane",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue