Prevent duplicate participants within a sports season, fixes #69 (#236)

- 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:
Chris Parsons 2026-03-27 01:09:04 -07:00 committed by GitHub
parent 3c4ed67946
commit 8f55d0d135
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 4264 additions and 16 deletions

View file

@ -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({

View file

@ -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',

View file

@ -5,8 +5,9 @@ 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";
@ -69,17 +70,30 @@ export async function action({ request, params }: Route.ActionArgs) {
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>
)} )}

View file

@ -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',

View file

@ -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(),

View file

@ -0,0 +1 @@
CREATE UNIQUE INDEX IF NOT EXISTS "participants_sports_season_name_unique" ON "participants" USING btree ("sports_season_id","name");

File diff suppressed because it is too large Load diff

View file

@ -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
} }
] ]
} }