- 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 * 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[]> {
|
||||
const db = database();
|
||||
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 { 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 { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||
import { getGolfSkillsForSeason, batchUpsertGolfSkills } from '~/models/golf-skills';
|
||||
|
|
@ -89,6 +89,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
if (!name) {
|
||||
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 });
|
||||
return {
|
||||
intent: 'create-participant',
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { logger } from "~/lib/logger";
|
|||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import {
|
||||
findParticipantsBySportsSeasonId,
|
||||
findParticipantByName,
|
||||
createParticipant,
|
||||
deleteParticipant
|
||||
deleteParticipant,
|
||||
} from "~/models/participant";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
|
@ -69,17 +70,30 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { error: "Please enter at least one participant name" };
|
||||
}
|
||||
|
||||
const names = bulkNames
|
||||
.split("\n")
|
||||
.map(name => name.trim())
|
||||
.filter(name => name.length > 0);
|
||||
// Dedup case-insensitively, preserving first occurrence's casing
|
||||
const seenKeys = new Map<string, string>();
|
||||
for (const raw of bulkNames.split("\n").map(n => n.trim()).filter(n => n.length > 0)) {
|
||||
const key = raw.toLowerCase();
|
||||
if (!seenKeys.has(key)) seenKeys.set(key, raw);
|
||||
}
|
||||
const names = [...seenKeys.values()];
|
||||
|
||||
if (names.length === 0) {
|
||||
return { error: "Please enter at least one participant name" };
|
||||
}
|
||||
|
||||
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) {
|
||||
if (existingNames.has(name.toLowerCase())) {
|
||||
skipped.push(name);
|
||||
continue;
|
||||
}
|
||||
await createParticipant({
|
||||
sportsSeasonId: params.id,
|
||||
name,
|
||||
|
|
@ -87,9 +101,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
externalId: null,
|
||||
expectedValue: "0",
|
||||
});
|
||||
added++;
|
||||
}
|
||||
|
||||
return { success: true, count: names.length };
|
||||
return { success: true, count: added, skipped };
|
||||
} catch (error) {
|
||||
logger.error("Error creating participants:", error);
|
||||
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" };
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const existing = await findParticipantByName(params.id, trimmedName);
|
||||
if (existing) {
|
||||
return { error: `"${trimmedName}" already exists in this season.` };
|
||||
}
|
||||
|
||||
try {
|
||||
await createParticipant({
|
||||
sportsSeasonId: params.id,
|
||||
name: name.trim(),
|
||||
name: trimmedName,
|
||||
shortName: null,
|
||||
externalId: null,
|
||||
expectedValue: "0",
|
||||
|
|
@ -214,9 +235,14 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
|
|||
</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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Route } from './+types/admin.sports-seasons.$id.surface-elo';
|
|||
|
||||
import { logger } from '~/lib/logger';
|
||||
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';
|
||||
|
|
@ -85,6 +85,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
if (!name) {
|
||||
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 });
|
||||
return {
|
||||
intent: 'create-participant',
|
||||
|
|
|
|||
|
|
@ -311,7 +311,9 @@ export const participants = pgTable("participants", {
|
|||
expectedValue: decimal("expected_value", { precision: 10, scale: 4 }).notNull().default("0"),
|
||||
createdAt: timestamp("created_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", {
|
||||
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,
|
||||
"tag": "0063_bored_ultimo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 64,
|
||||
"version": "7",
|
||||
"when": 1774598151282,
|
||||
"tag": "0064_fuzzy_wolfsbane",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue