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 diff --git a/app/competitions/[competitionId]/members/members-page-content.tsx b/app/competitions/[competitionId]/members/members-page-content.tsx deleted file mode 100644 index 6f2e124..0000000 --- a/app/competitions/[competitionId]/members/members-page-content.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { UserPlus } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { MembersTable, InviteMemberDialog } from "@/components/members"; -import type { VCompetitionMember } from "@/types/db_types"; - -interface MembersPageContentProps { - members: VCompetitionMember[]; - competitionId: number; - currentUserId: number; -} - -export function MembersPageContent({ - members, - competitionId, - currentUserId, -}: MembersPageContentProps) { - const [showInviteDialog, setShowInviteDialog] = useState(false); - - return ( -
-
-

- Manage who has access to this private competition. -

- -
- - - - 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..6c2f76e 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,31 @@ 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 [membersError, setMembersError] = 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 () => { + setMembersError(null); + const result = await getCompetitionMembers(competitionId); + if (result.success) { + setMembers(result.data); + } else { + setMembersError(result.error); + } + }); + }, [activeTab, isPrivate, competitionId, membersRefreshKey]); return (
@@ -222,16 +250,43 @@ export function CompetitionDashboard({
)} {activeTab === "members" && showMembersTab && ( -
-

- View and manage competition members -

- +
+ {isAdmin && ( +
+

+ Manage who has access to this competition. +

+ +
+ )} + {membersError ? ( +
+

{membersError}

+
+ ) : 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..0aa5824 100644 --- a/components/competition-dashboard/competition-header.tsx +++ b/components/competition-dashboard/competition-header.tsx @@ -1,15 +1,23 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; -import { MoreVertical, Plus, Settings, UserPlus, Users } from "lucide-react"; +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"; interface CompetitionHeaderProps { competitionId: number; @@ -30,6 +38,18 @@ export function CompetitionHeader({ forecasterCount, onAddProp, }: CompetitionHeaderProps) { + const [editOpen, setEditOpen] = useState(false); + const router = useRouter(); + + const competitionForForm = { + id: competitionId, + name: competitionName, + is_private: isPrivate, + forecasts_open_date: null, + forecasts_close_date: null, + end_date: null, + }; + return (
@@ -71,39 +91,37 @@ export function CompetitionHeader({ - {isPrivate && ( - <> - - - - Invite Members - - - - - - Manage Members - - - - )} - - + {isPrivate ? ( + setEditOpen(true)}> Competition Settings - - + + ) : ( + + + + Competition Settings + + + )} + + + + Edit Competition + { + setEditOpen(false); + router.refresh(); + }} + /> + +
)}
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..ebc92aa --- /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: PropertyKey[]; 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 3f5105f..c36abed 100644 --- a/components/forms/create-edit-competition-form.tsx +++ b/components/forms/create-edit-competition-form.tsx @@ -24,84 +24,16 @@ 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"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import DatePicker from "../ui/date-picker"; +import { competitionFormSchema } from "./competition-form-schema"; -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"], - }); - } - } - }); +type EditableCompetition = Pick< + Competition, + "id" | "name" | "is_private" | "forecasts_open_date" | "forecasts_close_date" | "end_date" +>; /* * Form for creating or editing a competition.. @@ -111,11 +43,11 @@ export function CreateEditCompetitionForm({ initialCompetition, onSubmit, }: { - initialCompetition?: Competition; + 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, @@ -151,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, @@ -200,36 +132,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 && (
void; + onMemberChange?: () => void; } export function InviteMemberDialog({ competitionId, isOpen, onClose, + onMemberChange, }: 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 [eligibleUsersError, setEligibleUsersError] = useState( + null, + ); + const [isLoadingUsers, startLoadingUsers] = useTransition(); const router = useRouter(); + // Load eligible users when the dialog opens + useEffect(() => { + if (!isOpen) return; + startLoadingUsers(async () => { + setEligibleUsersError(null); + const result = await getEligibleMembers(competitionId); + if (result.success) { + setEligibleUsers(result.data); + } else { + setEligibleUsersError(result.error); + } + }); + }, [isOpen, competitionId]); + const handleSuccess = () => { router.refresh(); - setEmail(""); + setSelectedUserId(null); setRole("forecaster"); + setEligibleUsers(null); + onMemberChange?.(); 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 +127,75 @@ 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. -

+ + + + + + + + + + {eligibleUsersError ? ( +
+ {eligibleUsersError} +
+ ) : isLoadingUsers ? ( +
+ +
+ ) : ( + <> + No users found. + + {(eligibleUsers ?? []).map((user) => ( + { + setSelectedUserId( + user.id === selectedUserId + ? null + : user.id, + ); + setPopoverOpen(false); + }} + > + + {formatUserLabel(user)} + + ))} + + + )} +
+
+
+
@@ -152,7 +254,10 @@ export function InviteMemberDialog({ > Cancel -
diff --git a/components/ui/command.tsx b/components/ui/command.tsx new file mode 100644 index 0000000..2cecd91 --- /dev/null +++ b/components/ui/command.tsx @@ -0,0 +1,153 @@ +"use client" + +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/lib/db_actions/competition-members.ts b/lib/db_actions/competition-members.ts index 5723821..55f4b5b 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 @@ -276,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") @@ -317,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; @@ -331,7 +198,7 @@ export async function removeCompetitionMember({ }); revalidatePath(`/competitions/${competitionId}`); - revalidatePath(`/competitions/${competitionId}/members`); + return success(undefined); } catch (err) { const errMessage = (err as Error).message; @@ -447,7 +314,7 @@ export async function updateMemberRole({ }); revalidatePath(`/competitions/${competitionId}`); - revalidatePath(`/competitions/${competitionId}/members`); + return success(undefined); } catch (err) { const errMessage = (err as Error).message; @@ -517,3 +384,199 @@ export async function getMemberCount( return error("Failed to get member count", ERROR_CODES.DATABASE_ERROR); } } + +/** + * Get all active users who are NOT already members of a competition. + * Used for the searchable user picker in the invite dialog. + */ +export async function getEligibleMembers( + competitionId: number, +): Promise< + ServerActionResult<{ id: number; name: string; username: string | null }[]> +> { + const currentUser = await getUserFromCookies(); + logger.debug("Getting eligible members", { + competitionId, + currentUserId: currentUser?.id, + }); + + const startTime = Date.now(); + try { + if (!currentUser) { + 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 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( + "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 + .selectFrom("competition_members") + .select("user_id") + .where("competition_id", "=", competitionId); + + return trx + .selectFrom("users") + .select(["id", "name", "username"]) + .where("deactivated_at", "is", null) + .where("id", "not in", existingMemberIds) + .orderBy("name", "asc") + .execute(); + }); + + const duration = Date.now() - startTime; + logger.debug(`Retrieved ${users.length} eligible members`, { + operation: "getEligibleMembers", + table: "users", + competitionId, + duration, + }); + + return success(users); + } catch (err) { + const duration = Date.now() - startTime; + logger.error("Failed to get eligible members", err as Error, { + operation: "getEligibleMembers", + table: "users", + competitionId, + duration, + }); + return error("Failed to fetch eligible users", ERROR_CODES.DATABASE_ERROR); + } +} + +/** + * Add a member to a competition by user ID. + * Only competition admins can add members. + */ +export async function addCompetitionMemberById({ + competitionId, + userId, + role, +}: { + competitionId: number; + userId: number; + role: CompetitionRole; +}): Promise> { + const currentUser = await getUserFromCookies(); + logger.debug("Adding competition member by ID", { + competitionId, + userId, + role, + currentUserId: currentUser?.id, + }); + + const startTime = Date.now(); + try { + if (!currentUser) { + return error("You must be logged in", 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 withRLS(currentUser.id, async (trx) => + 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, + ); + } + + // Check if user is already a member + 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( + "User is already a member of this competition", + ERROR_CODES.VALIDATION_ERROR, + ); + } + + 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) + .returningAll() + .executeTakeFirstOrThrow(); + }); + + const duration = Date.now() - startTime; + logger.info("Competition member added successfully", { + operation: "addCompetitionMemberById", + table: "competition_members", + competitionId, + addedUserId: inserted.user_id, + role, + duration, + }); + + revalidatePath(`/competitions/${competitionId}`); + return success(inserted); + } catch (err) { + const duration = Date.now() - startTime; + logger.error("Failed to add competition member by ID", err as Error, { + operation: "addCompetitionMemberById", + table: "competition_members", + competitionId, + userId, + duration, + }); + return error("Failed to add member", ERROR_CODES.DATABASE_ERROR); + } +} diff --git a/lib/db_actions/competitions.ts b/lib/db_actions/competitions.ts index 685a1fe..c91b6f7 100644 --- a/lib/db_actions/competitions.ts +++ b/lib/db_actions/competitions.ts @@ -152,15 +152,45 @@ 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 withRLS(currentUser.id, async (trx) => + trx + .selectFrom("competition_members") + .select("role") + .where("competition_id", "=", id) + .where("user_id", "=", currentUser.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", { + competitionId: id, + currentUserId: currentUser.id, + }); + return error( + "Only admins can update competitions", + ERROR_CODES.UNAUTHORIZED, + ); + } + } + + // Prevent changing is_private — conversions are not supported yet. + if ("is_private" in competition) { + delete (competition as Record)["is_private"]; } // If any of the date fields are being changed, validate ordering using the @@ -170,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); } diff --git a/package-lock.json b/package-lock.json index 1f4f47e..4c134d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "argon2": "^0.44.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "dotenv": "^17.2.3", @@ -8030,6 +8031,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", diff --git a/package.json b/package.json index a5433a9..487e2c4 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "argon2": "^0.44.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "dotenv": "^17.2.3",