diff --git a/client/web/package-lock.json b/client/web/package-lock.json
index 407533e8..f79add7f 100644
--- a/client/web/package-lock.json
+++ b/client/web/package-lock.json
@@ -112,6 +112,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -3459,6 +3460,7 @@
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -3469,6 +3471,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3479,6 +3482,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3528,6 +3532,7 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
@@ -3671,19 +3676,6 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/@typescript-eslint/utils": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
@@ -3766,6 +3758,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3859,9 +3852,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.3",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
- "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3914,6 +3907,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4278,7 +4272,8 @@
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
@@ -4385,6 +4380,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4753,9 +4749,9 @@
}
},
"node_modules/flatted": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
- "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -5639,9 +5635,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5743,6 +5739,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5773,6 +5770,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5785,6 +5783,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6203,6 +6202,7 @@
"resolved": "https://registry.npmjs.org/supertokens-web-js/-/supertokens-web-js-0.16.0.tgz",
"integrity": "sha512-wuIdlVJtOsx4ZX0kCyl8lxmmAodXLlMAniZEHyVhsH2fhersh7bMrHpvgN9WoC470HYNC22qpHdlJngvyh/cSA==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@simplewebauthn/browser": "^13.0.0",
"supertokens-js-override": "0.0.4",
@@ -6314,10 +6314,11 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -6386,6 +6387,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6572,6 +6574,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6659,10 +6662,11 @@
}
},
"node_modules/vite/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -6728,6 +6732,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/client/web/src/pages/admin/_shared/AppSidebar.tsx b/client/web/src/pages/admin/_shared/AppSidebar.tsx
index e810acf6..6f31e781 100644
--- a/client/web/src/pages/admin/_shared/AppSidebar.tsx
+++ b/client/web/src/pages/admin/_shared/AppSidebar.tsx
@@ -3,6 +3,7 @@
import {
Calendar,
ClipboardList,
+ Handshake,
ScanLine,
Settings,
Star,
@@ -51,6 +52,11 @@ const eventNav = [
url: "/admin/schedule",
icon: Calendar,
},
+ {
+ name: "Sponsors",
+ url: "/admin/sponsors",
+ icon: Handshake,
+ },
];
const superAdminNav = [
diff --git a/client/web/src/pages/admin/index.ts b/client/web/src/pages/admin/index.ts
index 495e36e5..e8c307c6 100644
--- a/client/web/src/pages/admin/index.ts
+++ b/client/web/src/pages/admin/index.ts
@@ -2,3 +2,4 @@ export { default as AllApplicantsPage } from "./all-applicants/AllApplicantsPage
export { default as ReviewsPage } from "./reviews/ReviewsPage";
export { default as ScansPage } from "./scans/ScansPage";
export { default as SchedulePage } from "./schedule/SchedulePage";
+export { default as SponsorsPage } from "./sponsors/SponsorsPage";
diff --git a/client/web/src/pages/admin/sponsors/SponsorsPage.tsx b/client/web/src/pages/admin/sponsors/SponsorsPage.tsx
new file mode 100644
index 00000000..1c0e88f0
--- /dev/null
+++ b/client/web/src/pages/admin/sponsors/SponsorsPage.tsx
@@ -0,0 +1,56 @@
+import { useEffect } from "react";
+
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+import { SponsorsTable } from "./components/SponsorsTable";
+import { useSponsorsStore } from "./store";
+
+export default function SponsorsPage() {
+ const {
+ sponsors,
+ loading,
+ saving,
+ fetch: fetchSponsors,
+ createSponsor,
+ updateSponsor,
+ deleteSponsor,
+ uploadLogo,
+ } = useSponsorsStore();
+
+ useEffect(() => {
+ const controller = new AbortController();
+ fetchSponsors(controller.signal);
+ return () => controller.abort();
+ }, [fetchSponsors]);
+
+ if (loading && sponsors.length === 0) {
+ return (
+
+
+
+
+
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/client/web/src/pages/admin/sponsors/api.ts b/client/web/src/pages/admin/sponsors/api.ts
new file mode 100644
index 00000000..bc759691
--- /dev/null
+++ b/client/web/src/pages/admin/sponsors/api.ts
@@ -0,0 +1,56 @@
+import {
+ deleteRequest,
+ getRequest,
+ postRequest,
+ putRequest,
+} from "@/shared/lib/api";
+import type { ApiResponse } from "@/types";
+
+import type { Sponsor, SponsorListResponse, SponsorPayload } from "./types";
+
+export async function fetchSponsors(
+ signal?: AbortSignal,
+): Promise> {
+ return getRequest("/admin/sponsors", "sponsors", signal);
+}
+
+export async function createSponsor(
+ payload: SponsorPayload,
+ signal?: AbortSignal,
+): Promise> {
+ return postRequest("/admin/sponsors", payload, "sponsor", signal);
+}
+
+export async function updateSponsor(
+ id: string,
+ payload: SponsorPayload,
+ signal?: AbortSignal,
+): Promise> {
+ return putRequest(
+ `/admin/sponsors/${id}`,
+ payload,
+ "sponsor",
+ signal,
+ );
+}
+
+export async function deleteSponsor(
+ id: string,
+ signal?: AbortSignal,
+): Promise> {
+ return deleteRequest(`/admin/sponsors/${id}`, "sponsor", signal);
+}
+
+export async function uploadSponsorLogo(
+ sponsorId: string,
+ logoData: string,
+ contentType: string,
+ signal?: AbortSignal,
+): Promise> {
+ return putRequest(
+ `/admin/sponsors/${sponsorId}/logo`,
+ { logo_data: logoData, content_type: contentType },
+ "sponsor logo",
+ signal,
+ );
+}
diff --git a/client/web/src/pages/admin/sponsors/components/SponsorFormDialog.tsx b/client/web/src/pages/admin/sponsors/components/SponsorFormDialog.tsx
new file mode 100644
index 00000000..1a98aac4
--- /dev/null
+++ b/client/web/src/pages/admin/sponsors/components/SponsorFormDialog.tsx
@@ -0,0 +1,268 @@
+import { ImagePlus, Loader2, X } from "lucide-react";
+import { useRef, useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+
+import type { Sponsor, SponsorPayload } from "../types";
+
+const TIER_OPTIONS = [
+ "Title",
+ "Platinum",
+ "Gold",
+ "Silver",
+ "Bronze",
+ "Standard",
+];
+const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
+const MAX_SIZE_BYTES = 1 * 1024 * 1024; // 1MB
+
+interface SponsorFormDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ sponsor: Sponsor | null;
+ saving: boolean;
+ onSubmit: (payload: SponsorPayload, logoFile?: File) => void;
+}
+
+function SponsorForm({
+ sponsor,
+ saving,
+ onSubmit,
+ onCancel,
+}: {
+ sponsor: Sponsor | null;
+ saving: boolean;
+ onSubmit: (payload: SponsorPayload, logoFile?: File) => void;
+ onCancel: () => void;
+}) {
+ const [name, setName] = useState(sponsor?.name ?? "");
+ const [tier, setTier] = useState(sponsor?.tier ?? "Standard");
+ const [websiteUrl, setWebsiteUrl] = useState(sponsor?.website_url ?? "");
+ const [description, setDescription] = useState(sponsor?.description ?? "");
+ const [displayOrder, setDisplayOrder] = useState(sponsor?.display_order ?? 0);
+ const [logoFile, setLogoFile] = useState(null);
+ const [logoPreview, setLogoPreview] = useState(
+ sponsor?.logo_data
+ ? `data:${sponsor.logo_content_type};base64,${sponsor.logo_data}`
+ : "",
+ );
+ const logoInputRef = useRef(null);
+
+ const handleLogoChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ if (!ALLOWED_TYPES.includes(file.type)) {
+ toast.error("Unsupported file type. Use PNG, JPEG, WebP, or GIF.");
+ return;
+ }
+
+ if (file.size > MAX_SIZE_BYTES) {
+ toast.error("File too large. Maximum size is 1MB.");
+ return;
+ }
+
+ setLogoFile(file);
+ const reader = new FileReader();
+ reader.onload = () => setLogoPreview(reader.result as string);
+ reader.readAsDataURL(file);
+ };
+
+ const clearLogo = () => {
+ setLogoFile(null);
+ setLogoPreview(
+ sponsor?.logo_data
+ ? `data:${sponsor.logo_content_type};base64,${sponsor.logo_data}`
+ : "",
+ );
+ if (logoInputRef.current) logoInputRef.current.value = "";
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!name.trim() || !tier) return;
+
+ onSubmit(
+ {
+ name: name.trim(),
+ tier,
+ website_url: websiteUrl.trim(),
+ description: description.trim(),
+ display_order: displayOrder,
+ },
+ logoFile ?? undefined,
+ );
+ };
+
+ return (
+
+ );
+}
+
+export function SponsorFormDialog({
+ open,
+ onOpenChange,
+ sponsor,
+ saving,
+ onSubmit,
+}: SponsorFormDialogProps) {
+ return (
+
+ );
+}
diff --git a/client/web/src/pages/admin/sponsors/components/SponsorsTable.tsx b/client/web/src/pages/admin/sponsors/components/SponsorsTable.tsx
new file mode 100644
index 00000000..b2fc2542
--- /dev/null
+++ b/client/web/src/pages/admin/sponsors/components/SponsorsTable.tsx
@@ -0,0 +1,643 @@
+import {
+ Check,
+ Code,
+ ExternalLink,
+ ImagePlus,
+ Loader2,
+ Pencil,
+ Plus,
+ Trash2,
+} from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+import { fetchSponsors } from "../api";
+import type { Sponsor, SponsorPayload } from "../types";
+import { SponsorFormDialog } from "./SponsorFormDialog";
+
+const tierColors: Record = {
+ Title: "bg-rose-100 text-rose-800",
+ Platinum: "bg-violet-100 text-violet-800",
+ Gold: "bg-amber-100 text-amber-800",
+ Silver: "bg-gray-100 text-gray-800",
+ Bronze: "bg-orange-100 text-orange-800",
+ Standard: "bg-blue-100 text-blue-800",
+};
+
+const tierOptions = [
+ "Title",
+ "Platinum",
+ "Gold",
+ "Silver",
+ "Bronze",
+ "Standard",
+];
+
+const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
+const MAX_SIZE_BYTES = 1 * 1024 * 1024; // 1MB
+
+interface SponsorsTableProps {
+ sponsors: Sponsor[];
+ saving: boolean;
+ onCreateSponsor: (payload: SponsorPayload) => Promise;
+ onUpdateSponsor: (id: string, payload: SponsorPayload) => Promise;
+ onDeleteSponsor: (id: string) => Promise;
+ onUploadLogo: (
+ sponsorId: string,
+ file: File,
+ ) => Promise<{ success: boolean } | null>;
+}
+
+export function SponsorsTable({
+ sponsors,
+ saving,
+ onCreateSponsor,
+ onUpdateSponsor,
+ onDeleteSponsor,
+ onUploadLogo,
+}: SponsorsTableProps) {
+ const [formOpen, setFormOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [uploadingLogoId, setUploadingLogoId] = useState(null);
+ const [jsonPopoverOpen, setJsonPopoverOpen] = useState(false);
+ const [loadingJson, setLoadingJson] = useState(false);
+ const [jsonResponse, setJsonResponse] = useState("");
+ const [jsonError, setJsonError] = useState(null);
+
+ // Inline editing state
+ const [editingIndex, setEditingIndex] = useState(null);
+ const [editName, setEditName] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const [editTier, setEditTier] = useState("");
+ const [editWebsiteUrl, setEditWebsiteUrl] = useState("");
+ const [editDisplayOrder, setEditDisplayOrder] = useState(0);
+ const editRowRef = useRef(null);
+
+ const loadJsonResponse = useCallback(async () => {
+ setLoadingJson(true);
+ setJsonError(null);
+
+ const response = await fetchSponsors();
+
+ if (response.status === 200 && response.data) {
+ const truncated = response.data.sponsors.map((s) => ({
+ ...s,
+ logo_data: s.logo_data
+ ? `${s.logo_data.slice(0, 40)}... (${Math.round((s.logo_data.length * 3) / 4 / 1024)}KB)`
+ : "",
+ }));
+ setJsonResponse(
+ JSON.stringify({ data: { sponsors: truncated } }, null, 2),
+ );
+ } else {
+ setJsonResponse("");
+ setJsonError(response.error ?? "Failed to fetch sponsors.");
+ }
+
+ setLoadingJson(false);
+ }, []);
+
+ const handleJsonPopoverOpenChange = useCallback(
+ (open: boolean) => {
+ setJsonPopoverOpen(open);
+ if (open) {
+ void loadJsonResponse();
+ }
+ },
+ [loadJsonResponse],
+ );
+
+ const logoInputRef = useRef(null);
+ const logoTargetIdRef = useRef(null);
+
+ const startEditing = (index: number) => {
+ if (saving) return;
+ const sponsor = sponsors[index];
+ if (!sponsor) return;
+ setEditingIndex(index);
+ setEditName(sponsor.name);
+ setEditDescription(sponsor.description);
+ setEditTier(sponsor.tier);
+ setEditWebsiteUrl(sponsor.website_url);
+ setEditDisplayOrder(sponsor.display_order);
+ };
+
+ const saveEdits = useCallback(async () => {
+ if (editingIndex === null) return;
+ const sponsor = sponsors[editingIndex];
+ if (!sponsor) return;
+
+ const trimmedName = editName.trim();
+ const trimmedDescription = editDescription.trim();
+ const trimmedUrl = editWebsiteUrl.trim();
+
+ if (
+ trimmedName === sponsor.name &&
+ trimmedDescription === sponsor.description &&
+ editTier === sponsor.tier &&
+ trimmedUrl === sponsor.website_url &&
+ editDisplayOrder === sponsor.display_order
+ ) {
+ return;
+ }
+
+ if (!trimmedName) {
+ toast.error("Name is required");
+ return;
+ }
+
+ const payload: SponsorPayload = {
+ name: trimmedName,
+ description: trimmedDescription,
+ tier: editTier,
+ website_url: trimmedUrl,
+ display_order: editDisplayOrder,
+ };
+
+ const success = await onUpdateSponsor(sponsor.id, payload);
+ if (success) {
+ toast.success("Sponsor updated");
+ } else {
+ toast.error("Failed to update sponsor");
+ }
+ }, [
+ editingIndex,
+ editName,
+ editDescription,
+ editTier,
+ editWebsiteUrl,
+ editDisplayOrder,
+ sponsors,
+ onUpdateSponsor,
+ ]);
+
+ const saveEditsRef = useRef(saveEdits);
+ useEffect(() => {
+ saveEditsRef.current = saveEdits;
+ }, [saveEdits]);
+
+ const closeEditing = useCallback(() => {
+ void saveEditsRef.current();
+ setEditingIndex(null);
+ }, []);
+
+ useEffect(() => {
+ if (editingIndex === null) return;
+
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as Node;
+ if ((target as Element).closest?.("[role='alertdialog']")) return;
+ if ((target as Element).closest?.("[data-radix-portal]")) return;
+ if (editRowRef.current && !editRowRef.current.contains(target)) {
+ closeEditing();
+ }
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ setEditingIndex(null);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [editingIndex, closeEditing]);
+
+ const handleCreateSubmit = async (
+ payload: SponsorPayload,
+ logoFile?: File,
+ ) => {
+ const sponsorId = await onCreateSponsor(payload);
+
+ if (sponsorId) {
+ toast.success("Sponsor created");
+ setFormOpen(false);
+
+ if (logoFile) {
+ const result = await onUploadLogo(sponsorId, logoFile);
+ if (result) {
+ toast.success("Logo uploaded");
+ } else {
+ toast.error("Failed to upload logo");
+ }
+ }
+ } else {
+ toast.error("Failed to create sponsor");
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return;
+ const success = await onDeleteSponsor(deleteTarget.id);
+ if (success) {
+ toast.success("Sponsor deleted");
+ setEditingIndex(null);
+ } else {
+ toast.error("Failed to delete sponsor");
+ }
+ setDeleteTarget(null);
+ };
+
+ const handleLogoClick = (sponsorId: string) => {
+ logoTargetIdRef.current = sponsorId;
+ logoInputRef.current?.click();
+ };
+
+ const handleLogoFileChange = async (
+ e: React.ChangeEvent,
+ ) => {
+ const file = e.target.files?.[0];
+ const sponsorId = logoTargetIdRef.current;
+ if (!file || !sponsorId) return;
+
+ if (!ALLOWED_TYPES.includes(file.type)) {
+ toast.error("Unsupported file type. Use PNG, JPEG, WebP, or GIF.");
+ return;
+ }
+
+ if (file.size > MAX_SIZE_BYTES) {
+ toast.error("File too large. Maximum size is 1MB.");
+ return;
+ }
+
+ setUploadingLogoId(sponsorId);
+ const result = await onUploadLogo(sponsorId, file);
+ setUploadingLogoId(null);
+
+ if (result) {
+ toast.success("Logo uploaded");
+ } else {
+ toast.error("Failed to upload logo");
+ }
+
+ if (logoInputRef.current) logoInputRef.current.value = "";
+ logoTargetIdRef.current = null;
+ };
+
+ const renderLogoButton = (sponsor: Sponsor) => (
+
+ );
+
+ return (
+ <>
+
+
+
+
+
+ {sponsors.length} sponsor(s) configured
+
+
+ {saving && (
+
+ )}
+
+
+
+
+
+
+
+ GET /v1/public/sponsors — logo_data truncated
+
+ {loadingJson ? (
+
+ Loading JSON response...
+
+ ) : jsonError ? (
+
{jsonError}
+ ) : (
+
+ {jsonResponse}
+
+ )}
+
+
+
+
+
+
+
+
+ {sponsors.length === 0 ? (
+
+ No sponsors yet. Click "Add Sponsor" to get started.
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {
+ if (!open) setDeleteTarget(null);
+ }}
+ >
+
+
+ Delete sponsor
+
+ Are you sure you want to delete{" "}
+ {deleteTarget?.name ?? "this sponsor"}? This
+ action cannot be undone.
+
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+
+ >
+ );
+}
diff --git a/client/web/src/pages/admin/sponsors/store.ts b/client/web/src/pages/admin/sponsors/store.ts
new file mode 100644
index 00000000..bd727c9e
--- /dev/null
+++ b/client/web/src/pages/admin/sponsors/store.ts
@@ -0,0 +1,115 @@
+import { create } from "zustand";
+
+import {
+ createSponsor as apiCreateSponsor,
+ deleteSponsor as apiDeleteSponsor,
+ fetchSponsors,
+ updateSponsor as apiUpdateSponsor,
+ uploadSponsorLogo,
+} from "./api";
+import type { Sponsor, SponsorPayload } from "./types";
+
+export interface SponsorsState {
+ sponsors: Sponsor[];
+ loading: boolean;
+ saving: boolean;
+
+ fetch: (signal?: AbortSignal) => Promise;
+ createSponsor: (payload: SponsorPayload) => Promise;
+ updateSponsor: (id: string, payload: SponsorPayload) => Promise;
+ deleteSponsor: (id: string) => Promise;
+ uploadLogo: (
+ sponsorId: string,
+ file: File,
+ ) => Promise<{ success: boolean } | null>;
+}
+
+export const useSponsorsStore = create((set) => ({
+ sponsors: [],
+ loading: false,
+ saving: false,
+
+ fetch: async (signal?: AbortSignal) => {
+ set({ loading: true });
+
+ const res = await fetchSponsors(signal);
+
+ if (signal?.aborted) return;
+
+ if (res.status === 200 && res.data) {
+ set({ sponsors: res.data.sponsors, loading: false });
+ } else {
+ set({ sponsors: [], loading: false });
+ }
+ },
+
+ createSponsor: async (payload: SponsorPayload) => {
+ set({ saving: true });
+ const res = await apiCreateSponsor(payload);
+ if (res.status === 201 && res.data) {
+ const created = res.data as Sponsor;
+ set((state) => ({
+ sponsors: [...state.sponsors, created],
+ saving: false,
+ }));
+ return created.id;
+ }
+ set({ saving: false });
+ return null;
+ },
+
+ updateSponsor: async (id: string, payload: SponsorPayload) => {
+ set({ saving: true });
+ const res = await apiUpdateSponsor(id, payload);
+ if (res.status === 200 && res.data) {
+ const updated = res.data;
+ set((state) => ({
+ sponsors: state.sponsors.map((s) =>
+ s.id === id ? (updated as Sponsor) : s,
+ ),
+ saving: false,
+ }));
+ return true;
+ }
+ set({ saving: false });
+ return false;
+ },
+
+ deleteSponsor: async (id: string) => {
+ set({ saving: true });
+ const res = await apiDeleteSponsor(id);
+ if (res.status === 204) {
+ set((state) => ({
+ sponsors: state.sponsors.filter((s) => s.id !== id),
+ saving: false,
+ }));
+ return true;
+ }
+ set({ saving: false });
+ return false;
+ },
+
+ uploadLogo: async (sponsorId: string, file: File) => {
+ const base64 = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = reader.result as string;
+ const base64Data = result.split(",")[1];
+ resolve(base64Data);
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+
+ const res = await uploadSponsorLogo(sponsorId, base64, file.type);
+ if (res.status === 200 && res.data) {
+ set((state) => ({
+ sponsors: state.sponsors.map((s) =>
+ s.id === sponsorId ? (res.data as Sponsor) : s,
+ ),
+ }));
+ return { success: true };
+ }
+ return null;
+ },
+}));
diff --git a/client/web/src/pages/admin/sponsors/types.ts b/client/web/src/pages/admin/sponsors/types.ts
new file mode 100644
index 00000000..4b64818c
--- /dev/null
+++ b/client/web/src/pages/admin/sponsors/types.ts
@@ -0,0 +1,24 @@
+export interface Sponsor {
+ id: string;
+ name: string;
+ tier: string;
+ logo_data: string;
+ logo_content_type: string;
+ website_url: string;
+ description: string;
+ display_order: number;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface SponsorPayload {
+ name: string;
+ tier: string;
+ website_url: string;
+ description: string;
+ display_order: number;
+}
+
+export interface SponsorListResponse {
+ sponsors: Sponsor[];
+}
diff --git a/client/web/src/pages/superadmin/user-management/UserManagementPage.tsx b/client/web/src/pages/superadmin/user-management/UserManagementPage.tsx
index fe495a66..47005212 100644
--- a/client/web/src/pages/superadmin/user-management/UserManagementPage.tsx
+++ b/client/web/src/pages/superadmin/user-management/UserManagementPage.tsx
@@ -115,7 +115,9 @@ export default function UserManagementPage() {
{searchInput.length >= MIN_SEARCH_LENGTH && (
matching "{searchInput}"
)}
- filtered by
+
+ {activeRoles.length === 0 ? "showing all" : "filtered by"}
+
{allRoles.map((role) => {
const isActive = activeRoles.includes(role);
return (
diff --git a/client/web/src/pages/superadmin/user-management/store.ts b/client/web/src/pages/superadmin/user-management/store.ts
index 1400dd07..d3abce9c 100644
--- a/client/web/src/pages/superadmin/user-management/store.ts
+++ b/client/web/src/pages/superadmin/user-management/store.ts
@@ -10,7 +10,7 @@ import {
updateUserRole as apiUpdateUserRole,
} from "./api";
import type { AdminUser, FetchUsersParams } from "./types";
-import { MIN_SEARCH_LENGTH, roleLabels } from "./utils";
+import { allRoles, MIN_SEARCH_LENGTH, roleLabels } from "./utils";
interface UserManagementState {
users: AdminUser[];
@@ -35,19 +35,16 @@ export const useUserManagementStore = create(
loading: true,
nextCursor: null,
prevCursor: null,
- activeRoles: ["admin", "super_admin"],
+ activeRoles: [],
searchInput: "",
togglingId: null,
updatingRoleId: null,
fetchUsers: async (params?: FetchUsersParams) => {
const state = get();
- const roles = params?.roles ?? state.activeRoles;
-
- if (roles.length === 0) {
- set({ users: [], nextCursor: null, prevCursor: null, loading: false });
- return;
- }
+ const activeRoles = params?.roles ?? state.activeRoles;
+ // No filter selected = fetch all roles
+ const roles = activeRoles.length === 0 ? [...allRoles] : activeRoles;
set({ loading: true });
try {
diff --git a/client/web/src/routes.tsx b/client/web/src/routes.tsx
index 86d97fe8..fcd84e01 100644
--- a/client/web/src/routes.tsx
+++ b/client/web/src/routes.tsx
@@ -42,6 +42,7 @@ const SuperAdminScansPage = lazy(
const AdminGradingPage = lazy(
() => import("@/pages/admin/reviews/grading/GradingPage"),
);
+const SponsorsPage = lazy(() => import("@/pages/admin/sponsors/SponsorsPage"));
export const router = createBrowserRouter([
// Public routes
@@ -149,6 +150,14 @@ export const router = createBrowserRouter([
),
},
+ {
+ path: "sponsors",
+ element: (
+ }>
+
+
+ ),
+ },
// Super Admin routes (nested under admin layout, guarded individually)
{
path: "sa/user-management",
diff --git a/cmd/api/api.go b/cmd/api/api.go
index e48d1ed8..0f1274e8 100644
--- a/cmd/api/api.go
+++ b/cmd/api/api.go
@@ -94,6 +94,7 @@ const swaggerTagsSorter = `(a, b) => {
"admin/reviews",
"admin/scans",
"admin/schedule",
+ "admin/sponsors",
"superadmin/applications",
"superadmin/settings",
"superadmin/users"
@@ -144,6 +145,7 @@ func (app *application) mount() http.Handler {
r.Route("/public", func(r chi.Router) {
r.Use(app.APIKeyMiddleware)
r.Get("/schedule", app.getPublicScheduleHandler)
+ r.Get("/sponsors", app.getPublicSponsorsHandler)
})
// Auth endpoints not handled by SuperTokens
@@ -218,6 +220,17 @@ func (app *application) mount() http.Handler {
r.Delete("/{scheduleID}", app.deleteScheduleHandler)
})
})
+
+ // Sponsors
+ r.Route("/sponsors", func(r chi.Router) {
+ r.Get("/", app.listSponsorsHandler)
+
+ // TODO: Protect Under a AdminSponsorEditPermissionMiddleware
+ r.Post("/", app.createSponsorHandler)
+ r.Put("/{sponsorID}", app.updateSponsorHandler)
+ r.Delete("/{sponsorID}", app.deleteSponsorHandler)
+ r.Put("/{sponsorID}/logo", app.uploadLogoHandler)
+ })
})
})
@@ -251,7 +264,6 @@ func (app *application) mount() http.Handler {
r.Get("/", app.searchUsersHandler)
r.Patch("/{userID}/role", app.updateUserRoleHandler)
})
-
})
})
})
diff --git a/cmd/api/public.go b/cmd/api/public.go
index c4a0f1c5..0c056296 100644
--- a/cmd/api/public.go
+++ b/cmd/api/public.go
@@ -18,3 +18,18 @@ import (
func (app *application) getPublicScheduleHandler(w http.ResponseWriter, r *http.Request) {
app.listScheduleHandler(w, r)
}
+
+// getPublicSponsorsHandler returns all sponsors (public, API key auth)
+//
+// @Summary Get sponsors (Public)
+// @Description Returns all sponsors, ordered by display order, with public logo URLs
+// @Tags public
+// @Produce json
+// @Param X-API-Key header string true "API Key"
+// @Success 200 {object} SponsorListResponse
+// @Failure 401 {object} object{error=string}
+// @Failure 500 {object} object{error=string}
+// @Router /public/sponsors [get]
+func (app *application) getPublicSponsorsHandler(w http.ResponseWriter, r *http.Request) {
+ app.listSponsorsHandler(w, r)
+}
diff --git a/cmd/api/resume_test.go b/cmd/api/resume_test.go
index 731630d8..b2a96dce 100644
--- a/cmd/api/resume_test.go
+++ b/cmd/api/resume_test.go
@@ -28,6 +28,7 @@ func TestGenerateResumeUploadURL(t *testing.T) {
mockApps.On("GetByUserID", user.ID).Return(application, nil).Once()
mockGCS.On(
"GenerateUploadURL",
+ mock.Anything,
mock.MatchedBy(func(path string) bool {
return strings.HasPrefix(path, "resumes/"+user.ID+"/") && strings.HasSuffix(path, ".pdf")
}),
@@ -103,7 +104,7 @@ func TestGenerateResumeUploadURL(t *testing.T) {
user := newTestUser()
application := &store.Application{ID: "app-1", UserID: user.ID, Status: store.StatusDraft}
mockApps.On("GetByUserID", user.ID).Return(application, nil).Once()
- mockGCS.On("GenerateUploadURL", mock.AnythingOfType("string")).Return("", errors.New("gcs failed")).Once()
+ mockGCS.On("GenerateUploadURL", mock.Anything, mock.AnythingOfType("string")).Return("", errors.New("gcs failed")).Once()
req, err := http.NewRequest(http.MethodPost, "/", nil)
require.NoError(t, err)
@@ -133,7 +134,7 @@ func TestDeleteResume(t *testing.T) {
}
mockApps.On("GetByUserID", user.ID).Return(application, nil).Once()
- mockGCS.On("DeleteObject", resumePath).Return(nil).Once()
+ mockGCS.On("DeleteObject", mock.Anything, resumePath).Return(nil).Once()
mockApps.On("Update", mock.AnythingOfType("*store.Application")).Run(func(args mock.Arguments) {
updated := args.Get(0).(*store.Application)
assert.Nil(t, updated.ResumePath)
@@ -202,7 +203,7 @@ func TestGetResumeDownloadURL(t *testing.T) {
resumePath := "resumes/user-1/file.pdf"
application := &store.Application{ID: "app-1", ResumePath: &resumePath}
mockApps.On("GetByID", "app-1").Return(application, nil).Once()
- mockGCS.On("GenerateDownloadURL", resumePath).Return("https://download.example.com", nil).Once()
+ mockGCS.On("GenerateDownloadURL", mock.Anything, resumePath).Return("https://download.example.com", nil).Once()
req, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err)
diff --git a/cmd/api/sponsors.go b/cmd/api/sponsors.go
new file mode 100644
index 00000000..72130d05
--- /dev/null
+++ b/cmd/api/sponsors.go
@@ -0,0 +1,276 @@
+package main
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/go-chi/chi"
+ "github.com/hackutd/portal/internal/store"
+)
+
+var allowedLogoContentTypes = map[string]bool{
+ "image/png": true,
+ "image/jpeg": true,
+ "image/webp": true,
+ "image/gif": true,
+}
+
+const maxLogoBytes = 1 * 1024 * 1024 // 1MB decoded limit
+
+type SponsorPayload struct {
+ Name string `json:"name" validate:"required,min=1,max=100"`
+ Tier string `json:"tier" validate:"required,min=1,max=50"`
+ WebsiteURL string `json:"website_url" validate:"omitempty,url"`
+ Description string `json:"description"`
+ DisplayOrder int `json:"display_order" validate:"min=0"`
+}
+
+type SponsorListResponse struct {
+ Sponsors []store.Sponsor `json:"sponsors"`
+}
+
+type LogoUploadPayload struct {
+ LogoData string `json:"logo_data" validate:"required"`
+ ContentType string `json:"content_type" validate:"required"`
+}
+
+// listSponsorsHandler returns all sponsors (Super Admin)
+//
+// @Summary List sponsors (Super Admin)
+// @Description Returns all sponsors ordered by display order
+// @Tags admin/sponsors
+// @Produce json
+// @Success 200 {object} SponsorListResponse
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string}
+// @Failure 500 {object} object{error=string}
+// @Security CookieAuth
+// @Router /admin/sponsors [get]
+func (app *application) listSponsorsHandler(w http.ResponseWriter, r *http.Request) {
+ sponsors, err := app.store.Sponsors.List(r.Context())
+ if err != nil {
+ app.internalServerError(w, r, err)
+ return
+ }
+
+ if err := app.jsonResponse(w, http.StatusOK, SponsorListResponse{Sponsors: sponsors}); err != nil {
+ app.internalServerError(w, r, err)
+ }
+}
+
+// createSponsorHandler creates a new sponsor (Super Admin)
+//
+// @Summary Create sponsor (Super Admin)
+// @Description Creates a new sponsor
+// @Tags admin/sponsors
+// @Accept json
+// @Produce json
+// @Param sponsor body SponsorPayload true "Sponsor to create"
+// @Success 201 {object} store.Sponsor
+// @Failure 400 {object} object{error=string}
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string}
+// @Failure 500 {object} object{error=string}
+// @Security CookieAuth
+// @Router /admin/sponsors [post]
+func (app *application) createSponsorHandler(w http.ResponseWriter, r *http.Request) {
+ var payload SponsorPayload
+ if err := readJSON(w, r, &payload); err != nil {
+ app.badRequestResponse(w, r, err)
+ return
+ }
+
+ if err := Validate.Struct(payload); err != nil {
+ app.badRequestResponse(w, r, err)
+ return
+ }
+
+ sponsor := &store.Sponsor{
+ Name: payload.Name,
+ Tier: payload.Tier,
+ WebsiteURL: payload.WebsiteURL,
+ Description: payload.Description,
+ DisplayOrder: payload.DisplayOrder,
+ }
+
+ if err := app.store.Sponsors.Create(r.Context(), sponsor); err != nil {
+ app.internalServerError(w, r, err)
+ return
+ }
+
+ if err := app.jsonResponse(w, http.StatusCreated, sponsor); err != nil {
+ app.internalServerError(w, r, err)
+ }
+}
+
+// updateSponsorHandler updates an existing sponsor (Super Admin)
+//
+// @Summary Update sponsor (Super Admin)
+// @Description Updates an existing sponsor
+// @Tags admin/sponsors
+// @Accept json
+// @Produce json
+// @Param sponsorID path string true "Sponsor ID"
+// @Param sponsor body SponsorPayload true "Sponsor updates"
+// @Success 200 {object} store.Sponsor
+// @Failure 400 {object} object{error=string}
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string}
+// @Failure 404 {object} object{error=string}
+// @Failure 500 {object} object{error=string}
+// @Security CookieAuth
+// @Router /admin/sponsors/{sponsorID} [put]
+func (app *application) updateSponsorHandler(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "sponsorID")
+ if id == "" {
+ app.badRequestResponse(w, r, errors.New("missing sponsor ID"))
+ return
+ }
+
+ var payload SponsorPayload
+ if err := readJSON(w, r, &payload); err != nil {
+ app.badRequestResponse(w, r, err)
+ return
+ }
+
+ if err := Validate.Struct(payload); err != nil {
+ app.badRequestResponse(w, r, err)
+ return
+ }
+
+ sponsor := &store.Sponsor{
+ ID: id,
+ Name: payload.Name,
+ Tier: payload.Tier,
+ WebsiteURL: payload.WebsiteURL,
+ Description: payload.Description,
+ DisplayOrder: payload.DisplayOrder,
+ }
+
+ if err := app.store.Sponsors.Update(r.Context(), sponsor); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ app.notFoundResponse(w, r, errors.New("sponsor not found"))
+ return
+ }
+ app.internalServerError(w, r, err)
+ return
+ }
+
+ if err := app.jsonResponse(w, http.StatusOK, sponsor); err != nil {
+ app.internalServerError(w, r, err)
+ }
+}
+
+// deleteSponsorHandler deletes a sponsor (Super Admin)
+//
+// @Summary Delete sponsor (Super Admin)
+// @Description Deletes a sponsor
+// @Tags admin/sponsors
+// @Param sponsorID path string true "Sponsor ID"
+// @Success 204
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string}
+// @Failure 404 {object} object{error=string}
+// @Failure 500 {object} object{error=string}
+// @Security CookieAuth
+// @Router /admin/sponsors/{sponsorID} [delete]
+func (app *application) deleteSponsorHandler(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "sponsorID")
+ if id == "" {
+ app.badRequestResponse(w, r, errors.New("missing sponsor ID"))
+ return
+ }
+
+ if err := app.store.Sponsors.Delete(r.Context(), id); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ app.notFoundResponse(w, r, errors.New("sponsor not found"))
+ return
+ }
+ app.internalServerError(w, r, err)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// uploadLogoHandler uploads a base64-encoded logo for a sponsor (Super Admin)
+//
+// @Summary Upload sponsor logo (Super Admin)
+// @Description Uploads a base64-encoded logo image for a sponsor
+// @Tags admin/sponsors
+// @Accept json
+// @Produce json
+// @Param sponsorID path string true "Sponsor ID"
+// @Param body body LogoUploadPayload true "Base64-encoded logo"
+// @Success 200 {object} store.Sponsor
+// @Failure 400 {object} object{error=string}
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string}
+// @Failure 404 {object} object{error=string}
+// @Failure 500 {object} object{error=string}
+// @Security CookieAuth
+// @Router /admin/sponsors/{sponsorID}/logo [put]
+func (app *application) uploadLogoHandler(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "sponsorID")
+ if id == "" {
+ app.badRequestResponse(w, r, errors.New("missing sponsor ID"))
+ return
+ }
+
+ if _, err := app.store.Sponsors.GetByID(r.Context(), id); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ app.notFoundResponse(w, r, errors.New("sponsor not found"))
+ return
+ }
+ app.internalServerError(w, r, err)
+ return
+ }
+
+ var payload LogoUploadPayload
+ if err := readJSON(w, r, &payload); err != nil {
+ app.badRequestResponse(w, r, err)
+ return
+ }
+
+ if err := Validate.Struct(payload); err != nil {
+ app.badRequestResponse(w, r, err)
+ return
+ }
+
+ if !allowedLogoContentTypes[payload.ContentType] {
+ app.badRequestResponse(w, r, fmt.Errorf("unsupported content type: %s", payload.ContentType))
+ return
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(payload.LogoData)
+ if err != nil {
+ app.badRequestResponse(w, r, errors.New("invalid base64 data"))
+ return
+ }
+
+ if len(decoded) > maxLogoBytes {
+ app.badRequestResponse(w, r, fmt.Errorf("logo exceeds maximum size of %d bytes", maxLogoBytes))
+ return
+ }
+
+ if err := app.store.Sponsors.UpdateLogo(r.Context(), id, payload.LogoData, payload.ContentType); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ app.notFoundResponse(w, r, errors.New("sponsor not found"))
+ return
+ }
+ app.internalServerError(w, r, err)
+ return
+ }
+
+ sponsor, err := app.store.Sponsors.GetByID(r.Context(), id)
+ if err != nil {
+ app.internalServerError(w, r, err)
+ return
+ }
+
+ if err := app.jsonResponse(w, http.StatusOK, sponsor); err != nil {
+ app.internalServerError(w, r, err)
+ }
+}
diff --git a/cmd/api/sponsors_test.go b/cmd/api/sponsors_test.go
new file mode 100644
index 00000000..e6bef09c
--- /dev/null
+++ b/cmd/api/sponsors_test.go
@@ -0,0 +1,333 @@
+package main
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/go-chi/chi"
+ "github.com/hackutd/portal/internal/store"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+// withSponsorRouteParam is a helper to add a URL parameter to a request for testing.
+func withSponsorRouteParam(req *http.Request, sponsorID string) *http.Request {
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("sponsorID", sponsorID)
+ return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+}
+
+func newTestSponsor(id string) store.Sponsor {
+ return store.Sponsor{
+ ID: id,
+ Name: "Test Sponsor",
+ Tier: "Gold",
+ LogoData: "iVBORw0KGgo=",
+ LogoContentType: "image/png",
+ WebsiteURL: "https://example.com",
+ Description: "A test sponsor.",
+ DisplayOrder: 1,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+}
+
+func TestListSponsors(t *testing.T) {
+ app := newTestApplication(t)
+ mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore)
+
+ t.Run("should list all sponsors", func(t *testing.T) {
+ sponsors := []store.Sponsor{newTestSponsor("sponsor-1"), newTestSponsor("sponsor-2")}
+ sponsors[1].LogoData = ""
+ sponsors[1].LogoContentType = ""
+
+ mockSponsors.On("List").Return(sponsors, nil).Once()
+
+ req, err := http.NewRequest(http.MethodGet, "/", nil)
+ require.NoError(t, err)
+ req = setUserContext(req, newSuperAdminUser())
+
+ rr := executeRequest(req, http.HandlerFunc(app.listSponsorsHandler))
+ checkResponseCode(t, http.StatusOK, rr.Code)
+
+ var body struct {
+ Data SponsorListResponse `json:"data"`
+ }
+ err = json.NewDecoder(rr.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Len(t, body.Data.Sponsors, 2)
+ assert.Equal(t, "iVBORw0KGgo=", body.Data.Sponsors[0].LogoData)
+ assert.Equal(t, "image/png", body.Data.Sponsors[0].LogoContentType)
+ assert.Empty(t, body.Data.Sponsors[1].LogoData)
+
+ mockSponsors.AssertExpectations(t)
+ })
+}
+
+func TestGetPublicSponsors(t *testing.T) {
+ app := newTestApplication(t)
+ mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore)
+ mux := app.mount()
+
+ t.Run("should return sponsors with valid api key", func(t *testing.T) {
+ sponsors := []store.Sponsor{newTestSponsor("sponsor-1")}
+ mockSponsors.On("List").Return(sponsors, nil).Once()
+
+ req, err := http.NewRequest(http.MethodGet, "/v1/public/sponsors", nil)
+ require.NoError(t, err)
+ req.Header.Set("X-API-Key", "test-api-key")
+
+ rr := executeRequest(req, mux)
+ checkResponseCode(t, http.StatusOK, rr.Code)
+
+ var body struct {
+ Data SponsorListResponse `json:"data"`
+ }
+ err = json.NewDecoder(rr.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Len(t, body.Data.Sponsors, 1)
+
+ mockSponsors.AssertExpectations(t)
+ })
+
+ t.Run("should return 401 with invalid api key", func(t *testing.T) {
+ req, err := http.NewRequest(http.MethodGet, "/v1/public/sponsors", nil)
+ require.NoError(t, err)
+ req.Header.Set("X-API-Key", "wrong-key")
+
+ rr := executeRequest(req, mux)
+ checkResponseCode(t, http.StatusUnauthorized, rr.Code)
+ })
+}
+
+func TestCreateSponsor(t *testing.T) {
+ app := newTestApplication(t)
+ mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore)
+
+ t.Run("should create a sponsor", func(t *testing.T) {
+ mockSponsors.On("Create", mock.AnythingOfType("*store.Sponsor")).Run(func(args mock.Arguments) {
+ sponsor := args.Get(0).(*store.Sponsor)
+ sponsor.ID = "new-sponsor"
+ }).Return(nil).Once()
+
+ body := `{"name":"New Sponsor","tier":"Platinum","website_url":"https://new.com","display_order":10}`
+ req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req = setUserContext(req, newSuperAdminUser())
+
+ rr := executeRequest(req, http.HandlerFunc(app.createSponsorHandler))
+ checkResponseCode(t, http.StatusCreated, rr.Code)
+
+ var respBody struct {
+ Data store.Sponsor `json:"data"`
+ }
+ err = json.NewDecoder(rr.Body).Decode(&respBody)
+ require.NoError(t, err)
+ assert.Equal(t, "new-sponsor", respBody.Data.ID)
+ assert.Equal(t, "New Sponsor", respBody.Data.Name)
+
+ mockSponsors.AssertExpectations(t)
+ })
+
+ t.Run("should return 400 for invalid payload", func(t *testing.T) {
+ body := `{"name":""}`
+ req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req = setUserContext(req, newSuperAdminUser())
+
+ rr := executeRequest(req, http.HandlerFunc(app.createSponsorHandler))
+ checkResponseCode(t, http.StatusBadRequest, rr.Code)
+ })
+}
+
+func TestUpdateSponsor(t *testing.T) {
+ app := newTestApplication(t)
+ mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore)
+
+ t.Run("should update a sponsor", func(t *testing.T) {
+ sponsorID := "sponsor-to-update"
+
+ mockSponsors.On("Update", mock.AnythingOfType("*store.Sponsor")).Run(func(args mock.Arguments) {
+ sponsor := args.Get(0).(*store.Sponsor)
+ sponsor.CreatedAt = time.Now()
+ sponsor.UpdatedAt = time.Now()
+ }).Return(nil).Once()
+
+ body := `{"name":"Updated Name","tier":"Gold","website_url":"https://example.com","display_order":1}`
+ req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req = setUserContext(req, newSuperAdminUser())
+ req = withSponsorRouteParam(req, sponsorID)
+
+ rr := executeRequest(req, http.HandlerFunc(app.updateSponsorHandler))
+ checkResponseCode(t, http.StatusOK, rr.Code)
+
+ var respBody struct {
+ Data store.Sponsor `json:"data"`
+ }
+ err = json.NewDecoder(rr.Body).Decode(&respBody)
+ require.NoError(t, err)
+ assert.Equal(t, "Updated Name", respBody.Data.Name)
+
+ mockSponsors.AssertExpectations(t)
+ })
+
+ t.Run("should return 404 if sponsor not found", func(t *testing.T) {
+ mockSponsors.On("Update", mock.AnythingOfType("*store.Sponsor")).Return(store.ErrNotFound).Once()
+
+ body := `{"name":"Updated Name","tier":"Gold","display_order":1}`
+ req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req = setUserContext(req, newSuperAdminUser())
+ req = withSponsorRouteParam(req, "nonexistent")
+
+ rr := executeRequest(req, http.HandlerFunc(app.updateSponsorHandler))
+ checkResponseCode(t, http.StatusNotFound, rr.Code)
+
+ mockSponsors.AssertExpectations(t)
+ })
+}
+
+func TestDeleteSponsor(t *testing.T) {
+ app := newTestApplication(t)
+ mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore)
+
+ t.Run("should delete a sponsor", func(t *testing.T) {
+ mockSponsors.On("Delete", "sponsor-to-delete").Return(nil).Once()
+
+ req, err := http.NewRequest(http.MethodDelete, "/", nil)
+ require.NoError(t, err)
+ req = setUserContext(req, newSuperAdminUser())
+ req = withSponsorRouteParam(req, "sponsor-to-delete")
+
+ rr := executeRequest(req, http.HandlerFunc(app.deleteSponsorHandler))
+ checkResponseCode(t, http.StatusNoContent, rr.Code)
+
+ mockSponsors.AssertExpectations(t)
+ })
+
+ t.Run("should return 404 if sponsor not found", func(t *testing.T) {
+ mockSponsors.On("Delete", "nonexistent").Return(store.ErrNotFound).Once()
+
+ req, err := http.NewRequest(http.MethodDelete, "/", nil)
+ require.NoError(t, err)
+ req = setUserContext(req, newSuperAdminUser())
+ req = withSponsorRouteParam(req, "nonexistent")
+
+ rr := executeRequest(req, http.HandlerFunc(app.deleteSponsorHandler))
+ checkResponseCode(t, http.StatusNotFound, rr.Code)
+
+ mockSponsors.AssertExpectations(t)
+ })
+}
+
+func TestUploadLogo(t *testing.T) {
+ app := newTestApplication(t)
+ mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore)
+
+ validBase64 := base64.StdEncoding.EncodeToString([]byte("fake-png-data"))
+
+ t.Run("should upload a logo", func(t *testing.T) {
+ sponsor := newTestSponsor("sponsor-1")
+ mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once()
+ mockSponsors.On("UpdateLogo", sponsor.ID, validBase64, "image/png").Return(nil).Once()
+ mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once()
+
+ reqBody := `{"logo_data":"` + validBase64 + `","content_type":"image/png"}`
+ req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(reqBody))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req = setUserContext(req, newSuperAdminUser())
+ req = withSponsorRouteParam(req, sponsor.ID)
+
+ rr := executeRequest(req, http.HandlerFunc(app.uploadLogoHandler))
+ checkResponseCode(t, http.StatusOK, rr.Code)
+
+ var body struct {
+ Data store.Sponsor `json:"data"`
+ }
+ err = json.NewDecoder(rr.Body).Decode(&body)
+ require.NoError(t, err)
+ assert.Equal(t, sponsor.ID, body.Data.ID)
+
+ mockSponsors.AssertExpectations(t)
+ })
+
+ t.Run("should return 404 if sponsor not found", func(t *testing.T) {
+ mockSponsors.On("GetByID", "nonexistent").Return(nil, store.ErrNotFound).Once()
+
+ req, err := http.NewRequest(http.MethodPut, "/", nil)
+ require.NoError(t, err)
+ req = setUserContext(req, newSuperAdminUser())
+ req = withSponsorRouteParam(req, "nonexistent")
+
+ rr := executeRequest(req, http.HandlerFunc(app.uploadLogoHandler))
+ checkResponseCode(t, http.StatusNotFound, rr.Code)
+
+ mockSponsors.AssertExpectations(t)
+ })
+
+ t.Run("should return 400 for unsupported content type", func(t *testing.T) {
+ sponsor := newTestSponsor("sponsor-1")
+ mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once()
+
+ reqBody := `{"logo_data":"` + validBase64 + `","content_type":"application/pdf"}`
+ req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(reqBody))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req = setUserContext(req, newSuperAdminUser())
+ req = withSponsorRouteParam(req, sponsor.ID)
+
+ rr := executeRequest(req, http.HandlerFunc(app.uploadLogoHandler))
+ checkResponseCode(t, http.StatusBadRequest, rr.Code)
+
+ mockSponsors.AssertExpectations(t)
+ })
+
+ t.Run("should return 400 for invalid base64", func(t *testing.T) {
+ sponsor := newTestSponsor("sponsor-1")
+ mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once()
+
+ reqBody := `{"logo_data":"not-valid-base64!!!","content_type":"image/png"}`
+ req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(reqBody))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req = setUserContext(req, newSuperAdminUser())
+ req = withSponsorRouteParam(req, sponsor.ID)
+
+ rr := executeRequest(req, http.HandlerFunc(app.uploadLogoHandler))
+ checkResponseCode(t, http.StatusBadRequest, rr.Code)
+
+ mockSponsors.AssertExpectations(t)
+ })
+
+ t.Run("should return 400 for oversized image", func(t *testing.T) {
+ sponsor := newTestSponsor("sponsor-1")
+ mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once()
+
+ oversizedData := make([]byte, maxLogoBytes+1)
+ oversizedBase64 := base64.StdEncoding.EncodeToString(oversizedData)
+
+ reqBody := `{"logo_data":"` + oversizedBase64 + `","content_type":"image/png"}`
+ req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(reqBody))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req = setUserContext(req, newSuperAdminUser())
+ req = withSponsorRouteParam(req, sponsor.ID)
+
+ rr := executeRequest(req, http.HandlerFunc(app.uploadLogoHandler))
+ checkResponseCode(t, http.StatusBadRequest, rr.Code)
+
+ mockSponsors.AssertExpectations(t)
+ })
+}
diff --git a/cmd/migrate/migrations/000023_create_sponsors.down.sql b/cmd/migrate/migrations/000023_create_sponsors.down.sql
new file mode 100644
index 00000000..d60d4181
--- /dev/null
+++ b/cmd/migrate/migrations/000023_create_sponsors.down.sql
@@ -0,0 +1,2 @@
+DROP TRIGGER IF EXISTS set_updated_at_sponsors ON sponsors;
+DROP TABLE sponsors;
diff --git a/cmd/migrate/migrations/000023_create_sponsors.up.sql b/cmd/migrate/migrations/000023_create_sponsors.up.sql
new file mode 100644
index 00000000..fc0efc7d
--- /dev/null
+++ b/cmd/migrate/migrations/000023_create_sponsors.up.sql
@@ -0,0 +1,15 @@
+CREATE TABLE sponsors (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ tier TEXT NOT NULL DEFAULT 'standard',
+ logo_path TEXT NOT NULL DEFAULT '',
+ website_url TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ display_order INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TRIGGER set_updated_at_sponsors
+ BEFORE UPDATE ON sponsors FOR EACH ROW
+ EXECUTE FUNCTION set_updated_at();
\ No newline at end of file
diff --git a/cmd/migrate/migrations/000024_sponsor_logo_base64.down.sql b/cmd/migrate/migrations/000024_sponsor_logo_base64.down.sql
new file mode 100644
index 00000000..d79426fb
--- /dev/null
+++ b/cmd/migrate/migrations/000024_sponsor_logo_base64.down.sql
@@ -0,0 +1,3 @@
+ALTER TABLE sponsors ADD COLUMN logo_path TEXT NOT NULL DEFAULT '';
+ALTER TABLE sponsors DROP COLUMN logo_data;
+ALTER TABLE sponsors DROP COLUMN logo_content_type;
diff --git a/cmd/migrate/migrations/000024_sponsor_logo_base64.up.sql b/cmd/migrate/migrations/000024_sponsor_logo_base64.up.sql
new file mode 100644
index 00000000..7f321236
--- /dev/null
+++ b/cmd/migrate/migrations/000024_sponsor_logo_base64.up.sql
@@ -0,0 +1,3 @@
+ALTER TABLE sponsors ADD COLUMN logo_data TEXT NOT NULL DEFAULT '';
+ALTER TABLE sponsors ADD COLUMN logo_content_type TEXT NOT NULL DEFAULT '';
+ALTER TABLE sponsors DROP COLUMN logo_path;
diff --git a/docs/docs.go b/docs/docs.go
index 9ee77088..13836cc3 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -1482,6 +1482,417 @@ const docTemplate = `{
}
}
},
+ "/admin/sponsors": {
+ "get": {
+ "security": [
+ {
+ "CookieAuth": []
+ }
+ ],
+ "description": "Returns all sponsors ordered by display order",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin/sponsors"
+ ],
+ "summary": "List sponsors (Super Admin)",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.SponsorListResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "CookieAuth": []
+ }
+ ],
+ "description": "Creates a new sponsor",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin/sponsors"
+ ],
+ "summary": "Create sponsor (Super Admin)",
+ "parameters": [
+ {
+ "description": "Sponsor to create",
+ "name": "sponsor",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.SponsorPayload"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/store.Sponsor"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/admin/sponsors/{sponsorID}": {
+ "put": {
+ "security": [
+ {
+ "CookieAuth": []
+ }
+ ],
+ "description": "Updates an existing sponsor",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin/sponsors"
+ ],
+ "summary": "Update sponsor (Super Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Sponsor ID",
+ "name": "sponsorID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Sponsor updates",
+ "name": "sponsor",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.SponsorPayload"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/store.Sponsor"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "CookieAuth": []
+ }
+ ],
+ "description": "Deletes a sponsor",
+ "tags": [
+ "admin/sponsors"
+ ],
+ "summary": "Delete sponsor (Super Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Sponsor ID",
+ "name": "sponsorID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content"
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/admin/sponsors/{sponsorID}/logo": {
+ "put": {
+ "security": [
+ {
+ "CookieAuth": []
+ }
+ ],
+ "description": "Uploads a base64-encoded logo image for a sponsor",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin/sponsors"
+ ],
+ "summary": "Upload sponsor logo (Super Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Sponsor ID",
+ "name": "sponsorID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Base64-encoded logo",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.LogoUploadPayload"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/store.Sponsor"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/applications/me": {
"get": {
"security": [
@@ -1990,6 +2401,57 @@ const docTemplate = `{
}
}
},
+ "/public/sponsors": {
+ "get": {
+ "description": "Returns all sponsors, ordered by display order, with public logo URLs",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "public"
+ ],
+ "summary": "Get sponsors (Public)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "API Key",
+ "name": "X-API-Key",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.SponsorListResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/superadmin/applications/assign": {
"post": {
"security": [
@@ -3424,6 +3886,21 @@ const docTemplate = `{
}
}
},
+ "main.LogoUploadPayload": {
+ "type": "object",
+ "required": [
+ "content_type",
+ "logo_data"
+ ],
+ "properties": {
+ "content_type": {
+ "type": "string"
+ },
+ "logo_data": {
+ "type": "string"
+ }
+ }
+ },
"main.NotesListResponse": {
"type": "object",
"properties": {
@@ -3635,6 +4112,46 @@ const docTemplate = `{
}
}
},
+ "main.SponsorListResponse": {
+ "type": "object",
+ "properties": {
+ "sponsors": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/store.Sponsor"
+ }
+ }
+ }
+ },
+ "main.SponsorPayload": {
+ "type": "object",
+ "required": [
+ "name",
+ "tier"
+ ],
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "display_order": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 100,
+ "minLength": 1
+ },
+ "tier": {
+ "type": "string",
+ "maxLength": 50,
+ "minLength": 1
+ },
+ "website_url": {
+ "type": "string"
+ }
+ }
+ },
"main.SubmitVotePayload": {
"type": "object",
"required": [
@@ -4344,6 +4861,41 @@ const docTemplate = `{
}
}
},
+ "store.Sponsor": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "display_order": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "logo_content_type": {
+ "type": "string"
+ },
+ "logo_data": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "tier": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ },
+ "website_url": {
+ "type": "string"
+ }
+ }
+ },
"store.UserListItem": {
"type": "object",
"properties": {
diff --git a/internal/gcs/client.go b/internal/gcs/client.go
index eb75ee72..37dd82ef 100644
--- a/internal/gcs/client.go
+++ b/internal/gcs/client.go
@@ -12,11 +12,20 @@ const (
signedURLExpiry = 15 * time.Minute
resumeContentType = "application/pdf"
maxResumeSizeBytes = 5 * 1024 * 1024
+ maxImageSizeBytes = 2 * 1024 * 1024
)
+var AllowedImageContentTypes = map[string]bool{
+ "image/png": true,
+ "image/jpeg": true,
+ "image/webp": true,
+ "image/gif": true,
+}
+
type GCSClient struct {
- client *storage.Client
- bucket *storage.BucketHandle
+ client *storage.Client
+ bucket *storage.BucketHandle
+ bucketName string
}
func New(ctx context.Context, bucketName string) (*GCSClient, error) {
@@ -26,8 +35,9 @@ func New(ctx context.Context, bucketName string) (*GCSClient, error) {
}
return &GCSClient{
- client: client,
- bucket: client.Bucket(bucketName),
+ client: client,
+ bucket: client.Bucket(bucketName),
+ bucketName: bucketName,
}, nil
}
@@ -48,6 +58,23 @@ func (c *GCSClient) GenerateUploadURL(_ context.Context, objectPath string) (str
return url, nil
}
+func (c *GCSClient) GenerateImageUploadURL(_ context.Context, objectPath string, contentType string) (string, error) {
+ url, err := c.bucket.SignedURL(objectPath, &storage.SignedURLOptions{
+ Method: "PUT",
+ Expires: time.Now().Add(signedURLExpiry),
+ ContentType: contentType,
+ Headers: []string{
+ fmt.Sprintf("x-goog-content-length-range:0,%d", maxImageSizeBytes),
+ },
+ Scheme: storage.SigningSchemeV4,
+ })
+ if err != nil {
+ return "", err
+ }
+
+ return url, nil
+}
+
func (c *GCSClient) GenerateDownloadURL(_ context.Context, objectPath string) (string, error) {
url, err := c.bucket.SignedURL(objectPath, &storage.SignedURLOptions{
Method: "GET",
@@ -68,3 +95,7 @@ func (c *GCSClient) DeleteObject(ctx context.Context, objectPath string) error {
func (c *GCSClient) Close() error {
return c.client.Close()
}
+
+func (c *GCSClient) GeneratePublicURL(objectPath string) string {
+ return fmt.Sprintf("https://storage.googleapis.com/%s/%s", c.bucketName, objectPath)
+}
diff --git a/internal/gcs/gcs.go b/internal/gcs/gcs.go
index 65ddb268..b369f161 100644
--- a/internal/gcs/gcs.go
+++ b/internal/gcs/gcs.go
@@ -4,6 +4,9 @@ import "context"
type Client interface {
GenerateUploadURL(ctx context.Context, objectPath string) (string, error)
+ GenerateImageUploadURL(ctx context.Context, objectPath string, contentType string) (string, error)
GenerateDownloadURL(ctx context.Context, objectPath string) (string, error)
DeleteObject(ctx context.Context, objectPath string) error
+ GeneratePublicURL(objectPath string) string
+ Close() error
}
diff --git a/internal/gcs/mock.go b/internal/gcs/mock.go
index 4aede657..c4630d22 100644
--- a/internal/gcs/mock.go
+++ b/internal/gcs/mock.go
@@ -10,17 +10,32 @@ type MockClient struct {
mock.Mock
}
-func (m *MockClient) GenerateUploadURL(_ context.Context, objectPath string) (string, error) {
- args := m.Called(objectPath)
+func (m *MockClient) GenerateUploadURL(ctx context.Context, objectPath string) (string, error) {
+ args := m.Called(ctx, objectPath)
return args.String(0), args.Error(1)
}
-func (m *MockClient) GenerateDownloadURL(_ context.Context, objectPath string) (string, error) {
- args := m.Called(objectPath)
+func (m *MockClient) GenerateImageUploadURL(ctx context.Context, objectPath string, contentType string) (string, error) {
+ args := m.Called(ctx, objectPath, contentType)
return args.String(0), args.Error(1)
}
-func (m *MockClient) DeleteObject(_ context.Context, objectPath string) error {
- args := m.Called(objectPath)
+func (m *MockClient) GenerateDownloadURL(ctx context.Context, objectPath string) (string, error) {
+ args := m.Called(ctx, objectPath)
+ return args.String(0), args.Error(1)
+}
+
+func (m *MockClient) DeleteObject(ctx context.Context, objectPath string) error {
+ args := m.Called(ctx, objectPath)
return args.Error(0)
}
+
+func (m *MockClient) Close() error {
+ args := m.Called()
+ return args.Error(0)
+}
+
+func (m *MockClient) GeneratePublicURL(objectPath string) string {
+ args := m.Called(objectPath)
+ return args.String(0)
+}
diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go
index c01e027e..748c708e 100644
--- a/internal/store/mock_store.go
+++ b/internal/store/mock_store.go
@@ -352,6 +352,47 @@ func (m *MockScheduleStore) Delete(ctx context.Context, id string) error {
return args.Error(0)
}
+// MockSponsorsStore is a mock implementation of the Sponsors interface
+type MockSponsorsStore struct {
+ mock.Mock
+}
+
+func (m *MockSponsorsStore) List(ctx context.Context) ([]Sponsor, error) {
+ args := m.Called()
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).([]Sponsor), args.Error(1)
+}
+
+func (m *MockSponsorsStore) Create(ctx context.Context, sponsor *Sponsor) error {
+ args := m.Called(sponsor)
+ return args.Error(0)
+}
+
+func (m *MockSponsorsStore) Update(ctx context.Context, sponsor *Sponsor) error {
+ args := m.Called(sponsor)
+ return args.Error(0)
+}
+
+func (m *MockSponsorsStore) Delete(ctx context.Context, id string) error {
+ args := m.Called(id)
+ return args.Error(0)
+}
+
+func (m *MockSponsorsStore) GetByID(ctx context.Context, id string) (*Sponsor, error) {
+ args := m.Called(id)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).(*Sponsor), args.Error(1)
+}
+
+func (m *MockSponsorsStore) UpdateLogo(ctx context.Context, id string, logoData string, logoContentType string) error {
+ args := m.Called(id, logoData, logoContentType)
+ return args.Error(0)
+}
+
// returns a Storage with all mock implementations
func NewMockStore() Storage {
return Storage{
@@ -361,5 +402,6 @@ func NewMockStore() Storage {
ApplicationReviews: &MockApplicationReviewsStore{},
Scans: &MockScansStore{},
Schedule: &MockScheduleStore{},
+ Sponsors: &MockSponsorsStore{},
}
}
diff --git a/internal/store/sponsors.go b/internal/store/sponsors.go
new file mode 100644
index 00000000..8aea3592
--- /dev/null
+++ b/internal/store/sponsors.go
@@ -0,0 +1,173 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "time"
+)
+
+type Sponsor struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Tier string `json:"tier"`
+ LogoData string `json:"logo_data"`
+ LogoContentType string `json:"logo_content_type"`
+ WebsiteURL string `json:"website_url"`
+ Description string `json:"description"`
+ DisplayOrder int `json:"display_order"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type SponsorsStore struct {
+ db *sql.DB
+}
+
+func (s *SponsorsStore) List(ctx context.Context) ([]Sponsor, error) {
+
+ ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
+ defer cancel()
+
+ query := `
+ SELECT id, name, tier, logo_data, logo_content_type, website_url, description, display_order, created_at, updated_at
+ FROM sponsors
+ ORDER BY display_order ASC
+ `
+
+ rows, err := s.db.QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var sponsors []Sponsor
+ for rows.Next() {
+ var sponsor Sponsor
+ if err := rows.Scan(
+ &sponsor.ID, &sponsor.Name, &sponsor.Tier, &sponsor.LogoData, &sponsor.LogoContentType,
+ &sponsor.WebsiteURL, &sponsor.Description, &sponsor.DisplayOrder,
+ &sponsor.CreatedAt, &sponsor.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ sponsors = append(sponsors, sponsor)
+ }
+
+ if sponsors == nil {
+ sponsors = []Sponsor{}
+ }
+
+ return sponsors, rows.Err()
+}
+
+func (s *SponsorsStore) Create(ctx context.Context, sponsor *Sponsor) error {
+ ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
+ defer cancel()
+
+ query := `
+ INSERT INTO sponsors (name, tier, website_url, description, display_order)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id, created_at, updated_at
+ `
+
+ return s.db.QueryRowContext(ctx, query,
+ sponsor.Name, sponsor.Tier, sponsor.WebsiteURL, sponsor.Description, sponsor.DisplayOrder,
+ ).Scan(&sponsor.ID, &sponsor.CreatedAt, &sponsor.UpdatedAt)
+}
+
+func (s *SponsorsStore) Update(ctx context.Context, sponsor *Sponsor) error {
+ ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
+ defer cancel()
+
+ query := `
+ UPDATE sponsors
+ SET name = $1, tier = $2, website_url = $3, description = $4, display_order = $5
+ WHERE id = $6
+ RETURNING logo_data, logo_content_type, created_at, updated_at
+ `
+
+ err := s.db.QueryRowContext(ctx, query,
+ sponsor.Name, sponsor.Tier, sponsor.WebsiteURL, sponsor.Description, sponsor.DisplayOrder, sponsor.ID,
+ ).Scan(&sponsor.LogoData, &sponsor.LogoContentType, &sponsor.CreatedAt, &sponsor.UpdatedAt)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
+ return err
+ }
+
+ return nil
+}
+
+func (s *SponsorsStore) Delete(ctx context.Context, id string) error {
+ ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
+ defer cancel()
+
+ query := `DELETE FROM sponsors WHERE id = $1`
+
+ result, err := s.db.ExecContext(ctx, query, id)
+ if err != nil {
+ return err
+ }
+
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+
+ if rows == 0 {
+ return ErrNotFound
+ }
+
+ return nil
+}
+
+func (s *SponsorsStore) GetByID(ctx context.Context, id string) (*Sponsor, error) {
+ ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
+ defer cancel()
+
+ query := `
+ SELECT id, name, tier, logo_data, logo_content_type, website_url, description, display_order, created_at, updated_at
+ FROM sponsors
+ WHERE id = $1
+ `
+
+ var sponsor Sponsor
+ err := s.db.QueryRowContext(ctx, query, id).Scan(
+ &sponsor.ID, &sponsor.Name, &sponsor.Tier, &sponsor.LogoData, &sponsor.LogoContentType,
+ &sponsor.WebsiteURL, &sponsor.Description, &sponsor.DisplayOrder,
+ &sponsor.CreatedAt, &sponsor.UpdatedAt,
+ )
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+
+ return &sponsor, nil
+}
+
+func (s *SponsorsStore) UpdateLogo(ctx context.Context, id string, logoData string, logoContentType string) error {
+ ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
+ defer cancel()
+
+ query := `UPDATE sponsors SET logo_data = $1, logo_content_type = $2 WHERE id = $3`
+
+ result, err := s.db.ExecContext(ctx, query, logoData, logoContentType, id)
+ if err != nil {
+ return err
+ }
+
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+
+ if rows == 0 {
+ return ErrNotFound
+ }
+
+ return nil
+}
diff --git a/internal/store/storage.go b/internal/store/storage.go
index e01a8965..90d38e89 100644
--- a/internal/store/storage.go
+++ b/internal/store/storage.go
@@ -75,6 +75,14 @@ type Storage struct {
Update(ctx context.Context, item *ScheduleItem) error
Delete(ctx context.Context, id string) error
}
+ Sponsors interface {
+ List(ctx context.Context) ([]Sponsor, error)
+ Create(ctx context.Context, sponsor *Sponsor) error
+ Update(ctx context.Context, sponsor *Sponsor) error
+ Delete(ctx context.Context, id string) error
+ GetByID(ctx context.Context, id string) (*Sponsor, error)
+ UpdateLogo(ctx context.Context, id string, logoData string, logoContentType string) error
+ }
}
func NewStorage(db *sql.DB) Storage {
@@ -85,5 +93,6 @@ func NewStorage(db *sql.DB) Storage {
ApplicationReviews: &ApplicationReviewsStore{db: db},
Scans: &ScansStore{db: db},
Schedule: &ScheduleStore{db: db},
+ Sponsors: &SponsorsStore{db: db},
}
}