Harden sport icon cleanup (#397)

This commit is contained in:
Chris Parsons 2026-05-08 21:19:09 -07:00 committed by GitHub
parent 79092f0409
commit e64cdabb17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 204 additions and 22 deletions

View file

@ -1,14 +1,21 @@
import { useRef, useState } from "react"; import { useId, useRef, useState } from "react";
import { ImageIcon, Trash2, Upload } from "lucide-react"; import { ImageIcon, Trash2, Upload } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { SportIcon } from "~/components/SportIcon"; import { SportIcon } from "~/components/SportIcon";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface SportIconOption {
id: string;
name: string;
iconUrl: string;
}
interface SportIconUploaderProps { interface SportIconUploaderProps {
name?: string; name?: string;
uploadUrl: string; uploadUrl: string;
initialIconUrl?: string | null; initialIconUrl?: string | null;
sportName?: string; sportName?: string;
existingIconOptions?: SportIconOption[];
} }
const MAX_UPLOAD_BYTES = 1024 * 1024; const MAX_UPLOAD_BYTES = 1024 * 1024;
@ -19,7 +26,9 @@ export function SportIconUploader({
uploadUrl, uploadUrl,
initialIconUrl = null, initialIconUrl = null,
sportName = "Sport icon", sportName = "Sport icon",
existingIconOptions = [],
}: SportIconUploaderProps) { }: SportIconUploaderProps) {
const selectId = useId();
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [iconUrl, setIconUrl] = useState(initialIconUrl ?? ""); const [iconUrl, setIconUrl] = useState(initialIconUrl ?? "");
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
@ -60,6 +69,11 @@ export function SportIconUploader({
clearSelectedFile(); clearSelectedFile();
} }
function chooseExistingIcon(nextIconUrl: string) {
setIconUrl(nextIconUrl);
clearSelectedFile();
}
async function uploadSelectedFile() { async function uploadSelectedFile() {
if (!selectedFile) { if (!selectedFile) {
setError("Choose an SVG first."); setError("Choose an SVG first.");
@ -107,6 +121,30 @@ export function SportIconUploader({
className="sr-only" className="sr-only"
/> />
{existingIconOptions.length > 0 && (
<div className="space-y-2">
<label htmlFor={selectId} className="text-sm font-medium">
Use an existing sport icon
</label>
<select
id={selectId}
value={existingIconOptions.some((option) => option.iconUrl === iconUrl) ? iconUrl : ""}
onChange={(event) => chooseExistingIcon(event.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Choose an icon from another sport...</option>
{existingIconOptions.map((option) => (
<option key={option.id} value={option.iconUrl}>
{option.name}
</option>
))}
</select>
<p className="text-sm text-muted-foreground">
Selecting a sport reuses its current icon URL; no upload required.
</p>
</div>
)}
{iconUrl && !selectedFile && ( {iconUrl && !selectedFile && (
<div className="space-y-3 rounded-md border border-border bg-muted/40 p-4"> <div className="space-y-3 rounded-md border border-border bg-muted/40 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View file

@ -28,6 +28,25 @@ describe("SportIconUploader", () => {
expect(await screen.findByText("Choose an SVG file.")).toBeInTheDocument(); expect(await screen.findByText("Choose an SVG file.")).toBeInTheDocument();
}); });
it("chooses an icon from an existing sport", () => {
const { container } = render(
<SportIconUploader
uploadUrl="/api/upload-sport-icon"
existingIconOptions={[
{ id: "sport-1", name: "NFL", iconUrl: "nfl.svg" },
{ id: "sport-2", name: "NBA", iconUrl: "nba.svg" },
]}
/>
);
fireEvent.change(screen.getByRole("combobox", { name: "Use an existing sport icon" }), {
target: { value: "nba.svg" },
});
expect(container.querySelector('input[name="iconUrl"]')).toHaveValue("nba.svg");
expect(screen.getByText("Current icon")).toBeInTheDocument();
});
it("uploads an SVG and updates the hidden input", async () => { it("uploads an SVG and updates the hidden input", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",

View file

@ -41,5 +41,6 @@ describe("sport icon URL validation", () => {
expect(parseSportIconUrlInput("https://res.cloudinary.com/demo/image/upload/v123/avatars/nfl.svg")).toBeUndefined(); expect(parseSportIconUrlInput("https://res.cloudinary.com/demo/image/upload/v123/avatars/nfl.svg")).toBeUndefined();
expect(parseSportIconUrlInput("nfl.png")).toBeUndefined(); expect(parseSportIconUrlInput("nfl.png")).toBeUndefined();
expect(parseSportIconUrlInput("../nfl.svg")).toBeUndefined(); expect(parseSportIconUrlInput("../nfl.svg")).toBeUndefined();
expect(parseSportIconUrlInput("/sports-icons/nfl.svg")).toBeUndefined();
}); });
}); });

View file

@ -0,0 +1,28 @@
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
import { logger } from "~/lib/logger";
import { isSportIconUrlUsed } from "~/models/sport";
interface DeleteSportIconIfUnusedArgs {
iconUrl: string;
excludeSportId?: string;
reason: "replaced" | "unused";
}
export async function deleteSportIconIfUnused({
iconUrl,
excludeSportId,
reason,
}: DeleteSportIconIfUnusedArgs): Promise<void> {
try {
if (await isSportIconUrlUsed(iconUrl, excludeSportId)) {
return;
}
} catch (error) {
logger.error("Failed to check whether sport icon is reused; skipping Cloudinary cleanup:", error);
return;
}
deleteCloudinaryImageByUrl(iconUrl).catch((error) => {
logger.error(`Failed to delete ${reason} sport icon from Cloudinary:`, error);
});
}

View file

@ -1,4 +1,4 @@
import { eq, sql } from "drizzle-orm"; import { and, eq, 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";
@ -48,6 +48,17 @@ export async function findAllSports(): Promise<Sport[]> {
}); });
} }
export async function isSportIconUrlUsed(iconUrl: string, excludeSportId?: string): Promise<boolean> {
const db = database();
const sport = await db.query.sports.findFirst({
where: excludeSportId
? and(eq(schema.sports.iconUrl, iconUrl), ne(schema.sports.id, excludeSportId))
: eq(schema.sports.iconUrl, iconUrl),
columns: { id: true },
});
return Boolean(sport);
}
export async function findAllSportsWithActiveSeasons(): Promise<SportWithActiveSeasons[]> { export async function findAllSportsWithActiveSeasons(): Promise<SportWithActiveSeasons[]> {
const db = database(); const db = database();
const sports = await db.query.sports.findMany({ const sports = await db.query.sports.findMany({

View file

@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/models/sport", () => ({ vi.mock("~/models/sport", () => ({
findAllSports: vi.fn(),
findSportById: vi.fn(), findSportById: vi.fn(),
isSportIconUrlUsed: vi.fn(),
updateSport: vi.fn(), updateSport: vi.fn(),
})); }));
vi.mock("~/lib/cloudinary.server", () => ({ vi.mock("~/lib/cloudinary.server", () => ({
@ -17,10 +19,10 @@ vi.mock("~/services/simulations/registry", () => ({
import { action } from "../admin.sports.$id"; import { action } from "../admin.sports.$id";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server"; import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
import { findSportById, updateSport } from "~/models/sport"; import { findSportById, isSportIconUrlUsed, updateSport } from "~/models/sport";
function makeRequest(iconUrl: string) { function makeRequest(iconUrl: string) {
const formData = new FormData(); const formData = new URLSearchParams();
formData.set("name", "NFL"); formData.set("name", "NFL");
formData.set("type", "team"); formData.set("type", "team");
formData.set("slug", "nfl"); formData.set("slug", "nfl");
@ -30,12 +32,14 @@ function makeRequest(iconUrl: string) {
return new Request("http://test/admin/sports/sport-1", { return new Request("http://test/admin/sports/sport-1", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData, body: formData,
}); });
} }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(isSportIconUrlUsed).mockResolvedValue(false);
vi.mocked(findSportById).mockResolvedValue({ vi.mocked(findSportById).mockResolvedValue({
id: "sport-1", id: "sport-1",
name: "Old NFL", name: "Old NFL",
@ -105,4 +109,33 @@ describe("admin sport edit action", () => {
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(nextIconUrl); expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(nextIconUrl);
}); });
it("does not delete a reused existing icon when update fails", async () => {
const nextIconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/new.svg";
vi.mocked(updateSport).mockRejectedValue(new Error("duplicate"));
vi.mocked(isSportIconUrlUsed).mockResolvedValue(true);
await action({
request: makeRequest(nextIconUrl),
params: { id: "sport-1" },
context: {},
} as never);
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
});
it("returns the update error when icon reuse lookup fails during cleanup", async () => {
const nextIconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/new.svg";
vi.mocked(updateSport).mockRejectedValue(new Error("database unavailable"));
vi.mocked(isSportIconUrlUsed).mockRejectedValue(new Error("database unavailable"));
const response = await action({
request: makeRequest(nextIconUrl),
params: { id: "sport-1" },
context: {},
} as never);
expect(response).toEqual({ error: "Failed to update sport: database unavailable" });
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
});
}); });

View file

@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/models/sport", () => ({ vi.mock("~/models/sport", () => ({
createSport: vi.fn(), createSport: vi.fn(),
findAllSports: vi.fn(),
isSportIconUrlUsed: vi.fn(),
})); }));
vi.mock("~/lib/cloudinary.server", () => ({ vi.mock("~/lib/cloudinary.server", () => ({
deleteCloudinaryImageByUrl: vi.fn().mockResolvedValue(undefined), deleteCloudinaryImageByUrl: vi.fn().mockResolvedValue(undefined),
@ -12,10 +14,10 @@ vi.mock("~/lib/logger", () => ({
import { action } from "../admin.sports.new"; import { action } from "../admin.sports.new";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server"; import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
import { createSport } from "~/models/sport"; import { createSport, isSportIconUrlUsed } from "~/models/sport";
function makeRequest(iconUrl: string) { function makeRequest(iconUrl: string) {
const formData = new FormData(); const formData = new URLSearchParams();
formData.set("name", "NFL"); formData.set("name", "NFL");
formData.set("type", "team"); formData.set("type", "team");
formData.set("slug", "nfl"); formData.set("slug", "nfl");
@ -24,6 +26,7 @@ function makeRequest(iconUrl: string) {
return new Request("http://test/admin/sports/new", { return new Request("http://test/admin/sports/new", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData, body: formData,
}); });
} }
@ -31,6 +34,7 @@ function makeRequest(iconUrl: string) {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(createSport).mockResolvedValue({} as never); vi.mocked(createSport).mockResolvedValue({} as never);
vi.mocked(isSportIconUrlUsed).mockResolvedValue(false);
}); });
describe("admin sport create action", () => { describe("admin sport create action", () => {
@ -57,4 +61,33 @@ describe("admin sport create action", () => {
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(iconUrl); expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(iconUrl);
}); });
it("does not delete a reused existing icon when create fails", async () => {
const iconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg";
vi.mocked(createSport).mockRejectedValue(new Error("duplicate"));
vi.mocked(isSportIconUrlUsed).mockResolvedValue(true);
await action({
request: makeRequest(iconUrl),
params: {},
context: {},
} as never);
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
});
it("returns the create error when icon reuse lookup fails during cleanup", async () => {
const iconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg";
vi.mocked(createSport).mockRejectedValue(new Error("database unavailable"));
vi.mocked(isSportIconUrlUsed).mockRejectedValue(new Error("database unavailable"));
const response = await action({
request: makeRequest(iconUrl),
params: {},
context: {},
} as never);
expect(response).toEqual({ error: "Failed to create sport. The slug might already exist." });
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
});
}); });

View file

@ -2,8 +2,8 @@ import { Form, Link, redirect } from "react-router";
import type { Route } from "./+types/admin.sports.$id"; import type { Route } from "./+types/admin.sports.$id";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { findSportById, updateSport } from "~/models/sport"; import { findAllSports, findSportById, updateSport } from "~/models/sport";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server"; import { deleteSportIconIfUnused } from "~/lib/sport-icon-cleanup.server";
import { parseSportIconUrlInput } from "~/lib/sport-icon-url"; import { parseSportIconUrlInput } from "~/lib/sport-icon-url";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
@ -37,7 +37,14 @@ export async function loader({ params }: Route.LoaderArgs) {
throw new Response("Sport not found", { status: 404 }); throw new Response("Sport not found", { status: 404 });
} }
return { sport }; const sports = await findAllSports();
return {
sport,
existingIconOptions: sports
.filter((option) => option.id !== sport.id && Boolean(option.iconUrl))
.map((option) => ({ id: option.id, name: option.name, iconUrl: option.iconUrl as string })),
};
} }
export async function action({ request, params }: Route.ActionArgs) { export async function action({ request, params }: Route.ActionArgs) {
@ -90,8 +97,10 @@ export async function action({ request, params }: Route.ActionArgs) {
}); });
if (existingSport?.iconUrl && existingSport.iconUrl !== updatedSport.iconUrl) { if (existingSport?.iconUrl && existingSport.iconUrl !== updatedSport.iconUrl) {
deleteCloudinaryImageByUrl(existingSport.iconUrl).catch((error) => { await deleteSportIconIfUnused({
logger.error("Failed to delete replaced sport icon from Cloudinary:", error); iconUrl: existingSport.iconUrl,
excludeSportId: params.id,
reason: "replaced",
}); });
} }
@ -99,9 +108,7 @@ export async function action({ request, params }: Route.ActionArgs) {
} catch (error) { } catch (error) {
logger.error("Error updating sport:", error); logger.error("Error updating sport:", error);
if (iconUrl && iconUrl !== existingSport?.iconUrl) { if (iconUrl && iconUrl !== existingSport?.iconUrl) {
deleteCloudinaryImageByUrl(iconUrl).catch((deleteError) => { await deleteSportIconIfUnused({ iconUrl, excludeSportId: params.id, reason: "unused" });
logger.error("Failed to delete unused sport icon from Cloudinary:", deleteError);
});
} }
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const isUniqueViolation = message.includes("unique") || message.includes("duplicate"); const isUniqueViolation = message.includes("unique") || message.includes("duplicate");
@ -114,7 +121,7 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
export default function EditSport({ loaderData, actionData }: Route.ComponentProps) { export default function EditSport({ loaderData, actionData }: Route.ComponentProps) {
const { sport } = loaderData; const { sport, existingIconOptions } = loaderData;
return ( return (
<div className="p-8"> <div className="p-8">
@ -222,6 +229,7 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
uploadUrl="/api/upload-sport-icon" uploadUrl="/api/upload-sport-icon"
initialIconUrl={sport.iconUrl} initialIconUrl={sport.iconUrl}
sportName={sport.name} sportName={sport.name}
existingIconOptions={existingIconOptions}
/> />
</div> </div>

View file

@ -2,8 +2,8 @@ import { Form, Link, redirect } from "react-router";
import type { Route } from "./+types/admin.sports.new"; import type { Route } from "./+types/admin.sports.new";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { createSport } from "~/models/sport"; import { createSport, findAllSports } from "~/models/sport";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server"; import { deleteSportIconIfUnused } from "~/lib/sport-icon-cleanup.server";
import { parseSportIconUrlInput } from "~/lib/sport-icon-url"; import { parseSportIconUrlInput } from "~/lib/sport-icon-url";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
@ -29,6 +29,15 @@ export function meta(): Route.MetaDescriptors {
return [{ title: "New Sport - Brackt Admin" }]; return [{ title: "New Sport - Brackt Admin" }];
} }
export async function loader() {
const sports = await findAllSports();
return {
existingIconOptions: sports
.filter((sport) => Boolean(sport.iconUrl))
.map((sport) => ({ id: sport.id, name: sport.name, iconUrl: sport.iconUrl as string })),
};
}
export async function action({ request }: Route.ActionArgs) { export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const name = formData.get("name"); const name = formData.get("name");
@ -73,15 +82,13 @@ export async function action({ request }: Route.ActionArgs) {
} catch (error) { } catch (error) {
logger.error("Error creating sport:", error); logger.error("Error creating sport:", error);
if (iconUrl) { if (iconUrl) {
deleteCloudinaryImageByUrl(iconUrl).catch((deleteError) => { await deleteSportIconIfUnused({ iconUrl, reason: "unused" });
logger.error("Failed to delete unused sport icon from Cloudinary:", deleteError);
});
} }
return { error: "Failed to create sport. The slug might already exist." }; return { error: "Failed to create sport. The slug might already exist." };
} }
} }
export default function NewSport({ actionData }: Route.ComponentProps) { export default function NewSport({ loaderData, actionData }: Route.ComponentProps) {
return ( return (
<div className="p-8"> <div className="p-8">
<div className="max-w-2xl"> <div className="max-w-2xl">
@ -155,7 +162,11 @@ export default function NewSport({ actionData }: Route.ComponentProps) {
<div className="space-y-2"> <div className="space-y-2">
<Label>Sport Icon (Optional)</Label> <Label>Sport Icon (Optional)</Label>
<SportIconUploader uploadUrl="/api/upload-sport-icon" sportName="New sport" /> <SportIconUploader
uploadUrl="/api/upload-sport-icon"
sportName="New sport"
existingIconOptions={loaderData.existingIconOptions}
/>
</div> </div>
{actionData?.error && ( {actionData?.error && (