Merge pull request 'Enforce unique team names within a season' (#49) from fix/team-name-uniqueness into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 1m26s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m16s
🚀 Deploy / 🔍 Lint (push) Successful in 48s
🚀 Deploy / 🐳 Build (push) Successful in 14m33s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s

Reviewed-on: #49
This commit is contained in:
chrisp 2026-05-24 00:07:28 +00:00
commit 3518bd77e8
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 * as schema from "~/database/schema";
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[]> {
const db = database();
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 { linkMultipleSportsToSeason, unlinkSportFromSeason } from "~/models/season-sport";
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 { logCommissionerAction } from "~/models/audit-log";
import { parseDraftSpeed } from "~/lib/draft-timer";
@ -417,6 +417,10 @@ export async function action(args: Route.ActionArgs) {
if (username) {
const strippedName = stripOwnerFromTeamName(team.name, username);
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 removeTeamOwner(teamId);
@ -478,6 +482,10 @@ export async function action(args: Route.ActionArgs) {
targetTeam.name,
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);
return { success: true, message: "Owner assigned successfully" };

View file

@ -4,6 +4,7 @@ import type { Route } from "./+types/$teamId.settings";
import {
findTeamById,
findTeamByNameInSeason,
updateTeam,
removeTeamOwner,
} from "~/models/team";
@ -156,8 +157,17 @@ export async function action(args: Route.ActionArgs) {
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, {
name: name.trim(),
name: trimmedName,
});
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
createdAt: timestamp("created_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", {
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,
"tag": "0112_married_captain_midlands",
"breakpoints": true
},
{
"idx": 113,
"version": "7",
"when": 1779579877169,
"tag": "0113_bent_banshee",
"breakpoints": true
}
]
}