Enforce unique team names within a season #49

Merged
chrisp merged 1 commit from fix/team-name-uniqueness into main 2026-05-24 00:07:29 +00:00
7 changed files with 6240 additions and 4 deletions

View file

@ -1,4 +1,4 @@
import { eq, and, isNull } from "drizzle-orm"; import { eq, and, isNull, ne, 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";
import { generateFlagConfig } from "~/lib/flag-generator"; import { generateFlagConfig } from "~/lib/flag-generator";
@ -61,6 +61,21 @@ export async function findTeamByOwnerAndSeason(
}); });
} }
export async function findTeamByNameInSeason(
seasonId: string,
name: string,
excludeTeamId?: string
): Promise<Team | undefined> {
const db = database();
return await db.query.teams.findFirst({
where: and(
eq(schema.teams.seasonId, seasonId),
sql`lower(${schema.teams.name}) = lower(${name})`,
excludeTeamId ? ne(schema.teams.id, excludeTeamId) : undefined
),
});
}
export async function findAvailableTeams(seasonId: string): Promise<Team[]> { export async function findAvailableTeams(seasonId: string): Promise<Team[]> {
const db = database(); const db = database();
return await db.query.teams.findMany({ return await db.query.teams.findMany({

View file

@ -19,7 +19,7 @@ import { deleteSeasonTimers } from "~/models/draft-timer";
import { findCurrentSeasonWithSports, type NewSeason, updateSeason } from "~/models/season"; import { findCurrentSeasonWithSports, type NewSeason, updateSeason } from "~/models/season";
import { linkMultipleSportsToSeason, unlinkSportFromSeason } from "~/models/season-sport"; import { linkMultipleSportsToSeason, unlinkSportFromSeason } from "~/models/season-sport";
import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season"; import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
import { claimTeam, deleteTeam, findTeamById, findTeamsBySeasonId, removeTeamOwner, renameTeam } from "~/models/team"; import { claimTeam, deleteTeam, findTeamById, findTeamByNameInSeason, findTeamsBySeasonId, removeTeamOwner, renameTeam } from "~/models/team";
import { findUserById, findUsersByIds, getUserDisplayName, isUserAdmin } from "~/models/user"; import { findUserById, findUsersByIds, getUserDisplayName, isUserAdmin } from "~/models/user";
import { logCommissionerAction } from "~/models/audit-log"; import { logCommissionerAction } from "~/models/audit-log";
import { parseDraftSpeed } from "~/lib/draft-timer"; import { parseDraftSpeed } from "~/lib/draft-timer";
@ -417,6 +417,10 @@ export async function action(args: Route.ActionArgs) {
if (username) { if (username) {
const strippedName = stripOwnerFromTeamName(team.name, username); const strippedName = stripOwnerFromTeamName(team.name, username);
if (strippedName !== team.name) { if (strippedName !== team.name) {
const nameConflict = await findTeamByNameInSeason(team.seasonId, strippedName, teamId);
if (nameConflict) {
return { error: "Cannot remove owner: the resulting team name is already taken in this season." };
}
await renameTeam(teamId, strippedName); await renameTeam(teamId, strippedName);
} }
await removeTeamOwner(teamId); await removeTeamOwner(teamId);
@ -478,6 +482,10 @@ export async function action(args: Route.ActionArgs) {
targetTeam.name, targetTeam.name,
assignedUser.username ?? assignedUser.displayName ?? "Member" assignedUser.username ?? assignedUser.displayName ?? "Member"
); );
const nameConflict = await findTeamByNameInSeason(season.id, teamName, teamId);
if (nameConflict) {
return { error: "Cannot assign owner: the resulting team name is already taken in this season." };
}
await claimTeam(teamId, assignedUserId, teamName); await claimTeam(teamId, assignedUserId, teamName);
return { success: true, message: "Owner assigned successfully" }; return { success: true, message: "Owner assigned successfully" };

View file

@ -4,6 +4,7 @@ import type { Route } from "./+types/$teamId.settings";
import { import {
findTeamById, findTeamById,
findTeamByNameInSeason,
updateTeam, updateTeam,
removeTeamOwner, removeTeamOwner,
} from "~/models/team"; } from "~/models/team";
@ -156,8 +157,17 @@ export async function action(args: Route.ActionArgs) {
return { error: "Team name is required" }; return { error: "Team name is required" };
} }
const trimmedName = name.trim();
if (!trimmedName) {
return { error: "Team name is required" };
}
const conflict = await findTeamByNameInSeason(team.seasonId, trimmedName, teamId);
if (conflict) {
return { error: "A team with that name already exists in this season." };
}
await updateTeam(teamId, { await updateTeam(teamId, {
name: name.trim(), name: trimmedName,
}); });
await syncPrivateBracktParticipants(team.seasonId); await syncPrivateBracktParticipants(team.seasonId);

View file

@ -246,7 +246,9 @@ export const teams = pgTable("teams", {
ownerId: varchar("owner_id", { length: 255 }), // UUID → users.id, nullable ownerId: varchar("owner_id", { length: 255 }), // UUID → users.id, nullable
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); }, (t) => [
uniqueIndex("teams_season_id_lower_name_unique").on(t.seasonId, sql`lower(${t.name})`),
]);
export const draftSlots = pgTable("draft_slots", { export const draftSlots = pgTable("draft_slots", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),

View file

@ -0,0 +1 @@
CREATE UNIQUE INDEX IF NOT EXISTS "teams_season_id_lower_name_unique" ON "teams" USING btree ("season_id",lower("name"));

File diff suppressed because it is too large Load diff

View file

@ -792,6 +792,13 @@
"when": 1779318579169, "when": 1779318579169,
"tag": "0112_married_captain_midlands", "tag": "0112_married_captain_midlands",
"breakpoints": true "breakpoints": true
},
{
"idx": 113,
"version": "7",
"when": 1779579877169,
"tag": "0113_bent_banshee",
"breakpoints": true
} }
] ]
} }