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;