diff --git a/jest.config.js b/jest.config.js index a4e7700..b7f15af 100644 --- a/jest.config.js +++ b/jest.config.js @@ -36,7 +36,7 @@ const config = { coverageThreshold: { global: { branches: 13, - functions: 15, + functions: 14, statements: 17, lines: 17, }, diff --git a/prisma/migrations/20260418210353_add_external_judging/migration.sql b/prisma/migrations/20260418210353_add_external_judging/migration.sql new file mode 100644 index 0000000..c45584e --- /dev/null +++ b/prisma/migrations/20260418210353_add_external_judging/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ExternalJudge" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "hackathonId" INTEGER NOT NULL, + "accessToken" TEXT NOT NULL, + CONSTRAINT "ExternalJudge_hackathonId_fkey" FOREIGN KEY ("hackathonId") REFERENCES "Hackathon" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ExternalTeamJudging" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "externalJudgeId" INTEGER NOT NULL, + "teamId" INTEGER NOT NULL, + "judgingSlotId" INTEGER NOT NULL, + "judgingVerdict" TEXT, + CONSTRAINT "ExternalTeamJudging_externalJudgeId_fkey" FOREIGN KEY ("externalJudgeId") REFERENCES "ExternalJudge" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ExternalTeamJudging_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "ExternalTeamJudging_judgingSlotId_fkey" FOREIGN KEY ("judgingSlotId") REFERENCES "JudgingSlot" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "ExternalJudge_accessToken_key" ON "ExternalJudge"("accessToken"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 81d7960..4f550ab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,6 +65,7 @@ model Hackathon { updatedAt DateTime @updatedAt tables Table[] judgingSlots JudgingSlot[] + externalJudges ExternalJudge[] } model Hacker { @@ -84,17 +85,18 @@ model Hacker { } model Team { - id Int @id @default(autoincrement()) - name String @unique - code String @unique - ownerId Int @unique - owner Hacker @relation(name: "TeamOwner", fields: [ownerId], references: [id]) - tableId Int? - table Table? @relation(fields: [tableId], references: [id]) - challenges Challenge[] - members Hacker[] - teamJudgings TeamJudging[] - sponsorJudgings SponsorJudging[] + id Int @id @default(autoincrement()) + name String @unique + code String @unique + ownerId Int @unique + owner Hacker @relation(name: "TeamOwner", fields: [ownerId], references: [id]) + tableId Int? + table Table? @relation(fields: [tableId], references: [id]) + challenges Challenge[] + members Hacker[] + teamJudgings TeamJudging[] + sponsorJudgings SponsorJudging[] + externalTeamJudgings ExternalTeamJudging[] } model Organizer { @@ -321,13 +323,14 @@ model Table { } model JudgingSlot { - id Int @id @default(autoincrement()) - startTime DateTime - endTime DateTime - hackathonId Int - hackathon Hackathon @relation(fields: [hackathonId], references: [id]) - teamJudgings TeamJudging[] - sponsorJudgings SponsorJudging[] + id Int @id @default(autoincrement()) + startTime DateTime + endTime DateTime + hackathonId Int + hackathon Hackathon @relation(fields: [hackathonId], references: [id]) + teamJudgings TeamJudging[] + sponsorJudgings SponsorJudging[] + externalTeamJudgings ExternalTeamJudging[] } model TeamJudging { @@ -353,3 +356,23 @@ model SponsorJudging { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model ExternalJudge { + id Int @id @default(autoincrement()) + name String + hackathonId Int + hackathon Hackathon @relation(fields: [hackathonId], references: [id]) + accessToken String @unique + teamJudgings ExternalTeamJudging[] +} + +model ExternalTeamJudging { + id Int @id @default(autoincrement()) + externalJudgeId Int + externalJudge ExternalJudge @relation(fields: [externalJudgeId], references: [id], onDelete: Cascade) + teamId Int + team Team @relation(fields: [teamId], references: [id]) + judgingSlotId Int + judgingSlot JudgingSlot @relation(fields: [judgingSlotId], references: [id]) + judgingVerdict String? +} diff --git a/src/app/dashboard/[hackathonId]/judging/page.tsx b/src/app/dashboard/[hackathonId]/judging/page.tsx index 58ad71e..e59404f 100644 --- a/src/app/dashboard/[hackathonId]/judging/page.tsx +++ b/src/app/dashboard/[hackathonId]/judging/page.tsx @@ -10,12 +10,22 @@ export const metadata: Metadata = { const Page = async ({ params: { hackathonId }, + searchParams, }: { params: { hackathonId: string }; + searchParams: { forOrganizer?: string }; }) => { await disallowVolunteer(hackathonId); await requireOrganizer(); - return ; + const forOrganizerId = searchParams.forOrganizer + ? Number(searchParams.forOrganizer) + : undefined; + return ( + + ); }; export default Page; diff --git a/src/app/judging/[token]/page.tsx b/src/app/judging/[token]/page.tsx new file mode 100644 index 0000000..4120d80 --- /dev/null +++ b/src/app/judging/[token]/page.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Metadata } from "next"; +import getExternalJudgingByToken from "@/server/getters/judging/getExternalJudgingByToken"; +import ExternalJudging from "@/scenes/Judging/ExternalJudging"; + +export const metadata: Metadata = { + title: "Judging", +}; + +const ExternalJudgingPage = async ({ + params: { token }, +}: { + params: { token: string }; +}) => { + const data = await getExternalJudgingByToken(token); + + if (!data) { + return ( +
+

+ Invalid or expired judging link. +

+
+ ); + } + + return ; +}; + +export default ExternalJudgingPage; diff --git a/src/scenes/Dashboard/scenes/Judging/Judging.tsx b/src/scenes/Dashboard/scenes/Judging/Judging.tsx index 930822a..a6aae67 100644 --- a/src/scenes/Dashboard/scenes/Judging/Judging.tsx +++ b/src/scenes/Dashboard/scenes/Judging/Judging.tsx @@ -5,14 +5,27 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { Button } from "@/components/ui/button"; import getMyJudgings from "@/server/getters/dashboard/judging/getMyJudgings"; -import JudgingSwitcher from "@/scenes/Dashboard/scenes/Judging/components/JudgingSwitcher"; +import JudgingList from "@/scenes/Dashboard/scenes/Judging/components/JudgingList"; +import getOrganizersForJudgingSelector from "@/server/getters/dashboard/judging/getOrganizersForJudgingSelector"; +import JudgeSelector from "@/scenes/Dashboard/scenes/Judging/components/JudgeSelector"; +import requireOrganizerSession from "@/server/services/helpers/auth/requireOrganizerSession"; -type JudgingManagerProps = { +type JudgingProps = { hackathonId: number; + forOrganizerId?: number; }; -const Judging = async ({ hackathonId }: JudgingManagerProps) => { + +const Judging = async ({ hackathonId, forOrganizerId }: JudgingProps) => { const session = await getServerSession(authOptions); - const { judgings, nextJudgingIndex } = await getMyJudgings(hackathonId); + const currentOrganizer = await requireOrganizerSession(); + const { judgings } = await getMyJudgings(hackathonId, forOrganizerId); + + const organizers = session?.isAdmin + ? await getOrganizersForJudgingSelector() + : []; + + const activeOrganizerId = forOrganizerId ?? currentOrganizer.id; + return ( @@ -20,28 +33,34 @@ const Judging = async ({ hackathonId }: JudgingManagerProps) => { {session?.isAdmin && ( -
- - - -
+ <> +
+ + + +
+ {organizers.length > 0 && activeOrganizerId !== undefined && ( + + )} + )} - +
); diff --git a/src/scenes/Dashboard/scenes/Judging/components/JudgeSelector.tsx b/src/scenes/Dashboard/scenes/Judging/components/JudgeSelector.tsx new file mode 100644 index 0000000..c4efd62 --- /dev/null +++ b/src/scenes/Dashboard/scenes/Judging/components/JudgeSelector.tsx @@ -0,0 +1,52 @@ +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { OrganizerForSelector } from "@/server/getters/dashboard/judging/getOrganizersForJudgingSelector"; + +type JudgeSelectorProps = { + organizers: OrganizerForSelector[]; + currentOrganizerId: number; + basePath: string; +}; + +const JudgeSelector = ({ + organizers, + currentOrganizerId, + basePath, +}: JudgeSelectorProps) => { + const router = useRouter(); + + const onChange = (value: string) => { + const url = new URL(basePath, window.location.origin); + url.searchParams.set("forOrganizer", value); + router.push(url.pathname + url.search); + }; + + return ( +
+

Judging as:

+ +
+ ); +}; + +export default JudgeSelector; diff --git a/src/scenes/Dashboard/scenes/Judging/components/JudgingList.tsx b/src/scenes/Dashboard/scenes/Judging/components/JudgingList.tsx new file mode 100644 index 0000000..282ec64 --- /dev/null +++ b/src/scenes/Dashboard/scenes/Judging/components/JudgingList.tsx @@ -0,0 +1,185 @@ +"use client"; + +import React, { useState } from "react"; +import { MyJudging } from "@/server/getters/dashboard/judging/getMyJudgings"; +import dateToTimeString from "@/services/helpers/dateToTimeString"; +import VotePicker from "@/scenes/Dashboard/scenes/ApplicationReview/components/VotePicker"; +import { VoteParametersData } from "@/server/getters/dashboard/voteParameterManager/voteParameters"; +import callServerAction from "@/services/helpers/server/callServerAction"; +import addVerdictToTeamJudging from "@/server/actions/dashboard/judging/addVerdictToTeamJudging"; +import { useToast } from "@/components/ui/use-toast"; +import { CheckCircle, Clock, ChevronDown, ChevronUp } from "lucide-react"; + +const voteParametersJudging: VoteParametersData = [ + { + id: 1, + name: "Innovation", + minValue: 1, + maxValue: 5, + weight: 1, + description: "How innovative is the project?", + }, + { + id: 2, + name: "Functionality", + minValue: 1, + maxValue: 5, + weight: 1, + description: "How functional is the project?", + }, + { + id: 3, + name: "Impact", + minValue: 1, + maxValue: 5, + weight: 1, + description: "How impactful is the project?", + }, + { + id: 4, + name: "Presentation", + minValue: 1, + maxValue: 5, + weight: 1, + description: "How well is the project presented?", + }, +]; + +type JudgingListProps = { + judgings: MyJudging[]; +}; + +const JudgingList = ({ judgings }: JudgingListProps) => { + const { toast } = useToast(); + const [expandedId, setExpandedId] = useState(null); + const [verdicts, setVerdicts] = useState>( + Object.fromEntries( + judgings + .filter((j) => j.judgingVerdict) + .map((j) => [j.id, j.judgingVerdict as string]) + ) + ); + + if (judgings.length === 0) { + return ( +

No judging assignments.

+ ); + } + + const doneCount = Object.keys(verdicts).length; + + const onVerdictSubmit = async ( + judgingId: number, + values: { voteParameterId: number; value: number }[] + ) => { + const verdict = values + .map(({ voteParameterId, value }) => { + const vp = voteParametersJudging.find((p) => p.id === voteParameterId); + return `${vp?.name ?? voteParameterId}-${value}`; + }) + .join(";"); + + const res = await callServerAction(addVerdictToTeamJudging, { + teamJudgingId: judgingId, + judgingVerdict: verdict, + }); + + if (res.success) { + setVerdicts((prev) => ({ ...prev, [judgingId]: verdict })); + setExpandedId(null); + toast({ title: "Score saved" }); + } + }; + + return ( +
+

+ {doneCount} / {judgings.length} scored +

+
+ {judgings.map((judging) => { + const verdict = verdicts[judging.id]; + const isExpanded = expandedId === judging.id; + const isScored = !!verdict; + + return ( +
+ + + {isExpanded && ( +
+ + onVerdictSubmit(judging.id, values) + } + buttonLabel={isScored ? "Update score" : "Save score"} + /> +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default JudgingList; diff --git a/src/scenes/Dashboard/scenes/Judging/scenes/JudgingOverview/DeleteTeamJudgingButton.tsx b/src/scenes/Dashboard/scenes/Judging/scenes/JudgingOverview/DeleteTeamJudgingButton.tsx new file mode 100644 index 0000000..ef17633 --- /dev/null +++ b/src/scenes/Dashboard/scenes/Judging/scenes/JudgingOverview/DeleteTeamJudgingButton.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import ConfirmationDialog from "@/components/common/ConfirmationDialog"; +import callServerAction from "@/services/helpers/server/callServerAction"; +import deleteTeamJudging from "@/server/actions/dashboard/judging/deleteTeamJudging"; + +type DeleteTeamJudgingButtonProps = { + teamJudgingId: number; +}; + +const DeleteTeamJudgingButton = ({ + teamJudgingId, +}: DeleteTeamJudgingButtonProps) => { + const [error, setError] = useState(null); + + return ( + <> + { + if (!answer) return; + const res = await callServerAction(deleteTeamJudging, { + teamJudgingId, + }); + if (!res.success) setError(res.message); + }} + > + + + {error &&

{error}

} + + ); +}; + +export default DeleteTeamJudgingButton; diff --git a/src/scenes/Dashboard/scenes/Judging/scenes/JudgingOverview/ExternalJudgeManager.tsx b/src/scenes/Dashboard/scenes/Judging/scenes/JudgingOverview/ExternalJudgeManager.tsx new file mode 100644 index 0000000..4c56bb1 --- /dev/null +++ b/src/scenes/Dashboard/scenes/Judging/scenes/JudgingOverview/ExternalJudgeManager.tsx @@ -0,0 +1,159 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { X, Copy, Check, UserPlus } from "lucide-react"; +import callServerAction from "@/services/helpers/server/callServerAction"; +import createExternalJudge from "@/server/actions/dashboard/judging/createExternalJudge"; +import deleteExternalJudge from "@/server/actions/dashboard/judging/deleteExternalJudge"; +import ConfirmationDialog from "@/components/common/ConfirmationDialog"; +import { + ExternalJudgeOverview, + JudgingOverviewSlot, +} from "@/server/getters/dashboard/judging/getJudgingOverview"; +import dateToTimeString from "@/services/helpers/dateToTimeString"; + +type ExternalJudgeManagerProps = { + hackathonId: number; + externalJudges: ExternalJudgeOverview[]; + slots: JudgingOverviewSlot[]; + baseUrl: string; +}; + +const ExternalJudgeManager = ({ + hackathonId, + externalJudges, + slots, + baseUrl, +}: ExternalJudgeManagerProps) => { + const slotById = new Map(slots.map((s) => [s.id, s])); + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const [copiedId, setCopiedId] = useState(null); + + const onAdd = async () => { + if (!name.trim()) return; + setError(null); + const res = await callServerAction(createExternalJudge, { + hackathonId, + name: name.trim(), + }); + if (res.success) { + setName(""); + } else { + setError(res.message); + } + }; + + const onDelete = async (id: number) => { + await callServerAction(deleteExternalJudge, { externalJudgeId: id }); + }; + + const copyLink = (accessToken: string, id: number) => { + navigator.clipboard.writeText(`${baseUrl}/judging/${accessToken}`); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + }; + + return ( +
+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onAdd()} + className="max-w-xs" + /> + +
+ {error &&

{error}

} + + {externalJudges.length === 0 ? ( +

+ No external judges yet. Add one above. +

+ ) : ( +
+ {externalJudges.map((judge) => ( +
+
+ {judge.name} +
+ + { + if (answer) await onDelete(judge.id); + }} + > + + +
+
+

+ Link: {baseUrl}/judging/{judge.accessToken} +

+ {judge.assignments.length > 0 && ( +
+ {judge.assignments.map((a) => { + const slot = slotById.get(a.slotId); + return ( +
+ + {slot ? dateToTimeString(slot.startTime) : "—"} + + {a.team?.name ?? "—"} + {a.team?.tableCode && ( + + ({a.team.tableCode}) + + )} + + {a.hasVerdict ? "✓" : "pending"} + +
+ ); + })} +
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default ExternalJudgeManager; diff --git a/src/scenes/Dashboard/scenes/Judging/scenes/JudgingOverview/JudgingOverview.tsx b/src/scenes/Dashboard/scenes/Judging/scenes/JudgingOverview/JudgingOverview.tsx index 40b9aa9..7240dd9 100644 --- a/src/scenes/Dashboard/scenes/Judging/scenes/JudgingOverview/JudgingOverview.tsx +++ b/src/scenes/Dashboard/scenes/Judging/scenes/JudgingOverview/JudgingOverview.tsx @@ -4,9 +4,12 @@ import { Stack } from "@/components/ui/stack"; import { ChevronLeftIcon } from "@heroicons/react/24/outline"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { JudgingOverviewData } from "@/server/getters/dashboard/judging/getJudgingOverview"; +import { headers } from "next/headers"; import AutoAssignButton from "./AutoAssignButton"; import AutoAssignSponsorButton from "./AutoAssignSponsorButton"; import ReassignJudgeDialog from "./ReassignJudgeDialog"; +import DeleteTeamJudgingButton from "./DeleteTeamJudgingButton"; +import ExternalJudgeManager from "./ExternalJudgeManager"; type JudgingOverviewProps = { hackathonId: number; @@ -31,7 +34,12 @@ type TeamJudgingRow = { }; const JudgingOverview = ({ hackathonId, data }: JudgingOverviewProps) => { - const { slots, judges, sponsors, challengeStats, teamStats } = data; + const { slots, judges, sponsors, externalJudges, challengeStats, teamStats } = + data; + const headersList = headers(); + const host = headersList.get("host") ?? ""; + const proto = headersList.get("x-forwarded-proto") ?? "https"; + const baseUrl = `${proto}://${host}`; const totalAssignments = judges.flatMap((j) => j.assignments.filter((a) => a.team) @@ -396,14 +404,19 @@ const JudgingOverview = ({ hackathonId, data }: JudgingOverviewProps) => {
{assignment.hasVerdict ? "✓ done" : "pending"}
- ({ - id: j.id, - name: j.name, - }))} - /> +
+ ({ + id: j.id, + name: j.name, + }))} + /> + +
); } @@ -528,6 +541,21 @@ const JudgingOverview = ({ hackathonId, data }: JudgingOverviewProps) => { )} + {/* External judges */} + + + External judges + + + + + + {/* Challenge breakdown */} diff --git a/src/scenes/Judging/ExternalJudging.tsx b/src/scenes/Judging/ExternalJudging.tsx new file mode 100644 index 0000000..146363c --- /dev/null +++ b/src/scenes/Judging/ExternalJudging.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ExternalJudgingData } from "@/server/getters/judging/getExternalJudgingByToken"; +import ExternalJudgingList from "./ExternalJudgingList"; + +type ExternalJudgingProps = { + data: ExternalJudgingData; + accessToken: string; +}; + +const ExternalJudging = ({ data, accessToken }: ExternalJudgingProps) => { + return ( + + + Judging — {data.hackathonName} +

+ Welcome, {data.judgeName} +

+
+ + + +
+ ); +}; + +export default ExternalJudging; diff --git a/src/scenes/Judging/ExternalJudgingList.tsx b/src/scenes/Judging/ExternalJudgingList.tsx new file mode 100644 index 0000000..17ba3db --- /dev/null +++ b/src/scenes/Judging/ExternalJudgingList.tsx @@ -0,0 +1,188 @@ +"use client"; + +import React, { useState } from "react"; +import { ExternalJudgingTeamJudging } from "@/server/getters/judging/getExternalJudgingByToken"; +import dateToTimeString from "@/services/helpers/dateToTimeString"; +import VotePicker from "@/scenes/Dashboard/scenes/ApplicationReview/components/VotePicker"; +import { VoteParametersData } from "@/server/getters/dashboard/voteParameterManager/voteParameters"; +import callServerAction from "@/services/helpers/server/callServerAction"; +import addVerdictToExternalTeamJudging from "@/server/actions/judging/addVerdictToExternalTeamJudging"; +import { useToast } from "@/components/ui/use-toast"; +import { CheckCircle, Clock, ChevronDown, ChevronUp } from "lucide-react"; + +const voteParameters: VoteParametersData = [ + { + id: 1, + name: "Innovation", + minValue: 1, + maxValue: 5, + weight: 1, + description: "How innovative is the project?", + }, + { + id: 2, + name: "Functionality", + minValue: 1, + maxValue: 5, + weight: 1, + description: "How functional is the project?", + }, + { + id: 3, + name: "Impact", + minValue: 1, + maxValue: 5, + weight: 1, + description: "How impactful is the project?", + }, + { + id: 4, + name: "Presentation", + minValue: 1, + maxValue: 5, + weight: 1, + description: "How well is the project presented?", + }, +]; + +type ExternalJudgingListProps = { + teamJudgings: ExternalJudgingTeamJudging[]; + accessToken: string; +}; + +const ExternalJudgingList = ({ + teamJudgings, + accessToken, +}: ExternalJudgingListProps) => { + const { toast } = useToast(); + const [expandedId, setExpandedId] = useState(null); + const [verdicts, setVerdicts] = useState>( + Object.fromEntries( + teamJudgings + .filter((tj) => tj.judgingVerdict) + .map((tj) => [tj.id, tj.judgingVerdict as string]) + ) + ); + + if (teamJudgings.length === 0) { + return ( +

No judging assignments.

+ ); + } + + const doneCount = Object.keys(verdicts).length; + + const onVerdictSubmit = async ( + tjId: number, + values: { voteParameterId: number; value: number }[] + ) => { + const verdict = values + .map(({ voteParameterId, value }) => { + const vp = voteParameters.find((p) => p.id === voteParameterId); + return `${vp?.name ?? voteParameterId}-${value}`; + }) + .join(";"); + + const res = await callServerAction(addVerdictToExternalTeamJudging, { + externalTeamJudgingId: tjId, + accessToken, + judgingVerdict: verdict, + }); + + if (res.success) { + setVerdicts((prev) => ({ ...prev, [tjId]: verdict })); + setExpandedId(null); + toast({ title: "Score saved" }); + } + }; + + return ( +
+

+ {doneCount} / {teamJudgings.length} scored +

+
+ {teamJudgings.map((tj) => { + const verdict = verdicts[tj.id]; + const isExpanded = expandedId === tj.id; + const isScored = !!verdict; + + return ( +
+ + + {isExpanded && ( +
+ onVerdictSubmit(tj.id, values)} + buttonLabel={isScored ? "Update score" : "Save score"} + /> +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default ExternalJudgingList; diff --git a/src/server/actions/dashboard/judging/addVerdictToTeamJudging.ts b/src/server/actions/dashboard/judging/addVerdictToTeamJudging.ts index dda5287..1606edb 100644 --- a/src/server/actions/dashboard/judging/addVerdictToTeamJudging.ts +++ b/src/server/actions/dashboard/judging/addVerdictToTeamJudging.ts @@ -12,7 +12,7 @@ const addVerdictToTeamJudging = async ({ teamJudgingId, judgingVerdict, }: AddVerdictToTeamJudgingInput) => { - const { id } = await requireOrganizerSession(); + const organizer = await requireOrganizerSession(); const teamJudging = await prisma.teamJudging.findUnique({ where: { @@ -32,7 +32,7 @@ const addVerdictToTeamJudging = async ({ throw new Error("Team judging not found"); } - if (teamJudging.organizerId !== id) { + if (!organizer.isAdmin && teamJudging.organizerId !== organizer.id) { throw new Error("Not authorized to add verdict to this team judging"); } diff --git a/src/server/actions/dashboard/judging/autoAssignSponsorJudging.ts b/src/server/actions/dashboard/judging/autoAssignSponsorJudging.ts index e02883d..418d180 100644 --- a/src/server/actions/dashboard/judging/autoAssignSponsorJudging.ts +++ b/src/server/actions/dashboard/judging/autoAssignSponsorJudging.ts @@ -85,29 +85,21 @@ const autoAssignSponsorJudging = async (hackathonId: number) => { for (const slot of slots) { for (const sponsor of sponsorsWithTeams) { + // Each sponsor gets at most 1 slot total + const sponsorAssigned = sponsorSlots.get(sponsor.id); + if (sponsorAssigned && sponsorAssigned.size > 0) continue; + // Skip if sponsor already assigned in this slot - if (sponsorSlots.get(sponsor.id)?.has(slot.id)) continue; + if (sponsorAssigned?.has(slot.id)) continue; const challengeTeams = sponsor.challenge?.teams ?? []; - // Find the challenge team with fewest assignments not already assigned to this sponsor in this slot - const existingAssignmentsForSponsorSlot = existingAssignments.filter( - (a) => a.sponsorId === sponsor.id && a.judgingSlotId === slot.id - ); const alreadyAssignedTeamIds = new Set( - existingAssignmentsForSponsorSlot.map((a) => a.teamId) + existingAssignments + .filter((a) => a.sponsorId === sponsor.id) + .map((a) => a.teamId) ); - // Also exclude teams already queued in toCreate for this sponsor+slot - for (const pending of toCreate) { - if ( - pending.sponsorId === sponsor.id && - pending.judgingSlotId === slot.id - ) { - alreadyAssignedTeamIds.add(pending.teamId); - } - } - const eligible = challengeTeams.filter( (team) => !alreadyAssignedTeamIds.has(team.id) ); diff --git a/src/server/actions/dashboard/judging/createExternalJudge.ts b/src/server/actions/dashboard/judging/createExternalJudge.ts new file mode 100644 index 0000000..1d01a0b --- /dev/null +++ b/src/server/actions/dashboard/judging/createExternalJudge.ts @@ -0,0 +1,33 @@ +"use server"; + +import { prisma } from "@/services/prisma"; +import requireAdminSession from "@/server/services/helpers/auth/requireAdminSession"; +import { revalidatePath } from "next/cache"; + +type CreateExternalJudgeInput = { + hackathonId: number; + name: string; +}; + +const createExternalJudge = async ({ + hackathonId, + name, +}: CreateExternalJudgeInput) => { + await requireAdminSession(); + + const accessToken = crypto.randomUUID(); + + const judge = await prisma.externalJudge.create({ + data: { + hackathonId, + name, + accessToken, + }, + }); + + revalidatePath(`/dashboard/${hackathonId}/judging/overview`, "page"); + + return judge; +}; + +export default createExternalJudge; diff --git a/src/server/actions/dashboard/judging/createExternalTeamJudging.ts b/src/server/actions/dashboard/judging/createExternalTeamJudging.ts new file mode 100644 index 0000000..af87927 --- /dev/null +++ b/src/server/actions/dashboard/judging/createExternalTeamJudging.ts @@ -0,0 +1,55 @@ +"use server"; + +import { prisma } from "@/services/prisma"; +import requireAdminSession from "@/server/services/helpers/auth/requireAdminSession"; +import { revalidatePath } from "next/cache"; +import { ExpectedServerActionError } from "@/services/types/serverErrors"; + +type CreateExternalTeamJudgingInput = { + externalJudgeId: number; + teamId: number; + judgingSlotId: number; +}; + +const createExternalTeamJudging = async ({ + externalJudgeId, + teamId, + judgingSlotId, +}: CreateExternalTeamJudgingInput) => { + await requireAdminSession(); + + const judgingSlot = await prisma.judgingSlot.findUnique({ + where: { id: judgingSlotId }, + select: { hackathonId: true }, + }); + + if (!judgingSlot) { + throw new Error("Judging slot not found"); + } + + const existing = await prisma.externalTeamJudging.findFirst({ + where: { externalJudgeId, judgingSlotId }, + select: { id: true }, + }); + + if (existing) { + throw new ExpectedServerActionError( + "External judge already assigned to this judging slot" + ); + } + + await prisma.externalTeamJudging.create({ + data: { externalJudgeId, teamId, judgingSlotId }, + }); + + revalidatePath( + `/dashboard/${judgingSlot.hackathonId}/judging/manage`, + "page" + ); + revalidatePath( + `/dashboard/${judgingSlot.hackathonId}/judging/overview`, + "page" + ); +}; + +export default createExternalTeamJudging; diff --git a/src/server/actions/dashboard/judging/deleteExternalJudge.ts b/src/server/actions/dashboard/judging/deleteExternalJudge.ts new file mode 100644 index 0000000..1e2b79a --- /dev/null +++ b/src/server/actions/dashboard/judging/deleteExternalJudge.ts @@ -0,0 +1,24 @@ +"use server"; + +import { prisma } from "@/services/prisma"; +import requireAdminSession from "@/server/services/helpers/auth/requireAdminSession"; +import { revalidatePath } from "next/cache"; + +type DeleteExternalJudgeInput = { + externalJudgeId: number; +}; + +const deleteExternalJudge = async ({ + externalJudgeId, +}: DeleteExternalJudgeInput) => { + await requireAdminSession(); + + const judge = await prisma.externalJudge.delete({ + where: { id: externalJudgeId }, + select: { hackathonId: true }, + }); + + revalidatePath(`/dashboard/${judge.hackathonId}/judging/overview`, "page"); +}; + +export default deleteExternalJudge; diff --git a/src/server/actions/dashboard/judging/deleteExternalTeamJudging.ts b/src/server/actions/dashboard/judging/deleteExternalTeamJudging.ts new file mode 100644 index 0000000..30779c3 --- /dev/null +++ b/src/server/actions/dashboard/judging/deleteExternalTeamJudging.ts @@ -0,0 +1,33 @@ +"use server"; + +import { prisma } from "@/services/prisma"; +import requireAdminSession from "@/server/services/helpers/auth/requireAdminSession"; +import { revalidatePath } from "next/cache"; + +type DeleteExternalTeamJudgingInput = { + externalTeamJudgingId: number; +}; + +const deleteExternalTeamJudging = async ({ + externalTeamJudgingId, +}: DeleteExternalTeamJudgingInput) => { + await requireAdminSession(); + + const record = await prisma.externalTeamJudging.delete({ + where: { id: externalTeamJudgingId }, + select: { + judgingSlot: { select: { hackathonId: true } }, + }, + }); + + revalidatePath( + `/dashboard/${record.judgingSlot.hackathonId}/judging/manage`, + "page" + ); + revalidatePath( + `/dashboard/${record.judgingSlot.hackathonId}/judging/overview`, + "page" + ); +}; + +export default deleteExternalTeamJudging; diff --git a/src/server/actions/dashboard/judging/deleteTeamJudging.ts b/src/server/actions/dashboard/judging/deleteTeamJudging.ts index d5b9755..1b9a26f 100644 --- a/src/server/actions/dashboard/judging/deleteTeamJudging.ts +++ b/src/server/actions/dashboard/judging/deleteTeamJudging.ts @@ -26,6 +26,7 @@ const deleteTeamJudging = async ({ teamJudgingId }: DeleteTeamJudgingInput) => { }); revalidatePath(`/dashboard/${hackathonId}/judging/manage`, "page"); + revalidatePath(`/dashboard/${hackathonId}/judging/overview`, "page"); }; export default deleteTeamJudging; diff --git a/src/server/actions/judging/addVerdictToExternalTeamJudging.ts b/src/server/actions/judging/addVerdictToExternalTeamJudging.ts new file mode 100644 index 0000000..eb8f58d --- /dev/null +++ b/src/server/actions/judging/addVerdictToExternalTeamJudging.ts @@ -0,0 +1,37 @@ +"use server"; + +import { prisma } from "@/services/prisma"; + +type AddVerdictToExternalTeamJudgingInput = { + externalTeamJudgingId: number; + accessToken: string; + judgingVerdict: string; +}; + +const addVerdictToExternalTeamJudging = async ({ + externalTeamJudgingId, + accessToken, + judgingVerdict, +}: AddVerdictToExternalTeamJudgingInput) => { + const record = await prisma.externalTeamJudging.findUnique({ + where: { id: externalTeamJudgingId }, + select: { + externalJudge: { select: { accessToken: true } }, + }, + }); + + if (!record) { + throw new Error("External team judging not found"); + } + + if (record.externalJudge.accessToken !== accessToken) { + throw new Error("Invalid access token"); + } + + await prisma.externalTeamJudging.update({ + where: { id: externalTeamJudgingId }, + data: { judgingVerdict }, + }); +}; + +export default addVerdictToExternalTeamJudging; diff --git a/src/server/getters/dashboard/judging/getExternalJudgesForHackathon.ts b/src/server/getters/dashboard/judging/getExternalJudgesForHackathon.ts new file mode 100644 index 0000000..4f66818 --- /dev/null +++ b/src/server/getters/dashboard/judging/getExternalJudgesForHackathon.ts @@ -0,0 +1,71 @@ +import requireAdminSession from "@/server/services/helpers/auth/requireAdminSession"; +import { prisma } from "@/services/prisma"; + +export type ExternalJudgeWithAssignments = { + id: number; + name: string; + accessToken: string; + teamJudgings: { + id: number; + judgingSlot: { + startTime: Date; + endTime: Date; + }; + team: { + name: string; + tableCode?: string; + }; + judgingVerdict?: string | null; + }[]; +}; + +const getExternalJudgesForHackathon = async ( + hackathonId: number +): Promise => { + await requireAdminSession(); + + const judges = await prisma.externalJudge.findMany({ + where: { hackathonId }, + select: { + id: true, + name: true, + accessToken: true, + teamJudgings: { + select: { + id: true, + judgingVerdict: true, + judgingSlot: { + select: { + startTime: true, + endTime: true, + }, + }, + team: { + select: { + name: true, + table: { select: { code: true } }, + }, + }, + }, + }, + }, + orderBy: { name: "asc" }, + }); + + return judges.map((judge) => ({ + id: judge.id, + name: judge.name, + accessToken: judge.accessToken, + teamJudgings: judge.teamJudgings.map((tj) => ({ + id: tj.id, + judgingSlot: tj.judgingSlot, + team: { + name: tj.team.name, + tableCode: tj.team.table?.code, + }, + judgingVerdict: tj.judgingVerdict, + })), + })); +}; + +export default getExternalJudgesForHackathon; diff --git a/src/server/getters/dashboard/judging/getJudgingOverview.ts b/src/server/getters/dashboard/judging/getJudgingOverview.ts index 42c8dff..747e003 100644 --- a/src/server/getters/dashboard/judging/getJudgingOverview.ts +++ b/src/server/getters/dashboard/judging/getJudgingOverview.ts @@ -52,12 +52,33 @@ export type TeamJudgingStats = { verdictCount: number; sponsorAssignmentCount: number; sponsorVerdictCount: number; + externalAssignmentCount: number; + externalVerdictCount: number; +}; + +export type ExternalJudgeOverviewAssignment = { + slotId: number; + externalTeamJudgingId: number; + team?: { + id: number; + name: string; + tableCode?: string; + }; + hasVerdict: boolean; +}; + +export type ExternalJudgeOverview = { + id: number; + name: string; + accessToken: string; + assignments: ExternalJudgeOverviewAssignment[]; }; export type JudgingOverviewData = { slots: JudgingOverviewSlot[]; judges: JudgingOverviewJudge[]; sponsors: JudgingOverviewSponsor[]; + externalJudges: ExternalJudgeOverview[]; challengeStats: ChallengeStats[]; teamStats: TeamJudgingStats[]; }; @@ -67,97 +88,131 @@ const getJudgingOverview = async ( ): Promise => { await requireAdminSession(); - const [slots, organizers, challenges, teams, sponsorJudgings] = - await Promise.all([ - prisma.judgingSlot.findMany({ - where: { hackathonId }, - orderBy: { startTime: "asc" }, - }), - prisma.organizer.findMany({ - select: { - id: true, - user: { select: { name: true, email: true } }, - teamJudgings: { - where: { judgingSlot: { hackathonId } }, - select: { - id: true, - judgingSlotId: true, - judgingVerdict: true, - team: { - select: { - id: true, - name: true, - table: { select: { code: true } }, - }, + const [ + slots, + organizers, + challenges, + teams, + sponsorJudgings, + externalJudgesRaw, + ] = await Promise.all([ + prisma.judgingSlot.findMany({ + where: { hackathonId }, + orderBy: { startTime: "asc" }, + }), + prisma.organizer.findMany({ + select: { + id: true, + user: { select: { name: true, email: true } }, + teamJudgings: { + where: { judgingSlot: { hackathonId } }, + select: { + id: true, + judgingSlotId: true, + judgingVerdict: true, + team: { + select: { + id: true, + name: true, + table: { select: { code: true } }, }, }, }, }, - orderBy: { user: { name: "asc" } }, - }), - prisma.challenge.findMany({ - where: { sponsor: { hackathonId } }, - select: { - id: true, - title: true, - teams: { - where: { - members: { some: { hackathonId } }, - table: { hackathonId }, - }, - select: { - name: true, - table: { select: { code: true } }, - }, + }, + orderBy: { user: { name: "asc" } }, + }), + prisma.challenge.findMany({ + where: { sponsor: { hackathonId } }, + select: { + id: true, + title: true, + teams: { + where: { + members: { some: { hackathonId } }, + table: { hackathonId }, + }, + select: { + name: true, + table: { select: { code: true } }, }, }, - orderBy: { title: "asc" }, - }), - prisma.team.findMany({ - where: { - members: { some: { hackathonId } }, - table: { hackathonId }, + }, + orderBy: { title: "asc" }, + }), + prisma.team.findMany({ + where: { + members: { some: { hackathonId } }, + table: { hackathonId }, + }, + select: { + id: true, + name: true, + table: { select: { code: true } }, + teamJudgings: { + where: { judgingSlot: { hackathonId } }, + select: { judgingVerdict: true }, }, - select: { - id: true, - name: true, - table: { select: { code: true } }, - teamJudgings: { - where: { judgingSlot: { hackathonId } }, - select: { judgingVerdict: true }, - }, - sponsorJudgings: { - where: { judgingSlot: { hackathonId } }, - select: { judgingVerdict: true }, + sponsorJudgings: { + where: { judgingSlot: { hackathonId } }, + select: { judgingVerdict: true }, + }, + externalTeamJudgings: { + where: { judgingSlot: { hackathonId } }, + select: { judgingVerdict: true }, + }, + }, + orderBy: { name: "asc" }, + }), + prisma.sponsor.findMany({ + where: { hackathonId }, + select: { + id: true, + company: true, + user: { select: { name: true, email: true } }, + sponsorJudgings: { + where: { judgingSlot: { hackathonId } }, + select: { + id: true, + judgingSlotId: true, + judgingVerdict: true, + team: { + select: { + id: true, + name: true, + table: { select: { code: true } }, + }, + }, }, }, - orderBy: { name: "asc" }, - }), - prisma.sponsor.findMany({ - where: { hackathonId }, - select: { - id: true, - company: true, - user: { select: { name: true, email: true } }, - sponsorJudgings: { - where: { judgingSlot: { hackathonId } }, - select: { - id: true, - judgingSlotId: true, - judgingVerdict: true, - team: { - select: { - id: true, - name: true, - table: { select: { code: true } }, - }, + }, + orderBy: { company: "asc" }, + }), + prisma.externalJudge.findMany({ + where: { hackathonId }, + select: { + id: true, + name: true, + accessToken: true, + teamJudgings: { + where: { judgingSlot: { hackathonId } }, + select: { + id: true, + judgingSlotId: true, + judgingVerdict: true, + team: { + select: { + id: true, + name: true, + table: { select: { code: true } }, }, }, }, }, - orderBy: { company: "asc" }, - }), - ]); + }, + orderBy: { name: "asc" }, + }), + ]); const judges: JudgingOverviewJudge[] = organizers.map((org) => ({ id: org.id, @@ -224,9 +279,37 @@ const getJudgingOverview = async ( sponsorAssignmentCount: team.sponsorJudgings.length, sponsorVerdictCount: team.sponsorJudgings.filter((sj) => sj.judgingVerdict) .length, + externalAssignmentCount: team.externalTeamJudgings.length, + externalVerdictCount: team.externalTeamJudgings.filter( + (etj) => etj.judgingVerdict + ).length, })); - return { slots, judges, sponsors, challengeStats, teamStats }; + // Only include external judges that have at least one assignment + const externalJudgesWithAssignments = externalJudgesRaw.filter( + (ej) => ej.teamJudgings.length > 0 + ); + + const externalJudges: ExternalJudgeOverview[] = + externalJudgesWithAssignments.map((ej) => ({ + id: ej.id, + name: ej.name, + accessToken: ej.accessToken, + assignments: ej.teamJudgings.map((tj) => ({ + slotId: tj.judgingSlotId, + externalTeamJudgingId: tj.id, + team: tj.team + ? { + id: tj.team.id, + name: tj.team.name, + tableCode: tj.team.table?.code, + } + : undefined, + hasVerdict: !!tj.judgingVerdict, + })), + })); + + return { slots, judges, sponsors, externalJudges, challengeStats, teamStats }; }; export default getJudgingOverview; diff --git a/src/server/getters/dashboard/judging/getMyJudgings.ts b/src/server/getters/dashboard/judging/getMyJudgings.ts index 15dc3a9..c7a56e8 100644 --- a/src/server/getters/dashboard/judging/getMyJudgings.ts +++ b/src/server/getters/dashboard/judging/getMyJudgings.ts @@ -18,8 +18,19 @@ type MyJudgings = { nextJudgingIndex: number; }; -const getMyJudgings = async (hackathonId: number): Promise => { - const { id: organizerId } = await requireOrganizerSession(); +const getMyJudgings = async ( + hackathonId: number, + forOrganizerId?: number +): Promise => { + const organizer = await requireOrganizerSession(); + + let organizerId = organizer.id; + if (forOrganizerId !== undefined) { + if (!organizer.isAdmin) { + throw new Error("Only admins can view other organizer judgings"); + } + organizerId = forOrganizerId; + } const judgingsDb = await prisma.teamJudging.findMany({ where: { AND: [ diff --git a/src/server/getters/dashboard/judging/getOrganizersForJudgingSelector.ts b/src/server/getters/dashboard/judging/getOrganizersForJudgingSelector.ts new file mode 100644 index 0000000..c6d95ee --- /dev/null +++ b/src/server/getters/dashboard/judging/getOrganizersForJudgingSelector.ts @@ -0,0 +1,28 @@ +import requireAdminSession from "@/server/services/helpers/auth/requireAdminSession"; +import { prisma } from "@/services/prisma"; + +export type OrganizerForSelector = { + id: number; + name: string; +}; + +const getOrganizersForJudgingSelector = async (): Promise< + OrganizerForSelector[] +> => { + await requireAdminSession(); + + const organizers = await prisma.organizer.findMany({ + select: { + id: true, + user: { select: { name: true, email: true } }, + }, + orderBy: { user: { name: "asc" } }, + }); + + return organizers.map((o) => ({ + id: o.id, + name: o.user.name || o.user.email, + })); +}; + +export default getOrganizersForJudgingSelector; diff --git a/src/server/getters/judging/getExternalJudgingByToken.ts b/src/server/getters/judging/getExternalJudgingByToken.ts new file mode 100644 index 0000000..3e45e4b --- /dev/null +++ b/src/server/getters/judging/getExternalJudgingByToken.ts @@ -0,0 +1,78 @@ +import { prisma } from "@/services/prisma"; + +export type ExternalJudgingTeamJudging = { + id: number; + startTime: Date; + endTime: Date; + team: { + name: string; + tableCode?: string; + challenges: string[]; + }; + judgingVerdict?: string | null; +}; + +export type ExternalJudgingData = { + judgeId: number; + judgeName: string; + hackathonName: string; + teamJudgings: ExternalJudgingTeamJudging[]; +}; + +const getExternalJudgingByToken = async ( + accessToken: string +): Promise => { + const judge = await prisma.externalJudge.findUnique({ + where: { accessToken }, + select: { + id: true, + name: true, + hackathon: { select: { name: true } }, + teamJudgings: { + select: { + id: true, + judgingVerdict: true, + judgingSlot: { + select: { + startTime: true, + endTime: true, + }, + }, + team: { + select: { + name: true, + table: { select: { code: true } }, + challenges: { + select: { title: true }, + }, + }, + }, + }, + orderBy: { judgingSlot: { startTime: "asc" } }, + }, + }, + }); + + if (!judge) { + return null; + } + + return { + judgeId: judge.id, + judgeName: judge.name, + hackathonName: judge.hackathon.name, + teamJudgings: judge.teamJudgings.map((tj) => ({ + id: tj.id, + startTime: tj.judgingSlot.startTime, + endTime: tj.judgingSlot.endTime, + team: { + name: tj.team.name, + tableCode: tj.team.table?.code, + challenges: tj.team.challenges.map((c) => c.title), + }, + judgingVerdict: tj.judgingVerdict, + })), + }; +}; + +export default getExternalJudgingByToken;