diff --git a/client/src/hooks/useHuddles.ts b/client/src/hooks/useHuddles.ts
index 4445a9c..b956bab 100644
--- a/client/src/hooks/useHuddles.ts
+++ b/client/src/hooks/useHuddles.ts
@@ -485,3 +485,70 @@ export function useDeleteAnnouncement() {
},
});
}
+
+// ── Awards ────────────────────────────────────────────────────────────────────
+
+import type { HuddleAward } from "../types/huddle";
+
+export function useAwards(huddleId: string | null, rosterId?: number) {
+ const { getToken } = useAuth();
+ return useQuery({
+ queryKey: ["awards", huddleId, rosterId ?? null],
+ queryFn: async () => {
+ const token = await getToken();
+ const params = rosterId !== undefined ? { rosterId } : {};
+ const res = await axios.get<{ awards: HuddleAward[] }>(
+ `/api/huddles/${huddleId}/awards`,
+ { params, headers: authHeader(token) },
+ );
+ return res.data.awards;
+ },
+ enabled: !!huddleId,
+ staleTime: 60 * 1000,
+ });
+}
+
+export function useCreateAward() {
+ const { getToken } = useAuth();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (input: {
+ huddleId: string;
+ rosterId: number;
+ glyph: string;
+ color: string;
+ title: string;
+ description?: string;
+ season?: string;
+ }) => {
+ const token = await getToken();
+ const res = await axios.post<{ award: HuddleAward }>(
+ `/api/huddles/${input.huddleId}/awards`,
+ { rosterId: input.rosterId, glyph: input.glyph, color: input.color,
+ title: input.title, description: input.description, season: input.season },
+ { headers: authHeader(token) },
+ );
+ return res.data.award;
+ },
+ onSuccess: (_award, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["awards", variables.huddleId] });
+ },
+ });
+}
+
+export function useDeleteAward() {
+ const { getToken } = useAuth();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (input: { huddleId: string; awardId: string }) => {
+ const token = await getToken();
+ await axios.delete(
+ `/api/huddles/${input.huddleId}/awards/${input.awardId}`,
+ { headers: authHeader(token) },
+ );
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["awards", variables.huddleId] });
+ },
+ });
+}
diff --git a/client/src/pages/CommissionerPage.tsx b/client/src/pages/CommissionerPage.tsx
index 4d79a85..f7d34b1 100644
--- a/client/src/pages/CommissionerPage.tsx
+++ b/client/src/pages/CommissionerPage.tsx
@@ -38,11 +38,15 @@ import {
useDues,
useSetDuesConfig,
useSetDuesPaid,
+ useAwards,
+ useCreateAward,
+ useDeleteAward,
} from "../hooks/useHuddles";
import type { Roster, TeamUser } from "../types/fantasy";
import type {
CommissionerSummary,
HuddleClaimSummary,
+ HuddleAward,
UserSummary,
} from "../types/huddle";
@@ -767,6 +771,292 @@ function DuesTrackerPanel({
);
}
+
+/** Preset colour swatches commissioners can choose from. */
+
+/** Preset colour swatches commissioners can choose from. */
+const AWARD_COLORS = [
+ { hex: "#ef4444", label: "Red" },
+ { hex: "#f97316", label: "Orange" },
+ { hex: "#eab308", label: "Yellow" },
+ { hex: "#22c55e", label: "Green" },
+ { hex: "#3b82f6", label: "Blue" },
+ { hex: "#8b5cf6", label: "Purple" },
+ { hex: "#ec4899", label: "Pink" },
+ { hex: "#6b7280", label: "Gray" },
+ { hex: "#f59e0b", label: "Amber" },
+ { hex: "#14b8a6", label: "Teal" },
+];
+
+/** A single award displayed as a colour-coded badge with a delete button. */
+function AwardBadge({
+ award,
+ teamName,
+ onDelete,
+ deleting,
+}: {
+ award: HuddleAward;
+ teamName: string;
+ onDelete: () => void;
+ deleting: boolean;
+}) {
+ return (
+
+
+ {/* Coloured glyph chip */}
+
+ {award.glyph}
+
+
+
+ {award.title}
+
+
+ {teamName}
+ {award.season ? ` · ${award.season}` : ""}
+
+ {award.description && (
+
+ {award.description}
+
+ )}
+
+
+
+ Remove
+
+
+ );
+}
+
+function CustomAwardsPanel({
+ huddleId,
+ rosters,
+ leagueUsers,
+}: {
+ huddleId: string;
+ rosters: Roster[];
+ leagueUsers: TeamUser[];
+}) {
+ const awardsQuery = useAwards(huddleId);
+ const createAward = useCreateAward();
+ const deleteAward = useDeleteAward();
+
+ // Form state
+ const [glyph, setGlyph] = useState("🏆");
+ const [color, setColor] = useState(AWARD_COLORS[4]!.hex); // blue default
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [rosterId, setRosterId] = useState("");
+ const [season, setSeason] = useState("");
+
+ /** Build a display name for a roster. */
+ function rosterLabel(r: Roster): string {
+ return rosterTeamName(r, leagueUsers);
+ }
+
+ const sortedRosters = useMemo(
+ () => [...rosters].sort((a, b) => a.rosterId - b.rosterId),
+ [rosters],
+ );
+
+ function teamNameForRosterId(id: number): string {
+ const r = rosters.find((x) => x.rosterId === id);
+ return r ? rosterLabel(r) : `Team ${id}`;
+ }
+
+ function handleSubmit() {
+ if (!rosterId || !title.trim() || !glyph.trim()) return;
+ createAward.mutate(
+ {
+ huddleId,
+ rosterId: rosterId as number,
+ glyph: glyph.trim(),
+ color,
+ title: title.trim(),
+ description: description.trim() || undefined,
+ season: season.trim() || undefined,
+ },
+ {
+ onSuccess: () => {
+ setGlyph("🏆");
+ setColor(AWARD_COLORS[4]!.hex);
+ setTitle("");
+ setDescription("");
+ setRosterId("");
+ setSeason("");
+ },
+ },
+ );
+ }
+
+ const awards = awardsQuery.data ?? [];
+
+ return (
+
+
+
+ {/* ── Create form ─────────────────────────────────────────────── */}
+
+ {/* Glyph + preview row */}
+
+
+
+ Glyph
+
+
setGlyph(e.target.value.slice(0, 4))}
+ maxLength={4}
+ className="w-16 text-center text-2xl border border-line rounded-md py-1.5 bg-paper text-ink font-sans"
+ placeholder="🏆"
+ />
+
+ {/* Large preview chip */}
+
+ {glyph || "?"}
+
+
+
+ Colour
+
+
+ {AWARD_COLORS.map((c) => (
+
+
{color}
+
+
+
+ {/* Title + team selector row */}
+
+
+
+ Title
+
+
setTitle(e.target.value.slice(0, 80))}
+ maxLength={80}
+ className="w-full text-[13px] font-sans border border-line rounded-md px-3 py-1.5 bg-paper text-ink"
+ placeholder="e.g. Sacko, Most Improved…"
+ />
+
+
+
+ Season
+
+
setSeason(e.target.value.slice(0, 10))}
+ maxLength={10}
+ className="w-20 text-[13px] font-sans border border-line rounded-md px-2 py-1.5 bg-paper text-ink"
+ placeholder={String(new Date().getFullYear())}
+ />
+
+
+
+ {/* Team selector */}
+
+
+ Recipient Team
+
+
+
+
+ {/* Description */}
+
+
+ Description{" "}
+ (optional)
+
+
+
+
+ {createAward.isError && (
+
+ {(createAward.error as Error).message}
+
+ )}
+
+ {createAward.isPending ? "Granting…" : "Grant award"}
+
+
+
+
+ {/* ── Existing awards list ─────────────────────────────────────── */}
+ {awards.length > 0 && (
+
+
+ Granted awards ({awards.length})
+
+ {awards.map((a) => (
+
+ deleteAward.mutate({ huddleId, awardId: a.id })
+ }
+ deleting={deleteAward.isPending}
+ />
+ ))}
+ {deleteAward.isError && (
+
+ {(deleteAward.error as Error).message}
+
+ )}
+
+ )}
+
+ );
+}
+
+
// ─── Coming-soon stub section ─────────────────────────────────────────────────
function StubSection({
@@ -879,12 +1169,20 @@ export function CommissionerPage() {
description="Define how the prize pool is distributed — 1st, 2nd, 3rd place, most points, best regular-season record, or any split you like."
tag="Finance"
/>
-
+ {huddle ? (
+
+ ) : (
+
+ )}
{/* ── Huddle management (live) ───────────────────────────────── */}
{huddle && detail ? (
diff --git a/client/src/pages/LeagueSettingsPage.tsx b/client/src/pages/LeagueSettingsPage.tsx
index 961b83b..ac036cb 100644
--- a/client/src/pages/LeagueSettingsPage.tsx
+++ b/client/src/pages/LeagueSettingsPage.tsx
@@ -20,6 +20,7 @@ import {
useHuddleDetail,
useSubmitClaim,
useRemoveClaim,
+ useAwards,
} from "../hooks/useHuddles";
import {
Tooltip,
@@ -27,7 +28,7 @@ import {
TooltipTrigger,
} from "../components/ui/tooltip";
import type { Roster, TeamUser } from "../types/fantasy";
-import type { HuddleClaimSummary } from "../types/huddle";
+import type { HuddleAward, HuddleClaimSummary } from "../types/huddle";
// ─── Shared primitives (mirrors CommissionerPage) ─────────────────────────────
@@ -133,6 +134,62 @@ function describeUser(u: { id: string; username: string | null; email: string |
return u.username ?? u.email ?? u.id;
}
+/** Read-only display of all huddle awards — visible to all members. */
+function AwardsSection({
+ huddleId,
+ rosters,
+ leagueUsers,
+}: {
+ huddleId: string;
+ rosters: Roster[];
+ leagueUsers: TeamUser[];
+}) {
+ const { data: awards } = useAwards(huddleId);
+
+ // Don't render if there are no awards yet
+ if (!awards || awards.length === 0) return null;
+
+ function teamNameForRosterId(id: number): string {
+ const r = rosters.find((x) => x.rosterId === id);
+ if (!r) return `Team ${id}`;
+ return rosterTeamName(r, leagueUsers);
+ }
+
+ return (
+
+
+
+ {awards.map((a: HuddleAward) => (
+
+ {/* Coloured glyph */}
+
+ {a.glyph}
+
+
+
+ {a.title}
+
+
+ {teamNameForRosterId(a.rosterId)}
+ {a.season ? ` · ${a.season}` : ""}
+
+
+
+ ))}
+
+
+ );
+}
+
// ─── Teams table ─────────────────────────────────────────────────────────────
function TeamsTable({
@@ -443,6 +500,15 @@ export function LeagueSettingsPage() {
)}
+ {/* Awards — read-only, only shown when awards exist */}
+ {huddle && rosters && leagueUsers && (
+
+ )}
+
{/* Preferences stub */}
diff --git a/client/src/pages/TeamPage.tsx b/client/src/pages/TeamPage.tsx
index 4ee2053..38656ac 100644
--- a/client/src/pages/TeamPage.tsx
+++ b/client/src/pages/TeamPage.tsx
@@ -30,6 +30,8 @@ import { useMyClaimedTeam } from "../hooks/useMyClaimedTeam";
import { getFamilySeasons } from "../utils/leagueFamily";
import { sleeperAvatarUrl } from "../utils/sleeperNormalize";
import { Avatar } from "../components/Avatar";
+import { useMyHuddles, useAwards } from "../hooks/useHuddles";
+import type { HuddleAward } from "../types/huddle";
// ─── Shared atoms ─────────────────────────────────────────────────────────────
@@ -965,11 +967,64 @@ function buildTrophies(stats: TeamStats): Trophy[] {
return trophies;
}
-function TrophyRoom({ stats }: { stats: TeamStats | undefined }) {
+/** Displays custom huddle awards for a specific roster as a horizontal badge strip. */
+function HuddleAwardsStrip({ awards }: { awards: HuddleAward[] }) {
+ if (awards.length === 0) return null;
+ return (
+
+
+ Commissioner Awards
+
+
+ {awards.map((a) => (
+
+
+ {a.glyph}
+
+
+
+ {a.title}
+
+ {(a.description || a.season) && (
+
+ {a.season ?? ""}
+ {a.season && a.description ? " · " : ""}
+ {a.description ?? ""}
+
+ )}
+
+
+ ))}
+
+
+ );
+}
+
+function TrophyRoom({
+ stats,
+ huddleId,
+ rosterId,
+}: {
+ stats: TeamStats | undefined;
+ huddleId: string | null;
+ rosterId: number | null;
+}) {
// While loading, show placeholder skeleton. Once stats arrive, build real trophies.
- const awards = stats ? buildTrophies(stats) : null;
- const display = awards ?? PLACEHOLDER_TROPHIES;
- const isLoading = awards === null;
+ const trophies = stats ? buildTrophies(stats) : null;
+ const display = trophies ?? PLACEHOLDER_TROPHIES;
+ const isLoading = trophies === null;
+
+ // Fetch custom commissioner awards for this roster
+ const { data: huddleAwards } = useAwards(
+ huddleId,
+ rosterId ?? undefined,
+ );
return (
@@ -979,7 +1034,7 @@ function TrophyRoom({ stats }: { stats: TeamStats | undefined }) {
rule={
isLoading
? "Superlatives · loading…"
- : `${awards!.length} award${awards!.length !== 1 ? "s" : ""}`
+ : `${trophies!.length} award${trophies!.length !== 1 ? "s" : ""}`
}
/>
{isLoading && (
@@ -992,10 +1047,13 @@ function TrophyRoom({ stats }: { stats: TeamStats | undefined }) {
))}
+ {/* Custom huddle awards below the auto-generated trophies */}
+
);
}
+
// ─── Main page ────────────────────────────────────────────────────────────────
export function TeamPage() {
@@ -1051,6 +1109,13 @@ export function TeamPage() {
[selectedLeagueId, allLeagues],
);
+ // Find the huddle linked to the currently-selected league so we can show custom awards
+ const { data: myHuddles } = useMyHuddles();
+ const selectedLeagueHuddleId = useMemo(
+ () => myHuddles?.find((h) => h.leagueId === selectedLeagueId)?.id ?? null,
+ [myHuddles, selectedLeagueId],
+ );
+
// Season trail — fetch all scored weeks for this roster
const seasonLog = useTeamSeasonLog(selectedLeagueId, rosterId, lastWeek);
@@ -1135,7 +1200,7 @@ export function TeamPage() {
currentRosterId={rosterId}
stats={teamStats}
/>
-
+
);
diff --git a/client/src/types/huddle.ts b/client/src/types/huddle.ts
index b3ab17e..6109d6c 100644
--- a/client/src/types/huddle.ts
+++ b/client/src/types/huddle.ts
@@ -92,3 +92,15 @@ export interface DuesResponse {
config: DuesConfig | null;
payments: DuesPayment[];
}
+
+export interface HuddleAward {
+ id: string;
+ huddleId: string;
+ rosterId: number;
+ glyph: string;
+ color: string;
+ title: string;
+ description: string | null;
+ season: string | null;
+ createdAt: string;
+}
diff --git a/server/drizzle/0005_awards.sql b/server/drizzle/0005_awards.sql
new file mode 100644
index 0000000..d857c45
--- /dev/null
+++ b/server/drizzle/0005_awards.sql
@@ -0,0 +1,17 @@
+-- Custom awards table: commissioners grant glyph+color badges to teams
+CREATE TABLE IF NOT EXISTS "huddle_awards" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "huddle_id" uuid NOT NULL REFERENCES "huddles"("id") ON DELETE CASCADE,
+ "roster_id" integer NOT NULL,
+ "glyph" text NOT NULL,
+ "color" text NOT NULL,
+ "title" text NOT NULL,
+ "description" text,
+ "granted_by" text NOT NULL,
+ "season" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS "huddle_awards_huddle_idx" ON "huddle_awards" ("huddle_id");
+CREATE INDEX IF NOT EXISTS "huddle_awards_huddle_roster_idx" ON "huddle_awards" ("huddle_id", "roster_id");
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
index 6ccb57d..083f984 100644
--- a/server/src/db/schema.ts
+++ b/server/src/db/schema.ts
@@ -159,3 +159,35 @@ export type HuddleAnnouncement = typeof huddleAnnouncements.$inferSelect;
export type NewHuddleAnnouncement = typeof huddleAnnouncements.$inferInsert;
export type HuddleDuesConfig = typeof huddleDuesConfig.$inferSelect;
export type HuddleDuesPayment = typeof huddleDuesPayments.$inferSelect;
+
+// ── Custom awards ─────────────────────────────────────────────────────────────
+
+export const huddleAwards = pgTable(
+ "huddle_awards",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ huddleId: uuid("huddle_id")
+ .notNull()
+ .references(() => huddles.id, { onDelete: "cascade" }),
+ rosterId: integer("roster_id").notNull(),
+ glyph: text("glyph").notNull(),
+ color: text("color").notNull(),
+ title: text("title").notNull(),
+ description: text("description"),
+ grantedBy: text("granted_by").notNull(),
+ season: text("season"),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ },
+ (t) => ({
+ byHuddle: index("huddle_awards_huddle_idx").on(t.huddleId),
+ byHuddleRoster: index("huddle_awards_huddle_roster_idx").on(t.huddleId, t.rosterId),
+ }),
+);
+
+export type HuddleAward = typeof huddleAwards.$inferSelect;
+export type NewHuddleAward = typeof huddleAwards.$inferInsert;
diff --git a/server/src/routes/huddleRoutes.ts b/server/src/routes/huddleRoutes.ts
index efc8606..5b75b38 100644
--- a/server/src/routes/huddleRoutes.ts
+++ b/server/src/routes/huddleRoutes.ts
@@ -31,6 +31,12 @@ import {
setDuesConfig,
setDuesPaid,
} from "../services/duesService.js";
+import {
+ listAwards,
+ listAwardsForRoster,
+ createAward,
+ deleteAward,
+} from "../services/awardsService.js";
const clerkSecretKey = process.env["CLERK_SECRET_KEY"];
if (!clerkSecretKey) {
@@ -693,4 +699,70 @@ export function initHuddleRoutes(app: Express) {
}
},
);
+
+ // GET /api/huddles/:id/awards — all members; ?rosterId=N for team-page filter
+ app.get(
+ "/api/huddles/:id/awards",
+ requireAuth,
+ async (req: Request, res: Response) => {
+ try {
+ const huddleId = req.params.id!;
+ const rosterIdParam = req.query["rosterId"];
+ let awards;
+ if (rosterIdParam !== undefined) {
+ const rosterId = Number(rosterIdParam);
+ if (!Number.isInteger(rosterId) || rosterId < 1) {
+ res.status(400).json({ error: "rosterId must be a positive integer" });
+ return;
+ }
+ awards = await listAwardsForRoster(huddleId, rosterId);
+ } else {
+ awards = await listAwards(huddleId);
+ }
+ res.json({ awards });
+ } catch (err) {
+ handleError(err, res);
+ }
+ },
+ );
+
+ // POST /api/huddles/:id/awards — commissioner only
+ app.post(
+ "/api/huddles/:id/awards",
+ requireAuth,
+ async (req: Request, res: Response) => {
+ try {
+ const { userId } = getAuth(req);
+ const huddleId = req.params.id!;
+ const { rosterId, glyph, color, title, description, season } =
+ req.body as Record;
+ const award = await createAward(huddleId, userId!, {
+ rosterId,
+ glyph,
+ color,
+ title,
+ description,
+ season,
+ });
+ res.status(201).json({ award });
+ } catch (err) {
+ handleError(err, res);
+ }
+ },
+ );
+
+ // DELETE /api/huddles/:id/awards/:awardId — commissioner only
+ app.delete(
+ "/api/huddles/:id/awards/:awardId",
+ requireAuth,
+ async (req: Request, res: Response) => {
+ try {
+ const { userId } = getAuth(req);
+ await deleteAward(req.params.id!, req.params.awardId!, userId!);
+ res.status(204).end();
+ } catch (err) {
+ handleError(err, res);
+ }
+ },
+ );
}
diff --git a/server/src/services/awardsService.ts b/server/src/services/awardsService.ts
new file mode 100644
index 0000000..10b5607
--- /dev/null
+++ b/server/src/services/awardsService.ts
@@ -0,0 +1,187 @@
+/**
+ * Awards service — CRUD for custom commissioner-granted awards.
+ *
+ * All writes are commissioner-gated via the shared isCommissioner helper
+ * imported from huddlesService.
+ */
+import { and, desc, eq } from "drizzle-orm";
+import { db } from "../db/client.js";
+import { huddleAwards, type HuddleAward } from "../db/schema.js";
+import { HuddlesServiceError, isCommissioner } from "./huddlesService.js";
+
+const fail = (status: number, message: string): never => {
+ throw new HuddlesServiceError(status, message);
+};
+
+// ── Validation constants ──────────────────────────────────────────────────────
+
+const MAX_GLYPH_LEN = 4;
+const MAX_TITLE_LEN = 80;
+const MAX_DESC_LEN = 300;
+const HEX_RE = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/;
+
+function validateAwardInput(input: {
+ rosterId: unknown;
+ glyph: unknown;
+ color: unknown;
+ title: unknown;
+ description?: unknown;
+ season?: unknown;
+}): {
+ rosterId: number;
+ glyph: string;
+ color: string;
+ title: string;
+ description: string | null;
+ season: string | null;
+} {
+ if (
+ typeof input.rosterId !== "number" ||
+ !Number.isInteger(input.rosterId) ||
+ input.rosterId < 1
+ ) {
+ fail(400, "rosterId must be a positive integer");
+ }
+
+ if (
+ typeof input.glyph !== "string" ||
+ !input.glyph.trim() ||
+ input.glyph.trim().length > MAX_GLYPH_LEN
+ ) {
+ fail(400, `glyph is required (max ${MAX_GLYPH_LEN} chars)`);
+ }
+
+ if (typeof input.color !== "string" || !HEX_RE.test(input.color)) {
+ fail(400, "color must be a valid hex string (e.g. #f59e0b)");
+ }
+
+ if (
+ typeof input.title !== "string" ||
+ !input.title.trim() ||
+ input.title.trim().length > MAX_TITLE_LEN
+ ) {
+ fail(400, `title is required (max ${MAX_TITLE_LEN} chars)`);
+ }
+
+ let description: string | null = null;
+ if (input.description !== undefined && input.description !== null && input.description !== "") {
+ if (
+ typeof input.description !== "string" ||
+ input.description.length > MAX_DESC_LEN
+ ) {
+ fail(400, `description must be a string up to ${MAX_DESC_LEN} chars`);
+ }
+ description = (input.description as string).trim() || null;
+ }
+
+ let season: string | null = null;
+ if (input.season !== undefined && input.season !== null && input.season !== "") {
+ if (typeof input.season !== "string") fail(400, "season must be a string");
+ season = (input.season as string).trim() || null;
+ }
+
+ return {
+ rosterId: input.rosterId as number,
+ glyph: (input.glyph as string).trim(),
+ color: input.color as string,
+ title: (input.title as string).trim(),
+ description,
+ season,
+ };
+}
+
+// ── Queries ───────────────────────────────────────────────────────────────────
+
+/**
+ * All awards for a huddle, newest first.
+ */
+export async function listAwards(huddleId: string): Promise {
+ return db
+ .select()
+ .from(huddleAwards)
+ .where(eq(huddleAwards.huddleId, huddleId))
+ .orderBy(desc(huddleAwards.createdAt));
+}
+
+/**
+ * Awards for a specific roster within a huddle, newest first.
+ */
+export async function listAwardsForRoster(
+ huddleId: string,
+ rosterId: number,
+): Promise {
+ return db
+ .select()
+ .from(huddleAwards)
+ .where(
+ and(
+ eq(huddleAwards.huddleId, huddleId),
+ eq(huddleAwards.rosterId, rosterId),
+ ),
+ )
+ .orderBy(desc(huddleAwards.createdAt));
+}
+
+// ── Mutations ─────────────────────────────────────────────────────────────────
+
+/**
+ * Grant a new award. Only commissioners may call this.
+ */
+export async function createAward(
+ huddleId: string,
+ userId: string,
+ input: {
+ rosterId: unknown;
+ glyph: unknown;
+ color: unknown;
+ title: unknown;
+ description?: unknown;
+ season?: unknown;
+ },
+): Promise {
+ if (!(await isCommissioner(huddleId, userId)))
+ fail(403, "Only a commissioner can grant awards");
+
+ const validated = validateAwardInput(input);
+
+ const [created] = await db
+ .insert(huddleAwards)
+ .values({
+ huddleId,
+ grantedBy: userId,
+ ...validated,
+ })
+ .returning();
+
+ if (!created) fail(500, "Failed to create award");
+ return created!;
+}
+
+/**
+ * Delete an award. Only commissioners may call this.
+ */
+export async function deleteAward(
+ huddleId: string,
+ awardId: string,
+ userId: string,
+): Promise {
+ if (!(await isCommissioner(huddleId, userId)))
+ fail(403, "Only a commissioner can delete awards");
+
+ const rows = await db
+ .select()
+ .from(huddleAwards)
+ .where(
+ and(
+ eq(huddleAwards.id, awardId),
+ eq(huddleAwards.huddleId, huddleId),
+ ),
+ )
+ .limit(1);
+
+ if (!rows[0]) fail(404, "Award not found");
+
+ await db
+ .delete(huddleAwards)
+ .where(eq(huddleAwards.id, awardId));
+}