From a23cfc59014d6bb29ec727b13efd1815e73cf194 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:02:47 -0600 Subject: [PATCH 01/16] Replace email input with searchable user picker in Add Member dialog The invite member dialog now shows a searchable combobox of eligible users (filtered to exclude existing members) instead of a blank email field. Users display as "Name (username)" and can be filtered by typing. - Add shadcn Command component (cmdk) for searchable list UI - Add getEligibleMembers server action to fetch non-member active users - Add addCompetitionMemberById server action accepting userId directly - Rewrite InviteMemberDialog with Popover + Command combobox Co-Authored-By: Claude Opus 4.5 --- components/members/invite-member-dialog.tsx | 144 ++++++++++++--- components/ui/command.tsx | 153 ++++++++++++++++ lib/db_actions/competition-members.ts | 186 ++++++++++++++++++++ package-lock.json | 17 ++ package.json | 1 + 5 files changed, 475 insertions(+), 26 deletions(-) create mode 100644 components/ui/command.tsx diff --git a/components/members/invite-member-dialog.tsx b/components/members/invite-member-dialog.tsx index 7d60095..8b495d1 100644 --- a/components/members/invite-member-dialog.tsx +++ b/components/members/invite-member-dialog.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useTransition } from "react"; import { useRouter } from "next/navigation"; +import { Check, ChevronsUpDown } from "lucide-react"; import { Dialog, DialogContent, @@ -11,15 +12,39 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; import { useServerAction } from "@/hooks/use-server-action"; import { - addCompetitionMember, + addCompetitionMemberById, + getEligibleMembers, type CompetitionRole, } from "@/lib/db_actions/competition-members"; +interface EligibleUser { + id: number; + name: string; + username: string | null; +} + +function formatUserLabel(user: EligibleUser): string { + return user.username ? `${user.name} (${user.username})` : user.name; +} + interface InviteMemberDialogProps { competitionId: number; isOpen: boolean; @@ -31,41 +56,61 @@ export function InviteMemberDialog({ isOpen, onClose, }: InviteMemberDialogProps) { - const [email, setEmail] = useState(""); + const [selectedUserId, setSelectedUserId] = useState(null); const [role, setRole] = useState("forecaster"); + const [popoverOpen, setPopoverOpen] = useState(false); + const [eligibleUsers, setEligibleUsers] = useState( + null, + ); + const [isLoadingUsers, startLoadingUsers] = useTransition(); const router = useRouter(); + // Load eligible users when the dialog opens + useEffect(() => { + if (!isOpen) return; + startLoadingUsers(async () => { + const result = await getEligibleMembers(competitionId); + if (result.success) { + setEligibleUsers(result.data); + } + }); + }, [isOpen, competitionId]); + const handleSuccess = () => { router.refresh(); - setEmail(""); + setSelectedUserId(null); setRole("forecaster"); + setEligibleUsers(null); onClose(); }; - const addMemberAction = useServerAction(addCompetitionMember, { + const addMemberAction = useServerAction(addCompetitionMemberById, { successMessage: "Member added successfully", onSuccess: handleSuccess, }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!email.trim()) return; + if (selectedUserId === null) return; await addMemberAction.execute({ competitionId, - userEmail: email.trim(), + userId: selectedUserId, role, }); }; const handleClose = () => { if (!addMemberAction.isLoading) { - setEmail(""); + setSelectedUserId(null); setRole("forecaster"); + setPopoverOpen(false); onClose(); } }; + const selectedUser = eligibleUsers?.find((u) => u.id === selectedUserId); + return ( @@ -73,27 +118,71 @@ export function InviteMemberDialog({ Add Member - Add a new member to this competition by their email address. + Search for a user to add to this competition.
- - setEmail(e.target.value)} - disabled={addMemberAction.isLoading} - required - /> -

- The user must already have an account in the system. -

+ + + + + + + + + + {isLoadingUsers ? ( +
+ +
+ ) : ( + <> + No users found. + + {(eligibleUsers ?? []).map((user) => ( + { + setSelectedUserId( + user.id === selectedUserId + ? null + : user.id, + ); + setPopoverOpen(false); + }} + > + + {formatUserLabel(user)} + + ))} + + + )} +
+
+
+
@@ -152,7 +241,10 @@ export function InviteMemberDialog({ > Cancel - -
- - - - setShowInviteDialog(false)} - /> -
- ); -} diff --git a/app/competitions/[competitionId]/members/page.tsx b/app/competitions/[competitionId]/members/page.tsx deleted file mode 100644 index dd4602d..0000000 --- a/app/competitions/[competitionId]/members/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { getUserFromCookies } from "@/lib/get-user"; -import { getCompetitionById } from "@/lib/db_actions"; -import { - getCurrentUserRole, - getCompetitionMembers, -} from "@/lib/db_actions/competition-members"; -import ErrorPage from "@/components/pages/error-page"; -import { InaccessiblePage } from "@/components/inaccessible-page"; -import PageHeading from "@/components/page-heading"; -import { MembersPageContent } from "./members-page-content"; - -export default async function MembersPage({ - params, -}: { - params: Promise<{ competitionId: string }>; -}) { - const { competitionId: competitionIdString } = await params; - const competitionId = parseInt(competitionIdString, 10); - if (isNaN(competitionId)) { - return ( - - ); - } - - const user = (await getUserFromCookies())!; - - const competitionResult = await getCompetitionById(competitionId); - if (!competitionResult.success) { - return ; - } - const competition = competitionResult.data; - - // Only private competitions have members - if (!competition.is_private) { - return ( - - ); - } - - // Check if user is a member and get their role - const roleResult = await getCurrentUserRole(competitionId); - if (!roleResult.success) { - return ; - } - - const userRole = roleResult.data; - if (userRole === null) { - return ( - - ); - } - - // Only admins can view the members page - if (userRole !== "admin") { - return ( - - ); - } - - // Fetch all members - const membersResult = await getCompetitionMembers(competitionId); - if (!membersResult.success) { - return ; - } - - return ( -
- - - -
- ); -} diff --git a/components/competition-dashboard/competition-dashboard.tsx b/components/competition-dashboard/competition-dashboard.tsx index 6380ac2..e2d2d98 100644 --- a/components/competition-dashboard/competition-dashboard.tsx +++ b/components/competition-dashboard/competition-dashboard.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState, useTransition } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { UserPlus } from "lucide-react"; import { CompetitionHeader } from "./competition-header"; import { CompetitionTabs, type DashboardTab } from "./competition-tabs"; import { StatCards } from "./stat-cards"; @@ -10,9 +11,13 @@ import { LeaderboardSidebar } from "./leaderboard-sidebar"; import { ForecastablePropsTable } from "@/components/forecastable-props-table"; import { PropsTable } from "@/components/props/props-table"; import Leaderboard from "@/components/scores/leaderboard"; +import { MembersTable, InviteMemberDialog } from "@/components/members"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { getCompetitionMembers } from "@/lib/db_actions/competition-members"; import type { CompetitionStats, UpcomingDeadline } from "@/lib/db_actions/competition-stats"; import type { CompetitionScore } from "@/lib/db_actions"; -import type { Category, PropWithUserForecast } from "@/types/db_types"; +import type { Category, PropWithUserForecast, VCompetitionMember } from "@/types/db_types"; interface CompetitionDashboardProps { competitionId: number; @@ -118,8 +123,27 @@ export function CompetitionDashboard({ // Calculate forecaster count from scores for public competitions const forecasterCount = scores.overallScores?.length ?? 0; - // Show members tab only for private competitions where user is admin - const showMembersTab = isPrivate && isAdmin; + // Show members tab for all private competition members + const showMembersTab = isPrivate; + + // Members tab state — fetch members when the tab is active + const [members, setMembers] = useState(null); + const [isLoadingMembers, startLoadingMembers] = useTransition(); + const [showInviteDialog, setShowInviteDialog] = useState(false); + const [membersRefreshKey, setMembersRefreshKey] = useState(0); + const refreshMembers = useCallback(() => { + setMembersRefreshKey((k) => k + 1); + }, []); + + useEffect(() => { + if (activeTab !== "members" || !isPrivate) return; + startLoadingMembers(async () => { + const result = await getCompetitionMembers(competitionId); + if (result.success) { + setMembers(result.data); + } + }); + }, [activeTab, isPrivate, competitionId, membersRefreshKey]); return (
@@ -222,16 +246,39 @@ export function CompetitionDashboard({
)} {activeTab === "members" && showMembersTab && ( -
-

- View and manage competition members -

- +
+ {isAdmin && ( +
+

+ Manage who has access to this competition. +

+ +
+ )} + {isLoadingMembers || members === null ? ( +
+ +
+ ) : ( + + )} + {isAdmin && ( + setShowInviteDialog(false)} + onMemberChange={refreshMembers} + /> + )}
)}
diff --git a/components/competition-dashboard/competition-header.tsx b/components/competition-dashboard/competition-header.tsx index a82a5b9..965ab81 100644 --- a/components/competition-dashboard/competition-header.tsx +++ b/components/competition-dashboard/competition-header.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { MoreVertical, Plus, Settings, UserPlus, Users } from "lucide-react"; +import { MoreVertical, Plus, Settings } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -22,7 +22,6 @@ interface CompetitionHeaderProps { } export function CompetitionHeader({ - competitionId, competitionName, isPrivate, isAdmin, @@ -71,28 +70,6 @@ export function CompetitionHeader({ - {isPrivate && ( - <> - - - - Invite Members - - - - - - Manage Members - - - - )} void; + onMemberChange?: () => void; } export function InviteMemberDialog({ competitionId, isOpen, onClose, + onMemberChange, }: InviteMemberDialogProps) { const [selectedUserId, setSelectedUserId] = useState(null); const [role, setRole] = useState("forecaster"); @@ -81,6 +83,7 @@ export function InviteMemberDialog({ setSelectedUserId(null); setRole("forecaster"); setEligibleUsers(null); + onMemberChange?.(); onClose(); }; diff --git a/components/members/members-table.tsx b/components/members/members-table.tsx index ff289c5..c1865a6 100644 --- a/components/members/members-table.tsx +++ b/components/members/members-table.tsx @@ -37,6 +37,7 @@ interface MembersTableProps { competitionId: number; currentUserId: number; isAdmin: boolean; + onMemberChange?: () => void; } function RoleBadge({ role }: { role: CompetitionRole }) { @@ -65,6 +66,7 @@ interface MemberRowProps { currentUserId: number; isAdmin: boolean; isOnlyAdmin: boolean; + onMemberChange?: () => void; } function MemberRow({ @@ -73,6 +75,7 @@ function MemberRow({ currentUserId, isAdmin, isOnlyAdmin, + onMemberChange, }: MemberRowProps) { const router = useRouter(); const [showRemoveDialog, setShowRemoveDialog] = useState(false); @@ -83,6 +86,7 @@ function MemberRow({ const handleSuccess = () => { router.refresh(); + onMemberChange?.(); }; const removeMemberAction = useServerAction(removeCompetitionMember, { @@ -225,6 +229,7 @@ export function MembersTable({ competitionId, currentUserId, isAdmin, + onMemberChange, }: MembersTableProps) { // Count admins to prevent removing the last one const adminCount = members.filter((m) => m.role === "admin").length; @@ -261,6 +266,7 @@ export function MembersTable({ currentUserId={currentUserId} isAdmin={isAdmin} isOnlyAdmin={isOnlyAdmin} + onMemberChange={onMemberChange} /> ))} From f67ac0a2ae2e099fc9c7724a580bcd7ab5bae88b Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:24:07 -0600 Subject: [PATCH 03/16] Allow competition admins to edit their own private competitions The updateCompetition server action now checks for competition admin role on private competitions, not just system admin. The dashboard header opens an inline edit dialog for private competitions instead of linking to the system admin page. Co-Authored-By: Claude Opus 4.5 --- .../competition-header.tsx | 60 ++++++++++++++++--- lib/db_actions/competitions.ts | 39 +++++++++--- 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/components/competition-dashboard/competition-header.tsx b/components/competition-dashboard/competition-header.tsx index 965ab81..fc61b7d 100644 --- a/components/competition-dashboard/competition-header.tsx +++ b/components/competition-dashboard/competition-header.tsx @@ -1,15 +1,24 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { MoreVertical, Plus, Settings } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { CreateEditCompetitionForm } from "@/components/forms/create-edit-competition-form"; +import type { Competition } from "@/types/db_types"; interface CompetitionHeaderProps { competitionId: number; @@ -22,6 +31,7 @@ interface CompetitionHeaderProps { } export function CompetitionHeader({ + competitionId, competitionName, isPrivate, isAdmin, @@ -29,6 +39,22 @@ export function CompetitionHeader({ forecasterCount, onAddProp, }: CompetitionHeaderProps) { + const [editOpen, setEditOpen] = useState(false); + const router = useRouter(); + + // Build a minimal Competition object for the edit form + const competitionForForm: Competition = { + id: competitionId, + name: competitionName, + is_private: isPrivate, + forecasts_open_date: null, + forecasts_close_date: null, + end_date: null, + created_by_user_id: null, + created_at: new Date(), + updated_at: new Date(), + }; + return (
@@ -70,17 +96,37 @@ export function CompetitionHeader({ - - + {isPrivate ? ( + setEditOpen(true)}> Competition Settings - - + + ) : ( + + + + Competition Settings + + + )} + + + + Edit Competition + { + setEditOpen(false); + router.refresh(); + }} + /> + +
)}
diff --git a/lib/db_actions/competitions.ts b/lib/db_actions/competitions.ts index 685a1fe..efa2756 100644 --- a/lib/db_actions/competitions.ts +++ b/lib/db_actions/competitions.ts @@ -152,15 +152,36 @@ export async function updateCompetition({ const startTime = Date.now(); try { - if (!currentUser?.is_admin) { - logger.warn("Unauthorized attempt to update competition", { - competitionId: id, - currentUserId: currentUser?.id, - }); - return error( - "Only admins can update competitions", - ERROR_CODES.UNAUTHORIZED, - ); + if (!currentUser) { + return error("You must be logged in", ERROR_CODES.UNAUTHORIZED); + } + + // System admins can update any competition. + // Competition admins can update their own private competitions. + if (!currentUser.is_admin) { + const membership = await db + .selectFrom("competition_members") + .select("role") + .where("competition_id", "=", id) + .where("user_id", "=", currentUser.id) + .executeTakeFirst(); + + const comp = await db + .selectFrom("competitions") + .select("is_private") + .where("id", "=", id) + .executeTakeFirst(); + + if (!comp?.is_private || membership?.role !== "admin") { + logger.warn("Unauthorized attempt to update competition", { + competitionId: id, + currentUserId: currentUser.id, + }); + return error( + "Only admins can update competitions", + ERROR_CODES.UNAUTHORIZED, + ); + } } // If any of the date fields are being changed, validate ordering using the From 4aa1e92d2d446ca2b4b51240927c79a37492b48f Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:30:44 -0600 Subject: [PATCH 04/16] Prevent switching competitions between private and public Hide the is_private toggle in edit mode so only new competitions can set visibility. Strip is_private from the update server action payload as a safeguard. Also add autoComplete="off" to the competition name input to prevent 1Password autofill. Co-Authored-By: Claude Opus 4.5 --- .../forms/create-edit-competition-form.tsx | 51 ++++++++++--------- lib/db_actions/competitions.ts | 5 ++ 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/components/forms/create-edit-competition-form.tsx b/components/forms/create-edit-competition-form.tsx index 3f5105f..3c42b3a 100644 --- a/components/forms/create-edit-competition-form.tsx +++ b/components/forms/create-edit-competition-form.tsx @@ -200,36 +200,39 @@ export function CreateEditCompetitionForm({ {...field} className="h-11" placeholder="Enter competition name" + autoComplete="off" /> )} /> - ( - -
- - - Private Competition - - - Only invited members can view and participate. Deadlines are - set per-prop instead of competition-wide. - -
- - - -
- )} - /> + {!initialCompetition && ( + ( + +
+ + + Private Competition + + + Only invited members can view and participate. Deadlines are + set per-prop instead of competition-wide. + +
+ + + +
+ )} + /> + )} {!isPrivate && (
)["is_private"]; + } + // If any of the date fields are being changed, validate ordering using the // new values overlaid on the existing row. if ( From 48cba7e05bac7b46a7efe056eae0fc6e60b9e06b Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:36:54 -0600 Subject: [PATCH 05/16] Add admin authorization check to getEligibleMembers Previously any logged-in user could call getEligibleMembers and get the full list of active non-member users, leaking the user directory. Now only competition admins and system admins can access this endpoint. Co-Authored-By: Claude Opus 4.5 --- lib/db_actions/competition-members.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/db_actions/competition-members.ts b/lib/db_actions/competition-members.ts index c46dcfd..8642d9f 100644 --- a/lib/db_actions/competition-members.ts +++ b/lib/db_actions/competition-members.ts @@ -539,6 +539,23 @@ export async function getEligibleMembers( return error("You must be logged in", ERROR_CODES.UNAUTHORIZED); } + // Only competition admins (or system admins) can view eligible members + if (!currentUser.is_admin) { + const membership = await db + .selectFrom("competition_members") + .select("role") + .where("competition_id", "=", competitionId) + .where("user_id", "=", currentUser.id) + .executeTakeFirst(); + + if (membership?.role !== "admin") { + return error( + "Only competition admins can view eligible members", + ERROR_CODES.UNAUTHORIZED, + ); + } + } + const users = await withRLS(currentUser.id, async (trx) => { // Get IDs of existing members const existingMemberIds = trx From 2032ae2553bbd188456ac01af33a033b9df65b30 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:37:25 -0600 Subject: [PATCH 06/16] Remove dead addCompetitionMember (email-based) function This function was replaced by addCompetitionMemberById when the invite dialog switched to a searchable user picker. No callers remain. Also removes the now-unused sql import from kysely. Co-Authored-By: Claude Opus 4.5 --- lib/db_actions/competition-members.ts | 131 -------------------------- 1 file changed, 131 deletions(-) diff --git a/lib/db_actions/competition-members.ts b/lib/db_actions/competition-members.ts index 8642d9f..0eff8cc 100644 --- a/lib/db_actions/competition-members.ts +++ b/lib/db_actions/competition-members.ts @@ -1,6 +1,5 @@ "use server"; -import { sql } from "kysely"; import { db } from "@/lib/database"; import { withRLS } from "@/lib/db-helpers"; import { @@ -121,136 +120,6 @@ export async function getCurrentUserRole( } } -/** - * Add a member to a competition by email - * Only competition admins can add members - */ -export async function addCompetitionMember({ - competitionId, - userEmail, - role, -}: { - competitionId: number; - userEmail: string; - role: CompetitionRole; -}): Promise> { - const currentUser = await getUserFromCookies(); - logger.debug("Adding competition member", { - competitionId, - userEmail, - role, - currentUserId: currentUser?.id, - }); - - const startTime = Date.now(); - try { - if (!currentUser) { - return error("You must be logged in", ERROR_CODES.UNAUTHORIZED); - } - - const inserted = await withRLS(currentUser.id, async (trx) => { - // Check if current user is an admin of this competition - const currentUserMembership = await trx - .selectFrom("competition_members") - .select("role") - .where("competition_id", "=", competitionId) - .where("user_id", "=", currentUser.id) - .executeTakeFirst(); - - if (currentUserMembership?.role !== "admin") { - logger.warn("Unauthorized attempt to add competition member", { - competitionId, - currentUserId: currentUser.id, - currentRole: currentUserMembership?.role, - }); - throw new Error("UNAUTHORIZED: Only competition admins can add members"); - } - - // Find user by email (case-insensitive) - const userToAdd = await trx - .selectFrom("users") - .select("id") - .where(sql`lower(email)`, "=", userEmail.toLowerCase()) - .where("deactivated_at", "is", null) - .executeTakeFirst(); - - if (!userToAdd) { - throw new Error("NOT_FOUND: No active user found with that email"); - } - - // Check if user is already a member - const existingMembership = await trx - .selectFrom("competition_members") - .select("id") - .where("competition_id", "=", competitionId) - .where("user_id", "=", userToAdd.id) - .executeTakeFirst(); - - if (existingMembership) { - throw new Error("VALIDATION: User is already a member of this competition"); - } - - const newMember: NewCompetitionMember = { - competition_id: competitionId, - user_id: userToAdd.id, - role, - }; - - return trx - .insertInto("competition_members") - .values(newMember) - .returningAll() - .executeTakeFirstOrThrow(); - }); - - const duration = Date.now() - startTime; - logger.info("Competition member added successfully", { - operation: "addCompetitionMember", - table: "competition_members", - competitionId, - addedUserId: inserted.user_id, - role, - duration, - }); - - revalidatePath(`/competitions/${competitionId}`); - revalidatePath(`/competitions/${competitionId}/members`); - return success(inserted); - } catch (err) { - const duration = Date.now() - startTime; - const errorMessage = (err as Error).message; - - // Handle specific error types thrown from within the transaction - if (errorMessage.startsWith("UNAUTHORIZED:")) { - return error( - errorMessage.replace("UNAUTHORIZED: ", ""), - ERROR_CODES.UNAUTHORIZED, - ); - } - if (errorMessage.startsWith("NOT_FOUND:")) { - return error( - errorMessage.replace("NOT_FOUND: ", ""), - ERROR_CODES.NOT_FOUND, - ); - } - if (errorMessage.startsWith("VALIDATION:")) { - return error( - errorMessage.replace("VALIDATION: ", ""), - ERROR_CODES.VALIDATION_ERROR, - ); - } - - logger.error("Failed to add competition member", err as Error, { - operation: "addCompetitionMember", - table: "competition_members", - competitionId, - userEmail, - duration, - }); - return error("Failed to add member", ERROR_CODES.DATABASE_ERROR); - } -} - /** * Remove a member from a competition * Only competition admins can remove members From 7b9518099dd287ae7e9c5ffd9fc38dcdb7b0f2ab Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:37:54 -0600 Subject: [PATCH 07/16] Handle error state in members tab fetch If getCompetitionMembers fails, show the error message instead of spinning indefinitely. Clears the error on each new fetch attempt. Co-Authored-By: Claude Opus 4.5 --- .../competition-dashboard/competition-dashboard.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/components/competition-dashboard/competition-dashboard.tsx b/components/competition-dashboard/competition-dashboard.tsx index e2d2d98..5825a5b 100644 --- a/components/competition-dashboard/competition-dashboard.tsx +++ b/components/competition-dashboard/competition-dashboard.tsx @@ -128,6 +128,7 @@ export function CompetitionDashboard({ // Members tab state — fetch members when the tab is active const [members, setMembers] = useState(null); + const [membersError, setMembersError] = useState(null); const [isLoadingMembers, startLoadingMembers] = useTransition(); const [showInviteDialog, setShowInviteDialog] = useState(false); const [membersRefreshKey, setMembersRefreshKey] = useState(0); @@ -137,10 +138,13 @@ export function CompetitionDashboard({ useEffect(() => { if (activeTab !== "members" || !isPrivate) return; + setMembersError(null); startLoadingMembers(async () => { const result = await getCompetitionMembers(competitionId); if (result.success) { setMembers(result.data); + } else { + setMembersError(result.error); } }); }, [activeTab, isPrivate, competitionId, membersRefreshKey]); @@ -258,7 +262,11 @@ export function CompetitionDashboard({
)} - {isLoadingMembers || members === null ? ( + {membersError ? ( +
+

{membersError}

+
+ ) : isLoadingMembers || members === null ? (
From 34a8d63621df895b242ef3eaf75194b5649db8fe Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:41:41 -0600 Subject: [PATCH 08/16] Refactor addCompetitionMemberById to use early returns Move auth, user existence, and duplicate-member checks before the withRLS callback so they return error() directly, matching the pattern used by other server actions in this file. Removes the throw/catch string-prefix parsing approach. Co-Authored-By: Claude Opus 4.5 --- lib/db_actions/competition-members.ts | 109 +++++++++++--------------- 1 file changed, 46 insertions(+), 63 deletions(-) diff --git a/lib/db_actions/competition-members.ts b/lib/db_actions/competition-members.ts index 0eff8cc..762de25 100644 --- a/lib/db_actions/competition-members.ts +++ b/lib/db_actions/competition-members.ts @@ -489,53 +489,58 @@ export async function addCompetitionMemberById({ return error("You must be logged in", ERROR_CODES.UNAUTHORIZED); } - const inserted = await withRLS(currentUser.id, async (trx) => { - // Check if current user is an admin of this competition - const currentUserMembership = await trx - .selectFrom("competition_members") - .select("role") - .where("competition_id", "=", competitionId) - .where("user_id", "=", currentUser.id) - .executeTakeFirst(); + // Check if current user is an admin of this competition + const currentUserMembership = await db + .selectFrom("competition_members") + .select("role") + .where("competition_id", "=", competitionId) + .where("user_id", "=", currentUser.id) + .executeTakeFirst(); + + if (currentUserMembership?.role !== "admin") { + return error( + "Only competition admins can add members", + ERROR_CODES.UNAUTHORIZED, + ); + } - if (currentUserMembership?.role !== "admin") { - throw new Error( - "UNAUTHORIZED: Only competition admins can add members", - ); - } + // Verify the target user exists and is active + const userToAdd = await db + .selectFrom("users") + .select("id") + .where("id", "=", userId) + .where("deactivated_at", "is", null) + .executeTakeFirst(); - // Verify the target user exists and is active - const userToAdd = await trx - .selectFrom("users") - .select("id") - .where("id", "=", userId) - .where("deactivated_at", "is", null) - .executeTakeFirst(); + if (!userToAdd) { + return error( + "No active user found with that ID", + ERROR_CODES.NOT_FOUND, + ); + } - if (!userToAdd) { - throw new Error("NOT_FOUND: No active user found with that ID"); - } + // Check if user is already a member + const existingMembership = await db + .selectFrom("competition_members") + .select("id") + .where("competition_id", "=", competitionId) + .where("user_id", "=", userId) + .executeTakeFirst(); - // Check if user is already a member - const existingMembership = await trx - .selectFrom("competition_members") - .select("id") - .where("competition_id", "=", competitionId) - .where("user_id", "=", userId) - .executeTakeFirst(); - - if (existingMembership) { - throw new Error( - "VALIDATION: User is already a member of this competition", - ); - } + if (existingMembership) { + return error( + "User is already a member of this competition", + ERROR_CODES.VALIDATION_ERROR, + ); + } - const newMember: NewCompetitionMember = { - competition_id: competitionId, - user_id: userId, - role, - }; + const newMember: NewCompetitionMember = { + competition_id: competitionId, + user_id: userId, + role, + }; + const inserted = await withRLS(currentUser.id, async (trx) => { return trx .insertInto("competition_members") .values(newMember) @@ -554,31 +559,9 @@ export async function addCompetitionMemberById({ }); revalidatePath(`/competitions/${competitionId}`); - revalidatePath(`/competitions/${competitionId}/members`); return success(inserted); } catch (err) { const duration = Date.now() - startTime; - const errorMessage = (err as Error).message; - - if (errorMessage.startsWith("UNAUTHORIZED:")) { - return error( - errorMessage.replace("UNAUTHORIZED: ", ""), - ERROR_CODES.UNAUTHORIZED, - ); - } - if (errorMessage.startsWith("NOT_FOUND:")) { - return error( - errorMessage.replace("NOT_FOUND: ", ""), - ERROR_CODES.NOT_FOUND, - ); - } - if (errorMessage.startsWith("VALIDATION:")) { - return error( - errorMessage.replace("VALIDATION: ", ""), - ERROR_CODES.VALIDATION_ERROR, - ); - } - logger.error("Failed to add competition member by ID", err as Error, { operation: "addCompetitionMemberById", table: "competition_members", From 24bc8d9aa9bd8559010c231d9047766a2fa98d2f Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:41:59 -0600 Subject: [PATCH 09/16] Remove stale revalidatePath calls for deleted /members page The standalone /competitions/[id]/members page was removed when member management moved into the dashboard Members tab. These revalidatePath calls targeted a route that no longer exists. Co-Authored-By: Claude Opus 4.5 --- lib/db_actions/competition-members.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/db_actions/competition-members.ts b/lib/db_actions/competition-members.ts index 762de25..02b2572 100644 --- a/lib/db_actions/competition-members.ts +++ b/lib/db_actions/competition-members.ts @@ -200,7 +200,7 @@ export async function removeCompetitionMember({ }); revalidatePath(`/competitions/${competitionId}`); - revalidatePath(`/competitions/${competitionId}/members`); + return success(undefined); } catch (err) { const errMessage = (err as Error).message; @@ -316,7 +316,7 @@ export async function updateMemberRole({ }); revalidatePath(`/competitions/${competitionId}`); - revalidatePath(`/competitions/${competitionId}/members`); + return success(undefined); } catch (err) { const errMessage = (err as Error).message; From 8011383ceaf1454b1f08ddb32dcc586b1049bd45 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:44:39 -0600 Subject: [PATCH 10/16] Handle error state in invite dialog user fetch If getEligibleMembers fails, show the error message inside the command list instead of spinning indefinitely. Matches the error handling pattern used in the dashboard members tab. Co-Authored-By: Claude Opus 4.5 --- components/members/invite-member-dialog.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/components/members/invite-member-dialog.tsx b/components/members/invite-member-dialog.tsx index e0172df..a9476b6 100644 --- a/components/members/invite-member-dialog.tsx +++ b/components/members/invite-member-dialog.tsx @@ -64,16 +64,22 @@ export function InviteMemberDialog({ const [eligibleUsers, setEligibleUsers] = useState( null, ); + const [eligibleUsersError, setEligibleUsersError] = useState( + null, + ); const [isLoadingUsers, startLoadingUsers] = useTransition(); const router = useRouter(); // Load eligible users when the dialog opens useEffect(() => { if (!isOpen) return; + setEligibleUsersError(null); startLoadingUsers(async () => { const result = await getEligibleMembers(competitionId); if (result.success) { setEligibleUsers(result.data); + } else { + setEligibleUsersError(result.error); } }); }, [isOpen, competitionId]); @@ -147,7 +153,11 @@ export function InviteMemberDialog({ - {isLoadingUsers ? ( + {eligibleUsersError ? ( +
+ {eligibleUsersError} +
+ ) : isLoadingUsers ? (
From f4a26a59dae7309ac2a04b3eea298df903f11991 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:47:33 -0600 Subject: [PATCH 11/16] Narrow edit form prop type to only the fields it uses Replace the full Competition type with a Pick of the six fields the form actually reads (id, name, is_private, and the three date fields). This removes the need for dummy created_at/updated_at/created_by_user_id values in the competition header. Co-Authored-By: Claude Opus 4.5 --- components/competition-dashboard/competition-header.tsx | 7 +------ components/forms/create-edit-competition-form.tsx | 9 +++++++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/components/competition-dashboard/competition-header.tsx b/components/competition-dashboard/competition-header.tsx index fc61b7d..0aa5824 100644 --- a/components/competition-dashboard/competition-header.tsx +++ b/components/competition-dashboard/competition-header.tsx @@ -18,7 +18,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { CreateEditCompetitionForm } from "@/components/forms/create-edit-competition-form"; -import type { Competition } from "@/types/db_types"; interface CompetitionHeaderProps { competitionId: number; @@ -42,17 +41,13 @@ export function CompetitionHeader({ const [editOpen, setEditOpen] = useState(false); const router = useRouter(); - // Build a minimal Competition object for the edit form - const competitionForForm: Competition = { + const competitionForForm = { id: competitionId, name: competitionName, is_private: isPrivate, forecasts_open_date: null, forecasts_close_date: null, end_date: null, - created_by_user_id: null, - created_at: new Date(), - updated_at: new Date(), }; return ( diff --git a/components/forms/create-edit-competition-form.tsx b/components/forms/create-edit-competition-form.tsx index 3c42b3a..dd59c48 100644 --- a/components/forms/create-edit-competition-form.tsx +++ b/components/forms/create-edit-competition-form.tsx @@ -24,7 +24,12 @@ import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Competition } from "@/types/db_types"; +import type { Competition } from "@/types/db_types"; + +type EditableCompetition = Pick< + Competition, + "id" | "name" | "is_private" | "forecasts_open_date" | "forecasts_close_date" | "end_date" +>; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import DatePicker from "../ui/date-picker"; @@ -111,7 +116,7 @@ export function CreateEditCompetitionForm({ initialCompetition, onSubmit, }: { - initialCompetition?: Competition; + initialCompetition?: EditableCompetition; onSubmit?: () => void; }) { const form = useForm>({ From a8e48527c5264ebf37c014945230f0a2e81adca8 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Tue, 3 Feb 2026 23:53:15 -0600 Subject: [PATCH 12/16] Add tests for competition form schema validation Extract the Zod schema to its own file (competition-form-schema.ts) so it can be tested without pulling in database dependencies. Add 16 tests covering: - Name length validation - Private competitions skipping date requirements and ordering - Public competitions requiring all three dates - Date ordering constraints (open < close < end) - Edge cases (equal dates, missing subset of dates) Co-Authored-By: Claude Opus 4.5 --- components/forms/competition-form-schema.ts | 75 +++++++ .../create-edit-competition-form.test.ts | 204 ++++++++++++++++++ .../forms/create-edit-competition-form.tsx | 87 +------- 3 files changed, 286 insertions(+), 80 deletions(-) create mode 100644 components/forms/competition-form-schema.ts create mode 100644 components/forms/create-edit-competition-form.test.ts diff --git a/components/forms/competition-form-schema.ts b/components/forms/competition-form-schema.ts new file mode 100644 index 0000000..219a9e6 --- /dev/null +++ b/components/forms/competition-form-schema.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +export const competitionFormSchema = z + .object({ + name: z.string().min(8).max(1000), + is_private: z.boolean(), + forecasts_open_date: z.date().optional(), + forecasts_close_date: z.date().optional(), + end_date: z.date().optional(), + }) + .superRefine((values, ctx) => { + const { + is_private, + forecasts_open_date, + forecasts_close_date, + end_date, + } = values; + + // Private competitions don't require dates (deadlines are per-prop) + if (is_private) { + return; + } + + // Public competitions require all dates + if (!forecasts_open_date) { + ctx.addIssue({ + code: "custom", + message: "Open date is required for public competitions", + path: ["forecasts_open_date"], + }); + } + if (!forecasts_close_date) { + ctx.addIssue({ + code: "custom", + message: "Close date is required for public competitions", + path: ["forecasts_close_date"], + }); + } + if (!end_date) { + ctx.addIssue({ + code: "custom", + message: "End date is required for public competitions", + path: ["end_date"], + }); + } + + // Only validate ordering if all dates are present + if (forecasts_open_date && forecasts_close_date && end_date) { + if (forecasts_open_date >= forecasts_close_date) { + ctx.addIssue({ + code: "custom", + message: "Open date must be before close date", + path: ["forecasts_open_date"], + }); + ctx.addIssue({ + code: "custom", + message: "Close date must be after open date", + path: ["forecasts_close_date"], + }); + } + + if (forecasts_close_date >= end_date) { + ctx.addIssue({ + code: "custom", + message: "Close date must be before end date", + path: ["forecasts_close_date"], + }); + ctx.addIssue({ + code: "custom", + message: "End date must be after close date", + path: ["end_date"], + }); + } + } + }); diff --git a/components/forms/create-edit-competition-form.test.ts b/components/forms/create-edit-competition-form.test.ts new file mode 100644 index 0000000..c8eb807 --- /dev/null +++ b/components/forms/create-edit-competition-form.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from "vitest"; +import { competitionFormSchema } from "./competition-form-schema"; + +// Helper to build a valid public competition input +function validPublic(overrides = {}) { + return { + name: "Test Competition 2025", + is_private: false, + forecasts_open_date: new Date("2025-01-01"), + forecasts_close_date: new Date("2025-06-01"), + end_date: new Date("2025-12-01"), + ...overrides, + }; +} + +// Helper to build a valid private competition input +function validPrivate(overrides = {}) { + return { + name: "Private Competition 2025", + is_private: true, + ...overrides, + }; +} + +function getFieldErrors(result: { error?: { issues: { path: (string | number)[]; message: string }[] } }) { + const map: Record = {}; + for (const issue of result.error?.issues ?? []) { + const key = issue.path.join("."); + if (!map[key]) map[key] = []; + map[key].push(issue.message); + } + return map; +} + +describe("competitionFormSchema", () => { + describe("name validation", () => { + it("rejects names shorter than 8 characters", () => { + const result = competitionFormSchema.safeParse(validPublic({ name: "Short" })); + expect(result.success).toBe(false); + const errors = getFieldErrors(result); + expect(errors["name"]).toBeDefined(); + }); + + it("accepts names with exactly 8 characters", () => { + const result = competitionFormSchema.safeParse(validPublic({ name: "12345678" })); + expect(result.success).toBe(true); + }); + + it("rejects empty names", () => { + const result = competitionFormSchema.safeParse(validPublic({ name: "" })); + expect(result.success).toBe(false); + }); + }); + + describe("private competitions", () => { + it("passes with no dates", () => { + const result = competitionFormSchema.safeParse(validPrivate()); + expect(result.success).toBe(true); + }); + + it("passes even with dates provided", () => { + const result = competitionFormSchema.safeParse( + validPrivate({ + forecasts_open_date: new Date("2025-01-01"), + forecasts_close_date: new Date("2025-06-01"), + end_date: new Date("2025-12-01"), + }), + ); + expect(result.success).toBe(true); + }); + + it("passes with out-of-order dates (private ignores date ordering)", () => { + const result = competitionFormSchema.safeParse( + validPrivate({ + forecasts_open_date: new Date("2025-12-01"), + forecasts_close_date: new Date("2025-06-01"), + end_date: new Date("2025-01-01"), + }), + ); + expect(result.success).toBe(true); + }); + }); + + describe("public competitions — date requirements", () => { + it("requires forecasts_open_date", () => { + const result = competitionFormSchema.safeParse( + validPublic({ forecasts_open_date: undefined }), + ); + expect(result.success).toBe(false); + const errors = getFieldErrors(result); + expect(errors["forecasts_open_date"]).toBeDefined(); + }); + + it("requires forecasts_close_date", () => { + const result = competitionFormSchema.safeParse( + validPublic({ forecasts_close_date: undefined }), + ); + expect(result.success).toBe(false); + const errors = getFieldErrors(result); + expect(errors["forecasts_close_date"]).toBeDefined(); + }); + + it("requires end_date", () => { + const result = competitionFormSchema.safeParse( + validPublic({ end_date: undefined }), + ); + expect(result.success).toBe(false); + const errors = getFieldErrors(result); + expect(errors["end_date"]).toBeDefined(); + }); + + it("reports all missing dates at once", () => { + const result = competitionFormSchema.safeParse( + validPublic({ + forecasts_open_date: undefined, + forecasts_close_date: undefined, + end_date: undefined, + }), + ); + expect(result.success).toBe(false); + const errors = getFieldErrors(result); + expect(errors["forecasts_open_date"]).toBeDefined(); + expect(errors["forecasts_close_date"]).toBeDefined(); + expect(errors["end_date"]).toBeDefined(); + }); + }); + + describe("public competitions — date ordering", () => { + it("passes with correctly ordered dates", () => { + const result = competitionFormSchema.safeParse(validPublic()); + expect(result.success).toBe(true); + }); + + it("rejects open_date equal to close_date", () => { + const sameDate = new Date("2025-06-01"); + const result = competitionFormSchema.safeParse( + validPublic({ + forecasts_open_date: sameDate, + forecasts_close_date: sameDate, + }), + ); + expect(result.success).toBe(false); + const errors = getFieldErrors(result); + expect(errors["forecasts_open_date"]).toBeDefined(); + expect(errors["forecasts_close_date"]).toBeDefined(); + }); + + it("rejects open_date after close_date", () => { + const result = competitionFormSchema.safeParse( + validPublic({ + forecasts_open_date: new Date("2025-07-01"), + forecasts_close_date: new Date("2025-06-01"), + }), + ); + expect(result.success).toBe(false); + const errors = getFieldErrors(result); + expect(errors["forecasts_open_date"]).toBeDefined(); + expect(errors["forecasts_close_date"]).toBeDefined(); + }); + + it("rejects close_date equal to end_date", () => { + const sameDate = new Date("2025-12-01"); + const result = competitionFormSchema.safeParse( + validPublic({ + forecasts_close_date: sameDate, + end_date: sameDate, + }), + ); + expect(result.success).toBe(false); + const errors = getFieldErrors(result); + expect(errors["forecasts_close_date"]).toBeDefined(); + expect(errors["end_date"]).toBeDefined(); + }); + + it("rejects close_date after end_date", () => { + const result = competitionFormSchema.safeParse( + validPublic({ + forecasts_close_date: new Date("2025-12-15"), + end_date: new Date("2025-12-01"), + }), + ); + expect(result.success).toBe(false); + const errors = getFieldErrors(result); + expect(errors["forecasts_close_date"]).toBeDefined(); + expect(errors["end_date"]).toBeDefined(); + }); + + it("skips ordering validation when some dates are missing", () => { + // When close_date is missing, we get the "required" error but NOT ordering errors + const result = competitionFormSchema.safeParse( + validPublic({ + forecasts_open_date: new Date("2025-12-01"), // would be after close if close existed + forecasts_close_date: undefined, + end_date: new Date("2025-06-01"), + }), + ); + expect(result.success).toBe(false); + const errors = getFieldErrors(result); + // Should only have the "required" error for close_date + expect(errors["forecasts_close_date"]).toHaveLength(1); + expect(errors["forecasts_close_date"]![0]).toMatch(/required/i); + }); + }); +}); diff --git a/components/forms/create-edit-competition-form.tsx b/components/forms/create-edit-competition-form.tsx index dd59c48..c36abed 100644 --- a/components/forms/create-edit-competition-form.tsx +++ b/components/forms/create-edit-competition-form.tsx @@ -25,88 +25,15 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import type { Competition } from "@/types/db_types"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import DatePicker from "../ui/date-picker"; +import { competitionFormSchema } from "./competition-form-schema"; type EditableCompetition = Pick< Competition, "id" | "name" | "is_private" | "forecasts_open_date" | "forecasts_close_date" | "end_date" >; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import DatePicker from "../ui/date-picker"; - -const formSchema = z - .object({ - name: z.string().min(8).max(1000), - is_private: z.boolean(), - forecasts_open_date: z.date().optional(), - forecasts_close_date: z.date().optional(), - end_date: z.date().optional(), - }) - .superRefine((values, ctx) => { - const { - is_private, - forecasts_open_date, - forecasts_close_date, - end_date, - } = values; - - // Private competitions don't require dates (deadlines are per-prop) - if (is_private) { - return; - } - - // Public competitions require all dates - if (!forecasts_open_date) { - ctx.addIssue({ - code: "custom", - message: "Open date is required for public competitions", - path: ["forecasts_open_date"], - }); - } - if (!forecasts_close_date) { - ctx.addIssue({ - code: "custom", - message: "Close date is required for public competitions", - path: ["forecasts_close_date"], - }); - } - if (!end_date) { - ctx.addIssue({ - code: "custom", - message: "End date is required for public competitions", - path: ["end_date"], - }); - } - - // Only validate ordering if all dates are present - if (forecasts_open_date && forecasts_close_date && end_date) { - if (forecasts_open_date >= forecasts_close_date) { - ctx.addIssue({ - code: "custom", - message: "Open date must be before close date", - path: ["forecasts_open_date"], - }); - ctx.addIssue({ - code: "custom", - message: "Close date must be after open date", - path: ["forecasts_close_date"], - }); - } - - if (forecasts_close_date >= end_date) { - ctx.addIssue({ - code: "custom", - message: "Close date must be before end date", - path: ["forecasts_close_date"], - }); - ctx.addIssue({ - code: "custom", - message: "End date must be after close date", - path: ["end_date"], - }); - } - } - }); /* * Form for creating or editing a competition.. @@ -119,8 +46,8 @@ export function CreateEditCompetitionForm({ initialCompetition?: EditableCompetition; onSubmit?: () => void; }) { - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm>({ + resolver: zodResolver(competitionFormSchema), defaultValues: { name: initialCompetition?.name || "", is_private: initialCompetition?.is_private ?? false, @@ -156,7 +83,7 @@ export function CreateEditCompetitionForm({ createCompetitionAction.isLoading || updateCompetitionAction.isLoading; const error = createCompetitionAction.error || updateCompetitionAction.error; - async function handleSubmit(values: z.infer) { + async function handleSubmit(values: z.infer) { // Build the competition object explicitly to ensure proper values const competition = { name: values.name, From 8b475de6d8e93bc636452836751ce78253a87970 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Wed, 4 Feb 2026 00:03:57 -0600 Subject: [PATCH 13/16] Update CLAUDE.md with server action patterns, testing gotcha, and competition_members table Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index eb5b15e..28f873d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ This is a Next.js forecasting application inspired by Philip Tetlock's Good Judg **Testing Setup:** - Unit tests use Vitest with Node.js environment +- **Gotcha**: Importing components that transitively import `lib/database.ts` will fail in unit tests (requires `DATABASE_URL`). Extract pure logic (e.g., Zod schemas) into separate files for testability. - Testcontainers integration available for database testing with real PostgreSQL instances - Test files: `**/*.{test,spec}.{ts,tsx}` - Coverage provided by V8 with HTML/JSON/text reports @@ -49,13 +50,17 @@ This is a Next.js forecasting application inspired by Philip Tetlock's Good Judg - **Database**: PostgreSQL with Kysely query builder - **Connection**: `/lib/database.ts` exports `db` instance - **Types**: `/types/db_types.ts` contains all database types and table definitions -- **Tables**: users, forecasts, props, competitions, categories, resolutions, feature_flags +- **Tables**: users, forecasts, props, competitions, categories, resolutions, feature_flags, competition_members (roles: `admin`/`forecaster`) - **Views**: Prefixed with `v_` (e.g., `v_forecasts`, `v_props`) for complex queries with joins ### Server Actions Pattern This codebase follows a structured server action pattern that returns results instead of throwing errors. **See `/docs/server-actions-best-practices.md` for complete documentation and examples.** +- Server actions return `ServerActionResult` — either `success(data)` or `error(message, code)` +- Use `withRLS(userId, async (trx) => ...)` from `/lib/db-helpers.ts` for queries needing Row Level Security +- Client components consume server actions via the `useServerAction` hook from `/hooks/use-server-action.ts` + ### Authentication & Authorization - JWT-based auth with cookies From b21304e4c618ee1cc15178baf86afac9ea0c301f Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Wed, 4 Feb 2026 00:05:30 -0600 Subject: [PATCH 14/16] Fix lint errors: move setState into transition callbacks, remove unused variable Move setMembersError and setEligibleUsersError calls inside startTransition callbacks to avoid synchronous setState in effect bodies. Remove unused `result` assignment in removeCompetitionMember. Co-Authored-By: Claude Opus 4.5 --- components/competition-dashboard/competition-dashboard.tsx | 2 +- components/members/invite-member-dialog.tsx | 2 +- lib/db_actions/competition-members.ts | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/components/competition-dashboard/competition-dashboard.tsx b/components/competition-dashboard/competition-dashboard.tsx index 5825a5b..6c2f76e 100644 --- a/components/competition-dashboard/competition-dashboard.tsx +++ b/components/competition-dashboard/competition-dashboard.tsx @@ -138,8 +138,8 @@ export function CompetitionDashboard({ useEffect(() => { if (activeTab !== "members" || !isPrivate) return; - setMembersError(null); startLoadingMembers(async () => { + setMembersError(null); const result = await getCompetitionMembers(competitionId); if (result.success) { setMembers(result.data); diff --git a/components/members/invite-member-dialog.tsx b/components/members/invite-member-dialog.tsx index a9476b6..9437951 100644 --- a/components/members/invite-member-dialog.tsx +++ b/components/members/invite-member-dialog.tsx @@ -73,8 +73,8 @@ export function InviteMemberDialog({ // Load eligible users when the dialog opens useEffect(() => { if (!isOpen) return; - setEligibleUsersError(null); startLoadingUsers(async () => { + setEligibleUsersError(null); const result = await getEligibleMembers(competitionId); if (result.success) { setEligibleUsers(result.data); diff --git a/lib/db_actions/competition-members.ts b/lib/db_actions/competition-members.ts index 02b2572..c83aea0 100644 --- a/lib/db_actions/competition-members.ts +++ b/lib/db_actions/competition-members.ts @@ -145,7 +145,7 @@ export async function removeCompetitionMember({ return error("You must be logged in", ERROR_CODES.UNAUTHORIZED); } - const result = await withRLS(currentUser.id, async (trx) => { + await withRLS(currentUser.id, async (trx) => { // Check if current user is an admin of this competition const currentUserMembership = await trx .selectFrom("competition_members") @@ -186,8 +186,6 @@ export async function removeCompetitionMember({ if (Number(deleteResult.numDeletedRows) === 0) { throw new Error("NOT_FOUND: Member not found in this competition"); } - - return deleteResult; }); const duration = Date.now() - startTime; From 1a4f0be6da1dc4639e35e121c1e7064054486f4b Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Wed, 4 Feb 2026 20:51:30 -0600 Subject: [PATCH 15/16] Fix RLS bypass in addCompetitionMemberById and getEligibleMembers Bare db queries on competition_members fail because RLS policies require app.current_user_id to be set. Switch pre-check queries to use withRLS, and add system admin bypass to addCompetitionMemberById matching the pattern in getEligibleMembers. Also fix PropertyKey[] type in test helper to match Zod's issue type. Co-Authored-By: Claude Opus 4.5 --- .../create-edit-competition-form.test.ts | 2 +- lib/db_actions/competition-members.ts | 70 +++++++++++-------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/components/forms/create-edit-competition-form.test.ts b/components/forms/create-edit-competition-form.test.ts index c8eb807..ebc92aa 100644 --- a/components/forms/create-edit-competition-form.test.ts +++ b/components/forms/create-edit-competition-form.test.ts @@ -22,7 +22,7 @@ function validPrivate(overrides = {}) { }; } -function getFieldErrors(result: { error?: { issues: { path: (string | number)[]; message: string }[] } }) { +function getFieldErrors(result: { error?: { issues: { path: PropertyKey[]; message: string }[] } }) { const map: Record = {}; for (const issue of result.error?.issues ?? []) { const key = issue.path.join("."); diff --git a/lib/db_actions/competition-members.ts b/lib/db_actions/competition-members.ts index c83aea0..55f4b5b 100644 --- a/lib/db_actions/competition-members.ts +++ b/lib/db_actions/competition-members.ts @@ -408,12 +408,14 @@ export async function getEligibleMembers( // Only competition admins (or system admins) can view eligible members if (!currentUser.is_admin) { - const membership = await db - .selectFrom("competition_members") - .select("role") - .where("competition_id", "=", competitionId) - .where("user_id", "=", currentUser.id) - .executeTakeFirst(); + const membership = await withRLS(currentUser.id, async (trx) => + trx + .selectFrom("competition_members") + .select("role") + .where("competition_id", "=", competitionId) + .where("user_id", "=", currentUser.id) + .executeTakeFirst(), + ); if (membership?.role !== "admin") { return error( @@ -487,28 +489,34 @@ export async function addCompetitionMemberById({ return error("You must be logged in", ERROR_CODES.UNAUTHORIZED); } - // Check if current user is an admin of this competition - const currentUserMembership = await db - .selectFrom("competition_members") - .select("role") - .where("competition_id", "=", competitionId) - .where("user_id", "=", currentUser.id) - .executeTakeFirst(); - - if (currentUserMembership?.role !== "admin") { - return error( - "Only competition admins can add members", - ERROR_CODES.UNAUTHORIZED, + // Only competition admins (or system admins) can add members + if (!currentUser.is_admin) { + const currentUserMembership = await withRLS(currentUser.id, async (trx) => + trx + .selectFrom("competition_members") + .select("role") + .where("competition_id", "=", competitionId) + .where("user_id", "=", currentUser.id) + .executeTakeFirst(), ); + + if (currentUserMembership?.role !== "admin") { + return error( + "Only competition admins can add members", + ERROR_CODES.UNAUTHORIZED, + ); + } } // Verify the target user exists and is active - const userToAdd = await db - .selectFrom("users") - .select("id") - .where("id", "=", userId) - .where("deactivated_at", "is", null) - .executeTakeFirst(); + const userToAdd = await withRLS(currentUser.id, async (trx) => + trx + .selectFrom("users") + .select("id") + .where("id", "=", userId) + .where("deactivated_at", "is", null) + .executeTakeFirst(), + ); if (!userToAdd) { return error( @@ -518,12 +526,14 @@ export async function addCompetitionMemberById({ } // Check if user is already a member - const existingMembership = await db - .selectFrom("competition_members") - .select("id") - .where("competition_id", "=", competitionId) - .where("user_id", "=", userId) - .executeTakeFirst(); + const existingMembership = await withRLS(currentUser.id, async (trx) => + trx + .selectFrom("competition_members") + .select("id") + .where("competition_id", "=", competitionId) + .where("user_id", "=", userId) + .executeTakeFirst(), + ); if (existingMembership) { return error( From c736aa7d1f50424c3179351aadf5c52b76907b70 Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Wed, 4 Feb 2026 20:54:29 -0600 Subject: [PATCH 16/16] Fix RLS bypass in updateCompetition queries The auth check and date validation queries used bare db without RLS context, causing "Competition not found" for system admins and "Only admins can update" for competition admins. Switch all pre-update queries to use withRLS. Co-Authored-By: Claude Opus 4.5 --- lib/db_actions/competitions.ts | 38 ++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/lib/db_actions/competitions.ts b/lib/db_actions/competitions.ts index 583c4ef..c91b6f7 100644 --- a/lib/db_actions/competitions.ts +++ b/lib/db_actions/competitions.ts @@ -159,18 +159,22 @@ export async function updateCompetition({ // System admins can update any competition. // Competition admins can update their own private competitions. if (!currentUser.is_admin) { - const membership = await db - .selectFrom("competition_members") - .select("role") - .where("competition_id", "=", id) - .where("user_id", "=", currentUser.id) - .executeTakeFirst(); + const membership = await withRLS(currentUser.id, async (trx) => + trx + .selectFrom("competition_members") + .select("role") + .where("competition_id", "=", id) + .where("user_id", "=", currentUser.id) + .executeTakeFirst(), + ); - const comp = await db - .selectFrom("competitions") - .select("is_private") - .where("id", "=", id) - .executeTakeFirst(); + const comp = await withRLS(currentUser.id, async (trx) => + trx + .selectFrom("competitions") + .select("is_private") + .where("id", "=", id) + .executeTakeFirst(), + ); if (!comp?.is_private || membership?.role !== "admin") { logger.warn("Unauthorized attempt to update competition", { @@ -196,11 +200,13 @@ export async function updateCompetition({ "forecasts_close_date" in competition || "end_date" in competition ) { - const existing = await db - .selectFrom("competitions") - .select(["forecasts_open_date", "forecasts_close_date", "end_date"]) - .where("id", "=", id) - .executeTakeFirst(); + const existing = await withRLS(currentUser.id, async (trx) => + trx + .selectFrom("competitions") + .select(["forecasts_open_date", "forecasts_close_date", "end_date"]) + .where("id", "=", id) + .executeTakeFirst(), + ); if (!existing) { return error("Competition not found", ERROR_CODES.NOT_FOUND); }