From b788f21d137a1bf5c3d6a2f4f8b71ce898c9c32f Mon Sep 17 00:00:00 2001
From: WAED__03
Date: Wed, 6 May 2026 18:14:03 +0200
Subject: [PATCH] Implement full exam feature workflow
---
app/(app)/classes/[classId]/exam/page.tsx | 47 +-
.../classes/[classId]/leaderboard/page.tsx | 306 +-
app/(app)/classes/[classId]/results/page.tsx | 14 +
app/(app)/layout.tsx | 17 +-
.../attempts/[attemptId]/answers/route.ts | 35 +
.../attempts/[attemptId]/events/route.ts | 40 +
.../attempts/[attemptId]/grade/route.ts | 34 +
.../attempts/[attemptId]/integrity/route.ts | 39 +
.../attempts/[attemptId]/release/route.ts | 32 +
.../attempts/[attemptId]/retake/route.ts | 32 +
.../attempts/[attemptId]/submit/route.ts | 33 +
.../exams/[examId]/attempts/route.ts | 34 +
.../[classId]/exams/[examId]/publish/route.ts | 31 +
.../classes/[classId]/exams/[examId]/route.ts | 80 +
app/api/classes/[classId]/exams/route.ts | 70 +
components/sidebar.tsx | 90 +-
components/top-bar.tsx | 51 +-
features/exam/exam-header.tsx | 52 +-
features/exam/exam-lobby.tsx | 98 +-
features/exam/exam-lock.test.ts | 20 +
features/exam/exam-lock.tsx | 229 +
features/exam/exam-results.tsx | 290 +-
features/exam/exam-screen.tsx | 319 +-
features/exam/manager-detail-state.test.ts | 200 +
features/exam/manager-detail-state.ts | 137 +
features/exam/manager-exam-screen.tsx | 1610 +++++++
features/exam/question-navigator.tsx | 18 +-
features/exam/question-view.tsx | 54 +-
features/exam/use-class-exam.ts | 521 +++
features/exam/use-exam-session.ts | 261 +-
features/results/class-results-screen.tsx | 483 +++
lib/education/selectors.test.ts | 91 +-
lib/education/selectors.ts | 34 +
lib/exams/audit.ts | 25 +
lib/exams/grading.test.ts | 139 +
lib/exams/grading.ts | 115 +
lib/exams/http.ts | 69 +
lib/exams/integrity.test.ts | 70 +
lib/exams/integrity.ts | 65 +
lib/exams/service.test.ts | 530 +++
lib/exams/service.ts | 3859 +++++++++++++++++
lib/exams/types.ts | 259 ++
lib/features/feature-registry.test.ts | 15 +
lib/features/feature-registry.ts | 18 +-
lib/supabase/server.ts | 28 +
.../20260503190000_create_exam_system.sql | 482 ++
...000_fix_exam_attempt_retake_constraint.sql | 9 +
47 files changed, 10498 insertions(+), 587 deletions(-)
create mode 100644 app/(app)/classes/[classId]/results/page.tsx
create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/answers/route.ts
create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/events/route.ts
create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/grade/route.ts
create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/integrity/route.ts
create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/release/route.ts
create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/retake/route.ts
create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/submit/route.ts
create mode 100644 app/api/classes/[classId]/exams/[examId]/attempts/route.ts
create mode 100644 app/api/classes/[classId]/exams/[examId]/publish/route.ts
create mode 100644 app/api/classes/[classId]/exams/[examId]/route.ts
create mode 100644 app/api/classes/[classId]/exams/route.ts
create mode 100644 features/exam/exam-lock.test.ts
create mode 100644 features/exam/exam-lock.tsx
create mode 100644 features/exam/manager-detail-state.test.ts
create mode 100644 features/exam/manager-detail-state.ts
create mode 100644 features/exam/manager-exam-screen.tsx
create mode 100644 features/exam/use-class-exam.ts
create mode 100644 features/results/class-results-screen.tsx
create mode 100644 lib/exams/audit.ts
create mode 100644 lib/exams/grading.test.ts
create mode 100644 lib/exams/grading.ts
create mode 100644 lib/exams/http.ts
create mode 100644 lib/exams/integrity.test.ts
create mode 100644 lib/exams/integrity.ts
create mode 100644 lib/exams/service.test.ts
create mode 100644 lib/exams/service.ts
create mode 100644 lib/exams/types.ts
create mode 100644 lib/supabase/server.ts
create mode 100644 supabase/migrations/20260503190000_create_exam_system.sql
create mode 100644 supabase/migrations/20260505124000_fix_exam_attempt_retake_constraint.sql
diff --git a/app/(app)/classes/[classId]/exam/page.tsx b/app/(app)/classes/[classId]/exam/page.tsx
index f94905f..1e3a5a5 100644
--- a/app/(app)/classes/[classId]/exam/page.tsx
+++ b/app/(app)/classes/[classId]/exam/page.tsx
@@ -1,13 +1,14 @@
"use client"
import { use } from "react"
-import { EXAMS } from "@/lib/mock-data"
import {
ClassFeatureDisabledFallback,
ClassRouteFallback,
useClassFeatureRoute,
} from "@/features/classes/use-class-route"
-import { ExamScreen, NoExamState } from "@/features/exam/exam-screen"
+import { ExamScreen } from "@/features/exam/exam-screen"
+import { ManagerExamScreen } from "@/features/exam/manager-exam-screen"
+import { useClassExam } from "@/features/exam/use-class-exam"
export default function ExamPage({
params,
@@ -15,25 +16,61 @@ export default function ExamPage({
params: Promise<{ classId: string }>
}) {
const { classId } = use(params)
+
const { cls, isLoading, errorMessage, isFeatureDisabled } =
useClassFeatureRoute(classId, "exam")
- const exam = EXAMS.find((e) => e.classId === classId)
+ const examApi = useClassExam(classId)
+ const {
+ data: exam,
+ isLoading: examLoading,
+ isMutating: isSubmitting,
+ startExam,
+ saveAnswer,
+ submitExam,
+ recordEvent,
+ } = examApi
+
+ // fallback: class loading / error
if (!cls) {
return (
)
}
+ // feature disabled
if (isFeatureDisabled) {
return (
)
}
+ // exam loading
+ if (examLoading) {
+ return Loading exam...
+ }
+
+ // no exam available
if (!exam) {
- return
+ return No exam available
+ }
+
+ // main screen
+ if (exam.canManage) {
+ return
}
- return
+ return (
+
+ )
}
diff --git a/app/(app)/classes/[classId]/leaderboard/page.tsx b/app/(app)/classes/[classId]/leaderboard/page.tsx
index d420690..0a9ceb7 100644
--- a/app/(app)/classes/[classId]/leaderboard/page.tsx
+++ b/app/(app)/classes/[classId]/leaderboard/page.tsx
@@ -1,309 +1,11 @@
-"use client"
+import { redirect } from "next/navigation"
-import { use } from "react"
-import {
- getLeaderboardByClass,
- getUserById,
- LeaderboardEntry,
-} from "@/lib/mock-data"
-import {
- ClassFeatureDisabledFallback,
- ClassRouteFallback,
- useClassFeatureRoute,
-} from "@/features/classes/use-class-route"
-import { useApp } from "@/lib/store"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Avatar, AvatarFallback } from "@/components/ui/avatar"
-import { Progress } from "@/components/ui/progress"
-import { Badge } from "@/components/ui/badge"
-import { ChartColumn, Medal, Star, Users } from "lucide-react"
-import { cn } from "@/lib/utils"
-
-const RANK_STYLES = [
- {
- bg: "bg-amber-100 dark:bg-amber-900/30",
- border: "border-amber-300 dark:border-amber-700",
- text: "text-amber-700 dark:text-amber-300",
- icon: "text-amber-500",
- },
- {
- bg: "bg-slate-100 dark:bg-slate-800/50",
- border: "border-slate-300 dark:border-slate-600",
- text: "text-slate-600 dark:text-slate-300",
- icon: "text-slate-400",
- },
- {
- bg: "bg-orange-100 dark:bg-orange-900/20",
- border: "border-orange-300 dark:border-orange-700",
- text: "text-orange-700 dark:text-orange-300",
- icon: "text-orange-500",
- },
-]
-
-export default function LeaderboardPage({
+export default async function LeaderboardPage({
params,
}: {
params: Promise<{ classId: string }>
}) {
- const { classId } = use(params)
- const { currentUser } = useApp()
- const { cls, isLoading, errorMessage, isFeatureDisabled } =
- useClassFeatureRoute(classId, "leaderboard")
- const entries = getLeaderboardByClass(classId)
-
- if (!cls) {
- return (
-
- )
- }
-
- if (isFeatureDisabled) {
- return (
-
- )
- }
-
- const maxScore = entries[0]?.totalScore ?? 1
- const myEntry = entries.find((e) => e.studentId === currentUser.id)
-
- const top3 = entries.slice(0, 3)
- const rest = entries.slice(3)
-
- return (
-
- {/* Header */}
-
-
-
{cls.name}
-
- {cls.code} · Results · {entries.length} students
-
-
-
-
-
- {cls.semester}
-
-
-
-
- {/* My rank callout */}
- {myEntry && currentUser.role === "student" && (
-
-
-
-
-
-
-
- Your Result
-
-
- {myEntry.totalScore} points · {myEntry.avgScore}% average
- · {myEntry.assignments} assignments
-
-
-
-
#{myEntry.rank}
-
- of {entries.length}
-
-
-
-
- )}
-
- {/* Top 3 podium */}
- {top3.length >= 2 && (
-
- {/* 2nd */}
-
- {/* 1st */}
-
- {/* 3rd */}
- {top3[2] && (
-
- )}
-
- )}
-
- {/* Full table */}
-
-
-
-
- Class Results
-
-
-
-
- {entries.map((entry) => {
- const user = getUserById(entry.studentId)
- if (!user) return null
- const isMe = entry.studentId === currentUser.id
- const rankStyle = RANK_STYLES[entry.rank - 1]
- return (
-
- {/* Rank */}
-
- {entry.rank <= 3 ? (
-
- ) : (
- entry.rank
- )}
-
-
- {/* Avatar */}
-
-
- {user.avatar}
-
-
-
- {/* Name + progress */}
-
-
-
- {user.name}
- {isMe && (
-
- (you)
-
- )}
-
-
-
-
-
- {entry.avgScore}%
-
-
-
-
- {/* Result */}
-
-
- {entry.totalScore}
-
-
points
-
-
- )
- })}
-
-
-
-
- )
-}
-
-function PodiumCard({
- entry,
- rank,
- maxScore,
- height,
- currentUserId,
-}: {
- entry: LeaderboardEntry
- rank: number
- maxScore: number
- height: string
- currentUserId: string
-}) {
- const user = getUserById(entry.studentId)
- if (!user) return null
- const isMe = entry.studentId === currentUserId
- const style = RANK_STYLES[rank - 1]
+ const { classId } = await params
- return (
-
-
-
- {user.avatar}
-
-
-
-
- {user.name.split(" ")[0]}
-
-
- {entry.totalScore} points
-
-
-
-
- )
+ redirect(`/classes/${classId}/results`)
}
diff --git a/app/(app)/classes/[classId]/results/page.tsx b/app/(app)/classes/[classId]/results/page.tsx
new file mode 100644
index 0000000..28c7604
--- /dev/null
+++ b/app/(app)/classes/[classId]/results/page.tsx
@@ -0,0 +1,14 @@
+"use client"
+
+import { use } from "react"
+import { ClassResultsScreen } from "@/features/results/class-results-screen"
+
+export default function ResultsPage({
+ params,
+}: {
+ params: Promise<{ classId: string }>
+}) {
+ const { classId } = use(params)
+
+ return
+}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index 21c9cb4..a398914 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -9,6 +9,7 @@ import {
LiveSessionMiniBar,
LiveSessionProvider,
} from "@/features/session/live-session-provider"
+import { ExamLockProvider } from "@/features/exam/exam-lock"
import { useApp } from "@/lib/store"
function AppShell({ children }: { children: React.ReactNode }) {
@@ -71,14 +72,16 @@ function AppShell({ children }: { children: React.ReactNode }) {
return (
-
-
-
+
)
}
diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/answers/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/answers/route.ts
new file mode 100644
index 0000000..07fa780
--- /dev/null
+++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/answers/route.ts
@@ -0,0 +1,35 @@
+import { NextResponse } from "next/server"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import { parseSaveAnswerInput, saveExamAnswer } from "@/lib/exams/service"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string; examId: string; attemptId: string }>
+}
+
+export async function PATCH(request: Request, context: RouteContext) {
+ const { classId, examId, attemptId } = await context.params
+
+ try {
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ const body = parseSaveAnswerInput(await request.json().catch(() => null))
+ const payload = await saveExamAnswer({
+ authSupabase: supabase,
+ classId,
+ examId,
+ attemptId,
+ userId: user.id,
+ body,
+ })
+ return NextResponse.json(payload)
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/events/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/events/route.ts
new file mode 100644
index 0000000..4a0ee50
--- /dev/null
+++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/events/route.ts
@@ -0,0 +1,40 @@
+import { NextResponse } from "next/server"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import {
+ parseIntegrityEventInput,
+ recordExamIntegrityEvent,
+} from "@/lib/exams/service"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string; examId: string; attemptId: string }>
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { classId, examId, attemptId } = await context.params
+
+ try {
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ const body = parseIntegrityEventInput(
+ await request.json().catch(() => null),
+ )
+ const payload = await recordExamIntegrityEvent({
+ authSupabase: supabase,
+ classId,
+ examId,
+ attemptId,
+ userId: user.id,
+ body,
+ })
+ return NextResponse.json(payload)
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/grade/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/grade/route.ts
new file mode 100644
index 0000000..e05fa29
--- /dev/null
+++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/grade/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from "next/server"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import { gradeExamAttempt, parseGradeAttemptInput } from "@/lib/exams/service"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string; examId: string; attemptId: string }>
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { classId, examId, attemptId } = await context.params
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ try {
+ const body = parseGradeAttemptInput(await request.json().catch(() => null))
+ const payload = await gradeExamAttempt({
+ authSupabase: supabase,
+ classId,
+ examId,
+ attemptId,
+ userId: user.id,
+ body,
+ })
+ return NextResponse.json({ exam: payload })
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/integrity/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/integrity/route.ts
new file mode 100644
index 0000000..0ffafa1
--- /dev/null
+++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/integrity/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import {
+ applyExamIntegrityAction,
+ parseIntegrityActionInput,
+} from "@/lib/exams/service"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string; examId: string; attemptId: string }>
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { classId, examId, attemptId } = await context.params
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ try {
+ const body = parseIntegrityActionInput(
+ await request.json().catch(() => null),
+ )
+ const payload = await applyExamIntegrityAction({
+ authSupabase: supabase,
+ classId,
+ examId,
+ attemptId,
+ userId: user.id,
+ body,
+ })
+ return NextResponse.json({ exam: payload })
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/release/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/release/route.ts
new file mode 100644
index 0000000..60cca34
--- /dev/null
+++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/release/route.ts
@@ -0,0 +1,32 @@
+import { NextResponse } from "next/server"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import { releaseExamAttempt } from "@/lib/exams/service"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string; examId: string; attemptId: string }>
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { classId, examId, attemptId } = await context.params
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ try {
+ const payload = await releaseExamAttempt({
+ authSupabase: supabase,
+ classId,
+ examId,
+ attemptId,
+ userId: user.id,
+ })
+ return NextResponse.json({ exam: payload })
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/retake/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/retake/route.ts
new file mode 100644
index 0000000..3d0a956
--- /dev/null
+++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/retake/route.ts
@@ -0,0 +1,32 @@
+import { NextResponse } from "next/server"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import { grantExamRetake } from "@/lib/exams/service"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string; examId: string; attemptId: string }>
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { classId, examId, attemptId } = await context.params
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ try {
+ const payload = await grantExamRetake({
+ authSupabase: supabase,
+ classId,
+ examId,
+ attemptId,
+ userId: user.id,
+ })
+ return NextResponse.json({ exam: payload })
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/submit/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/submit/route.ts
new file mode 100644
index 0000000..433cd09
--- /dev/null
+++ b/app/api/classes/[classId]/exams/[examId]/attempts/[attemptId]/submit/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import { submitExamAttempt } from "@/lib/exams/service"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string; examId: string; attemptId: string }>
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { classId, examId, attemptId } = await context.params
+
+ try {
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ await submitExamAttempt({
+ authSupabase: supabase,
+ classId,
+ examId,
+ attemptId,
+ userId: user.id,
+ })
+ return NextResponse.json({ ok: true })
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/app/api/classes/[classId]/exams/[examId]/attempts/route.ts b/app/api/classes/[classId]/exams/[examId]/attempts/route.ts
new file mode 100644
index 0000000..2ca174e
--- /dev/null
+++ b/app/api/classes/[classId]/exams/[examId]/attempts/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from "next/server"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import { parseStartAttemptInput, startExamAttempt } from "@/lib/exams/service"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string; examId: string }>
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { classId, examId } = await context.params
+
+ try {
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ const body = parseStartAttemptInput(await request.json().catch(() => null))
+ const payload = await startExamAttempt({
+ authSupabase: supabase,
+ classId,
+ examId,
+ userId: user.id,
+ body,
+ })
+ return NextResponse.json({ activeExam: payload })
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/app/api/classes/[classId]/exams/[examId]/publish/route.ts b/app/api/classes/[classId]/exams/[examId]/publish/route.ts
new file mode 100644
index 0000000..3baf195
--- /dev/null
+++ b/app/api/classes/[classId]/exams/[examId]/publish/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import { publishExam } from "@/lib/exams/service"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string; examId: string }>
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { classId, examId } = await context.params
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ try {
+ const payload = await publishExam({
+ authSupabase: supabase,
+ classId,
+ examId,
+ userId: user.id,
+ })
+ return NextResponse.json({ exam: payload })
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/app/api/classes/[classId]/exams/[examId]/route.ts b/app/api/classes/[classId]/exams/[examId]/route.ts
new file mode 100644
index 0000000..1ffee76
--- /dev/null
+++ b/app/api/classes/[classId]/exams/[examId]/route.ts
@@ -0,0 +1,80 @@
+import { NextResponse } from "next/server"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import {
+ deleteExam,
+ loadManagerExamDetail,
+ parseUpsertExamInput,
+ updateExam,
+} from "@/lib/exams/service"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string; examId: string }>
+}
+
+export async function GET(request: Request, context: RouteContext) {
+ const { classId, examId } = await context.params
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ try {
+ const payload = await loadManagerExamDetail({
+ authSupabase: supabase,
+ classId,
+ examId,
+ userId: user.id,
+ })
+ return NextResponse.json({ exam: payload })
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
+
+export async function PATCH(request: Request, context: RouteContext) {
+ const { classId, examId } = await context.params
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ try {
+ const body = parseUpsertExamInput(await request.json().catch(() => null))
+ const payload = await updateExam({
+ authSupabase: supabase,
+ classId,
+ examId,
+ userId: user.id,
+ body,
+ })
+ return NextResponse.json({ exam: payload })
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
+
+export async function DELETE(request: Request, context: RouteContext) {
+ const { classId, examId } = await context.params
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ try {
+ const payload = await deleteExam({
+ authSupabase: supabase,
+ classId,
+ examId,
+ userId: user.id,
+ })
+ return NextResponse.json(payload)
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/app/api/classes/[classId]/exams/route.ts b/app/api/classes/[classId]/exams/route.ts
new file mode 100644
index 0000000..244037e
--- /dev/null
+++ b/app/api/classes/[classId]/exams/route.ts
@@ -0,0 +1,70 @@
+import { NextResponse } from "next/server"
+import { toExamErrorResponse } from "@/lib/exams/http"
+import {
+ createExam,
+ loadClassExamApiData,
+ loadManagerExamDetail,
+ parseUpsertExamInput,
+} from "@/lib/exams/service"
+import { requireRouteUser } from "@/lib/api/supabase-route"
+
+export const runtime = "nodejs"
+
+type RouteContext = {
+ params: Promise<{ classId: string }>
+}
+
+export async function GET(request: Request, context: RouteContext) {
+ const { classId } = await context.params
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ try {
+ const requestUrl = new URL(request.url)
+ const detailExamId = requestUrl.searchParams.get("detailExamId")
+
+ if (detailExamId) {
+ const payload = await loadManagerExamDetail({
+ authSupabase: supabase,
+ classId,
+ examId: detailExamId,
+ userId: user.id,
+ })
+ return NextResponse.json({ exam: payload })
+ }
+
+ const payload = await loadClassExamApiData({
+ authSupabase: supabase,
+ classId,
+ userId: user.id,
+ })
+ return NextResponse.json(payload)
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { classId } = await context.params
+ const { user, supabase, error } = await requireRouteUser(request)
+
+ if (error || !user || !supabase) {
+ return NextResponse.json({ error: error }, { status: 401 })
+ }
+
+ try {
+ const body = parseUpsertExamInput(await request.json().catch(() => null))
+ const payload = await createExam({
+ authSupabase: supabase,
+ classId,
+ userId: user.id,
+ body,
+ })
+ return NextResponse.json({ exam: payload })
+ } catch (routeError) {
+ return toExamErrorResponse(routeError)
+ }
+}
diff --git a/components/sidebar.tsx b/components/sidebar.tsx
index 794225c..eaf1cc4 100644
--- a/components/sidebar.tsx
+++ b/components/sidebar.tsx
@@ -2,6 +2,7 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
+import type { ReactNode } from "react"
import {
BookOpen,
GraduationCap,
@@ -9,6 +10,7 @@ import {
ChevronRight,
} from "lucide-react"
import { cn } from "@/lib/utils"
+import { useExamLock } from "@/features/exam/exam-lock"
import { useApp } from "@/lib/store"
import { getClassesForUser } from "@/lib/education/classes"
import {
@@ -55,6 +57,7 @@ export function Sidebar({ collapsed, setCollapsed }: SidebarProps) {
featureDefinitions,
organizationClasses,
} = useApp()
+ const { canNavigateToPath, isLocked, lock } = useExamLock()
const isTeacher = currentUser.role === "teacher"
const isAdmin = currentUser.role === "admin"
@@ -99,11 +102,14 @@ export function Sidebar({ collapsed, setCollapsed }: SidebarProps) {
) : (
<>
-
@@ -113,7 +119,7 @@ export function Sidebar({ collapsed, setCollapsed }: SidebarProps) {
Eduverse
-
+
setCollapsed(true)}
className="absolute right-2 text-muted-foreground hover:text-sidebar-foreground transition-colors"
@@ -153,6 +159,11 @@ export function Sidebar({ collapsed, setCollapsed }: SidebarProps) {
>
Classes
+ {isLocked && !collapsed && (
+
+ Exam mode active. Navigation is locked to the exam.
+
+ )}
{userClasses.map((cls) => {
const isActiveClass = activeClassId === cls.id
const classNavFeatures = activeOrganization
@@ -169,17 +180,20 @@ export function Sidebar({ collapsed, setCollapsed }: SidebarProps) {
: []
const landingSegment =
getFirstClassNavRouteSegment(classNavFeatures) ?? "home"
+ const classHref = `/classes/${cls.id}/${landingSegment}`
+ const classDisabled = isLocked && !canNavigateToPath(classHref)
return (
@@ -213,10 +235,12 @@ function ClassFeatureNavItem({
classId,
feature,
activeSegment,
+ disabled,
}: {
classId: string
feature: ResolvedClassFeature
activeSegment?: string
+ disabled?: boolean
}) {
const isActive =
isFeatureRouteActive(feature.routeSegment, activeSegment) ||
@@ -245,6 +269,7 @@ function ClassFeatureNavItem({
classId={classId}
feature={child}
active={isFeatureRouteActive(child.routeSegment, activeSegment)}
+ disabled={disabled}
/>
))}
@@ -257,6 +282,7 @@ function ClassFeatureNavItem({
classId={classId}
feature={feature}
active={isFeatureRouteActive(feature.routeSegment, activeSegment)}
+ disabled={disabled}
/>
)
}
@@ -265,26 +291,31 @@ function ClassFeatureNavLink({
classId,
feature,
active,
+ disabled = false,
}: {
classId: string
feature: ResolvedClassFeature
active: boolean
+ disabled?: boolean
}) {
if (!feature.routeSegment) return null
return (
-
{feature.label}
-
+
)
}
@@ -322,6 +353,7 @@ interface NavItemProps {
collapsed: boolean
colorDot?: string
live?: boolean
+ disabled?: boolean
}
const DOT_COLOR_MAP: Record = {
@@ -341,15 +373,19 @@ function NavItem({
collapsed,
colorDot,
live = false,
+ disabled = false,
}: NavItemProps) {
const content = (
-
{colorDot ? (
@@ -380,7 +416,7 @@ function NavItem({
Live
) : null}
-
+
)
if (collapsed) {
@@ -394,3 +430,29 @@ function NavItem({
return content
}
+
+function NavItemContent({
+ href,
+ disabled,
+ className,
+ children,
+}: {
+ href: string
+ disabled: boolean
+ className: string
+ children: ReactNode
+}) {
+ if (disabled) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/components/top-bar.tsx b/components/top-bar.tsx
index 69f00cf..63b4825 100644
--- a/components/top-bar.tsx
+++ b/components/top-bar.tsx
@@ -1,30 +1,51 @@
"use client"
import { Search } from "lucide-react"
+import { useExamLock } from "@/features/exam/exam-lock"
import { AccountMenu } from "@/components/top-bar/account-menu"
import { NotificationsMenu } from "@/components/top-bar/notifications-menu"
import { OrganizationMenu } from "@/components/top-bar/organization-menu"
import { RoleMenu } from "@/components/top-bar/role-menu"
export function TopBar() {
+ const { isLocked, lock } = useExamLock()
+
return (
-
-
-
-
-
-
-
-
-
+ {!isLocked ? (
+
+
+
+
+ ) : (
+
+
+ Exam mode active
+
+
+ {lock?.examTitle ?? "Active exam"} is locking navigation for this
+ student attempt.
+
+
+ )}
-
-
+ {/* Right side */}
+ {!isLocked ? (
+
+ ) : (
+
+ Exam route locked
+
+ )}
)
}
diff --git a/features/exam/exam-header.tsx b/features/exam/exam-header.tsx
index 1c698f2..d4580c4 100644
--- a/features/exam/exam-header.tsx
+++ b/features/exam/exam-header.tsx
@@ -1,26 +1,35 @@
"use client"
-import { Clock, Send } from "lucide-react"
+import { Clock, Loader2, Send } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { cn } from "@/lib/utils"
-import type { Class, Exam } from "@/lib/mock-data"
interface ExamHeaderProps {
- exam: Exam
- cls: Class
+ title: string
+ classCode: string
+ questionCount: number
+ totalPoints: number
answeredCount: number
progress: number
timeLeft: number
+ isSaving: boolean
+ saveError: string | null
+ isSubmitting: boolean
onSubmit: () => void
}
export function ExamHeader({
- exam,
- cls,
+ title,
+ classCode,
+ questionCount,
+ totalPoints,
answeredCount,
progress,
timeLeft,
+ isSaving,
+ saveError,
+ isSubmitting,
onSubmit,
}: ExamHeaderProps) {
const mins = String(Math.floor(timeLeft / 60)).padStart(2, "0")
@@ -31,16 +40,24 @@ export function ExamHeader({
- {exam.title}
+ {title}
- {cls.code} · {exam.questions.length} questions ·{" "}
- {exam.totalPoints} pts
+ {classCode} · {questionCount} questions · {totalPoints}{" "}
+ pts
- {answeredCount}/{exam.questions.length} answered
+ {answeredCount}/{questionCount} answered
+
+
+ {saveError ? "Save failed" : isSaving ? "Autosaving..." : "Saved"}
{mins}:{secs}
-
-
- Submit Exam
+
+ {isSubmitting ? (
+
+ ) : (
+
+ )}
+ {isSubmitting ? "Submitting..." : "Submit Exam"}
)
diff --git a/features/exam/exam-lobby.tsx b/features/exam/exam-lobby.tsx
index a48e430..8cef005 100644
--- a/features/exam/exam-lobby.tsx
+++ b/features/exam/exam-lobby.tsx
@@ -4,17 +4,40 @@ import { AlertCircle, BookOpen } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
-import type { Class, Exam } from "@/lib/mock-data"
export function ExamLobby({
- exam,
- cls,
+ title,
+ className,
+ classCode,
+ status,
+ questionCount,
+ durationMinutes,
+ totalPoints,
+ requiresPasscode,
+ startBlockedReason,
+ passcode,
+ onPasscodeChange,
onStart,
+ disabled,
+ actionLabel,
}: {
- exam: Exam
- cls: Pick
+ title: string
+ className: string
+ classCode: string
+ status: "upcoming" | "live" | "ended"
+ questionCount: number | null
+ durationMinutes: number
+ totalPoints: number
+ requiresPasscode: boolean
+ startBlockedReason: string | null
+ passcode: string
+ onPasscodeChange: (value: string) => void
onStart: () => void
+ disabled: boolean
+ actionLabel: string
}) {
return (
@@ -26,43 +49,43 @@ export function ExamLobby({
variant="secondary"
className={cn(
"mb-2",
- exam.status === "live" &&
+ status === "live" &&
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
- exam.status === "upcoming" &&
+ status === "upcoming" &&
"bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
+ status === "ended" &&
+ "bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300",
)}
>
- {exam.status === "live"
+ {status === "live"
? "In Progress"
- : exam.status === "upcoming"
- ? "Upcoming"
+ : status === "upcoming"
+ ? "Scheduled"
: "Ended"}
- {exam.title}
+ {title}
- {cls.name} · {cls.code}
+ {className} · {classCode}
- {exam.questions.length}
+ {questionCount ?? "?"}
Questions
- {exam.durationMinutes}
+ {durationMinutes}
Minutes
-
- {exam.totalPoints}
-
+
{totalPoints}
Total pts
@@ -73,9 +96,11 @@ export function ExamLobby({
{[
"Once started, the timer cannot be paused.",
- "Code questions include starter code - edit as needed.",
- "All answers are auto-saved as you type.",
- "Submit before time runs out or it submits automatically.",
+ "Questions can include multiple choice and short answers.",
+ "Answers are auto-saved through the backend.",
+ "Submitting ends the attempt immediately.",
+ "You must enter the teacher's passcode before the exam can start.",
+ "Fullscreen exam mode is required. Leaving fullscreen or switching tabs is recorded for the teacher.",
].map((note) => (
@@ -83,13 +108,42 @@ export function ExamLobby({
))}
+
+ {requiresPasscode && status === "live" && (
+
+ Passcode
+ onPasscodeChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && !disabled) {
+ event.preventDefault()
+ onStart()
+ }
+ }}
+ placeholder="Enter exam passcode"
+ autoFocus
+ minLength={4}
+ autoComplete="one-time-code"
+ />
+
+ )}
+
+ {startBlockedReason ? (
+
+ {startBlockedReason}
+
+ ) : null}
+
- {exam.status === "upcoming" ? "Exam not started yet" : "Begin Exam"}
+ {actionLabel}
)
diff --git a/features/exam/exam-lock.test.ts b/features/exam/exam-lock.test.ts
new file mode 100644
index 0000000..70f88d5
--- /dev/null
+++ b/features/exam/exam-lock.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "bun:test"
+import { isPathAllowedUnderExamLock } from "@/features/exam/exam-lock"
+
+describe("isPathAllowedUnderExamLock", () => {
+ test("allows staying on the exam route during a locked attempt", () => {
+ expect(
+ isPathAllowedUnderExamLock("/classes/class-1/exam", {
+ examRoute: "/classes/class-1/exam",
+ }),
+ ).toEqual(true)
+ })
+
+ test("blocks navigation to other class sections during a locked attempt", () => {
+ expect(
+ isPathAllowedUnderExamLock("/classes/class-1/assignments", {
+ examRoute: "/classes/class-1/exam",
+ }),
+ ).toEqual(false)
+ })
+})
diff --git a/features/exam/exam-lock.tsx b/features/exam/exam-lock.tsx
new file mode 100644
index 0000000..f869264
--- /dev/null
+++ b/features/exam/exam-lock.tsx
@@ -0,0 +1,229 @@
+"use client"
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react"
+import { usePathname, useRouter } from "next/navigation"
+import type { ClassExamApiDto } from "@/lib/exams/types"
+
+type ExamLockState = {
+ classId: string
+ examId: string
+ attemptId: string
+ examTitle: string
+ examRoute: string
+}
+
+type ExamLockContextValue = {
+ lock: ExamLockState | null
+ isLocked: boolean
+ setExamLock: (lock: ExamLockState | null) => void
+ canNavigateToPath: (path: string) => boolean
+}
+
+const STORAGE_KEY = "eduverse.exam-lock"
+
+const ExamLockContext = createContext(null)
+
+export function ExamLockProvider({ children }: { children: ReactNode }) {
+ const pathname = usePathname()
+ const router = useRouter()
+ const [lock, setLockState] = useState(null)
+ const [isLockConfirmed, setIsLockConfirmed] = useState(false)
+ const lastLeavePathRef = useRef(null)
+
+ const setExamLock = useCallback((nextLock: ExamLockState | null) => {
+ setLockState(nextLock)
+ setIsLockConfirmed(Boolean(nextLock))
+
+ if (typeof window === "undefined") return
+
+ if (!nextLock) {
+ window.sessionStorage.removeItem(STORAGE_KEY)
+ return
+ }
+
+ window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(nextLock))
+ }, [])
+
+ useEffect(() => {
+ if (typeof window === "undefined") return
+
+ const raw = window.sessionStorage.getItem(STORAGE_KEY)
+ if (!raw) return
+
+ try {
+ setLockState(JSON.parse(raw) as ExamLockState)
+ setIsLockConfirmed(false)
+ } catch {
+ window.sessionStorage.removeItem(STORAGE_KEY)
+ }
+ }, [])
+
+ useEffect(() => {
+ const classIdToCheck = lock?.classId ?? extractClassId(pathname)
+ if (!classIdToCheck) return
+ const nextClassId = classIdToCheck
+
+ let cancelled = false
+
+ async function syncLockFromServer() {
+ try {
+ const response = await fetch(
+ `/api/classes/${encodeURIComponent(nextClassId)}/exams`,
+ )
+ const payload = (await response.json().catch(() => null)) as
+ | (ClassExamApiDto & { error?: string })
+ | { error?: string }
+ | null
+
+ if (
+ cancelled ||
+ !response.ok ||
+ !payload ||
+ "error" in payload ||
+ !isClassExamApiPayload(payload)
+ ) {
+ return
+ }
+
+ if (payload.canManage) {
+ if (lock?.classId === nextClassId) {
+ setLockState(null)
+ setIsLockConfirmed(false)
+ if (typeof window !== "undefined") {
+ window.sessionStorage.removeItem(STORAGE_KEY)
+ }
+ }
+ return
+ }
+
+ const activeExam = payload.student.activeExam
+ const nextLock =
+ activeExam?.attempt === null || activeExam?.attempt === undefined
+ ? null
+ : {
+ classId: activeExam.classId,
+ examId: activeExam.id,
+ attemptId: activeExam.attempt.id,
+ examTitle: activeExam.title,
+ examRoute: `/classes/${activeExam.classId}/exam`,
+ }
+
+ setLockState(nextLock)
+ setIsLockConfirmed(Boolean(nextLock))
+
+ if (typeof window === "undefined") return
+
+ if (!nextLock) {
+ window.sessionStorage.removeItem(STORAGE_KEY)
+ return
+ }
+
+ window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(nextLock))
+ } catch {}
+ }
+
+ void syncLockFromServer()
+
+ return () => {
+ cancelled = true
+ }
+ }, [lock?.classId, pathname])
+
+ useEffect(() => {
+ if (!lock || !isLockConfirmed) return
+ if (isPathAllowedUnderExamLock(pathname, lock)) {
+ lastLeavePathRef.current = null
+ return
+ }
+
+ if (lastLeavePathRef.current !== pathname) {
+ lastLeavePathRef.current = pathname
+ void recordRouteLeaveAttempt(lock, pathname)
+ }
+
+ router.replace(lock.examRoute)
+ }, [isLockConfirmed, lock, pathname, router])
+
+ const value = useMemo(
+ () => ({
+ lock,
+ isLocked: Boolean(lock && isLockConfirmed),
+ setExamLock,
+ canNavigateToPath: (path: string) =>
+ !lock || !isLockConfirmed || isPathAllowedUnderExamLock(path, lock),
+ }),
+ [isLockConfirmed, lock, setExamLock],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useExamLock() {
+ const context = useContext(ExamLockContext)
+ if (!context) {
+ throw new Error("useExamLock must be used within an ExamLockProvider.")
+ }
+
+ return context
+}
+
+function extractClassId(pathname: string) {
+ const match = pathname.match(/^\/classes\/([^/]+)/)
+ return match?.[1] ?? null
+}
+
+function isClassExamApiPayload(value: unknown): value is ClassExamApiDto {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "canManage" in value &&
+ typeof value.canManage === "boolean"
+ )
+}
+
+export function isPathAllowedUnderExamLock(
+ pathname: string,
+ lock: Pick,
+) {
+ return (
+ pathname === lock.examRoute || pathname.startsWith(`${lock.examRoute}/`)
+ )
+}
+
+async function recordRouteLeaveAttempt(
+ lock: ExamLockState,
+ attemptedPath: string,
+) {
+ try {
+ await fetch(
+ `/api/classes/${encodeURIComponent(
+ lock.classId,
+ )}/exams/${encodeURIComponent(lock.examId)}/attempts/${encodeURIComponent(
+ lock.attemptId,
+ )}/events`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ eventType: "route_leave_attempt",
+ payload: {
+ attemptedPath,
+ },
+ }),
+ },
+ )
+ } catch {}
+}
diff --git a/features/exam/exam-results.tsx b/features/exam/exam-results.tsx
index 6a37c72..9b29233 100644
--- a/features/exam/exam-results.tsx
+++ b/features/exam/exam-results.tsx
@@ -1,92 +1,146 @@
"use client"
-import { CheckCircle2 } from "lucide-react"
+import { format } from "date-fns"
+import { AlertCircle, CheckCircle2, Clock3 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
+import type {
+ ReleasedExamQuestionResultDto,
+ ReleasedExamResultDto,
+} from "@/lib/exams/types"
import { cn } from "@/lib/utils"
-import type { Exam } from "@/lib/mock-data"
-
-export function ExamResults({
- exam,
- answers,
-}: {
- exam: Exam
- answers: Record
-}) {
- const mcqScore = exam.questions
- .filter((question) => question.type === "mcq")
- .reduce(
- (sum, question) =>
- answers[question.id] === question.correctIndex
- ? sum + question.points
- : sum,
- 0,
- )
- const pendingReview = exam.questions
- .filter((question) => question.type !== "mcq")
- .reduce((sum, question) => sum + question.points, 0)
- const percentage = Math.round((mcqScore / exam.totalPoints) * 100)
+
+export function ExamResults({ result }: { result: ReleasedExamResultDto }) {
+ const percentage =
+ result.isReleased && result.totalPoints > 0 && result.totalScore !== null
+ ? Math.round((result.totalScore / result.totalPoints) * 100)
+ : null
return (
-
-
+
+ {result.isReleased ? (
+
+ ) : (
+
+ )}
-
Exam Submitted!
+
{result.title}
- Your exam has been submitted successfully. Auto-graded results are
- shown below.
+ {result.isReleased
+ ? "Your released exam result is shown below."
+ : result.needsManualReview
+ ? "Your attempt was submitted successfully. Teacher review and result release are still pending."
+ : "Your attempt was submitted successfully. Scores stay hidden until results are released."}
+
+
+ {formatAttemptStatus(result.status)}
+
+
+ {formatIntegrityStatus(result.integrityStatus)}
+
+
-
- {mcqScore}
+
+ {result.totalScore ?? "Pending"}
+
+
+ {result.isReleased ? "Score" : "Score status"}
-
MCQ Score
-
- {pendingReview}
-
-
- Pending Review
+
+ {result.totalPoints}
+
Total pts
-
{percentage}%
-
MCQ %
+
+ {percentage === null ? "-" : `${percentage}%`}
+
+
Percent
+
+
+
+
+
+
+
+
+ Submitted
+
+
+ {result.submittedAt
+ ? format(new Date(result.submittedAt), "MMM d, h:mm a")
+ : "Not submitted"}
+
+
+
+
+ Reviewed
+
+
+ {result.gradedAt
+ ? format(new Date(result.gradedAt), "MMM d, h:mm a")
+ : "Pending"}
+
+
+
+
+ Released
+
+
+ {result.releasedAt
+ ? format(new Date(result.releasedAt), "MMM d, h:mm a")
+ : "Not released"}
+
Answer Summary
- {exam.questions.map((question, index) => {
- const answer = answers[question.id]
- const isCorrect =
- question.type === "mcq" ? answer === question.correctIndex : null
-
+ {result.questions.map((question, index) => {
return (
- {question.question}
+ {question.prompt}
- {question.type === "mcq" ? (
-
- {answer !== undefined ? (
-
- Your answer:{" "}
-
- {question.options?.[answer as number]}
-
-
- ) : null}
- {isCorrect === false ? (
-
- Correct:{" "}
- {question.options?.[question.correctIndex ?? 0]}
-
- ) : null}
-
- ) : (
-
- Pending teacher review
+
+
+ {formatQuestionStatus(question.status, result.isReleased)}
- )}
+
+ {formatAnswerPreview(question)}
+
+ {result.isReleased && hasCorrectAnswerReview(question) ? (
+
+ Correct answer: {formatCorrectAnswerPreview(question)}
+
+ ) : null}
+
+
+
+
+ {question.points} pts
+
+
+ {question.score === null
+ ? "Pending"
+ : `${question.score} / ${question.points}`}
+
-
- {question.points} pts
-
)
})}
+
+ {!result.isReleased && (
+
+
+
+ Released scores and per-question grading details stay hidden until
+ the backend marks this attempt as released.
+
+
+ )}
)
}
+
+function formatAttemptStatus(status: ReleasedExamResultDto["status"]) {
+ if (status === "graded") return "Graded"
+ if (status === "voided") return "Voided"
+ if (status === "submitted") return "Submitted"
+ return "In progress"
+}
+
+function formatIntegrityStatus(
+ status: ReleasedExamResultDto["integrityStatus"],
+) {
+ if (status === "flagged") return "Flagged"
+ if (status === "voided") return "Voided"
+ if (status === "reported") return "Reported"
+ return "Clear"
+}
+
+function integrityBadgeClass(status: ReleasedExamResultDto["integrityStatus"]) {
+ if (status === "voided") {
+ return "bg-destructive/10 text-destructive dark:bg-destructive/15"
+ }
+
+ if (status === "flagged" || status === "reported") {
+ return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
+ }
+
+ return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
+}
+
+function formatQuestionStatus(
+ status: ReleasedExamQuestionResultDto["status"],
+ isReleased: boolean,
+) {
+ if (!isReleased) {
+ return status === "unanswered" ? "No answer submitted" : "Awaiting release"
+ }
+
+ if (status === "correct") return "Correct"
+ if (status === "incorrect") return "Incorrect"
+ if (status === "reviewed") return "Reviewed"
+ return "Unanswered"
+}
+
+function formatAnswerPreview(question: ReleasedExamQuestionResultDto) {
+ if (question.selectedOptionIndex !== null) {
+ return `Selected option ${String.fromCharCode(65 + question.selectedOptionIndex)}`
+ }
+
+ if (question.selectedTextAnswer) {
+ return question.selectedTextAnswer
+ }
+
+ return "No answer submitted."
+}
+
+function hasCorrectAnswerReview(question: ReleasedExamQuestionResultDto) {
+ return (
+ question.correctOptionIndex !== null ||
+ (question.correctTextAnswer !== null &&
+ question.correctTextAnswer.trim().length > 0)
+ )
+}
+
+function formatCorrectAnswerPreview(question: ReleasedExamQuestionResultDto) {
+ if (question.correctOptionIndex !== null) {
+ return `Option ${String.fromCharCode(65 + question.correctOptionIndex)}`
+ }
+
+ if (question.correctTextAnswer) {
+ return question.correctTextAnswer
+ }
+
+ return "Not available."
+}
diff --git a/features/exam/exam-screen.tsx b/features/exam/exam-screen.tsx
index 2ab3129..01b3911 100644
--- a/features/exam/exam-screen.tsx
+++ b/features/exam/exam-screen.tsx
@@ -1,54 +1,291 @@
"use client"
-import { AlertCircle, ChevronLeft, ChevronRight, Send } from "lucide-react"
+import { useEffect, useRef, useState } from "react"
+import { format } from "date-fns"
+import {
+ AlertCircle,
+ ChevronLeft,
+ ChevronRight,
+ Loader2,
+ Send,
+} from "lucide-react"
+import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
-import type { Class, Exam } from "@/lib/mock-data"
+import { Spinner } from "@/components/ui/spinner"
+import type { JsonValue, StudentExamPageDto } from "@/lib/exams/types"
+import { resolveStudentExamPageState } from "@/lib/education/selectors"
import { ExamHeader } from "./exam-header"
+import { useExamLock } from "./exam-lock"
import { ExamLobby } from "./exam-lobby"
-import { ExamResults } from "./exam-results"
import { QuestionNavigator } from "./question-navigator"
import { QuestionView } from "./question-view"
import { useExamSession } from "./use-exam-session"
-export function ExamScreen({ cls, exam }: { cls: Class; exam: Exam }) {
+type ClassInfo = {
+ name: string
+ code: string
+}
+
+export function ExamScreen({
+ cls,
+ page,
+ isLoading,
+ isMutating,
+ errorMessage,
+ onStartExam,
+ onSaveAnswer,
+ onSubmitExam,
+ onRecordEvent,
+}: {
+ cls: ClassInfo
+ page: StudentExamPageDto | null
+ isLoading: boolean
+ isMutating: boolean
+ errorMessage: string | null
+ onStartExam: (examId: string, input: { passcode: string }) => Promise
+ onSaveAnswer: (input: {
+ examId: string
+ attemptId: string
+ questionId: string
+ answer: JsonValue | null
+ }) => Promise
+ onSubmitExam: (examId: string, attemptId: string) => Promise
+ onRecordEvent: (
+ examId: string,
+ attemptId: string,
+ body: {
+ eventType: string
+ payload: Record
+ },
+ ) => Promise
+}) {
+ const [passcode, setPasscode] = useState("")
+ const [actionError, setActionError] = useState(null)
+
+ const pendingSaveRef = useRef | null>(null)
+
+ const state = page ? resolveStudentExamPageState(page) : "none"
+ const activeExam = page?.activeExam ?? null
+ const { setExamLock } = useExamLock()
+
const {
- started,
- submitted,
currentQuestionIndex,
answers,
timeLeft,
- startExam,
- submitExam,
+ isSaving,
+ saveError,
+ isSubmitting,
+ isExamModeBlocked,
+ examModeError,
setCurrentQuestionIndex,
setAnswer,
- } = useExamSession(exam)
+ submitExam,
+ resumeExamMode,
+ } = useExamSession({
+ activeExam: activeExam?.attempt ? activeExam : null,
+
+ onSaveAnswer: async (questionId, answer) => {
+ if (!activeExam?.attempt) return
+
+ const p = onSaveAnswer({
+ examId: activeExam.id,
+ attemptId: activeExam.attempt.id,
+ questionId,
+ answer,
+ })
+
+ pendingSaveRef.current = p
+ await p
+ },
+
+ onSubmit: async () => {
+ if (!activeExam?.attempt) return
+
+ setActionError(null)
+
+ if (pendingSaveRef.current) {
+ await pendingSaveRef.current
+ }
- if (submitted) {
- return
+ await onSubmitExam(activeExam.id, activeExam.attempt.id)
+ },
+
+ onRecordEvent: async (eventType, payload) => {
+ if (!activeExam?.attempt) return
+
+ await onRecordEvent(activeExam.id, activeExam.attempt.id, {
+ eventType,
+ payload: payload ?? {},
+ })
+ },
+ })
+
+ useEffect(() => {
+ if (state !== "active") {
+ setPasscode("")
+ setActionError(null)
+ }
+ }, [state])
+
+ useEffect(() => {
+ if (!activeExam?.attempt) {
+ setExamLock(null)
+ return
+ }
+
+ setExamLock({
+ classId: activeExam.classId,
+ examId: activeExam.id,
+ attemptId: activeExam.attempt.id,
+ examTitle: activeExam.title,
+ examRoute: `/classes/${activeExam.classId}/exam`,
+ })
+ }, [activeExam, setExamLock])
+
+ if (isLoading && !page) {
+ return (
+
+
+ Loading exam...
+
+ )
+ }
+
+ if (errorMessage && !page) {
+ return (
+
+ )
}
- if (!started) {
- return
+ if (state === "scheduled" && page?.scheduledExam) {
+ return (
+ {}}
+ onStart={() => {}}
+ disabled
+ actionLabel={
+ page.scheduledExam.startAt
+ ? `Opens ${format(new Date(page.scheduledExam.startAt), "MMM d, h:mm a")}`
+ : "Scheduled"
+ }
+ />
+ )
}
- const question = exam.questions[currentQuestionIndex]
- const answeredCount = Object.keys(answers).length
- const progress = Math.round((answeredCount / exam.questions.length) * 100)
+ if (!activeExam) {
+ return (
+ No exam available
+ )
+ }
+
+ if (!activeExam.attempt) {
+ return (
+
+ {(errorMessage || actionError) && (
+
+
+ {actionError ?? errorMessage}
+
+
+ )}
+
+
void onStartExam(activeExam.id, { passcode })}
+ disabled={
+ isMutating ||
+ !activeExam.canStartAttempt ||
+ (activeExam.requiresPasscode && passcode.trim().length < 4)
+ }
+ actionLabel={
+ isMutating
+ ? "Starting..."
+ : activeExam.canStartAttempt
+ ? "Start Exam"
+ : "Start unavailable"
+ }
+ />
+
+ )
+ }
+
+ const question = activeExam.questions[currentQuestionIndex] ?? null
+
+ const answeredCount = activeExam.questions.filter((q) => {
+ const ans = answers[q.id]
+ return ans !== undefined && ans !== null && ans !== ""
+ }).length
+
+ const progress =
+ activeExam.questions.length > 0
+ ? Math.round((answeredCount / activeExam.questions.length) * 100)
+ : 0
+
+ if (!question) {
+ return (
+
+
+
+ This exam does not have any published questions yet.
+
+
+
+ )
+ }
return (
+ {(errorMessage || actionError || saveError || examModeError) && (
+
+
+
+ {actionError ?? examModeError ?? saveError ?? errorMessage}
+
+
+
+ )}
+
void submitExam()}
/>
setAnswer(question.id, value)}
/>
-
+
+
- setCurrentQuestionIndex((currentQuestionIndex ?? 0) - 1)
- }
- className="gap-1.5"
+ onClick={() => setCurrentQuestionIndex(currentQuestionIndex - 1)}
>
Previous
- {currentQuestionIndex < exam.questions.length - 1 ? (
+
+ {currentQuestionIndex < activeExam.questions.length - 1 ? (
setCurrentQuestionIndex(currentQuestionIndex + 1)
}
- className="gap-1.5"
>
Next
) : (
-
-
- Submit
+ void submitExam()}
+ disabled={isSubmitting}
+ >
+ {isSubmitting ? : }
+ {isSubmitting ? "Submitting..." : "Submit"}
)}
-
- )
-}
-export function NoExamState() {
- return (
-
-
-
No exam available
-
- There is no active exam for this class.
-
+ {activeExam.examModeEnabled && isExamModeBlocked && (
+
+ void resumeExamMode()}>
+ Resume fullscreen
+
+
+ )}
)
}
diff --git a/features/exam/manager-detail-state.test.ts b/features/exam/manager-detail-state.test.ts
new file mode 100644
index 0000000..7902f3f
--- /dev/null
+++ b/features/exam/manager-detail-state.test.ts
@@ -0,0 +1,200 @@
+import { describe, expect, test } from "bun:test"
+import {
+ buildGradeInputsForAttempt,
+ getEndedExamApprovalStatus,
+ getAttemptGradeIndicator,
+ getAttemptMonitorStatus,
+ getExamMonitorSummary,
+ isAttemptSuspicious,
+ resolveSelectedAttemptId,
+} from "@/features/exam/manager-detail-state"
+
+const attempt = {
+ id: "attempt-1",
+ studentUserId: "student-1",
+ studentDisplayName: "Student One",
+ studentEmail: "student@example.com",
+ status: "submitted" as const,
+ startedAt: "2026-05-04T08:00:00Z",
+ submittedAt: "2026-05-04T08:20:00Z",
+ totalScore: null,
+ attemptNumber: 1,
+ needsManualReview: true,
+ integrityStatus: "clear" as const,
+ resultsReleasedAt: null,
+ availableRetakeCount: 0,
+ answers: [
+ {
+ id: "answer-1",
+ questionId: "question-1",
+ answer: "draft",
+ autoScore: 2,
+ teacherScore: null,
+ },
+ {
+ id: "answer-2",
+ questionId: "question-2",
+ answer: 1,
+ autoScore: 5,
+ teacherScore: 4,
+ },
+ ],
+ integrityEvents: [],
+}
+
+describe("resolveSelectedAttemptId", () => {
+ test("keeps the detail panel closed until a student card is selected", () => {
+ expect(
+ resolveSelectedAttemptId({
+ attempts: [attempt],
+ currentSelectedAttemptId: null,
+ }),
+ ).toEqual(null)
+ })
+
+ test("keeps the current selected attempt during a background refresh", () => {
+ expect(
+ resolveSelectedAttemptId({
+ attempts: [attempt],
+ currentSelectedAttemptId: "attempt-1",
+ }),
+ ).toEqual("attempt-1")
+ })
+
+ test("falls back to the first attempt when the previous selection disappears", () => {
+ expect(
+ resolveSelectedAttemptId({
+ attempts: [attempt],
+ currentSelectedAttemptId: "missing-attempt",
+ }),
+ ).toEqual("attempt-1")
+ })
+})
+
+describe("buildGradeInputsForAttempt", () => {
+ test("keeps existing teacher grades stable across detail refreshes", () => {
+ expect(buildGradeInputsForAttempt(attempt)).toEqual({
+ "question-1": "",
+ "question-2": "4",
+ })
+ })
+})
+
+describe("monitor card helpers", () => {
+ test("marks attempts with integrity events as suspicious", () => {
+ expect(
+ isAttemptSuspicious({
+ integrityStatus: "clear",
+ integrityEvents: [
+ {
+ key: "event-1",
+ eventType: "fullscreen_exit",
+ createdAt: "2026-05-04T08:05:00Z",
+ payload: {},
+ },
+ ],
+ }),
+ ).toEqual(true)
+ })
+
+ test("returns normal status for clean attempts", () => {
+ expect(
+ getAttemptMonitorStatus({
+ integrityStatus: "clear",
+ integrityEvents: [],
+ }),
+ ).toEqual("Normal")
+ })
+
+ test("prioritizes released indicator over grading states", () => {
+ expect(
+ getAttemptGradeIndicator({
+ resultsReleasedAt: "2026-05-04T08:25:00Z",
+ needsManualReview: false,
+ status: "graded",
+ totalScore: 19,
+ }),
+ ).toEqual("Released")
+ })
+
+ test("shows needs grading for manual-review attempts", () => {
+ expect(
+ getAttemptGradeIndicator({
+ resultsReleasedAt: null,
+ needsManualReview: true,
+ status: "submitted",
+ totalScore: null,
+ }),
+ ).toEqual("Needs grading")
+ })
+
+ test("shows voided when the attempt was voided", () => {
+ expect(
+ getAttemptGradeIndicator({
+ resultsReleasedAt: null,
+ needsManualReview: false,
+ status: "voided",
+ totalScore: null,
+ }),
+ ).toEqual("Voided")
+ })
+
+ test("summarizes suspicious, graded, and entered students without double-counting retakes", () => {
+ expect(
+ getExamMonitorSummary([
+ attempt,
+ {
+ ...attempt,
+ status: "graded",
+ resultsReleasedAt: "2026-05-04T08:30:00Z",
+ integrityStatus: "flagged",
+ integrityEvents: [
+ {
+ key: "event-2",
+ eventType: "visibility_hidden",
+ createdAt: "2026-05-04T08:10:00Z",
+ payload: {},
+ },
+ ],
+ },
+ {
+ ...attempt,
+ studentUserId: "student-2",
+ integrityStatus: "clear",
+ integrityEvents: [],
+ resultsReleasedAt: null,
+ },
+ ] as any),
+ ).toEqual({
+ suspiciousStudents: 1,
+ gradedStudents: 1,
+ enteredStudents: 2,
+ })
+ })
+
+ test("shows grades confirmed when all entered students have approved results", () => {
+ expect(
+ getEndedExamApprovalStatus({
+ status: "ended",
+ enteredStudentCount: 2,
+ releasedStudentCount: 2,
+ }),
+ ).toEqual({
+ label: "Grades confirmed",
+ tone: "confirmed",
+ })
+ })
+
+ test("shows waiting for approval when an ended exam still has pending grades", () => {
+ expect(
+ getEndedExamApprovalStatus({
+ status: "ended",
+ enteredStudentCount: 3,
+ releasedStudentCount: 1,
+ }),
+ ).toEqual({
+ label: "Waiting for approval",
+ tone: "pending",
+ })
+ })
+})
diff --git a/features/exam/manager-detail-state.ts b/features/exam/manager-detail-state.ts
new file mode 100644
index 0000000..5d4a676
--- /dev/null
+++ b/features/exam/manager-detail-state.ts
@@ -0,0 +1,137 @@
+import type {
+ ManagerAttemptSummaryDto,
+ ManagerExamSummaryDto,
+} from "@/lib/exams/types"
+
+export function getExamMonitorSummary(
+ attempts: Pick<
+ ManagerAttemptSummaryDto,
+ | "studentUserId"
+ | "status"
+ | "integrityStatus"
+ | "integrityEvents"
+ | "resultsReleasedAt"
+ >[],
+) {
+ const enteredStudents = new Set()
+ const suspiciousStudents = new Set()
+ const gradedStudents = new Set()
+
+ for (const attempt of attempts) {
+ enteredStudents.add(attempt.studentUserId)
+
+ if (attempt.status === "voided" || isAttemptSuspicious(attempt)) {
+ suspiciousStudents.add(attempt.studentUserId)
+ }
+
+ if (attempt.resultsReleasedAt) {
+ gradedStudents.add(attempt.studentUserId)
+ }
+ }
+
+ return {
+ suspiciousStudents: suspiciousStudents.size,
+ gradedStudents: gradedStudents.size,
+ enteredStudents: enteredStudents.size,
+ }
+}
+
+export function getEndedExamApprovalStatus(
+ exam: Pick<
+ ManagerExamSummaryDto,
+ "status" | "enteredStudentCount" | "releasedStudentCount"
+ >,
+) {
+ if (exam.status !== "ended") {
+ return null
+ }
+
+ const isConfirmed =
+ exam.enteredStudentCount > 0 &&
+ exam.releasedStudentCount >= exam.enteredStudentCount
+
+ return isConfirmed
+ ? {
+ label: "Grades confirmed",
+ tone: "confirmed" as const,
+ }
+ : {
+ label: "Waiting for approval",
+ tone: "pending" as const,
+ }
+}
+
+export function resolveSelectedAttemptId(input: {
+ attempts: ManagerAttemptSummaryDto[]
+ currentSelectedAttemptId: string | null
+}) {
+ if (input.currentSelectedAttemptId === null) {
+ return null
+ }
+
+ const existingSelection = input.currentSelectedAttemptId
+ ? input.attempts.find(
+ (attempt) => attempt.id === input.currentSelectedAttemptId,
+ )
+ : null
+
+ return existingSelection?.id ?? input.attempts[0]?.id ?? null
+}
+
+export function buildGradeInputsForAttempt(
+ attempt: ManagerAttemptSummaryDto | null | undefined,
+) {
+ if (!attempt) return {}
+
+ return Object.fromEntries(
+ attempt.answers.map((answer) => [
+ answer.questionId,
+ answer.teacherScore === null ? "" : String(answer.teacherScore),
+ ]),
+ )
+}
+
+export function isAttemptSuspicious(
+ attempt: Pick<
+ ManagerAttemptSummaryDto,
+ "integrityStatus" | "integrityEvents"
+ >,
+) {
+ return (
+ attempt.integrityStatus !== "clear" || attempt.integrityEvents.length > 0
+ )
+}
+
+export function getAttemptMonitorStatus(
+ attempt: Pick<
+ ManagerAttemptSummaryDto,
+ "integrityStatus" | "integrityEvents"
+ >,
+) {
+ return isAttemptSuspicious(attempt) ? "Suspicious" : "Normal"
+}
+
+export function getAttemptGradeIndicator(
+ attempt: Pick<
+ ManagerAttemptSummaryDto,
+ "resultsReleasedAt" | "needsManualReview" | "status" | "totalScore"
+ >,
+) {
+ if (attempt.status === "voided") {
+ return "Voided"
+ }
+
+ if (attempt.resultsReleasedAt) {
+ return "Released"
+ }
+
+ if (attempt.needsManualReview) {
+ return "Needs grading"
+ }
+
+ if (attempt.totalScore === null) {
+ return "Pending score"
+ }
+
+ return "Awaiting approval"
+}
diff --git a/features/exam/manager-exam-screen.tsx b/features/exam/manager-exam-screen.tsx
new file mode 100644
index 0000000..02c7a09
--- /dev/null
+++ b/features/exam/manager-exam-screen.tsx
@@ -0,0 +1,1610 @@
+"use client"
+
+import { format } from "date-fns"
+import {
+ CheckCircle2,
+ ClipboardList,
+ Eye,
+ Loader2,
+ PlusCircle,
+ RotateCcw,
+ ShieldAlert,
+ Trash2,
+ Users,
+} from "lucide-react"
+import { useState, type FormEvent } from "react"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Spinner } from "@/components/ui/spinner"
+import { Textarea } from "@/components/ui/textarea"
+import { StatCard } from "@/components/shared/stat-card"
+import type {
+ GradeAttemptInput,
+ ManagerExamDetailDto,
+ ManagerExamSummaryDto,
+ UpsertExamInput,
+ UpsertExamQuestionInput,
+} from "@/lib/exams/types"
+import { canTeacherGradeQuestion } from "@/lib/exams/grading"
+import { formatIntegrityEvent } from "@/lib/exams/integrity"
+import type { Class } from "@/lib/mock-data"
+import { cn } from "@/lib/utils"
+import {
+ buildGradeInputsForAttempt,
+ getEndedExamApprovalStatus,
+ getAttemptGradeIndicator,
+ getAttemptMonitorStatus,
+ getExamMonitorSummary,
+ isAttemptSuspicious,
+ resolveSelectedAttemptId,
+} from "./manager-detail-state"
+import type { UseClassExamResult } from "./use-class-exam"
+
+type QuestionEditorState = {
+ type: UpsertExamQuestionInput["type"]
+ prompt: string
+ points: string
+ optionsText: string
+ correctAnswerText: string
+}
+
+type ExamFormState = {
+ title: string
+ durationMinutes: string
+ startAt: string
+ passcode: string
+ questions: QuestionEditorState[]
+}
+
+const EMPTY_QUESTION: QuestionEditorState = {
+ type: "mcq",
+ prompt: "",
+ points: "10",
+ optionsText: "Option A\nOption B",
+ correctAnswerText: "1",
+}
+
+const EMPTY_FORM: ExamFormState = {
+ title: "",
+ durationMinutes: "60",
+ startAt: "",
+ passcode: "",
+ questions: [{ ...EMPTY_QUESTION }],
+}
+
+export function ManagerExamScreen({
+ cls,
+ examApi,
+}: {
+ cls: Pick
+ examApi: UseClassExamResult
+}) {
+ const {
+ data,
+ isLoading,
+ isRefreshing,
+ isMutating,
+ errorMessage,
+ createExam,
+ updateExam,
+ publishExam,
+ deleteExam,
+ grantRetake,
+ getExamDetail,
+ gradeAttempt,
+ updateIntegrity,
+ } = examApi
+ const [isCreateOpen, setIsCreateOpen] = useState(false)
+ const [form, setForm] = useState(EMPTY_FORM)
+ const [formError, setFormError] = useState(null)
+ const [editingExam, setEditingExam] = useState(
+ null,
+ )
+ const [editingPasscodeProtected, setEditingPasscodeProtected] =
+ useState(false)
+ const [detailExamId, setDetailExamId] = useState(null)
+ const [detail, setDetail] = useState(null)
+ const [detailError, setDetailError] = useState(null)
+ const [isDetailLoading, setIsDetailLoading] = useState(false)
+ const [isDetailRefreshing, setIsDetailRefreshing] = useState(false)
+ const [successMessage, setSuccessMessage] = useState(null)
+ const [selectedAttemptId, setSelectedAttemptId] = useState(
+ null,
+ )
+ const [gradeInputs, setGradeInputs] = useState>({})
+
+ const exams = data?.canManage ? data.manager.exams : []
+ const selectedAttempt = detail?.attempts.find(
+ (attempt) => attempt.id === selectedAttemptId,
+ )
+ const isLiveMonitor = detail?.exam.status === "live"
+ const monitorSummary = detail ? getExamMonitorSummary(detail.attempts) : null
+ const suspiciousAttemptCount =
+ detail?.attempts.filter(isAttemptSuspicious).length ?? 0
+ const detailApprovalStatus = detail
+ ? getEndedExamApprovalStatus(detail.exam)
+ : null
+ const selectedAttemptMonitorStatus = selectedAttempt
+ ? getAttemptMonitorStatus(selectedAttempt)
+ : null
+ const selectedAttemptGradeIndicator = selectedAttempt
+ ? getAttemptGradeIndicator(selectedAttempt)
+ : null
+ const canGradeSelectedAttempt =
+ selectedAttempt !== undefined &&
+ selectedAttempt !== null &&
+ selectedAttempt.status !== "in_progress" &&
+ selectedAttempt.status !== "voided" &&
+ !selectedAttempt.resultsReleasedAt
+ const canGrantSelectedRetake =
+ selectedAttempt !== undefined &&
+ selectedAttempt !== null &&
+ selectedAttempt.status !== "in_progress" &&
+ selectedAttempt.availableRetakeCount === 0
+
+ async function openDetail(examId: string) {
+ setDetailExamId(examId)
+ setDetail(null)
+ setDetailError(null)
+ setSelectedAttemptId(null)
+ setIsDetailLoading(true)
+
+ try {
+ const nextDetail = await getExamDetail(examId)
+ applyDetailState(nextDetail, null)
+ } catch (error) {
+ setDetailError(
+ error instanceof Error ? error.message : "Could not load exam detail.",
+ )
+ } finally {
+ setIsDetailLoading(false)
+ }
+ }
+
+ async function refreshDetail(examId: string) {
+ setIsDetailRefreshing(true)
+ setDetailError(null)
+
+ try {
+ const nextDetail = await getExamDetail(examId)
+ applyDetailState(nextDetail, selectedAttemptId)
+ } catch (error) {
+ setDetailError(
+ error instanceof Error
+ ? error.message
+ : "Could not refresh exam detail.",
+ )
+ } finally {
+ setIsDetailRefreshing(false)
+ }
+ }
+
+ function applyDetailState(
+ nextDetail: ManagerExamDetailDto,
+ nextSelectedAttemptId: string | null,
+ ) {
+ setDetail(nextDetail)
+ const resolvedAttemptId = resolveSelectedAttemptId({
+ attempts: nextDetail.attempts,
+ currentSelectedAttemptId: nextSelectedAttemptId,
+ })
+ const resolvedAttempt =
+ nextDetail.attempts.find((attempt) => attempt.id === resolvedAttemptId) ??
+ null
+ setSelectedAttemptId(resolvedAttemptId)
+ setGradeInputs(buildGradeInputsForAttempt(resolvedAttempt))
+ }
+
+ function resetForm() {
+ setForm(EMPTY_FORM)
+ setFormError(null)
+ setEditingExam(null)
+ setEditingPasscodeProtected(false)
+ }
+
+ function openCreate() {
+ resetForm()
+ setIsCreateOpen(true)
+ }
+
+ async function openEdit(examId: string) {
+ try {
+ setFormError(null)
+ const nextDetail = await getExamDetail(examId)
+ hydrateFormFromDetail(nextDetail)
+ } catch (error) {
+ setFormError(
+ error instanceof Error ? error.message : "Could not load exam detail.",
+ )
+ }
+ }
+
+ function hydrateFormFromDetail(nextDetail: ManagerExamDetailDto) {
+ setEditingExam(nextDetail.exam)
+ setEditingPasscodeProtected(nextDetail.exam.passcodeProtected)
+ setForm({
+ title: nextDetail.exam.title,
+ durationMinutes: String(nextDetail.exam.durationMinutes),
+ startAt: toDatetimeLocalValue(nextDetail.exam.startAt),
+ passcode: "",
+ questions: nextDetail.questions.map((question) => ({
+ type: toSupportedQuestionType(question.type),
+ prompt: question.prompt,
+ points: String(question.points),
+ optionsText: question.options.join("\n"),
+ correctAnswerText:
+ typeof question.correctAnswer === "number"
+ ? String(question.correctAnswer + 1)
+ : typeof question.correctAnswer === "string"
+ ? question.correctAnswer
+ : "",
+ })),
+ })
+ setIsCreateOpen(true)
+ }
+
+ async function submitForm(event: FormEvent) {
+ event.preventDefault()
+
+ try {
+ setFormError(null)
+ setSuccessMessage(null)
+ const payload = toExamPayload(form, {
+ editingPasscodeProtected,
+ })
+ if (editingExam) {
+ await updateExam(editingExam.id, payload)
+ } else {
+ await createExam(payload)
+ }
+ setIsCreateOpen(false)
+ resetForm()
+ } catch (error) {
+ setFormError(
+ error instanceof Error ? error.message : "Could not save exam.",
+ )
+ }
+ }
+
+ async function publishSelectedExam(examId: string) {
+ try {
+ setDetailError(null)
+ await publishExam(examId)
+ setSuccessMessage("Exam published successfully.")
+ if (detailExamId === examId) {
+ await refreshDetail(examId)
+ }
+ } catch (error) {
+ setSuccessMessage(null)
+ setDetailError(
+ error instanceof Error ? error.message : "Could not publish exam.",
+ )
+ }
+ }
+
+ async function deleteSelectedExam(examId: string) {
+ if (
+ typeof window !== "undefined" &&
+ !window.confirm(
+ "Delete this exam and all of its attempts? This action cannot be undone.",
+ )
+ ) {
+ return
+ }
+
+ try {
+ setSuccessMessage(null)
+ await deleteExam(examId)
+ if (detailExamId === examId) {
+ setDetailExamId(null)
+ setDetail(null)
+ setSelectedAttemptId(null)
+ }
+ } catch (error) {
+ setDetailError(
+ error instanceof Error ? error.message : "Could not delete exam.",
+ )
+ }
+ }
+
+ async function submitGrades() {
+ if (!detail || !selectedAttempt) return
+
+ const answers: GradeAttemptInput["answers"] = detail.questions
+ .filter((question) =>
+ canTeacherGradeQuestion({
+ questionType: question.type,
+ correctAnswer: question.correctAnswer,
+ }),
+ )
+ .map((question) => ({
+ questionId: question.id,
+ teacherScore:
+ gradeInputs[question.id] === ""
+ ? null
+ : Number.parseFloat(gradeInputs[question.id] ?? ""),
+ }))
+
+ try {
+ setSuccessMessage(null)
+ await gradeAttempt(detail.exam.id, selectedAttempt.id, { answers })
+ await refreshDetail(detail.exam.id)
+ } catch (error) {
+ setDetailError(
+ error instanceof Error ? error.message : "Could not save grade.",
+ )
+ }
+ }
+
+ async function updateSelectedIntegrity(action: "flag" | "void" | "clear") {
+ if (!detail || !selectedAttempt) return
+
+ try {
+ setSuccessMessage(null)
+ await updateIntegrity(detail.exam.id, selectedAttempt.id, {
+ action,
+ })
+ await refreshDetail(detail.exam.id)
+ } catch (error) {
+ setDetailError(
+ error instanceof Error
+ ? error.message
+ : "Could not update integrity state.",
+ )
+ }
+ }
+
+ async function grantSelectedRetake() {
+ if (!detail || !selectedAttempt) return
+
+ try {
+ setSuccessMessage(null)
+ await grantRetake(detail.exam.id, selectedAttempt.id)
+ await refreshDetail(detail.exam.id)
+ } catch (error) {
+ setDetailError(
+ error instanceof Error ? error.message : "Could not grant retake.",
+ )
+ }
+ }
+
+ if (isLoading && !data) {
+ return (
+
+
+ Loading exams...
+
+ )
+ }
+
+ return (
+
+
+
+
{cls.name}
+
+ {cls.code} · {exams.length} exams
+ {isRefreshing ? " · Refreshing..." : ""}
+
+
+
+
+ New Exam
+
+
+
+ {(errorMessage || formError) && (
+
+ {errorMessage ?? formError}
+
+ )}
+
+ {successMessage && (
+
+
+ {successMessage}
+
+ )}
+
+ {exams.length === 0 ? (
+
+ ) : (
+
+ {exams.map((exam) => {
+ const endedApprovalStatus = getEndedExamApprovalStatus(exam)
+
+ return (
+
+
+
+
+
+
+
+
+ {exam.title}
+
+
+ {formatExamStatus(exam.status)}
+
+ {!exam.publishedAt && (
+
Draft
+ )}
+ {endedApprovalStatus ? (
+
+ {endedApprovalStatus.label}
+
+ ) : null}
+
+
+ {exam.durationMinutes} min
+ {exam.totalPoints} pts
+
+ {exam.startAt
+ ? format(new Date(exam.startAt), "MMM d, h:mm a")
+ : "No start time"}
+
+
+ {exam.attemptCounts.inProgress} in progress ·{" "}
+ {exam.attemptCounts.submitted} submitted ·{" "}
+ {exam.attemptCounts.graded} graded ·{" "}
+ {exam.attemptCounts.released} released
+
+ {exam.enteredStudentCount} student entries
+
+
+
+ void openDetail(exam.id)}
+ >
+
+
+ {canEditExam(exam) && (
+ void openEdit(exam.id)}>
+ Edit
+
+ )}
+ {!exam.publishedAt && (
+ void publishSelectedExam(exam.id)}
+ >
+ Publish
+
+ )}
+ void deleteSelectedExam(exam.id)}
+ >
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+
{
+ setIsCreateOpen(open)
+ if (!open && !isMutating) resetForm()
+ }}
+ >
+
+
+
+
+
+
{
+ if (!open) {
+ setDetailExamId(null)
+ setDetail(null)
+ setSelectedAttemptId(null)
+ setDetailError(null)
+ }
+ }}
+ >
+
+
+
+ {detail?.exam.title ?? "Exam detail"}
+ {detail ? (
+
+ {formatExamStatus(detail.exam.status)}
+
+ ) : null}
+ {detailApprovalStatus ? (
+
+ {detailApprovalStatus.label}
+
+ ) : null}
+
+
+ {isLiveMonitor
+ ? "Live control panel for monitoring students, integrity events, and grading progress."
+ : "Review completed attempts, grading, and released results."}
+
+
+
+ {detailError && (
+
+ {detailError}
+
+ )}
+
+ {isDetailRefreshing && detail ? (
+
+ Refreshing detail...
+
+ ) : null}
+
+ {isDetailLoading ? (
+
+
+ Loading exam detail...
+
+ ) : detail ? (
+
+
+
+
+
+
+
+
+
+
+ {isLiveMonitor ? "Student monitor" : "Student attempts"}
+
+
+ {detail.attempts.length} student
+ {detail.attempts.length === 1 ? "" : "s"}{" "}
+ {suspiciousAttemptCount > 0
+ ? `• ${suspiciousAttemptCount} suspicious`
+ : "• no suspicious activity"}
+ . Select a card to open full details.
+
+
+
+ {detail.attempts.length === 0 ? (
+
+ No attempts yet.
+
+ ) : (
+
+
+ {detail.attempts.map((attempt) => {
+ const monitorStatus = getAttemptMonitorStatus(attempt)
+ const suspicious = isAttemptSuspicious(attempt)
+
+ return (
+
{
+ setSelectedAttemptId(attempt.id)
+ setGradeInputs(
+ buildGradeInputsForAttempt(attempt),
+ )
+ }}
+ className={cn(
+ "w-full rounded-xl border p-4 text-left transition-colors",
+ suspicious
+ ? "border-red-500/60 bg-red-500/10 hover:bg-red-500/15"
+ : "bg-background hover:bg-muted/40",
+ attempt.id === selectedAttemptId &&
+ (suspicious
+ ? "ring-2 ring-red-500/30"
+ : "border-primary bg-primary/5 ring-2 ring-primary/20"),
+ )}
+ >
+
+
+
+ {attempt.studentDisplayName}
+
+
+ {monitorStatus}
+
+
+ {attempt.integrityEvents.length > 0 ? (
+
+ {attempt.integrityEvents.length} alert
+ {attempt.integrityEvents.length === 1
+ ? ""
+ : "s"}
+
+ ) : null}
+
+
+ {suspicious
+ ? "Suspicious activity detected. Open to review events and actions."
+ : "Normal activity. Open to review grading and attempt details."}
+
+
+ )
+ })}
+
+
+ )}
+
+
+
+
{
+ if (!open) {
+ setSelectedAttemptId(null)
+ }
+ }}
+ >
+
+ {selectedAttempt ? (
+ <>
+
+
+ {selectedAttempt.studentDisplayName}
+
+
+
+ {selectedAttempt.studentEmail}
+
+
+ {selectedAttemptGradeIndicator} · Attempt{" "}
+ {selectedAttempt.attemptNumber} ·{" "}
+ {selectedAttempt.totalScore === null
+ ? "Score pending"
+ : `${selectedAttempt.totalScore} points`}
+
+
+
+
+
+
+
+ {selectedAttemptMonitorStatus}
+
+ {selectedAttempt.resultsReleasedAt ? (
+
+ Released
+
+ ) : null}
+
+
+
+
+ Student overview
+
+
+
+
+ Monitor status
+
+
+ {selectedAttemptMonitorStatus}
+
+
+
+
+ Grade state
+
+
+ {selectedAttemptGradeIndicator}
+
+
+
+
+ Retakes available
+
+
+ {selectedAttempt.availableRetakeCount}
+
+
+
+
+ Exam mode alerts
+
+
+ {selectedAttempt.integrityEvents.length}
+
+
+
+
+
+
+
Grade information
+ {detail.questions.map((question) => {
+ const answer = selectedAttempt.answers.find(
+ (candidate) =>
+ candidate.questionId === question.id,
+ )
+ const teacherCanGradeQuestion =
+ canTeacherGradeQuestion({
+ questionType: question.type,
+ correctAnswer: question.correctAnswer,
+ })
+
+ return (
+
+
+
+
+ {question.type === "mcq"
+ ? "MCQ"
+ : "Short answer"}
+
+
+ {question.prompt}
+
+
+
+ {question.points} pts
+
+
+
+ {formatManagerAnswer(answer?.answer)}
+
+
+
+ {teacherCanGradeQuestion ? (
+
+ setGradeInputs((current) => ({
+ ...current,
+ [question.id]: value,
+ }))
+ }
+ disabled={!canGradeSelectedAttempt}
+ min="0"
+ />
+ ) : null}
+
+ {!teacherCanGradeQuestion ? (
+
+ This answer is graded automatically and is
+ not editable.
+
+ ) : (
+
+ Manual grading is required because no model
+ answer was provided for this short answer.
+
+ )}
+
+ )
+ })}
+
+
+
+
Exam mode events
+ {selectedAttempt.integrityEvents.length === 0 ? (
+
+ No fullscreen or tab-switch events recorded for
+ this attempt.
+
+ ) : (
+
+ {selectedAttempt.integrityEvents.map((event) => {
+ const formattedEvent = formatIntegrityEvent({
+ eventType: event.eventType,
+ payload: event.payload,
+ })
+
+ return (
+
+
+
+ {formattedEvent.title}
+
+
+ {format(
+ new Date(event.createdAt),
+ "MMM d, h:mm:ss a",
+ )}
+
+
+
+ {formattedEvent.detail}
+
+ {Object.keys(event.payload).length > 0 ? (
+
+
+ Details
+
+
+ {JSON.stringify(
+ event.payload,
+ null,
+ 2,
+ )}
+
+
+ ) : null}
+
+ )
+ })}
+
+ )}
+
+
+ {!isLiveMonitor ? (
+
+ Void controls are only active while the exam is
+ live.
+
+ ) : null}
+
+
+ void submitGrades()}
+ disabled={isMutating || !canGradeSelectedAttempt}
+ >
+
+ Approve grade
+
+ void grantSelectedRetake()}
+ disabled={isMutating || !canGrantSelectedRetake}
+ >
+
+ Grant retake
+
+ void updateSelectedIntegrity("void")}
+ disabled={
+ !isLiveMonitor ||
+ isMutating ||
+ selectedAttempt.status === "voided"
+ }
+ >
+
+ Void attempt
+
+
+
+ >
+ ) : null}
+
+
+
+ ) : (
+
+ Select an exam to review.
+
+ )}
+
+
+
+ )
+
+ function updateQuestion(index: number, patch: Partial) {
+ setForm((current) => ({
+ ...current,
+ questions: current.questions.map((question, questionIndex) =>
+ questionIndex === index ? { ...question, ...patch } : question,
+ ),
+ }))
+ }
+}
+
+function toExamPayload(
+ form: ExamFormState,
+ options: {
+ editingPasscodeProtected: boolean
+ },
+): UpsertExamInput {
+ const durationMinutes = Number.parseInt(form.durationMinutes, 10)
+ const title = form.title.trim()
+ const passcode = form.passcode.trim()
+
+ if (!title) {
+ throw new Error("Exam title is required.")
+ }
+
+ if (!form.startAt) {
+ throw new Error("Select a valid exam start date and time.")
+ }
+
+ if (!Number.isFinite(durationMinutes) || durationMinutes <= 0) {
+ throw new Error("Duration must be greater than zero.")
+ }
+
+ if (!passcode && !options.editingPasscodeProtected) {
+ throw new Error("Exam passcode is required.")
+ }
+
+ if (passcode && passcode.length < 4) {
+ throw new Error("Exam passcode must be at least 4 characters.")
+ }
+
+ return {
+ title,
+ durationMinutes,
+ startAt: new Date(form.startAt).toISOString(),
+ passcode: passcode.length > 0 ? passcode : undefined,
+ questions: form.questions.map((question) => {
+ const prompt = question.prompt.trim()
+ const points = Number.parseInt(question.points, 10)
+ const options =
+ question.type === "mcq"
+ ? question.optionsText
+ .split("\n")
+ .map((option) => option.trim())
+ .filter(Boolean)
+ : []
+ const correctOptionNumber = Number.parseInt(
+ question.correctAnswerText,
+ 10,
+ )
+ const modelAnswer = question.correctAnswerText.trim()
+
+ if (!prompt) {
+ throw new Error("Each question needs a prompt.")
+ }
+
+ if (!Number.isFinite(points) || points <= 0) {
+ throw new Error("Each question must be worth at least 1 point.")
+ }
+
+ if (question.type === "mcq") {
+ if (options.length === 0) {
+ throw new Error("Multiple choice questions need at least one option.")
+ }
+
+ if (
+ !Number.isFinite(correctOptionNumber) ||
+ correctOptionNumber < 1 ||
+ correctOptionNumber > options.length
+ ) {
+ throw new Error(
+ "Correct option number must be between 1 and the number of options.",
+ )
+ }
+ }
+
+ return {
+ type: question.type,
+ prompt,
+ options,
+ correctAnswer:
+ question.type === "mcq"
+ ? correctOptionNumber - 1
+ : modelAnswer.length > 0
+ ? modelAnswer
+ : null,
+ points,
+ }
+ }),
+ }
+}
+
+function statusBadge(status: string) {
+ if (status === "live") {
+ return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
+ }
+
+ if (status === "ended") {
+ return "bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300"
+ }
+
+ return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
+}
+
+function formatExamStatus(status: string) {
+ if (status === "live") return "Live"
+ if (status === "ended") return "Ended"
+ return "Upcoming"
+}
+
+function canEditExam(exam: ManagerExamSummaryDto) {
+ const attemptTotal =
+ exam.attemptCounts.inProgress +
+ exam.attemptCounts.submitted +
+ exam.attemptCounts.graded
+
+ return exam.status !== "ended" && attemptTotal === 0
+}
+
+function formatManagerAnswer(answer: unknown) {
+ if (answer === null || answer === undefined) {
+ return "No answer submitted."
+ }
+
+ if (typeof answer === "string") return answer
+ if (typeof answer === "number") return `Selected option ${answer + 1}`
+ return JSON.stringify(answer, null, 2)
+}
+
+function toDatetimeLocalValue(value: string | null) {
+ if (!value) return ""
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return ""
+
+ const offsetMs = date.getTimezoneOffset() * 60_000
+ return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
+}
+
+function toSupportedQuestionType(
+ type: ManagerExamDetailDto["questions"][number]["type"],
+) {
+ return type === "mcq" ? "mcq" : "short"
+}
+
+function formatQuestionType(type: QuestionEditorState["type"]) {
+ return type === "mcq" ? "MCQ" : "Short Answer"
+}
+
+function getStartDatePart(value: string) {
+ return toDatetimeLocalValue(value).split("T")[0] ?? ""
+}
+
+const HOUR_OPTIONS = Array.from({ length: 12 }, (_, index) => {
+ const value = String(index + 1).padStart(2, "0")
+ return { value, label: value }
+})
+
+const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => {
+ const value = String(index).padStart(2, "0")
+ return { value, label: value }
+})
+
+function getStartTimeParts(value: string) {
+ const time = toDatetimeLocalValue(value).split("T")[1]?.slice(0, 5) ?? "00:00"
+ const [hour24Raw, minuteRaw] = time.split(":").map(Number)
+ const hour24 = Number.isFinite(hour24Raw) ? hour24Raw : 0
+ const minute = Number.isFinite(minuteRaw) ? minuteRaw : 0
+ const period: "AM" | "PM" = hour24 >= 12 ? "PM" : "AM"
+ const hour12 = hour24 % 12 || 12
+
+ return {
+ hour: String(hour12).padStart(2, "0"),
+ minute: String(minute).padStart(2, "0"),
+ period,
+ }
+}
+
+function combineStartParts(
+ date: string,
+ time: {
+ hour: string
+ minute: string
+ period: "AM" | "PM"
+ },
+) {
+ if (!date) return ""
+
+ const [year, month, day] = date.split("-").map(Number)
+ const parsedHour = Number.parseInt(time.hour, 10)
+ const parsedMinute = Number.parseInt(time.minute, 10)
+
+ if (
+ !Number.isFinite(parsedHour) ||
+ parsedHour < 1 ||
+ parsedHour > 12 ||
+ !Number.isFinite(parsedMinute) ||
+ parsedMinute < 0 ||
+ parsedMinute > 59
+ ) {
+ return ""
+ }
+
+ const hour24 =
+ time.period === "PM"
+ ? parsedHour === 12
+ ? 12
+ : parsedHour + 12
+ : parsedHour === 12
+ ? 0
+ : parsedHour
+
+ const startAt = new Date(year, month - 1, day, hour24, parsedMinute)
+
+ return Number.isNaN(startAt.getTime()) ? "" : startAt.toISOString()
+}
+
+function Field({
+ label,
+ value,
+ onChange,
+ type = "text",
+ disabled = false,
+ min,
+ minLength,
+ placeholder,
+ step,
+ required = false,
+}: {
+ label: string
+ value: string
+ onChange?: (value: string) => void
+ type?: string
+ disabled?: boolean
+ min?: string
+ minLength?: number
+ placeholder?: string
+ step?: string
+ required?: boolean
+}) {
+ return (
+
+ {label}
+ onChange?.(event.target.value)}
+ disabled={disabled}
+ min={min}
+ minLength={minLength}
+ placeholder={placeholder}
+ step={step}
+ required={required}
+ />
+
+ )
+}
+
+function StartTimeFields({
+ value,
+ onChange,
+}: {
+ value: string
+ onChange: (value: string) => void
+}) {
+ const date = getStartDatePart(value)
+ const time = getStartTimeParts(value)
+
+ return (
+
+
+ Start date
+
+ onChange(combineStartParts(event.target.value, time))
+ }
+ required
+ />
+
+
+ Hour
+
+ onChange(
+ combineStartParts(date, {
+ hour,
+ minute: time.minute,
+ period: time.period,
+ }),
+ )
+ }
+ >
+
+
+
+
+ {HOUR_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ Minute
+
+ onChange(
+ combineStartParts(date, {
+ hour: time.hour,
+ minute,
+ period: time.period,
+ }),
+ )
+ }
+ >
+
+
+
+
+ {MINUTE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ Period
+
+ onChange(combineStartParts(date, { ...time, period }))
+ }
+ >
+
+
+
+
+ AM
+ PM
+
+
+
+
+ )
+}
diff --git a/features/exam/question-navigator.tsx b/features/exam/question-navigator.tsx
index f90e234..1c631a2 100644
--- a/features/exam/question-navigator.tsx
+++ b/features/exam/question-navigator.tsx
@@ -1,17 +1,17 @@
"use client"
+import type { JsonValue, StudentQuestionDto } from "@/lib/exams/types"
import { cn } from "@/lib/utils"
-import type { Exam } from "@/lib/mock-data"
interface QuestionNavigatorProps {
- exam: Exam
+ questions: StudentQuestionDto[]
currentQuestionIndex: number
- answers: Record
+ answers: Record
onSelectQuestion: (index: number) => void
}
export function QuestionNavigator({
- exam,
+ questions,
currentQuestionIndex,
answers,
onSelectQuestion,
@@ -22,7 +22,7 @@ export function QuestionNavigator({
Questions
- {exam.questions.map((question, index) => (
+ {questions.map((question, index) => (
onSelectQuestion(index)}
@@ -30,7 +30,7 @@ export function QuestionNavigator({
"w-8 h-8 rounded-lg text-xs font-semibold transition-colors",
index === currentQuestionIndex
? "bg-primary text-primary-foreground"
- : answers[question.id] !== undefined
+ : hasAnswerValue(answers[question.id])
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground",
)}
@@ -56,3 +56,9 @@ export function QuestionNavigator({
)
}
+
+function hasAnswerValue(value: JsonValue | null | undefined) {
+ if (value === null || value === undefined) return false
+ if (typeof value === "string") return value.trim().length > 0
+ return true
+}
diff --git a/features/exam/question-view.tsx b/features/exam/question-view.tsx
index 11946dc..5fda9d7 100644
--- a/features/exam/question-view.tsx
+++ b/features/exam/question-view.tsx
@@ -1,27 +1,20 @@
"use client"
-import dynamic from "next/dynamic"
import { Badge } from "@/components/ui/badge"
+import type { JsonValue, StudentQuestionDto } from "@/lib/exams/types"
import { cn } from "@/lib/utils"
-import { Code2 } from "lucide-react"
-import type { ExamQuestion } from "@/lib/mock-data"
-
-const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
- ssr: false,
-})
const TYPE_LABELS: Record = {
mcq: "Multiple Choice",
short: "Short Answer",
- code: "Code",
}
interface QuestionViewProps {
- question: ExamQuestion
+ question: StudentQuestionDto
index: number
totalQuestions: number
- answer: string | number | undefined
- onAnswer: (value: string | number) => void
+ answer: JsonValue | null | undefined
+ onAnswer: (value: JsonValue | null) => void
}
export function QuestionView({
@@ -46,13 +39,8 @@ export function QuestionView({
"bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
question.type === "short" &&
"bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
- question.type === "code" &&
- "bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300",
)}
>
- {question.type === "code" ? (
-
- ) : null}
{TYPE_LABELS[question.type]}
@@ -62,14 +50,14 @@ export function QuestionView({
- {question.question}
+ {question.prompt}
{question.type === "mcq" && question.options ? (
{question.options.map((option, optionIndex) => (
onAnswer(optionIndex)}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-xl border text-sm font-medium text-left transition-all",
@@ -103,36 +91,6 @@ export function QuestionView({
className="w-full px-4 py-3 rounded-xl border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/50 resize-none leading-relaxed"
/>
) : null}
-
- {question.type === "code" ? (
-
-
-
-
- {question.language ?? "python"}
-
-
- Starter code provided
-
-
-
onAnswer(value ?? "")}
- options={{
- fontSize: 13,
- minimap: { enabled: false },
- scrollBeyondLastLine: false,
- lineNumbers: "on",
- wordWrap: "on",
- padding: { top: 12, bottom: 12 },
- fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
- }}
- />
-
- ) : null}
)
}
diff --git a/features/exam/use-class-exam.ts b/features/exam/use-class-exam.ts
new file mode 100644
index 0000000..0569a55
--- /dev/null
+++ b/features/exam/use-class-exam.ts
@@ -0,0 +1,521 @@
+"use client"
+
+import { useCallback, useEffect, useRef, useState } from "react"
+import type {
+ ClassExamApiDto,
+ GradeAttemptInput,
+ IntegrityEventInput,
+ IntegrityActionInput,
+ ManagerExamDetailDto,
+ StartAttemptInput,
+ StudentActiveExamDto,
+ UpsertExamInput,
+} from "@/lib/exams/types"
+
+export function useClassExam(
+ classId: string,
+ options?: {
+ enabled?: boolean
+ },
+) {
+ const enabled = options?.enabled ?? true
+ const [data, setData] = useState
(null)
+ const [isLoading, setIsLoading] = useState(enabled)
+ const [isRefreshing, setIsRefreshing] = useState(false)
+ const [isMutating, setIsMutating] = useState(false)
+ const [errorMessage, setErrorMessage] = useState(null)
+ const dataRef = useRef(null)
+
+ useEffect(() => {
+ dataRef.current = data
+ }, [data])
+
+ const refresh = useCallback(
+ async (options?: { background?: boolean }) => {
+ if (!enabled) {
+ setData(null)
+ setErrorMessage(null)
+ setIsLoading(false)
+ setIsRefreshing(false)
+ return null
+ }
+
+ const shouldRefreshInBackground =
+ options?.background === true || dataRef.current !== null
+
+ if (shouldRefreshInBackground) {
+ setIsRefreshing(true)
+ } else {
+ setIsLoading(true)
+ }
+ setErrorMessage(null)
+
+ try {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(classId)}/exams`,
+ fallbackMessage: "Could not load exams.",
+ retryCount: 1,
+ })
+ const payload = (await response.json().catch(() => null)) as
+ | (ClassExamApiDto & { error?: string })
+ | { error?: string }
+ | null
+
+ if (!response.ok || !payload || "error" in payload) {
+ throw new Error(payload?.error ?? "Could not load exams.")
+ }
+
+ setData(payload as ClassExamApiDto)
+ return payload
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Could not load exams."
+ if (!shouldRefreshInBackground) {
+ setData(null)
+ }
+ setErrorMessage(message)
+ throw new Error(message)
+ } finally {
+ setIsLoading(false)
+ setIsRefreshing(false)
+ }
+ },
+ [classId, enabled],
+ )
+
+ useEffect(() => {
+ if (!enabled) {
+ setData(null)
+ setErrorMessage(null)
+ setIsLoading(false)
+ return
+ }
+
+ refresh().catch(() => {})
+ }, [enabled, refresh])
+
+ async function createExam(body: UpsertExamInput) {
+ return mutate(async () => {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(classId)}/exams`,
+ init: {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ fallbackMessage: "Could not create exam.",
+ })
+ await parseActionResponse(response, "Could not create exam.")
+ await refresh({ background: true })
+ })
+ }
+
+ async function updateExam(examId: string, body: UpsertExamInput) {
+ return mutate(async () => {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(classId)}/exams/${encodeURIComponent(examId)}`,
+ init: {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ fallbackMessage: "Could not update exam.",
+ })
+ await parseActionResponse(response, "Could not update exam.")
+ await refresh({ background: true })
+ })
+ }
+
+ async function publishExam(examId: string) {
+ return mutate(async () => {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(
+ classId,
+ )}/exams/${encodeURIComponent(examId)}/publish`,
+ init: { method: "POST" },
+ fallbackMessage: "Could not publish exam.",
+ })
+ await parseActionResponse(response, "Could not publish exam.")
+ await refresh({ background: true })
+ })
+ }
+
+ async function deleteExam(examId: string) {
+ return mutate(async () => {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(classId)}/exams/${encodeURIComponent(examId)}`,
+ init: {
+ method: "DELETE",
+ },
+ fallbackMessage: "Could not delete exam.",
+ })
+ await parseActionResponse(response, "Could not delete exam.")
+ await refresh({ background: true })
+ })
+ }
+
+ async function grantRetake(examId: string, attemptId: string) {
+ return mutate(async () => {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(
+ classId,
+ )}/exams/${encodeURIComponent(examId)}/attempts/${encodeURIComponent(
+ attemptId,
+ )}/retake`,
+ init: { method: "POST" },
+ fallbackMessage: "Could not grant retake.",
+ })
+ await parseActionResponse(response, "Could not grant retake.")
+ await refresh({ background: true })
+ })
+ }
+
+ async function getExamDetail(examId: string) {
+ let lastError = "Could not load exam detail."
+
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ const directResult = await fetchExamDetail(
+ `/api/classes/${encodeURIComponent(classId)}/exams/${encodeURIComponent(examId)}`,
+ )
+ if (directResult.exam) {
+ return directResult.exam
+ }
+
+ lastError = directResult.error
+
+ const shouldUseFallbackRoute =
+ directResult.status === 404 || directResult.exam === null
+
+ if (shouldUseFallbackRoute) {
+ const fallbackResult = await fetchExamDetail(
+ `/api/classes/${encodeURIComponent(classId)}/exams?detailExamId=${encodeURIComponent(examId)}`,
+ )
+
+ if (fallbackResult.exam) {
+ return fallbackResult.exam
+ }
+
+ lastError = fallbackResult.error
+ }
+
+ if (attempt === 0) {
+ await wait(250)
+ }
+ }
+
+ throw new Error(lastError)
+ }
+
+ async function startExam(examId: string, body: StartAttemptInput) {
+ return mutate(async () => {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(
+ classId,
+ )}/exams/${encodeURIComponent(examId)}/attempts`,
+ init: {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ fallbackMessage: "Could not start exam.",
+ })
+ const payload = (await response.json().catch(() => null)) as {
+ activeExam?: StudentActiveExamDto
+ error?: string
+ } | null
+
+ if (!response.ok || !payload?.activeExam) {
+ throw new Error(payload?.error ?? "Could not start exam.")
+ }
+
+ await refresh({ background: true })
+ return payload.activeExam
+ })
+ }
+
+ async function saveAnswer(input: {
+ examId: string
+ attemptId: string
+ questionId: string
+ answer: unknown
+ }) {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(
+ classId,
+ )}/exams/${encodeURIComponent(input.examId)}/attempts/${encodeURIComponent(
+ input.attemptId,
+ )}/answers`,
+ init: {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ questionId: input.questionId,
+ answer: input.answer,
+ }),
+ },
+ fallbackMessage: "Could not save answer.",
+ })
+
+ await parseActionResponse(response, "Could not save answer.")
+ }
+
+ async function submitExam(examId: string, attemptId: string) {
+ return mutate(async () => {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(
+ classId,
+ )}/exams/${encodeURIComponent(examId)}/attempts/${encodeURIComponent(
+ attemptId,
+ )}/submit`,
+ init: { method: "POST" },
+ fallbackMessage: "Could not submit exam.",
+ })
+ await parseActionResponse(response, "Could not submit exam.")
+
+ await refresh({ background: true })
+ })
+ }
+
+ async function gradeAttempt(
+ examId: string,
+ attemptId: string,
+ body: GradeAttemptInput,
+ ) {
+ return mutate(async () => {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(
+ classId,
+ )}/exams/${encodeURIComponent(examId)}/attempts/${encodeURIComponent(
+ attemptId,
+ )}/grade`,
+ init: {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ fallbackMessage: "Could not save grade.",
+ })
+ await parseActionResponse(response, "Could not save grade.")
+ await refresh({ background: true })
+ })
+ }
+
+ async function releaseAttempt(examId: string, attemptId: string) {
+ return mutate(async () => {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(
+ classId,
+ )}/exams/${encodeURIComponent(examId)}/attempts/${encodeURIComponent(
+ attemptId,
+ )}/release`,
+ init: { method: "POST" },
+ fallbackMessage: "Could not release results.",
+ })
+ await parseActionResponse(response, "Could not release results.")
+ await refresh({ background: true })
+ })
+ }
+
+ async function updateIntegrity(
+ examId: string,
+ attemptId: string,
+ body: IntegrityActionInput,
+ ) {
+ return mutate(async () => {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(
+ classId,
+ )}/exams/${encodeURIComponent(examId)}/attempts/${encodeURIComponent(
+ attemptId,
+ )}/integrity`,
+ init: {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ fallbackMessage: "Could not update integrity state.",
+ })
+ await parseActionResponse(response, "Could not update integrity state.")
+ await refresh({ background: true })
+ })
+ }
+
+ async function recordEvent(
+ examId: string,
+ attemptId: string,
+ body: IntegrityEventInput,
+ ) {
+ try {
+ const response = await requestExamApi({
+ url: `/api/classes/${encodeURIComponent(
+ classId,
+ )}/exams/${encodeURIComponent(examId)}/attempts/${encodeURIComponent(
+ attemptId,
+ )}/events`,
+ init: {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ keepalive: true,
+ },
+ fallbackMessage: "Could not record integrity event.",
+ retryCount: 0,
+ })
+
+ await parseActionResponse(response, "Could not record integrity event.")
+ } catch {
+ // Integrity logging is best-effort in the browser lifecycle and should
+ // never crash the active exam session.
+ }
+ }
+
+ async function mutate(callback: () => Promise) {
+ setIsMutating(true)
+ setErrorMessage(null)
+
+ try {
+ return await callback()
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Exam action failed."
+ setErrorMessage(message)
+ throw new Error(message)
+ } finally {
+ setIsMutating(false)
+ }
+ }
+
+ return {
+ data,
+ isLoading,
+ isRefreshing,
+ isMutating,
+ errorMessage,
+ refresh,
+ createExam,
+ updateExam,
+ publishExam,
+ deleteExam,
+ grantRetake,
+ getExamDetail,
+ startExam,
+ saveAnswer,
+ submitExam,
+ gradeAttempt,
+ releaseAttempt,
+ updateIntegrity,
+ recordEvent,
+ }
+}
+
+export type UseClassExamResult = ReturnType
+
+async function parseActionResponse(
+ response: Response,
+ fallbackMessage: string,
+) {
+ const payload = (await response.json().catch(() => null)) as {
+ error?: string
+ } | null
+
+ if (!response.ok) {
+ throw new Error(payload?.error ?? fallbackMessage)
+ }
+
+ return payload
+}
+
+function wait(durationMs: number) {
+ return new Promise((resolve) => setTimeout(resolve, durationMs))
+}
+
+async function fetchExamDetail(url: string) {
+ try {
+ const response = await requestExamApi({
+ url,
+ fallbackMessage: "Could not load exam detail.",
+ retryCount: 1,
+ })
+ const payload = (await response.json().catch(() => null)) as {
+ exam?: ManagerExamDetailDto
+ error?: string
+ } | null
+
+ if (response.ok && payload?.exam) {
+ return {
+ exam: payload.exam,
+ error: null,
+ status: response.status,
+ }
+ }
+
+ return {
+ exam: null,
+ error:
+ payload?.error ??
+ (response.status
+ ? `Could not load exam detail (${response.status}).`
+ : "Could not load exam detail."),
+ status: response.status,
+ }
+ } catch (error) {
+ return {
+ exam: null,
+ error:
+ error instanceof Error ? error.message : "Could not load exam detail.",
+ status: 0,
+ }
+ }
+}
+
+const EXAM_REQUEST_RETRY_DELAY_MS = 250
+
+async function requestExamApi(input: {
+ url: string
+ init?: RequestInit
+ fallbackMessage: string
+ retryCount?: number
+}) {
+ const retryCount = input.retryCount ?? 1
+ let lastError: unknown = null
+
+ for (let attempt = 0; attempt <= retryCount; attempt += 1) {
+ try {
+ return await fetch(input.url, input.init)
+ } catch (error) {
+ lastError = error
+
+ if (!isRetryableExamRequestError(error) || attempt >= retryCount) {
+ throw toExamRequestError(error, input.fallbackMessage)
+ }
+
+ await wait(EXAM_REQUEST_RETRY_DELAY_MS * (attempt + 1))
+ }
+ }
+
+ throw toExamRequestError(lastError, input.fallbackMessage)
+}
+
+function isRetryableExamRequestError(error: unknown) {
+ if (!(error instanceof Error)) {
+ return false
+ }
+
+ const message = error.message.toLowerCase()
+ return error.name === "AbortError" || message.includes("fetch failed")
+}
+
+function toExamRequestError(error: unknown, fallbackMessage: string) {
+ if (!(error instanceof Error)) {
+ return new Error(fallbackMessage)
+ }
+
+ if (
+ error.name === "AbortError" ||
+ error.message.toLowerCase().includes("fetch failed")
+ ) {
+ return new Error(fallbackMessage)
+ }
+
+ return error
+}
diff --git a/features/exam/use-exam-session.ts b/features/exam/use-exam-session.ts
index 0c55106..dd5064c 100644
--- a/features/exam/use-exam-session.ts
+++ b/features/exam/use-exam-session.ts
@@ -1,48 +1,253 @@
"use client"
-import { useEffect, useState } from "react"
-import type { Exam } from "@/lib/mock-data"
+import { useEffect, useRef, useState } from "react"
+import type { JsonValue, StudentActiveExamDto } from "@/lib/exams/types"
-export function useExamSession(exam: Exam | undefined) {
- const [started, setStarted] = useState(false)
- const [submitted, setSubmitted] = useState(false)
+export function useExamSession(input: {
+ activeExam: StudentActiveExamDto | null
+ onSaveAnswer: (questionId: string, answer: JsonValue | null) => Promise
+ onSubmit: () => Promise
+ onRecordEvent: (
+ eventType: string,
+ payload?: Record,
+ ) => Promise
+}) {
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
- const [answers, setAnswers] = useState>({})
+ const [answers, setAnswers] = useState>({})
const [timeLeft, setTimeLeft] = useState(0)
+ const [isSaving, setIsSaving] = useState(false)
+ const [saveError, setSaveError] = useState(null)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isExamModeBlocked, setIsExamModeBlocked] = useState(false)
+ const [examModeError, setExamModeError] = useState(null)
+ const timersRef = useRef>>(
+ new Map(),
+ )
+ const autoSubmitRef = useRef(false)
+ const examModeEnabled = input.activeExam?.examModeEnabled === true
useEffect(() => {
- if (!exam) return
- setTimeLeft(exam.durationMinutes * 60)
- }, [exam])
+ const nextAnswers =
+ input.activeExam?.questions.reduce>(
+ (result, question) => {
+ result[question.id] = question.savedAnswer ?? null
+ return result
+ },
+ {},
+ ) ?? {}
+
+ setAnswers(nextAnswers)
+ setCurrentQuestionIndex(0)
+ autoSubmitRef.current = false
+ setIsExamModeBlocked(false)
+ setExamModeError(null)
+ }, [input.activeExam])
useEffect(() => {
- if (!started || submitted || timeLeft <= 0) return
-
- const timer = setInterval(() => {
- setTimeLeft((current) => {
- if (current <= 1) {
- clearInterval(timer)
- setSubmitted(true)
- return 0
- }
+ const deadlineAt = input.activeExam?.attempt?.deadlineAt ?? null
+ if (!deadlineAt) {
+ setTimeLeft(0)
+ return
+ }
- return current - 1
- })
- }, 1000)
+ const updateTime = () => {
+ setTimeLeft(getTimeLeftSeconds(deadlineAt))
+ }
+ updateTime()
+ const timer = setInterval(updateTime, 1000)
return () => clearInterval(timer)
- }, [started, submitted, timeLeft])
+ }, [input.activeExam?.attempt?.deadlineAt])
+
+ useEffect(() => {
+ const deadlineAt = input.activeExam?.attempt?.deadlineAt ?? null
+ if (
+ !input.activeExam?.attempt ||
+ !deadlineAt ||
+ autoSubmitRef.current ||
+ isSubmitting
+ ) {
+ return
+ }
+
+ if (getTimeLeftSeconds(deadlineAt) > 0) {
+ return
+ }
+
+ autoSubmitRef.current = true
+ void submitExam()
+ }, [input.activeExam?.attempt, isSubmitting, timeLeft])
+
+ useEffect(() => {
+ if (!input.activeExam?.attempt) return
+
+ const recordEventSafely = (
+ eventType: string,
+ payload?: Record,
+ ) => {
+ void input.onRecordEvent(eventType, payload).catch(() => {
+ // Integrity events are best-effort and should never raise unhandled
+ // promise rejections in the active exam UI.
+ })
+ }
+
+ const handleVisibility = () => {
+ if (document.visibilityState === "hidden") {
+ if (examModeEnabled) {
+ setIsExamModeBlocked(true)
+ }
+ recordEventSafely("visibility_hidden", {
+ visibilityState: document.visibilityState,
+ })
+ }
+ }
+
+ const handleWindowBlur = () => {
+ if (examModeEnabled) {
+ setIsExamModeBlocked(true)
+ }
+ recordEventSafely("window_blur")
+ }
+
+ const handleFullscreen = () => {
+ if (!examModeEnabled) return
+
+ if (!document.fullscreenElement) {
+ setIsExamModeBlocked(true)
+ recordEventSafely("fullscreen_exit")
+ return
+ }
+
+ setIsExamModeBlocked(false)
+ setExamModeError(null)
+ }
+
+ if (examModeEnabled && !document.fullscreenElement) {
+ setIsExamModeBlocked(true)
+ }
+
+ document.addEventListener("visibilitychange", handleVisibility)
+ window.addEventListener("blur", handleWindowBlur)
+ document.addEventListener("fullscreenchange", handleFullscreen)
+
+ return () => {
+ document.removeEventListener("visibilitychange", handleVisibility)
+ window.removeEventListener("blur", handleWindowBlur)
+ document.removeEventListener("fullscreenchange", handleFullscreen)
+ }
+ }, [examModeEnabled, input.activeExam?.attempt, input.onRecordEvent])
+
+ useEffect(() => {
+ return () => {
+ for (const timer of timersRef.current.values()) {
+ clearTimeout(timer)
+ }
+ timersRef.current.clear()
+ }
+ }, [])
+
+ function setAnswer(questionId: string, value: JsonValue | null) {
+ setAnswers((current) => ({ ...current, [questionId]: value }))
+ setSaveError(null)
+
+ const existingTimer = timersRef.current.get(questionId)
+ if (existingTimer) clearTimeout(existingTimer)
+
+ const timer = setTimeout(() => {
+ void saveAnswer(questionId, value)
+ }, 500)
+
+ timersRef.current.set(questionId, timer)
+ }
+
+ async function saveAnswer(questionId: string, value: JsonValue | null) {
+ if (!input.activeExam?.attempt) return
+
+ setIsSaving(true)
+ try {
+ await input.onSaveAnswer(questionId, value)
+ timersRef.current.delete(questionId)
+ setSaveError(null)
+ } catch (error) {
+ setSaveError(
+ error instanceof Error ? error.message : "Could not save answer.",
+ )
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ async function submitExam() {
+ if (!input.activeExam?.attempt || isSubmitting) return
+
+ setIsSubmitting(true)
+ try {
+ for (const timer of timersRef.current.values()) {
+ clearTimeout(timer)
+ }
+ timersRef.current.clear()
+
+ const pendingSaves = Object.entries(answers).map(([questionId, answer]) =>
+ input.onSaveAnswer(questionId, answer),
+ )
+
+ await Promise.allSettled(pendingSaves)
+ await input.onSubmit()
+ } catch {
+ autoSubmitRef.current = false
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ async function resumeExamMode() {
+ if (!examModeEnabled || !input.activeExam?.attempt) {
+ setIsExamModeBlocked(false)
+ return true
+ }
+
+ const resumed = await requestExamModeFullscreen()
+ setIsExamModeBlocked(!resumed)
+ setExamModeError(
+ resumed
+ ? null
+ : "Fullscreen is required for this exam. Please allow fullscreen and try again.",
+ )
+
+ return resumed
+ }
return {
- started,
- submitted,
currentQuestionIndex,
answers,
timeLeft,
- startExam: () => setStarted(true),
- submitExam: () => setSubmitted(true),
+ isSaving,
+ saveError,
+ isSubmitting,
+ isExamModeBlocked,
+ examModeError,
setCurrentQuestionIndex,
- setAnswer: (questionId: string, value: string | number) =>
- setAnswers((prev) => ({ ...prev, [questionId]: value })),
+ setAnswer,
+ submitExam,
+ resumeExamMode,
}
}
+
+async function requestExamModeFullscreen() {
+ if (typeof document === "undefined") return false
+ if (document.fullscreenElement) return true
+ if (typeof document.documentElement.requestFullscreen !== "function") {
+ return false
+ }
+
+ try {
+ await document.documentElement.requestFullscreen()
+ return Boolean(document.fullscreenElement)
+ } catch {
+ return false
+ }
+}
+
+function getTimeLeftSeconds(deadlineAt: string) {
+ return Math.max(0, Math.floor((Date.parse(deadlineAt) - Date.now()) / 1000))
+}
diff --git a/features/results/class-results-screen.tsx b/features/results/class-results-screen.tsx
new file mode 100644
index 0000000..4355780
--- /dev/null
+++ b/features/results/class-results-screen.tsx
@@ -0,0 +1,483 @@
+"use client"
+
+import { useState } from "react"
+import { format } from "date-fns"
+import { ChartColumn, ClipboardList, FileText } from "lucide-react"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Spinner } from "@/components/ui/spinner"
+import {
+ ClassFeatureDisabledFallback,
+ ClassRouteFallback,
+ useClassFeatureRoute,
+} from "@/features/classes/use-class-route"
+import {
+ useClassAssignments,
+ type ClassAssignment,
+} from "@/features/assignments/use-class-assignments"
+import { ExamResults } from "@/features/exam/exam-results"
+import { useClassExam } from "@/features/exam/use-class-exam"
+import { resolveClassFeatures } from "@/lib/features/feature-registry"
+import type { ReleasedExamResultDto } from "@/lib/exams/types"
+import { useApp } from "@/lib/store"
+
+type StudentAssignmentResult = {
+ id: string
+ title: string
+ score: number
+ maxScore: number
+ gradedAt: string
+ feedback: string
+}
+
+type ManagerAssignmentResultSummary = {
+ id: string
+ title: string
+ dueAt: string
+ maxScore: number
+ gradedCount: number
+ submittedCount: number
+ pendingCount: number
+}
+
+export function ClassResultsScreen({ classId }: { classId: string }) {
+ const { authUser, currentUser, activeOrganization, featureDefinitions } =
+ useApp()
+ const { cls, classRow, isLoading, errorMessage, isFeatureDisabled } =
+ useClassFeatureRoute(classId, "leaderboard")
+
+ const canManage =
+ currentUser.role === "admin" ||
+ (currentUser.role === "teacher" &&
+ (classRow?.teacher_user_id === currentUser.id ||
+ classRow?.memberships.some(
+ (membership) =>
+ membership.user_id === currentUser.id &&
+ (membership.role === "teacher" || membership.role === "ta"),
+ ) === true))
+
+ const examFeatureEnabled =
+ !!classRow &&
+ !!activeOrganization &&
+ resolveClassFeatures({
+ definitions: featureDefinitions,
+ organizationSettings: activeOrganization.featureSettings,
+ classSettings: classRow.featureSettings,
+ }).find((feature) => feature.key === "exam")?.enabled !== false
+
+ const examApi = useClassExam(classId, {
+ enabled: examFeatureEnabled,
+ })
+ const assignmentsApi = useClassAssignments({
+ classId,
+ currentUserId: authUser?.id ?? currentUser.id ?? null,
+ canManage,
+ })
+ const [selectedExamResult, setSelectedExamResult] =
+ useState(null)
+
+ if (!cls) {
+ return (
+
+ )
+ }
+
+ if (isFeatureDisabled) {
+ return (
+
+ )
+ }
+
+ const studentAssignmentResults = canManage
+ ? []
+ : getStudentAssignmentResults(assignmentsApi.assignments)
+ const studentExamResults =
+ !canManage && examApi.data && !examApi.data.canManage
+ ? getStudentExamResults(examApi.data.student)
+ : []
+ const managerAssignmentSummaries = canManage
+ ? getManagerAssignmentResults(assignmentsApi.assignments)
+ : []
+ const managerExamSummaries =
+ canManage && examApi.data?.canManage ? examApi.data.manager.exams : []
+ const assignmentResultsCount = studentAssignmentResults.length
+ const examResultsCount = studentExamResults.length
+ const gradedAssignmentsCount = managerAssignmentSummaries.reduce(
+ (total, assignment) => total + assignment.gradedCount,
+ 0,
+ )
+ const releasedExamResultsCount = managerExamSummaries.reduce(
+ (total, exam) => total + exam.attemptCounts.released,
+ 0,
+ )
+
+ return (
+
+
+
+
{cls.name}
+
+ {cls.code} · Results
+
+
+
+
+
+ {canManage ? "Class overview" : "Your records"}
+
+
+
+
+ {(assignmentsApi.errorMessage || examApi.errorMessage) && (
+
+
+ {assignmentsApi.errorMessage ?? examApi.errorMessage}
+
+
+ )}
+
+
+
+
+
+
+ {canManage ? (
+
+
+
+
+
+ Assignment Results
+
+
+
+ {assignmentsApi.isLoading ? (
+
+ ) : managerAssignmentSummaries.length === 0 ? (
+
+ ) : (
+ managerAssignmentSummaries.map((assignment) => (
+
+
+
+
+ {assignment.title}
+
+
+ Due{" "}
+ {format(new Date(assignment.dueAt), "MMM d, h:mm a")}
+
+
+
+ {assignment.maxScore} pts
+
+
+
+ {assignment.gradedCount} graded
+ {assignment.submittedCount} submitted
+ {assignment.pendingCount} pending
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ Exam Results
+
+
+
+ {examFeatureEnabled && examApi.isLoading && !examApi.data ? (
+
+ ) : !examFeatureEnabled ? (
+
+ ) : managerExamSummaries.length === 0 ? (
+
+ ) : (
+ managerExamSummaries.map((exam) => (
+
+
+
+
+ {exam.title}
+
+
+ {exam.startAt
+ ? format(new Date(exam.startAt), "MMM d, h:mm a")
+ : "No start time"}
+
+
+
+ {formatExamStatus(exam.status)}
+
+
+
+ {exam.attemptCounts.inProgress} in progress
+ {exam.attemptCounts.submitted} submitted
+ {exam.attemptCounts.graded} graded
+ {exam.attemptCounts.released} released
+
+
+ ))
+ )}
+
+
+
+ ) : (
+
+
+
+
+
+ Assignment Results
+
+
+
+ {assignmentsApi.isLoading ? (
+
+ ) : studentAssignmentResults.length === 0 ? (
+
+ ) : (
+ studentAssignmentResults.map((assignment) => (
+
+
+
+
+ {assignment.title}
+
+
+ Graded{" "}
+ {format(
+ new Date(assignment.gradedAt),
+ "MMM d, h:mm a",
+ )}
+
+
+
+ {assignment.score}/{assignment.maxScore}
+
+
+ {assignment.feedback ? (
+
+ {assignment.feedback}
+
+ ) : null}
+
+ ))
+ )}
+
+
+
+
+
+
+
+ Exam Results
+
+
+
+ {examFeatureEnabled && examApi.isLoading && !examApi.data ? (
+
+ ) : !examFeatureEnabled ? (
+
+ ) : studentExamResults.length === 0 ? (
+
+ ) : (
+ studentExamResults.map((result) => (
+ setSelectedExamResult(result)}
+ className="w-full rounded-lg border p-3 text-left transition-colors hover:bg-muted/40"
+ >
+
+
+
+ {result.title}
+
+
+ Released{" "}
+ {format(
+ new Date(
+ result.releasedAt ??
+ result.submittedAt ??
+ new Date(0).toISOString(),
+ ),
+ "MMM d, h:mm a",
+ )}
+
+
+
+ {result.totalScore}/{result.totalPoints}
+
+
+
+ {result.integrityStatus}
+
+
+ ))
+ )}
+
+
+
+ )}
+
+
{
+ if (!open) {
+ setSelectedExamResult(null)
+ }
+ }}
+ >
+
+ {selectedExamResult ? (
+ <>
+
+ {selectedExamResult.title}
+
+ Review becomes available here after the teacher saves and
+ approves the exam result.
+
+
+
+ >
+ ) : null}
+
+
+
+ )
+}
+
+function SummaryCard({
+ icon: Icon,
+ label,
+ value,
+}: {
+ icon: typeof FileText
+ label: string
+ value: number
+}) {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+function LoadingRow({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ )
+}
+
+function EmptyState({ label }: { label: string }) {
+ return {label}
+}
+
+function getStudentAssignmentResults(assignments: ClassAssignment[]) {
+ return assignments
+ .flatMap((assignment) => {
+ const submission = assignment.mySubmission
+ if (!submission?.gradedAt || submission.score === null) return []
+
+ return [
+ {
+ id: assignment.id,
+ title: assignment.title,
+ score: submission.score,
+ maxScore: assignment.maxScore,
+ gradedAt: submission.gradedAt,
+ feedback: submission.feedback,
+ } satisfies StudentAssignmentResult,
+ ]
+ })
+ .sort(
+ (left, right) => Date.parse(right.gradedAt) - Date.parse(left.gradedAt),
+ )
+}
+
+function getManagerAssignmentResults(assignments: ClassAssignment[]) {
+ return assignments
+ .map((assignment) => {
+ const submittedCount = assignment.submissions.length
+ const gradedCount = assignment.submissions.filter(
+ (submission) => submission.gradedAt,
+ ).length
+
+ return {
+ id: assignment.id,
+ title: assignment.title,
+ dueAt: assignment.dueAt,
+ maxScore: assignment.maxScore,
+ gradedCount,
+ submittedCount,
+ pendingCount: Math.max(submittedCount - gradedCount, 0),
+ } satisfies ManagerAssignmentResultSummary
+ })
+ .sort((left, right) => Date.parse(right.dueAt) - Date.parse(left.dueAt))
+}
+
+function getStudentExamResults(page: {
+ releasedResults: ReleasedExamResultDto[]
+}) {
+ return [...page.releasedResults]
+ .filter((result) => result.isReleased)
+ .filter(
+ (result, index, results) =>
+ results.findIndex(
+ (candidate) => candidate.attemptId === result.attemptId,
+ ) === index,
+ )
+ .sort((left, right) => {
+ const leftReleaseAt =
+ left.releasedAt ?? left.submittedAt ?? new Date(0).toISOString()
+ const rightReleaseAt =
+ right.releasedAt ?? right.submittedAt ?? new Date(0).toISOString()
+
+ return Date.parse(rightReleaseAt) - Date.parse(leftReleaseAt)
+ })
+}
+
+function formatExamStatus(status: string) {
+ if (status === "live") return "Live"
+ if (status === "ended") return "Ended"
+ return "Upcoming"
+}
diff --git a/lib/education/selectors.test.ts b/lib/education/selectors.test.ts
index 98b3dca..b2a1ab5 100644
--- a/lib/education/selectors.test.ts
+++ b/lib/education/selectors.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"
import {
- mergeMessagesById,
+ getHistoricalExamResults,
getAssignmentProgress,
+ mergeMessagesById,
+ resolveStudentExamPageState,
} from "@/lib/education/selectors"
import type { Assignment, Message } from "@/lib/mock-data"
@@ -85,3 +87,90 @@ describe("mergeMessagesById", () => {
).toEqual(["m1", "m2"])
})
})
+
+describe("resolveStudentExamPageState", () => {
+ test("prefers active exam state over other populated payloads", () => {
+ expect(
+ resolveStudentExamPageState({
+ state: "none",
+ activeExam: {
+ id: "exam-1",
+ title: "Midterm",
+ classId: "class-1",
+ durationMinutes: 60,
+ totalPoints: 100,
+ questionCount: 10,
+ startAt: null,
+ endAt: null,
+ status: "live",
+ requiresPasscode: false,
+ examModeEnabled: false,
+ canStartAttempt: true,
+ startBlockedReason: null,
+ attempt: null,
+ questions: [],
+ },
+ scheduledExam: {
+ id: "exam-2",
+ title: "Final",
+ durationMinutes: 90,
+ totalPoints: 120,
+ startAt: "2026-05-05T10:00:00Z",
+ endAt: "2026-05-05T11:30:00Z",
+ status: "upcoming",
+ },
+ }),
+ ).toEqual("active")
+ })
+
+ test("falls back to none when no state payload is populated", () => {
+ expect(
+ resolveStudentExamPageState({
+ state: "none",
+ activeExam: null,
+ scheduledExam: null,
+ }),
+ ).toEqual("none")
+ })
+})
+
+describe("getHistoricalExamResults", () => {
+ test("sorts released exam history newest first and removes duplicates", () => {
+ expect(
+ getHistoricalExamResults({
+ history: [
+ {
+ attemptId: "attempt-1",
+ examId: "exam-1",
+ title: "Quiz 1",
+ totalScore: 18,
+ totalPoints: 20,
+ releasedAt: "2026-04-01T10:00:00Z",
+ submittedAt: "2026-04-01T09:30:00Z",
+ integrityStatus: "clear",
+ },
+ {
+ attemptId: "attempt-2",
+ examId: "exam-2",
+ title: "Quiz 2",
+ totalScore: 22,
+ totalPoints: 25,
+ releasedAt: "2026-04-10T10:00:00Z",
+ submittedAt: "2026-04-10T09:30:00Z",
+ integrityStatus: "reported",
+ },
+ {
+ attemptId: "attempt-2",
+ examId: "exam-2",
+ title: "Quiz 2 duplicate",
+ totalScore: 22,
+ totalPoints: 25,
+ releasedAt: "2026-04-10T10:00:00Z",
+ submittedAt: "2026-04-10T09:30:00Z",
+ integrityStatus: "reported",
+ },
+ ],
+ }).map((result) => result.attemptId),
+ ).toEqual(["attempt-2", "attempt-1"])
+ })
+})
diff --git a/lib/education/selectors.ts b/lib/education/selectors.ts
index 91b8f0d..77a59d8 100644
--- a/lib/education/selectors.ts
+++ b/lib/education/selectors.ts
@@ -1,4 +1,9 @@
import { addDays, isPast, isWithinInterval } from "date-fns"
+import type {
+ ReleasedExamResultSummaryDto,
+ StudentExamPageDto,
+ StudentExamPageState,
+} from "@/lib/exams/types"
import type {
Assignment,
Class,
@@ -110,3 +115,32 @@ export function mergeMessagesById(
index,
)
}
+
+export function resolveStudentExamPageState(
+ page: Pick,
+): StudentExamPageState {
+ if (page.activeExam) return "active"
+ if (page.scheduledExam) return "scheduled"
+ return "none"
+}
+
+export function getHistoricalExamResults(
+ page: Pick,
+) {
+ return page.history
+ .filter((result) => Boolean(result.releasedAt))
+ .filter(
+ (result, index, results) =>
+ results.findIndex(
+ (candidate) => candidate.attemptId === result.attemptId,
+ ) === index,
+ )
+ .sort(compareReleasedExamResultsDesc)
+}
+
+function compareReleasedExamResultsDesc(
+ left: ReleasedExamResultSummaryDto,
+ right: ReleasedExamResultSummaryDto,
+) {
+ return Date.parse(right.releasedAt) - Date.parse(left.releasedAt)
+}
diff --git a/lib/exams/audit.ts b/lib/exams/audit.ts
new file mode 100644
index 0000000..1c3f1b2
--- /dev/null
+++ b/lib/exams/audit.ts
@@ -0,0 +1,25 @@
+import { createServerClient } from "@/lib/supabase/server"
+import type { JsonValue } from "@/lib/exams/types"
+
+export async function writeExamAuditLog(input: {
+ organizationId: string
+ actorUserId: string | null
+ action: string
+ entityType: string
+ entityId: string | null
+ payload?: Record
+}) {
+ const supabase = createServerClient()
+ const { error } = await supabase.from("audit_logs").insert({
+ organization_id: input.organizationId,
+ actor_user_id: input.actorUserId,
+ action: input.action,
+ entity_type: input.entityType,
+ entity_id: input.entityId,
+ payload: input.payload ?? {},
+ })
+
+ if (error) {
+ throw new Error(error.message)
+ }
+}
diff --git a/lib/exams/grading.test.ts b/lib/exams/grading.test.ts
new file mode 100644
index 0000000..eb9095b
--- /dev/null
+++ b/lib/exams/grading.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, test } from "bun:test"
+import {
+ canTeacherGradeQuestion,
+ evaluateExamAnswer,
+ getReleasedAnswerStatus,
+ questionRequiresManualGrading,
+ resolveAnswerScore,
+} from "@/lib/exams/grading"
+
+describe("evaluateExamAnswer", () => {
+ test("auto-grades matching mcq answers", () => {
+ expect(
+ evaluateExamAnswer(
+ {
+ questionType: "mcq",
+ points: 5,
+ correctAnswer: 2,
+ },
+ 2,
+ ),
+ ).toEqual({
+ autoScore: 5,
+ gradedAutomatically: true,
+ })
+ })
+
+ test("auto-grades short answers when the teacher provides a model answer", () => {
+ expect(
+ evaluateExamAnswer(
+ {
+ questionType: "short",
+ points: 4,
+ correctAnswer: "Hyper Text Transfer Protocol",
+ },
+ " hyper text transfer protocol ",
+ ),
+ ).toEqual({
+ autoScore: 4,
+ gradedAutomatically: true,
+ })
+ })
+
+ test("keeps short answers in manual review when no model answer exists", () => {
+ expect(
+ evaluateExamAnswer(
+ {
+ questionType: "short",
+ points: 4,
+ correctAnswer: null,
+ },
+ "A reflective response",
+ ),
+ ).toEqual({
+ autoScore: null,
+ gradedAutomatically: false,
+ })
+ })
+})
+
+describe("resolveAnswerScore", () => {
+ test("prefers teacher scores over auto scores", () => {
+ expect(
+ resolveAnswerScore({
+ teacherScore: 7,
+ autoScore: 3,
+ }),
+ ).toEqual(7)
+ })
+})
+
+describe("questionRequiresManualGrading", () => {
+ test("only allows teacher grading for short answers without a model answer", () => {
+ expect(
+ questionRequiresManualGrading({
+ questionType: "mcq",
+ correctAnswer: 0,
+ }),
+ ).toEqual(false)
+
+ expect(
+ questionRequiresManualGrading({
+ questionType: "short",
+ correctAnswer: "Defined answer",
+ }),
+ ).toEqual(false)
+
+ expect(
+ questionRequiresManualGrading({
+ questionType: "short",
+ correctAnswer: "",
+ }),
+ ).toEqual(true)
+
+ expect(
+ canTeacherGradeQuestion({
+ questionType: "short",
+ correctAnswer: null,
+ }),
+ ).toEqual(true)
+ })
+})
+
+describe("getReleasedAnswerStatus", () => {
+ test("returns incorrect for released mcq mismatches", () => {
+ expect(
+ getReleasedAnswerStatus(
+ {
+ questionType: "mcq",
+ correctAnswer: { choice: 1, meta: { label: "B" } },
+ },
+ { meta: { label: "C" }, choice: 2 },
+ ),
+ ).toEqual("incorrect")
+ })
+
+ test("returns reviewed for released non-mcq answers", () => {
+ expect(
+ getReleasedAnswerStatus(
+ {
+ questionType: "short",
+ correctAnswer: null,
+ },
+ "A reflective response",
+ ),
+ ).toEqual("reviewed")
+ })
+
+ test("returns correct for released short answers that match the model answer", () => {
+ expect(
+ getReleasedAnswerStatus(
+ {
+ questionType: "short",
+ correctAnswer: "Machine learning",
+ },
+ " machine learning ",
+ ),
+ ).toEqual("correct")
+ })
+})
diff --git a/lib/exams/grading.ts b/lib/exams/grading.ts
new file mode 100644
index 0000000..e80b034
--- /dev/null
+++ b/lib/exams/grading.ts
@@ -0,0 +1,115 @@
+import type { ExamQuestionKind, JsonValue } from "@/lib/exams/types"
+
+export function questionRequiresManualGrading(question: {
+ questionType: ExamQuestionKind
+ correctAnswer: JsonValue | null
+}) {
+ return (
+ question.questionType === "short" &&
+ (typeof question.correctAnswer !== "string" ||
+ normalizeTextAnswer(question.correctAnswer).length === 0)
+ )
+}
+
+export function canTeacherGradeQuestion(question: {
+ questionType: ExamQuestionKind
+ correctAnswer: JsonValue | null
+}) {
+ return questionRequiresManualGrading(question)
+}
+
+export function evaluateExamAnswer(
+ question: {
+ questionType: ExamQuestionKind
+ points: number
+ correctAnswer: JsonValue | null
+ },
+ answer: JsonValue | null,
+) {
+ if (question.questionType === "mcq") {
+ const isCorrect = deepEqual(answer, question.correctAnswer ?? null)
+ return {
+ autoScore: isCorrect ? Number(question.points) : 0,
+ gradedAutomatically: true,
+ }
+ }
+
+ if (!questionRequiresManualGrading(question)) {
+ const isCorrect =
+ typeof answer === "string" &&
+ normalizeTextAnswer(answer) ===
+ normalizeTextAnswer(String(question.correctAnswer))
+
+ return {
+ autoScore: isCorrect ? Number(question.points) : 0,
+ gradedAutomatically: true,
+ }
+ }
+
+ return {
+ autoScore: null,
+ gradedAutomatically: false,
+ }
+}
+
+export function resolveAnswerScore(input: {
+ teacherScore: number | null | undefined
+ autoScore: number | null | undefined
+}) {
+ return Number(input.teacherScore ?? input.autoScore ?? 0)
+}
+
+export function getReleasedAnswerStatus(
+ question: {
+ questionType: ExamQuestionKind
+ correctAnswer: JsonValue | null
+ },
+ answer: JsonValue | null,
+) {
+ if (answer === null || answer === undefined) return "unanswered"
+
+ if (question.questionType === "mcq") {
+ return deepEqual(answer, question.correctAnswer ?? null)
+ ? "correct"
+ : "incorrect"
+ }
+
+ if (
+ question.questionType === "short" &&
+ !questionRequiresManualGrading(question) &&
+ typeof answer === "string"
+ ) {
+ return normalizeTextAnswer(answer) ===
+ normalizeTextAnswer(String(question.correctAnswer))
+ ? "correct"
+ : "incorrect"
+ }
+
+ return "reviewed"
+}
+
+function deepEqual(left: JsonValue | null, right: JsonValue | null): boolean {
+ return JSON.stringify(sortJson(left)) === JSON.stringify(sortJson(right))
+}
+
+function sortJson(value: JsonValue | null): JsonValue | null {
+ if (Array.isArray(value)) return value.map(sortJson)
+ if (isRecord(value)) {
+ return Object.keys(value)
+ .sort()
+ .reduce>((next, key) => {
+ next[key] = sortJson(value[key] as JsonValue) as JsonValue
+ return next
+ }, {})
+ }
+
+ return value
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+function normalizeTextAnswer(value: string) {
+ return value.trim().replace(/\s+/g, " ").toLowerCase()
+}
diff --git a/lib/exams/http.ts b/lib/exams/http.ts
new file mode 100644
index 0000000..a4eeb76
--- /dev/null
+++ b/lib/exams/http.ts
@@ -0,0 +1,69 @@
+import { NextResponse } from "next/server"
+import { ZodError } from "zod"
+
+const FORBIDDEN_MESSAGES = [
+ "Only class teachers",
+ "Switch to the student role",
+ "Invalid passcode",
+ "The exam feature is disabled",
+ "A teacher must grant a retake",
+ "You have reached the maximum number of attempts",
+ "This exam has not started yet.",
+ "This exam is no longer active.",
+ "This exam is missing its required passcode.",
+]
+
+const RATE_LIMIT_MESSAGES = ["Too many invalid passcode attempts."]
+const SERVICE_UNAVAILABLE_MESSAGES = ["fetch failed"]
+
+const NOT_FOUND_MESSAGES = [
+ "Class not found.",
+ "Exam not found.",
+ "Attempt not found.",
+ "Question not found.",
+]
+
+export function toExamErrorResponse(error: unknown) {
+ if (error instanceof ZodError) {
+ return NextResponse.json(
+ {
+ error: error.issues[0]?.message ?? "Invalid exam request.",
+ },
+ { status: 400 },
+ )
+ }
+
+ const message =
+ error instanceof Error ? error.message : "Exam request failed."
+
+ if (message === "Authentication required") {
+ return NextResponse.json({ error: message }, { status: 401 })
+ }
+
+ if (NOT_FOUND_MESSAGES.includes(message)) {
+ return NextResponse.json({ error: message }, { status: 404 })
+ }
+
+ if (RATE_LIMIT_MESSAGES.some((candidate) => message.startsWith(candidate))) {
+ return NextResponse.json({ error: message }, { status: 429 })
+ }
+
+ if (
+ SERVICE_UNAVAILABLE_MESSAGES.some((candidate) =>
+ message.toLowerCase().includes(candidate),
+ )
+ ) {
+ return NextResponse.json(
+ {
+ error: "Exam service is temporarily unavailable. Please try again.",
+ },
+ { status: 503 },
+ )
+ }
+
+ if (FORBIDDEN_MESSAGES.some((candidate) => message.startsWith(candidate))) {
+ return NextResponse.json({ error: message }, { status: 403 })
+ }
+
+ return NextResponse.json({ error: message }, { status: 400 })
+}
diff --git a/lib/exams/integrity.test.ts b/lib/exams/integrity.test.ts
new file mode 100644
index 0000000..ab8148f
--- /dev/null
+++ b/lib/exams/integrity.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, test } from "bun:test"
+import {
+ formatIntegrityEvent,
+ shouldMarkIntegrityReported,
+} from "@/lib/exams/integrity"
+
+describe("shouldMarkIntegrityReported", () => {
+ test("marks suspicious client events when the attempt is still clear", () => {
+ expect(
+ shouldMarkIntegrityReported({
+ currentStatus: "clear",
+ eventType: "visibility_hidden",
+ }),
+ ).toEqual(true)
+
+ expect(
+ shouldMarkIntegrityReported({
+ currentStatus: "clear",
+ eventType: "route_leave_attempt",
+ }),
+ ).toEqual(true)
+ })
+
+ test("ignores benign events and already escalated attempts", () => {
+ expect(
+ shouldMarkIntegrityReported({
+ currentStatus: "reported",
+ eventType: "window_blur",
+ }),
+ ).toEqual(false)
+
+ expect(
+ shouldMarkIntegrityReported({
+ currentStatus: "clear",
+ eventType: "heartbeat",
+ }),
+ ).toEqual(false)
+ })
+})
+
+describe("formatIntegrityEvent", () => {
+ test("formats visibility payloads without exposing raw JSON inline", () => {
+ expect(
+ formatIntegrityEvent({
+ eventType: "visibility_hidden",
+ payload: {
+ visibilityState: "hidden",
+ },
+ }),
+ ).toEqual({
+ title: "Student hid the exam tab or app",
+ detail: "Browser visibility changed to hidden.",
+ })
+ })
+
+ test("formats route leave events with a readable detail", () => {
+ expect(
+ formatIntegrityEvent({
+ eventType: "route_leave_attempt",
+ payload: {
+ destination: "/classes/demo/materials",
+ },
+ }),
+ ).toEqual({
+ title: "Student attempted to leave the exam",
+ detail:
+ "Navigation was blocked before leaving for /classes/demo/materials.",
+ })
+ })
+})
diff --git a/lib/exams/integrity.ts b/lib/exams/integrity.ts
new file mode 100644
index 0000000..02d96ff
--- /dev/null
+++ b/lib/exams/integrity.ts
@@ -0,0 +1,65 @@
+import type { ExamIntegrityStatus, JsonValue } from "@/lib/exams/types"
+
+export const SUSPICIOUS_INTEGRITY_EVENTS = [
+ "fullscreen_exit",
+ "route_leave_attempt",
+ "visibility_hidden",
+ "window_blur",
+] as const
+
+export function shouldMarkIntegrityReported(input: {
+ currentStatus: ExamIntegrityStatus
+ eventType: string
+}) {
+ return (
+ input.currentStatus === "clear" &&
+ SUSPICIOUS_INTEGRITY_EVENTS.includes(
+ input.eventType as (typeof SUSPICIOUS_INTEGRITY_EVENTS)[number],
+ )
+ )
+}
+
+export function formatIntegrityEvent(input: {
+ eventType: string
+ payload: Record
+}) {
+ if (input.eventType === "visibility_hidden") {
+ return {
+ title: "Student hid the exam tab or app",
+ detail: `Browser visibility changed to ${readString(input.payload.visibilityState) ?? "hidden"}.`,
+ }
+ }
+
+ if (input.eventType === "window_blur") {
+ return {
+ title: "Student switched window focus",
+ detail: "The browser window lost focus during the attempt.",
+ }
+ }
+
+ if (input.eventType === "fullscreen_exit") {
+ return {
+ title: "Student exited fullscreen",
+ detail: "Fullscreen exam mode was exited during the attempt.",
+ }
+ }
+
+ if (input.eventType === "route_leave_attempt") {
+ return {
+ title: "Student attempted to leave the exam",
+ detail:
+ readString(input.payload.destination) !== null
+ ? `Navigation was blocked before leaving for ${String(input.payload.destination)}.`
+ : "Navigation away from the exam route was blocked.",
+ }
+ }
+
+ return {
+ title: input.eventType,
+ detail: "Integrity event recorded.",
+ }
+}
+
+function readString(value: JsonValue | undefined) {
+ return typeof value === "string" && value.length > 0 ? value : null
+}
diff --git a/lib/exams/service.test.ts b/lib/exams/service.test.ts
new file mode 100644
index 0000000..4e79e90
--- /dev/null
+++ b/lib/exams/service.test.ts
@@ -0,0 +1,530 @@
+import { describe, expect, test } from "bun:test"
+import {
+ hashExamPasscode,
+ isExamPasscodeValid,
+ resolveExamPasscodeHash,
+ resolvePasscodeCooldown,
+ resolveExamAttemptAvailability,
+ resolveStudentExamPageSelection,
+ selectLatestCompletedAttemptWithExam,
+ toStudentQuestionDto,
+} from "@/lib/exams/service"
+
+describe("selectLatestCompletedAttemptWithExam", () => {
+ test("returns the newest completed attempt whose exam still exists", () => {
+ const result = selectLatestCompletedAttemptWithExam(
+ [
+ {
+ id: "attempt-missing",
+ exam_id: "exam-missing",
+ status: "graded",
+ submitted_at: "2026-05-04T08:00:00Z",
+ graded_at: "2026-05-04T08:10:00Z",
+ results_released_at: "2026-05-04T08:15:00Z",
+ updated_at: "2026-05-04T08:15:00Z",
+ },
+ {
+ id: "attempt-valid",
+ exam_id: "exam-valid",
+ status: "graded",
+ submitted_at: "2026-05-04T07:00:00Z",
+ graded_at: "2026-05-04T07:10:00Z",
+ results_released_at: "2026-05-04T07:15:00Z",
+ updated_at: "2026-05-04T07:15:00Z",
+ },
+ ],
+ [
+ {
+ id: "exam-valid",
+ title: "Final",
+ },
+ ],
+ )
+
+ expect(result).toEqual({
+ attempt: {
+ id: "attempt-valid",
+ exam_id: "exam-valid",
+ status: "graded",
+ submitted_at: "2026-05-04T07:00:00Z",
+ graded_at: "2026-05-04T07:10:00Z",
+ results_released_at: "2026-05-04T07:15:00Z",
+ updated_at: "2026-05-04T07:15:00Z",
+ },
+ exam: {
+ id: "exam-valid",
+ title: "Final",
+ },
+ })
+ })
+
+ test("returns null when every completed attempt points to a missing exam", () => {
+ expect(
+ selectLatestCompletedAttemptWithExam(
+ [
+ {
+ id: "attempt-missing",
+ exam_id: "exam-missing",
+ status: "graded",
+ submitted_at: "2026-05-04T08:00:00Z",
+ graded_at: "2026-05-04T08:10:00Z",
+ results_released_at: "2026-05-04T08:15:00Z",
+ updated_at: "2026-05-04T08:15:00Z",
+ },
+ ],
+ [],
+ ),
+ ).toEqual(null)
+ })
+})
+
+describe("isExamPasscodeValid", () => {
+ test("rejects exams that do not have a configured passcode", () => {
+ expect(isExamPasscodeValid(null, "anything")).toEqual(false)
+ })
+
+ test("rejects missing or wrong passcodes for protected exams", () => {
+ const passcodeHash =
+ "5d642faad6c6b9bfb9457d1eec508cc9a9201f3d4cf0a0b82f78220f1148b6a2"
+
+ expect(isExamPasscodeValid(passcodeHash, "")).toEqual(false)
+ expect(isExamPasscodeValid(passcodeHash, "wrong-code")).toEqual(false)
+ })
+
+ test("accepts the correct passcode for protected exams", () => {
+ const passcodeHash =
+ "5d642faad6c6b9bfb9457d1eec508cc9a9201f3d4cf0a0b82f78220f1148b6a2"
+
+ expect(isExamPasscodeValid(passcodeHash, "teacher-code")).toEqual(true)
+ })
+
+ test("accepts alphanumeric passcodes hashed by the backend", () => {
+ const passcodeHash = hashExamPasscode("A1b2C3")
+
+ expect(isExamPasscodeValid(passcodeHash, "A1b2C3")).toEqual(true)
+ expect(isExamPasscodeValid(passcodeHash, "a1b2c3")).toEqual(false)
+ })
+})
+
+describe("resolveExamPasscodeHash", () => {
+ test("keeps the existing stored passcode when edit input is blank", () => {
+ const existingPasscodeHash = hashExamPasscode("teacher-code")
+
+ expect(
+ resolveExamPasscodeHash({
+ existingPasscodeHash,
+ nextPasscode: "",
+ }),
+ ).toEqual(existingPasscodeHash)
+
+ expect(
+ resolveExamPasscodeHash({
+ existingPasscodeHash,
+ nextPasscode: undefined,
+ }),
+ ).toEqual(existingPasscodeHash)
+ })
+
+ test("replaces the stored passcode when a new value is provided", () => {
+ const existingPasscodeHash = hashExamPasscode("teacher-code")
+ const nextPasscodeHash = resolveExamPasscodeHash({
+ existingPasscodeHash,
+ nextPasscode: "new-passcode",
+ })
+
+ expect(nextPasscodeHash === existingPasscodeHash).toEqual(false)
+ expect(isExamPasscodeValid(nextPasscodeHash, "new-passcode")).toEqual(true)
+ })
+
+ test("reports a missing stored passcode only when the exam truly has none", () => {
+ try {
+ resolveExamPasscodeHash({
+ existingPasscodeHash: null,
+ nextPasscode: "",
+ })
+ throw new Error("Expected resolveExamPasscodeHash to throw.")
+ } catch (error) {
+ expect(
+ error instanceof Error
+ ? error.message
+ : "Expected resolveExamPasscodeHash to throw.",
+ ).toEqual("This exam is missing its required passcode.")
+ }
+ })
+})
+
+describe("resolvePasscodeCooldown", () => {
+ test("allows another try when failures stay below the limit", () => {
+ expect(
+ resolvePasscodeCooldown({
+ failedAttempts: [
+ {
+ created_at: "2026-05-05T10:00:00Z",
+ },
+ {
+ created_at: "2026-05-05T09:59:30Z",
+ },
+ ],
+ now: Date.parse("2026-05-05T10:00:10Z"),
+ }),
+ ).toEqual({
+ failureCount: 2,
+ attemptsRemaining: 1,
+ retryAfterSeconds: 0,
+ isBlocked: false,
+ })
+ })
+
+ test("blocks passcode retries after three recent failures", () => {
+ const result = resolvePasscodeCooldown({
+ failedAttempts: [
+ {
+ created_at: "2026-05-05T10:00:00Z",
+ },
+ {
+ created_at: "2026-05-05T09:59:50Z",
+ },
+ {
+ created_at: "2026-05-05T09:59:40Z",
+ },
+ ],
+ now: Date.parse("2026-05-05T10:00:15Z"),
+ })
+
+ expect(result.failureCount).toEqual(3)
+ expect(result.attemptsRemaining).toEqual(0)
+ expect(result.isBlocked).toEqual(true)
+ expect(result.retryAfterSeconds > 0).toEqual(true)
+ })
+
+ test("clears the cooldown window after it expires", () => {
+ expect(
+ resolvePasscodeCooldown({
+ failedAttempts: [
+ {
+ created_at: "2026-05-05T10:00:00Z",
+ },
+ {
+ created_at: "2026-05-05T09:59:40Z",
+ },
+ {
+ created_at: "2026-05-05T09:59:20Z",
+ },
+ ],
+ now: Date.parse("2026-05-05T10:01:10Z"),
+ }),
+ ).toEqual({
+ failureCount: 0,
+ attemptsRemaining: 3,
+ retryAfterSeconds: 0,
+ isBlocked: false,
+ })
+ })
+})
+
+describe("resolveStudentExamPageSelection", () => {
+ const baseExam = {
+ published_at: "2026-05-04T08:00:00Z",
+ end_at: "2026-05-04T10:00:00Z",
+ created_at: "2026-05-01T08:00:00Z",
+ }
+
+ test("prefers an active in-progress attempt over other exam states", () => {
+ const selection = resolveStudentExamPageSelection({
+ allExams: [
+ {
+ id: "live-exam",
+ ...baseExam,
+ start_at: "2026-05-04T08:30:00Z",
+ },
+ ],
+ publishedExams: [
+ {
+ id: "live-exam",
+ ...baseExam,
+ start_at: "2026-05-04T08:30:00Z",
+ },
+ ],
+ attempts: [
+ {
+ id: "attempt-1",
+ exam_id: "live-exam",
+ status: "in_progress",
+ submitted_at: null,
+ graded_at: null,
+ results_released_at: null,
+ updated_at: "2026-05-04T08:40:00Z",
+ },
+ ],
+ now: Date.parse("2026-05-04T08:45:00Z"),
+ })
+
+ expect(selection.state).toEqual("active")
+ expect(selection.activeAttempt?.id).toEqual("attempt-1")
+ expect(selection.activeExam?.id).toEqual("live-exam")
+ })
+
+ test("shows the current published exam before any scheduled or historical result", () => {
+ const selection = resolveStudentExamPageSelection({
+ allExams: [
+ {
+ id: "live-exam",
+ ...baseExam,
+ start_at: "2026-05-04T08:30:00Z",
+ },
+ {
+ id: "scheduled-exam",
+ ...baseExam,
+ start_at: "2026-05-05T08:30:00Z",
+ end_at: "2026-05-05T09:30:00Z",
+ },
+ ],
+ publishedExams: [
+ {
+ id: "live-exam",
+ ...baseExam,
+ start_at: "2026-05-04T08:30:00Z",
+ },
+ {
+ id: "scheduled-exam",
+ ...baseExam,
+ start_at: "2026-05-05T08:30:00Z",
+ end_at: "2026-05-05T09:30:00Z",
+ },
+ ],
+ attempts: [
+ {
+ id: "released-attempt",
+ exam_id: "old-exam",
+ status: "graded",
+ submitted_at: "2026-05-01T09:00:00Z",
+ graded_at: "2026-05-01T09:10:00Z",
+ results_released_at: "2026-05-01T09:20:00Z",
+ updated_at: "2026-05-01T09:20:00Z",
+ },
+ ],
+ now: Date.parse("2026-05-04T08:45:00Z"),
+ })
+
+ expect(selection.state).toEqual("active")
+ expect(selection.activeExam?.id).toEqual("live-exam")
+ })
+
+ test("shows the next scheduled published exam when nothing is live", () => {
+ const selection = resolveStudentExamPageSelection({
+ allExams: [
+ {
+ id: "scheduled-exam",
+ ...baseExam,
+ start_at: "2026-05-05T08:30:00Z",
+ end_at: "2026-05-05T09:30:00Z",
+ },
+ ],
+ publishedExams: [
+ {
+ id: "scheduled-exam",
+ ...baseExam,
+ start_at: "2026-05-05T08:30:00Z",
+ end_at: "2026-05-05T09:30:00Z",
+ },
+ ],
+ attempts: [],
+ now: Date.parse("2026-05-04T08:45:00Z"),
+ })
+
+ expect(selection.state).toEqual("scheduled")
+ expect(selection.scheduledExam?.id).toEqual("scheduled-exam")
+ })
+
+ test("returns none when there is no live or scheduled published exam", () => {
+ const selection = resolveStudentExamPageSelection({
+ allExams: [
+ {
+ id: "old-exam",
+ ...baseExam,
+ start_at: "2026-05-01T08:30:00Z",
+ end_at: "2026-05-01T09:30:00Z",
+ },
+ ],
+ publishedExams: [],
+ attempts: [
+ {
+ id: "released-attempt",
+ exam_id: "old-exam",
+ status: "graded",
+ submitted_at: "2026-05-01T09:00:00Z",
+ graded_at: "2026-05-01T09:10:00Z",
+ results_released_at: "2026-05-01T09:20:00Z",
+ updated_at: "2026-05-01T09:20:00Z",
+ },
+ ],
+ now: Date.parse("2026-05-04T08:45:00Z"),
+ })
+
+ expect(selection.state).toEqual("none")
+ expect(selection.activeExam).toEqual(null)
+ expect(selection.scheduledExam).toEqual(null)
+ })
+})
+
+describe("resolveExamAttemptAvailability", () => {
+ test("blocks another attempt after submission until a retake is granted", () => {
+ expect(
+ resolveExamAttemptAvailability({
+ attempts: [
+ {
+ status: "submitted",
+ attempt_number: 1,
+ integrity_status: "clear",
+ },
+ ],
+ availableRetakeCount: 0,
+ }),
+ ).toEqual({
+ canStart: false,
+ reason:
+ "A teacher must grant a retake before you can start this exam again.",
+ nextAttemptNumber: null,
+ })
+ })
+
+ test("blocks flagged or voided integrity attempts until a retake exists", () => {
+ expect(
+ resolveExamAttemptAvailability({
+ attempts: [
+ {
+ status: "voided",
+ attempt_number: 1,
+ integrity_status: "voided",
+ },
+ ],
+ availableRetakeCount: 0,
+ }).canStart,
+ ).toEqual(false)
+ })
+
+ test("allows exactly one extra attempt after a retake grant", () => {
+ expect(
+ resolveExamAttemptAvailability({
+ attempts: [
+ {
+ status: "voided",
+ attempt_number: 1,
+ integrity_status: "voided",
+ },
+ ],
+ availableRetakeCount: 1,
+ }),
+ ).toEqual({
+ canStart: true,
+ reason: null,
+ nextAttemptNumber: 2,
+ })
+ })
+
+ test("prevents duplicate active attempts", () => {
+ expect(
+ resolveExamAttemptAvailability({
+ attempts: [
+ {
+ status: "in_progress",
+ attempt_number: 1,
+ integrity_status: "clear",
+ },
+ ],
+ availableRetakeCount: 1,
+ }),
+ ).toEqual({
+ canStart: false,
+ reason:
+ "Return to your current in-progress attempt to continue the exam.",
+ nextAttemptNumber: null,
+ })
+ })
+
+ test("consumes a retake after the extra attempt exists", () => {
+ expect(
+ resolveExamAttemptAvailability({
+ attempts: [
+ {
+ status: "submitted",
+ attempt_number: 1,
+ integrity_status: "clear",
+ },
+ {
+ status: "submitted",
+ attempt_number: 2,
+ integrity_status: "clear",
+ },
+ ],
+ availableRetakeCount: 0,
+ }),
+ ).toEqual({
+ canStart: false,
+ reason:
+ "A teacher must grant a retake before you can start this exam again.",
+ nextAttemptNumber: null,
+ })
+ })
+
+ test("allows a new attempt after a later teacher-granted retake", () => {
+ expect(
+ resolveExamAttemptAvailability({
+ attempts: [
+ {
+ status: "submitted",
+ attempt_number: 1,
+ integrity_status: "clear",
+ },
+ {
+ status: "voided",
+ attempt_number: 2,
+ integrity_status: "voided",
+ },
+ ],
+ availableRetakeCount: 1,
+ }),
+ ).toEqual({
+ canStart: true,
+ reason: null,
+ nextAttemptNumber: 3,
+ })
+ })
+})
+
+describe("toStudentQuestionDto", () => {
+ test("does not expose grading internals to student payloads", () => {
+ const dto = toStudentQuestionDto(
+ {
+ id: "question-1",
+ organization_id: "org-1",
+ exam_id: "exam-1",
+ position: 1,
+ question_type: "short",
+ prompt: "Name the protocol",
+ options_json: null,
+ correct_answer_json: "hyper text transfer protocol",
+ points: 5,
+ language: null,
+ starter_code: null,
+ visible_tests_json: null,
+ hidden_tests_json: [{ input: "secret", expectedOutput: "hidden" }],
+ evaluator_key: "internal",
+ created_at: "2026-05-04T08:00:00Z",
+ updated_at: "2026-05-04T08:00:00Z",
+ },
+ "http",
+ )
+
+ expect(dto).toEqual({
+ id: "question-1",
+ position: 1,
+ type: "short",
+ prompt: "Name the protocol",
+ options: [],
+ points: 5,
+ savedAnswer: "http",
+ })
+ expect("correctAnswer" in dto).toEqual(false)
+ })
+})
diff --git a/lib/exams/service.ts b/lib/exams/service.ts
new file mode 100644
index 0000000..87abf5c
--- /dev/null
+++ b/lib/exams/service.ts
@@ -0,0 +1,3859 @@
+import { createHash, timingSafeEqual } from "node:crypto"
+import type { SupabaseClient } from "@supabase/supabase-js"
+import { z } from "zod"
+import { writeExamAuditLog } from "@/lib/exams/audit"
+import {
+ canTeacherGradeQuestion,
+ evaluateExamAnswer,
+ getReleasedAnswerStatus,
+ questionRequiresManualGrading,
+ resolveAnswerScore,
+} from "@/lib/exams/grading"
+import { shouldMarkIntegrityReported } from "@/lib/exams/integrity"
+import type {
+ ClassExamApiDto,
+ ExamAttemptStatus,
+ ExamIntegrityStatus,
+ GradeAttemptInput,
+ IntegrityActionInput,
+ IntegrityEventInput,
+ ExamQuestionKind,
+ ExamStatus,
+ JsonValue,
+ ManagerAttemptSummaryDto,
+ ManagerExamDetailDto,
+ ManagerIntegrityEventDto,
+ ManagerExamSummaryDto,
+ ManagerQuestionDto,
+ ReleasedExamResultDto,
+ ReleasedExamResultSummaryDto,
+ SaveAnswerInput,
+ StartAttemptInput,
+ StudentActiveExamDto,
+ StudentAttemptDto,
+ StudentExamPageDto,
+ StudentQuestionDto,
+ UpsertExamInput,
+} from "@/lib/exams/types"
+import { createServerClient } from "@/lib/supabase/server"
+
+type AppRole = "org_owner" | "org_admin" | "teacher" | "student"
+
+type ClassContext = {
+ id: string
+ organizationId: string
+ code: string
+ name: string
+ semester: string | null
+ canManage: boolean
+ selectedRole: AppRole | null
+ isStudentMember: boolean
+ isFeatureEnabled: boolean
+}
+
+type ExamRow = {
+ id: string
+ organization_id: string
+ class_id: string
+ title: string
+ duration_minutes: number
+ total_points: number
+ start_at: string | null
+ end_at: string | null
+ status: ExamStatus
+ created_by_user_id: string | null
+ published_at: string | null
+ passcode_hash: string | null
+ rules_override_json: Record
+ created_at: string
+ updated_at: string
+}
+
+type ExamQuestionRow = {
+ id: string
+ organization_id: string
+ exam_id: string
+ position: number
+ question_type: ExamQuestionKind
+ prompt: string
+ options_json: JsonValue[] | null
+ correct_answer_json: JsonValue | null
+ points: number
+ language: string | null
+ starter_code: string | null
+ visible_tests_json: JsonValue[] | null
+ hidden_tests_json: JsonValue[] | null
+ evaluator_key: string | null
+ created_at: string
+ updated_at: string
+}
+
+type ExamAttemptRow = {
+ id: string
+ organization_id: string
+ class_id: string
+ exam_id: string
+ student_user_id: string
+ status: ExamAttemptStatus
+ started_at: string | null
+ submitted_at: string | null
+ total_score: number | null
+ attempt_number: number
+ deadline_at: string | null
+ rules_snapshot_json: Record
+ needs_manual_review: boolean
+ auto_submitted_at: string | null
+ integrity_status: ExamIntegrityStatus
+ flagged_at: string | null
+ flagged_by_user_id: string | null
+ flag_reason: string | null
+ voided_at: string | null
+ voided_by_user_id: string | null
+ void_reason: string | null
+ graded_at: string | null
+ graded_by_user_id: string | null
+ results_released_at: string | null
+ results_released_by_user_id: string | null
+ created_at: string
+ updated_at: string
+}
+
+type ExamAnswerRow = {
+ id: string
+ organization_id: string
+ exam_attempt_id: string
+ exam_question_id: string
+ answer_json: JsonValue | null
+ auto_score: number | null
+ teacher_score: number | null
+ created_at: string
+ updated_at: string
+}
+
+type ProfileRow = {
+ id: string
+ display_name: string
+ email: string
+}
+
+type SchemaMode = "unknown" | "extended" | "base"
+type SchemaTarget = "exams" | "questions" | "attempts"
+type RawRow = Record
+
+const schemaModes: Record = {
+ exams: "unknown",
+ questions: "unknown",
+ attempts: "unknown",
+}
+
+const EXAM_SELECT_BASE =
+ "id, organization_id, class_id, title, duration_minutes, total_points, start_at, end_at, status, created_by_user_id, created_at, updated_at"
+const EXAM_SELECT_EXTENDED = `${EXAM_SELECT_BASE}, published_at, passcode_hash, rules_override_json`
+const EXAM_SELECT_WITH_SETTINGS = `${EXAM_SELECT_BASE}, passcode_hash, rules_override_json`
+
+const QUESTION_SELECT_BASE =
+ "id, organization_id, exam_id, position, question_type, prompt, options_json, correct_answer_json, points, language, starter_code, created_at, updated_at"
+const QUESTION_SELECT_EXTENDED = `${QUESTION_SELECT_BASE}, visible_tests_json, hidden_tests_json, evaluator_key`
+
+const ATTEMPT_SELECT_BASE =
+ "id, organization_id, class_id, exam_id, student_user_id, status, started_at, submitted_at, total_score, created_at, updated_at"
+const ATTEMPT_SELECT_EXTENDED = `${ATTEMPT_SELECT_BASE}, attempt_number, deadline_at, rules_snapshot_json, needs_manual_review, auto_submitted_at, integrity_status, flagged_at, flagged_by_user_id, flag_reason, voided_at, voided_by_user_id, void_reason, graded_at, graded_by_user_id, results_released_at, results_released_by_user_id`
+
+const compatibilityColumns: Record = {
+ exams: ["published_at", "passcode_hash", "rules_override_json"],
+ questions: ["visible_tests_json", "hidden_tests_json", "evaluator_key"],
+ attempts: [
+ "attempt_number",
+ "deadline_at",
+ "rules_snapshot_json",
+ "needs_manual_review",
+ "auto_submitted_at",
+ "integrity_status",
+ "flagged_at",
+ "flagged_by_user_id",
+ "flag_reason",
+ "voided_at",
+ "voided_by_user_id",
+ "void_reason",
+ "graded_at",
+ "graded_by_user_id",
+ "results_released_at",
+ "results_released_by_user_id",
+ ],
+}
+
+type ExamSelectMode = "unknown" | "extended" | "settings" | "base"
+
+const examSelectCandidates = [
+ { mode: "extended", select: EXAM_SELECT_EXTENDED },
+ { mode: "settings", select: EXAM_SELECT_WITH_SETTINGS },
+ { mode: "base", select: EXAM_SELECT_BASE },
+] as const satisfies ReadonlyArray<{
+ mode: Exclude
+ select: string
+}>
+
+let examSelectMode: ExamSelectMode = "unknown"
+const EXAM_PASSCODE_MIN_LENGTH = 4
+const EXAM_PASSCODE_MAX_FAILURES = 3
+const EXAM_PASSCODE_COOLDOWN_MS = 60_000
+const EXAM_PASSCODE_REQUIRED_MESSAGE = "Exam passcode is required."
+const EXAM_PASSCODE_MISSING_MESSAGE =
+ "This exam is missing its required passcode."
+const EXAM_FEATURE_KEY = "exam"
+const EXAM_PASSCODE_HASHES_CONFIG_KEY = "passcodeHashesByExamId"
+
+const examQuestionInputSchema = z
+ .object({
+ id: z.string().uuid().optional(),
+ type: z.enum(["mcq", "short"]),
+ prompt: z.string().trim().min(1),
+ options: z.array(z.string().trim().min(1)).default([]),
+ correctAnswer: z.any().nullable().default(null),
+ points: z.number().int().positive(),
+ })
+ .superRefine((question, ctx) => {
+ if (question.type === "mcq") {
+ if (question.options.length === 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "MCQ questions require options.",
+ path: ["options"],
+ })
+ }
+ if (
+ typeof question.correctAnswer !== "number" ||
+ question.correctAnswer < 0 ||
+ question.correctAnswer >= question.options.length
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "MCQ questions require a valid correct answer index.",
+ path: ["correctAnswer"],
+ })
+ }
+ }
+
+ if (
+ question.type === "short" &&
+ question.correctAnswer !== null &&
+ (typeof question.correctAnswer !== "string" ||
+ question.correctAnswer.trim().length === 0)
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Short-answer auto grading requires a non-empty model answer.",
+ path: ["correctAnswer"],
+ })
+ }
+ })
+
+const examInputSchema = z.object({
+ title: z.string().trim().min(1),
+ durationMinutes: z.number().int().positive(),
+ startAt: z.string().datetime(),
+ passcode: z.string().trim().optional(),
+ questions: z.array(examQuestionInputSchema).min(1),
+})
+
+const startAttemptInputSchema = z.object({
+ passcode: z.string().trim().min(1, EXAM_PASSCODE_REQUIRED_MESSAGE),
+})
+
+const saveAnswerInputSchema = z.object({
+ questionId: z.string().uuid(),
+ answer: z.any().nullable(),
+})
+
+const gradeAttemptInputSchema = z.object({
+ answers: z
+ .array(
+ z.object({
+ answerId: z.string().uuid().optional(),
+ questionId: z.string().uuid().optional(),
+ teacherScore: z.number().min(0).nullable(),
+ }),
+ )
+ .default([]),
+})
+
+const integrityInputSchema = z.object({
+ action: z.enum(["flag", "void", "clear"]),
+ reason: z.string().trim().optional(),
+})
+
+const eventInputSchema = z.object({
+ eventType: z.string().trim().min(1),
+ payload: z.record(z.any()).default({}),
+})
+
+export function parseUpsertExamInput(body: unknown) {
+ return examInputSchema.parse(body) as UpsertExamInput
+}
+
+export function parseStartAttemptInput(body: unknown) {
+ return startAttemptInputSchema.parse(body ?? {}) as StartAttemptInput
+}
+
+export function parseSaveAnswerInput(body: unknown) {
+ return saveAnswerInputSchema.parse(body) as SaveAnswerInput
+}
+
+export function parseGradeAttemptInput(body: unknown) {
+ return gradeAttemptInputSchema.parse(body) as GradeAttemptInput
+}
+
+export function parseIntegrityActionInput(body: unknown) {
+ return integrityInputSchema.parse(body) as IntegrityActionInput
+}
+
+export function parseIntegrityEventInput(body: unknown) {
+ return eventInputSchema.parse(body) as IntegrityEventInput
+}
+
+export async function loadClassExamApiData(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ userId: string
+}) {
+ const context = await loadClassContext(input)
+
+ if (context.canManage) {
+ return {
+ canManage: true,
+ manager: {
+ exams: await loadManagerExamSummaries(context.classId),
+ },
+ student: null,
+ } satisfies ClassExamApiDto
+ }
+
+ return {
+ canManage: false,
+ manager: null,
+ student: await loadStudentExamPage({
+ authSupabase: input.authSupabase,
+ classId: context.classId,
+ organizationId: context.organizationId,
+ userId: input.userId,
+ selectedRole: context.selectedRole,
+ isStudentMember: context.isStudentMember,
+ }),
+ } satisfies ClassExamApiDto
+}
+
+export async function loadManagerExamDetail(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ userId: string
+}) {
+ await assertManagerContext(input)
+ const admin = createServerClient()
+ const exam = await loadExamById(admin, input.examId, input.classId)
+
+ return buildManagerExamDetailResponse(admin, exam)
+}
+
+export async function createExam(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ userId: string
+ body: UpsertExamInput
+}) {
+ const context = await assertManagerContext(input)
+ const admin = createServerClient()
+ const totalPoints = input.body.questions.reduce(
+ (sum, question) => sum + question.points,
+ 0,
+ )
+ const passcodeHash = hashExamPasscode(input.body.passcode)
+ const examWindow = resolveExamWindow(
+ input.body.startAt,
+ input.body.durationMinutes,
+ )
+ const examId = await runSchemaCompatible(
+ "exams",
+ async () => {
+ const { data, error } = await admin
+ .from("exams")
+ .insert({
+ organization_id: context.organizationId,
+ class_id: context.classId,
+ title: input.body.title,
+ duration_minutes: input.body.durationMinutes,
+ total_points: totalPoints,
+ start_at: examWindow.startAt,
+ end_at: examWindow.endAt,
+ created_by_user_id: input.userId,
+ passcode_hash: passcodeHash,
+ })
+ .select("id")
+ .single()
+
+ if (error || !data) {
+ throw new Error(error?.message ?? "Could not create exam.")
+ }
+
+ return data.id as string
+ },
+ async () => {
+ const { data, error } = await admin
+ .from("exams")
+ .insert({
+ organization_id: context.organizationId,
+ class_id: context.classId,
+ title: input.body.title,
+ duration_minutes: input.body.durationMinutes,
+ total_points: totalPoints,
+ start_at: examWindow.startAt,
+ end_at: examWindow.endAt,
+ created_by_user_id: input.userId,
+ })
+ .select("id")
+ .single()
+
+ if (error || !data) {
+ throw new Error(error?.message ?? "Could not create exam.")
+ }
+
+ return data.id as string
+ },
+ )
+
+ try {
+ await syncExamPasscodeHashStorage({
+ admin,
+ organizationId: context.organizationId,
+ classId: context.classId,
+ examId,
+ passcodeHash,
+ })
+ await replaceExamQuestions({
+ admin,
+ examId,
+ organizationId: context.organizationId,
+ questions: input.body.questions,
+ })
+ } catch (error) {
+ await admin.from("exams").delete().eq("id", examId)
+ throw error
+ }
+
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: "exam.created",
+ entityType: "exam",
+ entityId: examId,
+ payload: {
+ classId: context.classId,
+ title: input.body.title,
+ questionCount: input.body.questions.length,
+ },
+ })
+
+ return loadManagerExamDetail({
+ authSupabase: input.authSupabase,
+ classId: input.classId,
+ examId,
+ userId: input.userId,
+ })
+}
+
+export async function updateExam(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ userId: string
+ body: UpsertExamInput
+}) {
+ const context = await assertManagerContext(input)
+ const admin = createServerClient()
+ const exam = await loadExamById(admin, input.examId, input.classId)
+ const attempts = await loadExamAttempts(admin, [input.examId])
+
+ if (attempts.length > 0) {
+ throw new Error("Exams with attempts can no longer be edited.")
+ }
+
+ const totalPoints = input.body.questions.reduce(
+ (sum, question) => sum + question.points,
+ 0,
+ )
+ const nextPasscodeHash = resolveExamPasscodeHash({
+ existingPasscodeHash: exam.passcode_hash,
+ nextPasscode: input.body.passcode,
+ })
+ const examWindow = resolveExamWindow(
+ input.body.startAt,
+ input.body.durationMinutes,
+ )
+ const nextStatus = normalizeExamStatus({
+ ...exam,
+ start_at: examWindow.startAt,
+ end_at: examWindow.endAt,
+ })
+
+ await runSchemaCompatible(
+ "exams",
+ async () => {
+ const { error } = await admin
+ .from("exams")
+ .update({
+ title: input.body.title,
+ duration_minutes: input.body.durationMinutes,
+ total_points: totalPoints,
+ start_at: examWindow.startAt,
+ end_at: examWindow.endAt,
+ passcode_hash: nextPasscodeHash,
+ status: nextStatus,
+ })
+ .eq("id", input.examId)
+
+ if (error) throw new Error(error.message)
+ },
+ async () => {
+ const { error } = await admin
+ .from("exams")
+ .update({
+ title: input.body.title,
+ duration_minutes: input.body.durationMinutes,
+ total_points: totalPoints,
+ start_at: examWindow.startAt,
+ end_at: examWindow.endAt,
+ status: nextStatus,
+ })
+ .eq("id", input.examId)
+
+ if (error) throw new Error(error.message)
+ },
+ )
+
+ await syncExamPasscodeHashStorage({
+ admin,
+ organizationId: context.organizationId,
+ classId: context.classId,
+ examId: input.examId,
+ passcodeHash: nextPasscodeHash,
+ })
+
+ await replaceExamQuestions({
+ admin,
+ examId: input.examId,
+ organizationId: context.organizationId,
+ questions: input.body.questions,
+ })
+
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: "exam.updated",
+ entityType: "exam",
+ entityId: input.examId,
+ payload: {
+ title: input.body.title,
+ questionCount: input.body.questions.length,
+ },
+ })
+
+ return loadManagerExamDetail({
+ authSupabase: input.authSupabase,
+ classId: input.classId,
+ examId: input.examId,
+ userId: input.userId,
+ })
+}
+
+export async function publishExam(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ userId: string
+}) {
+ const context = await assertManagerContext(input)
+ const admin = createServerClient()
+ const exam = await loadExamById(admin, input.examId, input.classId)
+ const questions = await loadExamQuestions(admin, input.examId)
+
+ if (questions.length === 0) {
+ throw new Error("Add at least one question before publishing an exam.")
+ }
+
+ const now = new Date().toISOString()
+ const nextStatus = normalizeExamStatus({
+ ...exam,
+ published_at: exam.published_at ?? now,
+ })
+ await runSchemaCompatible(
+ "exams",
+ async () => {
+ const { error } = await admin
+ .from("exams")
+ .update({
+ published_at: exam.published_at ?? now,
+ status: nextStatus,
+ })
+ .eq("id", input.examId)
+
+ if (error) throw new Error(error.message)
+ },
+ async () => {
+ const { error } = await admin
+ .from("exams")
+ .update({
+ status: nextStatus,
+ })
+ .eq("id", input.examId)
+
+ if (error) throw new Error(error.message)
+ },
+ )
+
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: "exam.published",
+ entityType: "exam",
+ entityId: input.examId,
+ payload: {
+ publishedAt: exam.published_at ?? now,
+ },
+ })
+
+ return loadManagerExamDetail({
+ authSupabase: input.authSupabase,
+ classId: input.classId,
+ examId: input.examId,
+ userId: input.userId,
+ })
+}
+
+export async function deleteExam(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ userId: string
+}) {
+ const context = await assertManagerContext(input)
+ const admin = createServerClient()
+ const exam = await loadExamById(admin, input.examId, input.classId)
+ const attempts = await loadExamAttempts(admin, [input.examId])
+
+ const attemptIds = attempts.map((attempt) => attempt.id)
+ if (attemptIds.length > 0) {
+ const { error: answerDeleteError } = await admin
+ .from("exam_answers")
+ .delete()
+ .in("exam_attempt_id", attemptIds)
+
+ if (answerDeleteError) throw new Error(answerDeleteError.message)
+ }
+
+ const { error: attemptDeleteError } = await admin
+ .from("exam_attempts")
+ .delete()
+ .eq("exam_id", input.examId)
+
+ if (attemptDeleteError) throw new Error(attemptDeleteError.message)
+
+ const { error: questionDeleteError } = await admin
+ .from("exam_questions")
+ .delete()
+ .eq("exam_id", input.examId)
+
+ if (questionDeleteError) throw new Error(questionDeleteError.message)
+
+ const { error: examDeleteError } = await admin
+ .from("exams")
+ .delete()
+ .eq("id", input.examId)
+ .eq("class_id", input.classId)
+
+ if (examDeleteError) throw new Error(examDeleteError.message)
+
+ await removeExamPasscodeHashFallback({
+ admin,
+ organizationId: context.organizationId,
+ classId: input.classId,
+ examId: input.examId,
+ })
+
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: "exam.deleted",
+ entityType: "exam",
+ entityId: input.examId,
+ payload: {
+ classId: input.classId,
+ title: exam.title,
+ deletedAttemptCount: attempts.length,
+ },
+ })
+
+ return {
+ id: input.examId,
+ }
+}
+
+export async function startExamAttempt(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ userId: string
+ body: StartAttemptInput
+}) {
+ const context = await assertStudentContext(input)
+ const admin = createServerClient()
+ const exam = await loadExamById(admin, input.examId, input.classId)
+ ensurePublishedExam(exam)
+ await ensureExamCanStart(exam)
+ let attempts = await loadExamAttemptsForStudent(
+ admin,
+ input.examId,
+ input.userId,
+ )
+ const activeAttempt = attempts.find(
+ (attempt) => attempt.status === "in_progress",
+ )
+
+ if (activeAttempt) {
+ if (attemptExpired(activeAttempt, exam)) {
+ await submitAttemptInternal({
+ admin,
+ attempt: activeAttempt,
+ userId: input.userId,
+ organizationId: context.organizationId,
+ autoSubmitted: true,
+ })
+ attempts = await loadExamAttemptsForStudent(
+ admin,
+ input.examId,
+ input.userId,
+ )
+ } else {
+ return buildStudentActiveExamDto({
+ admin,
+ exam,
+ attempt: activeAttempt,
+ examModeEnabled: true,
+ })
+ }
+ }
+
+ await validateStartAttemptPasscode({
+ admin,
+ organizationId: context.organizationId,
+ classId: input.classId,
+ examId: input.examId,
+ studentUserId: input.userId,
+ passcodeHash: exam.passcode_hash,
+ passcode: input.body.passcode,
+ })
+
+ const availableRetakeCount = await loadAvailableRetakeCount(
+ admin,
+ input.examId,
+ input.userId,
+ )
+ const attemptAvailability = resolveExamAttemptAvailability({
+ attempts,
+ availableRetakeCount,
+ })
+
+ if (
+ !attemptAvailability.canStart ||
+ attemptAvailability.nextAttemptNumber === null
+ ) {
+ throw new Error(attemptAvailability.reason ?? RETAKE_REQUIRED_MESSAGE)
+ }
+
+ const attemptNumber = attemptAvailability.nextAttemptNumber
+ const startedAt = new Date()
+ const deadlineAt = computeAttemptDeadline(exam, startedAt)
+ const attempt = await runSchemaCompatible(
+ "attempts",
+ async () => {
+ const { data, error } = await admin
+ .from("exam_attempts")
+ .insert({
+ organization_id: context.organizationId,
+ class_id: input.classId,
+ exam_id: input.examId,
+ student_user_id: input.userId,
+ status: "in_progress",
+ started_at: startedAt.toISOString(),
+ attempt_number: attemptNumber,
+ deadline_at: deadlineAt,
+ rules_snapshot_json: {},
+ integrity_status: "clear",
+ })
+ .select(ATTEMPT_SELECT_BASE)
+ .single()
+
+ if (error || !data) {
+ throw new Error(error?.message ?? "Could not start exam attempt.")
+ }
+
+ return normalizeAttemptRow(data as RawRow, attemptNumber)
+ },
+ async () => {
+ const { data, error } = await admin
+ .from("exam_attempts")
+ .insert({
+ organization_id: context.organizationId,
+ class_id: input.classId,
+ exam_id: input.examId,
+ student_user_id: input.userId,
+ status: "in_progress",
+ started_at: startedAt.toISOString(),
+ })
+ .select(ATTEMPT_SELECT_BASE)
+ .single()
+
+ if (error || !data) {
+ throw new Error(error?.message ?? "Could not start exam attempt.")
+ }
+
+ return normalizeAttemptRow(
+ {
+ ...(data as RawRow),
+ deadline_at: deadlineAt,
+ rules_snapshot_json: {},
+ integrity_status: "clear",
+ },
+ attemptNumber,
+ )
+ },
+ )
+
+ if (attemptNumber > 1 && availableRetakeCount > 0) {
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: "exam.retake_consumed",
+ entityType: "exam",
+ entityId: input.examId,
+ payload: {
+ attemptId: attempt.id,
+ attemptNumber,
+ studentUserId: input.userId,
+ },
+ })
+ }
+
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: "exam.attempt_started",
+ entityType: "exam_attempt",
+ entityId: attempt.id,
+ payload: {
+ examId: input.examId,
+ attemptNumber,
+ deadlineAt,
+ },
+ })
+
+ return buildStudentActiveExamDto({
+ admin,
+ exam,
+ attempt,
+ examModeEnabled: true,
+ })
+}
+
+export async function saveExamAnswer(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ attemptId: string
+ userId: string
+ body: SaveAnswerInput
+}) {
+ const context = await assertStudentContext(input)
+ const admin = createServerClient()
+ const attempt = await loadAttemptById(admin, input.attemptId)
+
+ if (
+ attempt.class_id !== input.classId ||
+ attempt.exam_id !== input.examId ||
+ attempt.student_user_id !== input.userId
+ ) {
+ throw new Error("Attempt not found.")
+ }
+
+ if (attempt.status !== "in_progress") {
+ throw new Error("Submitted attempts can no longer be edited.")
+ }
+
+ const exam = await loadExamById(admin, input.examId, input.classId)
+
+ if (attemptExpired(attempt, exam)) {
+ await submitAttemptInternal({
+ admin,
+ attempt,
+ userId: input.userId,
+ organizationId: context.organizationId,
+ autoSubmitted: true,
+ })
+ throw new Error("This attempt has expired and was submitted automatically.")
+ }
+
+ const question = await loadQuestionById(admin, input.body.questionId)
+ if (question.exam_id !== input.examId) {
+ throw new Error("Question not found.")
+ }
+
+ const { error } = await admin.from("exam_answers").upsert(
+ {
+ organization_id: context.organizationId,
+ exam_attempt_id: input.attemptId,
+ exam_question_id: input.body.questionId,
+ answer_json: input.body.answer,
+ },
+ { onConflict: "exam_attempt_id,exam_question_id" },
+ )
+
+ if (error) throw new Error(error.message)
+
+ return { ok: true }
+}
+
+export async function submitExamAttempt(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ attemptId: string
+ userId: string
+}) {
+ await assertStudentContext(input)
+ const admin = createServerClient()
+ const attempt = await loadAttemptById(admin, input.attemptId)
+
+ if (
+ attempt.class_id !== input.classId ||
+ attempt.exam_id !== input.examId ||
+ attempt.student_user_id !== input.userId
+ ) {
+ throw new Error("Attempt not found.")
+ }
+
+ const nextAttempt = await submitAttemptInternal({
+ admin,
+ attempt,
+ userId: input.userId,
+ organizationId: attempt.organization_id,
+ autoSubmitted: false,
+ })
+ const exam = await loadExamById(admin, input.examId, input.classId)
+ return buildStudentReleasedResultDto({
+ admin,
+ exam,
+ attempt: nextAttempt,
+ })
+}
+
+export async function gradeExamAttempt(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ attemptId: string
+ userId: string
+ body: GradeAttemptInput
+}) {
+ const context = await assertManagerContext(input)
+ const admin = createServerClient()
+ const attempt = await loadAttemptById(admin, input.attemptId)
+
+ if (attempt.class_id !== input.classId || attempt.exam_id !== input.examId) {
+ throw new Error("Attempt not found.")
+ }
+
+ if (attempt.status === "in_progress") {
+ throw new Error("Submit the attempt before saving grades.")
+ }
+
+ if (attempt.status === "voided") {
+ throw new Error("Voided attempts cannot be graded.")
+ }
+
+ if (attempt.results_released_at) {
+ throw new Error("This result has already been approved.")
+ }
+
+ const questions = await loadExamQuestions(admin, input.examId)
+ const answers = await loadExamAnswers(admin, [input.attemptId])
+ const answersById = new Map(answers.map((answer) => [answer.id, answer]))
+ const answersByQuestionId = new Map(
+ answers.map((answer) => [answer.exam_question_id, answer]),
+ )
+ const updatedTeacherScores = new Map()
+
+ for (const update of input.body.answers) {
+ const answer =
+ (update.answerId ? answersById.get(update.answerId) : undefined) ??
+ (update.questionId
+ ? answersByQuestionId.get(update.questionId)
+ : undefined)
+ const questionId = answer?.exam_question_id ?? update.questionId
+
+ if (!questionId) {
+ throw new Error("Each grade update must reference an answer or question.")
+ }
+
+ const question = questions.find((candidate) => candidate.id === questionId)
+ if (!question) throw new Error("Question not found.")
+
+ const canTeacherGrade = canTeacherGradeQuestion({
+ questionType: question.question_type,
+ correctAnswer: question.correct_answer_json ?? null,
+ })
+
+ if (update.teacherScore !== null && !canTeacherGrade) {
+ throw new Error(
+ "Only short-answer questions without a model answer can be graded manually.",
+ )
+ }
+
+ updatedTeacherScores.set(questionId, update.teacherScore)
+
+ if (!canTeacherGrade) {
+ if (
+ answer?.teacher_score !== null &&
+ answer?.teacher_score !== undefined
+ ) {
+ const { error } = await admin
+ .from("exam_answers")
+ .update({ teacher_score: null })
+ .eq("id", answer.id)
+
+ if (error) throw new Error(error.message)
+ }
+ continue
+ }
+
+ if (
+ update.teacherScore !== null &&
+ update.teacherScore > Number(question.points)
+ ) {
+ throw new Error("Teacher score cannot exceed question points.")
+ }
+
+ if (answer) {
+ const { error } = await admin
+ .from("exam_answers")
+ .update({ teacher_score: update.teacherScore })
+ .eq("id", answer.id)
+
+ if (error) throw new Error(error.message)
+ } else {
+ const { error } = await admin.from("exam_answers").insert({
+ organization_id: attempt.organization_id,
+ exam_attempt_id: attempt.id,
+ exam_question_id: questionId,
+ answer_json: null,
+ teacher_score: update.teacherScore,
+ })
+
+ if (error) throw new Error(error.message)
+ }
+ }
+
+ const missingManualGrades = questions.filter((question) => {
+ if (
+ !questionRequiresManualGrading({
+ questionType: question.question_type,
+ correctAnswer: question.correct_answer_json ?? null,
+ })
+ ) {
+ return false
+ }
+
+ const savedAnswer = answersByQuestionId.get(question.id)
+ const teacherScore = updatedTeacherScores.has(question.id)
+ ? updatedTeacherScores.get(question.id)
+ : (savedAnswer?.teacher_score ?? null)
+
+ return teacherScore === null || teacherScore === undefined
+ })
+
+ if (missingManualGrades.length > 0) {
+ throw new Error(
+ "Score every manual-review short answer before saving the grade.",
+ )
+ }
+
+ const refreshedAttempt = await finalizeAttemptScores({
+ admin,
+ attemptId: input.attemptId,
+ gradedByUserId: input.userId,
+ status: "graded",
+ needsManualReview: false,
+ maybeReleaseImmediately: true,
+ releaseByUserId: input.userId,
+ })
+
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: "exam.graded",
+ entityType: "exam_attempt",
+ entityId: input.attemptId,
+ payload: {
+ examId: input.examId,
+ totalScore: refreshedAttempt.total_score ?? 0,
+ },
+ })
+
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: "exam.results_released",
+ entityType: "exam_attempt",
+ entityId: input.attemptId,
+ payload: {
+ examId: input.examId,
+ releasedAt:
+ refreshedAttempt.results_released_at ?? new Date().toISOString(),
+ },
+ })
+
+ const exam = await loadExamById(admin, input.examId, input.classId)
+ return buildManagerExamDetailResponse(admin, exam)
+}
+
+export async function releaseExamAttempt(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ attemptId: string
+ userId: string
+}) {
+ const context = await assertManagerContext(input)
+ const admin = createServerClient()
+ const attempt = await loadAttemptById(admin, input.attemptId)
+
+ if (attempt.class_id !== input.classId || attempt.exam_id !== input.examId) {
+ throw new Error("Attempt not found.")
+ }
+
+ const releasedAt = new Date().toISOString()
+ await runSchemaCompatible(
+ "attempts",
+ async () => {
+ const { error } = await admin
+ .from("exam_attempts")
+ .update({
+ results_released_at: releasedAt,
+ results_released_by_user_id: input.userId,
+ })
+ .eq("id", input.attemptId)
+
+ if (error) throw new Error(error.message)
+ },
+ async () => undefined,
+ )
+
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: "exam.results_released",
+ entityType: "exam_attempt",
+ entityId: input.attemptId,
+ payload: {
+ examId: input.examId,
+ releasedAt,
+ },
+ })
+
+ return buildManagerExamDetailResponse(
+ admin,
+ await loadExamById(admin, input.examId, input.classId),
+ )
+}
+
+export async function applyExamIntegrityAction(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ attemptId: string
+ userId: string
+ body: IntegrityActionInput
+}) {
+ const context = await assertManagerContext(input)
+ const admin = createServerClient()
+ const attempt = await loadAttemptById(admin, input.attemptId)
+
+ if (attempt.class_id !== input.classId || attempt.exam_id !== input.examId) {
+ throw new Error("Attempt not found.")
+ }
+
+ const timestamp = new Date().toISOString()
+ await runSchemaCompatible(
+ "attempts",
+ async () => {
+ if (input.body.action === "flag") {
+ const { error } = await admin
+ .from("exam_attempts")
+ .update({
+ integrity_status: "flagged",
+ flagged_at: timestamp,
+ flagged_by_user_id: input.userId,
+ flag_reason: input.body.reason ?? null,
+ })
+ .eq("id", input.attemptId)
+
+ if (error) throw new Error(error.message)
+ return
+ }
+
+ if (input.body.action === "void") {
+ const { error } = await admin
+ .from("exam_attempts")
+ .update({
+ integrity_status: "voided",
+ status: "voided",
+ voided_at: timestamp,
+ voided_by_user_id: input.userId,
+ void_reason: input.body.reason ?? null,
+ })
+ .eq("id", input.attemptId)
+
+ if (error) throw new Error(error.message)
+ return
+ }
+
+ const { error } = await admin
+ .from("exam_attempts")
+ .update({
+ integrity_status: "clear",
+ flagged_at: null,
+ flagged_by_user_id: null,
+ flag_reason: null,
+ })
+ .eq("id", input.attemptId)
+
+ if (error) throw new Error(error.message)
+ },
+ async () => {
+ if (input.body.action !== "void") return
+
+ const { error } = await admin
+ .from("exam_attempts")
+ .update({
+ status: "voided",
+ submitted_at: attempt.submitted_at ?? timestamp,
+ })
+ .eq("id", input.attemptId)
+
+ if (error) throw new Error(error.message)
+ },
+ )
+
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: input.body.action === "void" ? "exam.voided" : "exam.flagged",
+ entityType: "exam_attempt",
+ entityId: input.attemptId,
+ payload: {
+ examId: input.examId,
+ action: input.body.action,
+ reason: input.body.reason ?? null,
+ },
+ })
+
+ return buildManagerExamDetailResponse(
+ admin,
+ await loadExamById(admin, input.examId, input.classId),
+ )
+}
+
+export async function grantExamRetake(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ attemptId: string
+ userId: string
+}) {
+ const context = await assertManagerContext(input)
+ const admin = createServerClient()
+ const attempt = await loadAttemptById(admin, input.attemptId)
+
+ if (attempt.class_id !== input.classId || attempt.exam_id !== input.examId) {
+ throw new Error("Attempt not found.")
+ }
+
+ if (attempt.status === "in_progress") {
+ throw new Error(
+ "Finish or void the active attempt before granting a retake.",
+ )
+ }
+
+ const availableRetakeCount = await loadAvailableRetakeCount(
+ admin,
+ input.examId,
+ attempt.student_user_id,
+ )
+
+ if (availableRetakeCount > 0) {
+ throw new Error("This student already has an unused retake.")
+ }
+
+ await writeExamAuditLog({
+ organizationId: context.organizationId,
+ actorUserId: input.userId,
+ action: "exam.retake_granted",
+ entityType: "exam",
+ entityId: input.examId,
+ payload: {
+ attemptId: attempt.id,
+ studentUserId: attempt.student_user_id,
+ },
+ })
+
+ return buildManagerExamDetailResponse(
+ admin,
+ await loadExamById(admin, input.examId, input.classId),
+ )
+}
+
+export async function recordExamIntegrityEvent(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ examId: string
+ attemptId: string
+ userId: string
+ body: IntegrityEventInput
+}) {
+ await assertStudentContext(input)
+ const admin = createServerClient()
+ const attempt = await loadAttemptById(admin, input.attemptId)
+
+ if (
+ attempt.class_id !== input.classId ||
+ attempt.exam_id !== input.examId ||
+ attempt.student_user_id !== input.userId
+ ) {
+ throw new Error("Attempt not found.")
+ }
+
+ const shouldMarkReported = shouldMarkIntegrityReported({
+ currentStatus: attempt.integrity_status,
+ eventType: input.body.eventType,
+ })
+
+ if (shouldMarkReported) {
+ await runSchemaCompatible(
+ "attempts",
+ async () => {
+ const { error } = await admin
+ .from("exam_attempts")
+ .update({ integrity_status: "reported" })
+ .eq("id", input.attemptId)
+
+ if (error) throw new Error(error.message)
+ },
+ async () => undefined,
+ )
+ }
+
+ await writeExamAuditLog({
+ organizationId: attempt.organization_id,
+ actorUserId: input.userId,
+ action: "exam.attempt_event",
+ entityType: "exam_attempt",
+ entityId: input.attemptId,
+ payload: {
+ examId: input.examId,
+ eventType: input.body.eventType,
+ payload: sanitizePayload(input.body.payload),
+ },
+ })
+
+ return { ok: true }
+}
+
+export async function loadReleasedExamResultsForClass(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ userId: string
+}) {
+ const context = await loadClassContext(input)
+
+ if (context.canManage) {
+ return {
+ exams: await loadManagerExamSummaries(context.classId),
+ results: [] as ReleasedExamResultSummaryDto[],
+ }
+ }
+
+ const student = await loadStudentExamPage({
+ authSupabase: input.authSupabase,
+ classId: context.classId,
+ organizationId: context.organizationId,
+ userId: input.userId,
+ selectedRole: context.selectedRole,
+ isStudentMember: context.isStudentMember,
+ })
+
+ return {
+ exams: [] as ManagerExamSummaryDto[],
+ results:
+ student.releasedResults.length > 0
+ ? student.releasedResults.map(toReleasedExamSummary)
+ : student.history,
+ }
+}
+
+async function assertManagerContext(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ userId: string
+}) {
+ const context = await loadClassContext(input)
+ if (!context.canManage || !isManagerRole(context.selectedRole)) {
+ throw new Error(
+ "Only class teachers and organization admins can manage exams.",
+ )
+ }
+
+ return context
+}
+
+async function assertStudentContext(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ userId: string
+}) {
+ const context = await loadClassContext(input)
+ if (context.selectedRole !== "student" || !context.isStudentMember) {
+ throw new Error("Switch to the student role to access exam attempts.")
+ }
+
+ return context
+}
+
+async function loadClassContext(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ userId: string
+}) {
+ const { data: classRow, error: classError } = await input.authSupabase
+ .from("classes")
+ .select("id, organization_id, code, name, semester")
+ .eq("id", input.classId)
+ .eq("is_archived", false)
+ .maybeSingle()
+
+ if (classError) throw new Error(classError.message)
+ if (!classRow) throw new Error("Class not found.")
+
+ const [canManageResult, selectedRole, membership, featureEnabled] =
+ await Promise.all([
+ input.authSupabase.rpc("can_manage_class", {
+ target_org_id: classRow.organization_id,
+ target_class_id: input.classId,
+ }),
+ loadSelectedOrganizationRole(
+ input.authSupabase,
+ classRow.organization_id,
+ input.userId,
+ ),
+ input.authSupabase
+ .from("class_memberships")
+ .select("id, role")
+ .eq("organization_id", classRow.organization_id)
+ .eq("class_id", input.classId)
+ .eq("user_id", input.userId)
+ .eq("role", "student")
+ .maybeSingle(),
+ input.authSupabase.rpc("is_class_feature_enabled", {
+ target_class_id: input.classId,
+ target_feature_key: "exam",
+ }),
+ ])
+
+ if (canManageResult.error) throw new Error(canManageResult.error.message)
+ if (membership.error) throw new Error(membership.error.message)
+ if (featureEnabled.error) throw new Error(featureEnabled.error.message)
+ if (!featureEnabled.data) {
+ throw new Error("The exam feature is disabled for this class.")
+ }
+
+ return {
+ id: classRow.id,
+ classId: classRow.id,
+ organizationId: classRow.organization_id,
+ code: classRow.code,
+ name: classRow.name,
+ semester: classRow.semester,
+ canManage: Boolean(canManageResult.data),
+ selectedRole,
+ isStudentMember: Boolean(membership.data),
+ isFeatureEnabled: Boolean(featureEnabled.data),
+ } satisfies ClassContext & { classId: string }
+}
+
+async function loadSelectedOrganizationRole(
+ supabase: SupabaseClient,
+ organizationId: string,
+ userId: string,
+) {
+ const { data: membership, error } = await supabase
+ .from("organization_memberships")
+ .select("id, role, selected_role_id")
+ .eq("organization_id", organizationId)
+ .eq("user_id", userId)
+ .eq("status", "active")
+ .maybeSingle()
+
+ if (error) throw new Error(error.message)
+ if (!membership) return null
+
+ if (membership.selected_role_id) {
+ const { data: selectedRole, error: selectedRoleError } = await supabase
+ .from("organization_membership_roles")
+ .select("role")
+ .eq("id", membership.selected_role_id)
+ .eq("organization_membership_id", membership.id)
+ .eq("status", "active")
+ .maybeSingle()
+
+ if (selectedRoleError) throw new Error(selectedRoleError.message)
+ if (selectedRole?.role) return selectedRole.role as AppRole
+ }
+
+ return membership.role as AppRole
+}
+
+async function loadManagerExamSummaries(classId: string) {
+ const admin = createServerClient()
+ const exams = await loadExamsByClass(admin, classId)
+ const attempts = await loadExamAttempts(
+ admin,
+ exams.map((exam) => exam.id),
+ )
+
+ return exams.map((exam) => toManagerExamSummary(exam, attempts))
+}
+
+async function loadStudentExamPage(input: {
+ authSupabase: SupabaseClient
+ classId: string
+ organizationId: string
+ userId: string
+ selectedRole: AppRole | null
+ isStudentMember: boolean
+}) {
+ const admin = createServerClient()
+
+ if (input.selectedRole !== "student" || !input.isStudentMember) {
+ return {
+ state: "none",
+ scheduledExam: null,
+ activeExam: null,
+ releasedResults: [],
+ history: [],
+ } satisfies StudentExamPageDto
+ }
+
+ const allExams = await loadExamsByClass(admin, input.classId)
+ const publishedExams = allExams.filter(isExamPublished)
+ const examsById = new Map(allExams.map((exam) => [exam.id, exam]))
+ const attempts = await loadAttemptsForStudentByClass(
+ admin,
+ input.classId,
+ input.userId,
+ )
+ const openAttempt = attempts.find(
+ (attempt) => attempt.status === "in_progress",
+ )
+ const openAttemptExam = openAttempt
+ ? (examsById.get(openAttempt.exam_id) ?? null)
+ : null
+
+ if (openAttempt && attemptExpired(openAttempt, openAttemptExam)) {
+ await submitAttemptInternal({
+ admin,
+ attempt: openAttempt,
+ userId: input.userId,
+ organizationId: input.organizationId,
+ autoSubmitted: true,
+ })
+
+ return loadStudentExamPage(input)
+ }
+
+ const releasedResults = await buildReleasedResults({
+ admin,
+ attempts,
+ exams: allExams,
+ })
+ const history = releasedResults.map(toReleasedExamSummary)
+
+ const selection = resolveStudentExamPageSelection({
+ allExams,
+ publishedExams,
+ attempts,
+ })
+ const activeAttempt = selection.activeAttempt
+ const activeExam = selection.activeExam
+
+ if (activeExam) {
+ const availableRetakeCount = activeAttempt
+ ? 0
+ : await loadAvailableRetakeCount(admin, activeExam.id, input.userId)
+ const attemptAvailability = activeAttempt
+ ? null
+ : resolveExamAttemptAvailability({
+ attempts: attempts.filter(
+ (attempt) => attempt.exam_id === activeExam.id,
+ ),
+ availableRetakeCount,
+ })
+
+ return {
+ state: "active",
+ scheduledExam: null,
+ activeExam: await buildStudentActiveExamDto({
+ admin,
+ exam: activeExam,
+ attempt: activeAttempt ?? null,
+ examModeEnabled: true,
+ canStartAttempt: attemptAvailability?.canStart ?? true,
+ startBlockedReason: attemptAvailability?.reason ?? null,
+ }),
+ releasedResults,
+ history,
+ } satisfies StudentExamPageDto
+ }
+
+ const scheduledExam = selection.scheduledExam
+ if (scheduledExam) {
+ return {
+ state: "scheduled",
+ scheduledExam: toScheduledExam(scheduledExam),
+ activeExam: null,
+ releasedResults,
+ history,
+ } satisfies StudentExamPageDto
+ }
+
+ return {
+ state: "none",
+ scheduledExam: null,
+ activeExam: null,
+ releasedResults,
+ history,
+ } satisfies StudentExamPageDto
+}
+
+async function buildStudentActiveExamDto(input: {
+ admin: SupabaseClient
+ exam: ExamRow
+ attempt: ExamAttemptRow | null
+ examModeEnabled: boolean
+ canStartAttempt?: boolean
+ startBlockedReason?: string | null
+}) {
+ const questions = await loadExamQuestions(input.admin, input.exam.id)
+ const answers = input.attempt
+ ? await loadExamAnswers(input.admin, [input.attempt.id])
+ : []
+ const answersByQuestionId = new Map(
+ answers.map((answer) => [answer.exam_question_id, answer]),
+ )
+
+ return {
+ id: input.exam.id,
+ title: input.exam.title,
+ classId: input.exam.class_id,
+ durationMinutes: Number(input.exam.duration_minutes),
+ totalPoints: Number(input.exam.total_points),
+ questionCount: questions.length,
+ startAt: input.exam.start_at,
+ endAt: input.exam.end_at,
+ status: normalizeExamStatus(input.exam),
+ requiresPasscode: true,
+ examModeEnabled: input.examModeEnabled,
+ canStartAttempt: input.attempt
+ ? true
+ : Boolean(input.exam.passcode_hash) && (input.canStartAttempt ?? true),
+ startBlockedReason: input.attempt
+ ? null
+ : input.exam.passcode_hash
+ ? (input.startBlockedReason ?? null)
+ : EXAM_PASSCODE_MISSING_MESSAGE,
+ attempt: input.attempt
+ ? toStudentAttemptDto(input.attempt, input.exam)
+ : null,
+ questions: input.attempt
+ ? questions.map((question) =>
+ toStudentQuestionDto(
+ question,
+ answersByQuestionId.get(question.id)?.answer_json ?? null,
+ ),
+ )
+ : [],
+ } satisfies StudentActiveExamDto
+}
+
+async function buildStudentReleasedResultDto(input: {
+ admin: SupabaseClient
+ exam: ExamRow
+ attempt: ExamAttemptRow
+}) {
+ const questions = await loadExamQuestions(input.admin, input.exam.id)
+ const answers = await loadExamAnswers(input.admin, [input.attempt.id])
+ const answersByQuestionId = new Map(
+ answers.map((answer) => [answer.exam_question_id, answer]),
+ )
+
+ return {
+ attemptId: input.attempt.id,
+ examId: input.exam.id,
+ title: input.exam.title,
+ status: input.attempt.status,
+ totalScore: input.attempt.results_released_at
+ ? Number(input.attempt.total_score ?? 0)
+ : null,
+ totalPoints: Number(input.exam.total_points),
+ submittedAt: input.attempt.submitted_at,
+ releasedAt: input.attempt.results_released_at,
+ gradedAt: input.attempt.graded_at,
+ isReleased: Boolean(input.attempt.results_released_at),
+ needsManualReview: input.attempt.needs_manual_review,
+ integrityStatus: input.attempt.integrity_status,
+ questions: questions.map((question) => {
+ const answer = answersByQuestionId.get(question.id)
+ const isReleased = Boolean(input.attempt.results_released_at)
+ const selectedOptionIndex =
+ typeof answer?.answer_json === "number" ? answer.answer_json : null
+ const selectedTextAnswer =
+ typeof answer?.answer_json === "string" ? answer.answer_json : null
+ const correctOptionIndex =
+ isReleased &&
+ question.question_type === "mcq" &&
+ typeof question.correct_answer_json === "number"
+ ? question.correct_answer_json
+ : null
+ const correctTextAnswer =
+ isReleased &&
+ question.question_type === "short" &&
+ typeof question.correct_answer_json === "string" &&
+ question.correct_answer_json.trim().length > 0
+ ? question.correct_answer_json
+ : null
+
+ return {
+ id: question.id,
+ position: question.position,
+ prompt: question.prompt,
+ type: question.question_type,
+ points: question.points,
+ score: isReleased
+ ? resolveAnswerScore({
+ teacherScore: canTeacherGradeQuestion({
+ questionType: question.question_type,
+ correctAnswer: question.correct_answer_json ?? null,
+ })
+ ? answer?.teacher_score
+ : null,
+ autoScore: answer?.auto_score,
+ })
+ : null,
+ status: isReleased
+ ? getReleasedAnswerStatus(
+ {
+ questionType: question.question_type,
+ correctAnswer: question.correct_answer_json ?? null,
+ },
+ answer?.answer_json ?? null,
+ )
+ : answer?.answer_json === null || answer?.answer_json === undefined
+ ? "unanswered"
+ : "reviewed",
+ selectedOptionIndex,
+ selectedTextAnswer,
+ correctOptionIndex,
+ correctTextAnswer,
+ }
+ }),
+ } satisfies ReleasedExamResultDto
+}
+
+async function buildReleasedResults(input: {
+ admin: SupabaseClient
+ attempts: ExamAttemptRow[]
+ exams: ExamRow[]
+}) {
+ const examsById = new Map(input.exams.map((exam) => [exam.id, exam]))
+ const results = await Promise.all(
+ input.attempts
+ .filter((attempt) => Boolean(attempt.results_released_at))
+ .sort(compareReleasedAttemptsDesc)
+ .map(async (attempt) => {
+ const exam = examsById.get(attempt.exam_id)
+ if (!exam) return null
+
+ return buildStudentReleasedResultDto({
+ admin: input.admin,
+ exam,
+ attempt,
+ })
+ }),
+ )
+
+ return results.filter(
+ (result): result is ReleasedExamResultDto => result !== null,
+ )
+}
+
+async function submitAttemptInternal(input: {
+ admin: SupabaseClient
+ attempt: ExamAttemptRow
+ userId: string
+ organizationId: string
+ autoSubmitted: boolean
+}) {
+ const exam = await loadExamById(
+ input.admin,
+ input.attempt.exam_id,
+ input.attempt.class_id,
+ )
+ const questions = await loadExamQuestions(input.admin, input.attempt.exam_id)
+ const answers = await loadExamAnswers(input.admin, [input.attempt.id])
+ const answersByQuestionId = new Map(
+ answers.map((answer) => [answer.exam_question_id, answer]),
+ )
+
+ let needsManualReview = false
+
+ for (const question of questions) {
+ const answer = answersByQuestionId.get(question.id)
+ const evaluation = evaluateExamAnswer(
+ {
+ questionType: question.question_type,
+ points: Number(question.points),
+ correctAnswer: question.correct_answer_json ?? null,
+ },
+ answer?.answer_json ?? null,
+ )
+
+ if (!evaluation.gradedAutomatically) {
+ needsManualReview = needsManualReview || question.question_type !== "mcq"
+ }
+
+ if (answer) {
+ const { error } = await input.admin
+ .from("exam_answers")
+ .update({ auto_score: evaluation.autoScore })
+ .eq("id", answer.id)
+
+ if (error) throw new Error(error.message)
+ } else if (evaluation.autoScore !== null) {
+ const { error } = await input.admin.from("exam_answers").insert({
+ organization_id: input.attempt.organization_id,
+ exam_attempt_id: input.attempt.id,
+ exam_question_id: question.id,
+ answer_json: null,
+ auto_score: evaluation.autoScore,
+ })
+
+ if (error) throw new Error(error.message)
+ }
+ }
+
+ const refreshedAttempt = await finalizeAttemptScores({
+ admin: input.admin,
+ attemptId: input.attempt.id,
+ gradedByUserId: needsManualReview ? null : input.userId,
+ status: needsManualReview ? "submitted" : "graded",
+ submittedAt: new Date().toISOString(),
+ autoSubmittedAt: input.autoSubmitted ? new Date().toISOString() : null,
+ needsManualReview,
+ })
+
+ await writeExamAuditLog({
+ organizationId: input.organizationId,
+ actorUserId: input.userId,
+ action: "exam.attempt_submitted",
+ entityType: "exam_attempt",
+ entityId: input.attempt.id,
+ payload: {
+ examId: input.attempt.exam_id,
+ autoSubmitted: input.autoSubmitted,
+ totalScore: Number(refreshedAttempt.total_score ?? 0),
+ needsManualReview,
+ },
+ })
+
+ return refreshedAttempt
+}
+
+async function finalizeAttemptScores(input: {
+ admin: SupabaseClient
+ attemptId: string
+ gradedByUserId?: string | null
+ status?: ExamAttemptStatus
+ submittedAt?: string
+ autoSubmittedAt?: string | null
+ needsManualReview?: boolean
+ maybeReleaseImmediately?: boolean
+ releaseByUserId?: string
+}) {
+ const attempt = await loadAttemptById(input.admin, input.attemptId)
+ const exam = await loadExamById(
+ input.admin,
+ attempt.exam_id,
+ attempt.class_id,
+ )
+ const questions = await loadExamQuestions(input.admin, attempt.exam_id)
+ const answers = await loadExamAnswers(input.admin, [attempt.id])
+ const answersByQuestionId = new Map(
+ answers.map((answer) => [answer.exam_question_id, answer]),
+ )
+
+ const totalScore = questions.reduce((sum, question) => {
+ const answer = answersByQuestionId.get(question.id)
+ const nextScore = resolveAnswerScore({
+ teacherScore: canTeacherGradeQuestion({
+ questionType: question.question_type,
+ correctAnswer: question.correct_answer_json ?? null,
+ })
+ ? answer?.teacher_score
+ : null,
+ autoScore: answer?.auto_score,
+ })
+ return sum + nextScore
+ }, 0)
+
+ const nextStatus =
+ input.status ??
+ (input.needsManualReview
+ ? "submitted"
+ : totalScore >= Number(exam.total_points)
+ ? "graded"
+ : "graded")
+ const releasedAt = input.maybeReleaseImmediately
+ ? new Date().toISOString()
+ : null
+
+ return runSchemaCompatible(
+ "attempts",
+ async () => {
+ const { data, error } = await input.admin
+ .from("exam_attempts")
+ .update({
+ total_score: totalScore,
+ status: nextStatus,
+ submitted_at: input.submittedAt ?? attempt.submitted_at,
+ auto_submitted_at: input.autoSubmittedAt ?? attempt.auto_submitted_at,
+ needs_manual_review:
+ input.needsManualReview ?? attempt.needs_manual_review,
+ graded_at:
+ input.gradedByUserId === undefined
+ ? attempt.graded_at
+ : nextStatus === "graded"
+ ? new Date().toISOString()
+ : null,
+ graded_by_user_id:
+ input.gradedByUserId === undefined
+ ? attempt.graded_by_user_id
+ : nextStatus === "graded"
+ ? input.gradedByUserId
+ : null,
+ results_released_at: releasedAt ?? attempt.results_released_at,
+ results_released_by_user_id:
+ releasedAt && input.releaseByUserId
+ ? input.releaseByUserId
+ : attempt.results_released_by_user_id,
+ })
+ .eq("id", attempt.id)
+ .select(ATTEMPT_SELECT_EXTENDED)
+ .single()
+
+ if (error || !data) {
+ throw new Error(error?.message ?? "Could not finalize attempt.")
+ }
+
+ return normalizeAttemptRow(
+ {
+ ...(data as RawRow),
+ attempt_number: attempt.attempt_number,
+ },
+ attempt.attempt_number,
+ )
+ },
+ async () => {
+ const { data, error } = await input.admin
+ .from("exam_attempts")
+ .update({
+ total_score: totalScore,
+ status: nextStatus,
+ submitted_at: input.submittedAt ?? attempt.submitted_at,
+ })
+ .eq("id", attempt.id)
+ .select(ATTEMPT_SELECT_BASE)
+ .single()
+
+ if (error || !data) {
+ throw new Error(error?.message ?? "Could not finalize attempt.")
+ }
+
+ return normalizeAttemptRow(
+ {
+ ...(data as RawRow),
+ attempt_number: attempt.attempt_number,
+ deadline_at: attempt.deadline_at,
+ rules_snapshot_json: attempt.rules_snapshot_json,
+ needs_manual_review:
+ input.needsManualReview ?? attempt.needs_manual_review,
+ auto_submitted_at: input.autoSubmittedAt ?? attempt.auto_submitted_at,
+ integrity_status: attempt.integrity_status,
+ graded_at:
+ input.gradedByUserId && nextStatus === "graded"
+ ? new Date().toISOString()
+ : attempt.graded_at,
+ graded_by_user_id:
+ input.gradedByUserId && nextStatus === "graded"
+ ? input.gradedByUserId
+ : attempt.graded_by_user_id,
+ results_released_at: releasedAt ?? attempt.results_released_at,
+ results_released_by_user_id:
+ releasedAt && input.releaseByUserId
+ ? input.releaseByUserId
+ : attempt.results_released_by_user_id,
+ },
+ attempt.attempt_number,
+ )
+ },
+ )
+}
+
+async function replaceExamQuestions(input: {
+ admin: SupabaseClient
+ examId: string
+ organizationId: string
+ questions: UpsertExamInput["questions"]
+}) {
+ const { error: deleteError } = await input.admin
+ .from("exam_questions")
+ .delete()
+ .eq("exam_id", input.examId)
+
+ if (deleteError) throw new Error(deleteError.message)
+
+ const rows = input.questions.map((question, index) => ({
+ organization_id: input.organizationId,
+ exam_id: input.examId,
+ position: index + 1,
+ question_type: question.type,
+ prompt: question.prompt,
+ options_json: question.type === "mcq" ? question.options : null,
+ correct_answer_json: question.correctAnswer,
+ points: question.points,
+ language: null,
+ starter_code: null,
+ visible_tests_json: [],
+ hidden_tests_json: [],
+ evaluator_key: null,
+ }))
+ await runSchemaCompatible(
+ "questions",
+ async () => {
+ const { error } = await input.admin.from("exam_questions").insert(rows)
+ if (error) throw new Error(error.message)
+ },
+ async () => {
+ const { error } = await input.admin.from("exam_questions").insert(
+ rows.map((row) => ({
+ organization_id: row.organization_id,
+ exam_id: row.exam_id,
+ position: row.position,
+ question_type: row.question_type,
+ prompt: row.prompt,
+ options_json: row.options_json,
+ correct_answer_json: row.correct_answer_json,
+ points: row.points,
+ language: row.language,
+ starter_code: row.starter_code,
+ })),
+ )
+
+ if (error) throw new Error(error.message)
+ },
+ )
+}
+
+async function buildManagerExamDetailResponse(
+ admin: SupabaseClient,
+ exam: ExamRow,
+) {
+ const questions = await loadExamQuestions(admin, exam.id)
+ const attempts = await loadExamAttempts(admin, [exam.id])
+ const answers = await loadExamAnswers(
+ admin,
+ attempts.map((attempt) => attempt.id),
+ )
+ const integrityEventsByAttemptId = await loadAttemptIntegrityEvents(
+ admin,
+ attempts.map((attempt) => attempt.id),
+ )
+ const profiles = await loadProfiles(
+ admin,
+ attempts.map((attempt) => attempt.student_user_id),
+ )
+ const profilesById = new Map(profiles.map((profile) => [profile.id, profile]))
+ const availableRetakeCounts = await loadAvailableRetakeCounts(
+ admin,
+ exam.id,
+ attempts.map((attempt) => attempt.student_user_id),
+ )
+
+ return {
+ exam: {
+ ...toManagerExamSummary(exam, attempts),
+ classId: exam.class_id,
+ organizationId: exam.organization_id,
+ createdByUserId: exam.created_by_user_id,
+ passcodeProtected: Boolean(exam.passcode_hash),
+ },
+ questions: questions.map(toManagerQuestionDto),
+ attempts: attempts.map((attempt) =>
+ toManagerAttemptSummary(
+ attempt,
+ profilesById.get(attempt.student_user_id) ?? null,
+ answers.filter((answer) => answer.exam_attempt_id === attempt.id),
+ integrityEventsByAttemptId.get(attempt.id) ?? [],
+ availableRetakeCounts.get(attempt.student_user_id) ?? 0,
+ ),
+ ),
+ } satisfies ManagerExamDetailDto
+}
+
+async function runSchemaCompatible(
+ target: SchemaTarget,
+ runExtended: () => Promise,
+ runBase: () => Promise,
+) {
+ if (schemaModes[target] === "base") {
+ return runBase()
+ }
+
+ try {
+ const result = await runExtended()
+ schemaModes[target] = "extended"
+ return result
+ } catch (error) {
+ if (!isMissingSchemaColumnError(error, target)) {
+ throw error
+ }
+
+ schemaModes[target] = "base"
+ return runBase()
+ }
+}
+
+function isMissingSchemaColumnError(
+ error: unknown,
+ target: SchemaTarget,
+): boolean {
+ const message =
+ error instanceof Error
+ ? error.message.toLowerCase()
+ : String(error).toLowerCase()
+
+ return (
+ (message.includes("does not exist") || message.includes("schema cache")) &&
+ compatibilityColumns[target].some((column) =>
+ message.includes(column.toLowerCase()),
+ )
+ )
+}
+
+function normalizeExamRows(
+ rows: RawRow[],
+ publishedAtByExamId = new Map(),
+ passcodeHashesByExamId = new Map(),
+) {
+ return rows.map((row) =>
+ normalizeExamRow(
+ row,
+ publishedAtByExamId.get(readRequiredString(row.id, "Exam id")) ?? null,
+ passcodeHashesByExamId.get(readRequiredString(row.id, "Exam id")) ?? null,
+ ),
+ )
+}
+
+function normalizeExamRow(
+ row: RawRow,
+ publishedAtOverride: string | null = null,
+ passcodeHashOverride: string | null = null,
+): ExamRow {
+ const createdAt = readString(row.created_at) ?? new Date(0).toISOString()
+ const startAt = readString(row.start_at)
+ const status = readExamStatus(row.status)
+
+ return {
+ id: readRequiredString(row.id, "Exam id"),
+ organization_id: readRequiredString(
+ row.organization_id,
+ "Exam organization_id",
+ ),
+ class_id: readRequiredString(row.class_id, "Exam class_id"),
+ title: readRequiredString(row.title, "Exam title"),
+ duration_minutes: readNumber(row.duration_minutes),
+ total_points: readNumber(row.total_points),
+ start_at: startAt,
+ end_at: readString(row.end_at),
+ status,
+ created_by_user_id: readString(row.created_by_user_id),
+ published_at:
+ readString(row.published_at) ??
+ publishedAtOverride ??
+ deriveLegacyPublishedAt({
+ status,
+ startAt,
+ createdAt,
+ }),
+ passcode_hash: readString(row.passcode_hash) ?? passcodeHashOverride,
+ rules_override_json: readJsonRecord(row.rules_override_json),
+ created_at: createdAt,
+ updated_at: readString(row.updated_at) ?? createdAt,
+ }
+}
+
+function normalizeQuestionRows(rows: RawRow[]) {
+ return rows.map(normalizeQuestionRow)
+}
+
+function normalizeQuestionRow(row: RawRow): ExamQuestionRow {
+ const createdAt = readString(row.created_at) ?? new Date(0).toISOString()
+
+ return {
+ id: readRequiredString(row.id, "Question id"),
+ organization_id: readRequiredString(
+ row.organization_id,
+ "Question organization_id",
+ ),
+ exam_id: readRequiredString(row.exam_id, "Question exam_id"),
+ position: readNumber(row.position),
+ question_type: readQuestionKind(row.question_type),
+ prompt: readRequiredString(row.prompt, "Question prompt"),
+ options_json: readJsonArray(row.options_json),
+ correct_answer_json: readJsonValue(row.correct_answer_json),
+ points: readNumber(row.points),
+ language: readString(row.language),
+ starter_code: readString(row.starter_code),
+ visible_tests_json: readJsonArray(row.visible_tests_json),
+ hidden_tests_json: readJsonArray(row.hidden_tests_json),
+ evaluator_key: readString(row.evaluator_key),
+ created_at: createdAt,
+ updated_at: readString(row.updated_at) ?? createdAt,
+ }
+}
+
+function normalizeAttemptRows(
+ rows: RawRow[],
+ releasedAtByAttemptId = new Map(),
+) {
+ const attemptNumbers = new Map()
+ const sequences = new Map()
+
+ for (const row of [...rows].sort(compareRawAttemptsByCreatedAsc)) {
+ const id = readRequiredString(row.id, "Attempt id")
+ const explicitAttemptNumber = readOptionalNumber(row.attempt_number)
+ if (explicitAttemptNumber !== null) {
+ attemptNumbers.set(id, explicitAttemptNumber)
+ continue
+ }
+
+ const key = `${readRequiredString(row.exam_id, "Attempt exam_id")}::${readRequiredString(row.student_user_id, "Attempt student_user_id")}`
+ const nextAttemptNumber = (sequences.get(key) ?? 0) + 1
+ sequences.set(key, nextAttemptNumber)
+ attemptNumbers.set(id, nextAttemptNumber)
+ }
+
+ return rows.map((row) =>
+ normalizeAttemptRow(
+ row,
+ attemptNumbers.get(readRequiredString(row.id, "Attempt id")) ?? 1,
+ releasedAtByAttemptId.get(readRequiredString(row.id, "Attempt id")) ??
+ null,
+ ),
+ )
+}
+
+function normalizeAttemptRow(
+ row: RawRow,
+ fallbackAttemptNumber = 1,
+ releasedAtOverride: string | null = null,
+): ExamAttemptRow {
+ const createdAt = readString(row.created_at) ?? new Date(0).toISOString()
+ const updatedAt = readString(row.updated_at) ?? createdAt
+ const status = readAttemptStatus(row.status)
+ const submittedAt = readString(row.submitted_at)
+ const gradedAt =
+ readString(row.graded_at) ??
+ (status === "graded" ? (submittedAt ?? updatedAt) : null)
+ const releasedAt = readString(row.results_released_at) ?? releasedAtOverride
+
+ return {
+ id: readRequiredString(row.id, "Attempt id"),
+ organization_id: readRequiredString(
+ row.organization_id,
+ "Attempt organization_id",
+ ),
+ class_id: readRequiredString(row.class_id, "Attempt class_id"),
+ exam_id: readRequiredString(row.exam_id, "Attempt exam_id"),
+ student_user_id: readRequiredString(
+ row.student_user_id,
+ "Attempt student_user_id",
+ ),
+ status,
+ started_at: readString(row.started_at),
+ submitted_at: submittedAt,
+ total_score: readOptionalNumber(row.total_score),
+ attempt_number:
+ readOptionalNumber(row.attempt_number) ?? fallbackAttemptNumber,
+ deadline_at: readString(row.deadline_at),
+ rules_snapshot_json: readJsonRecord(row.rules_snapshot_json),
+ needs_manual_review:
+ readBoolean(row.needs_manual_review) ?? status === "submitted",
+ auto_submitted_at: readString(row.auto_submitted_at),
+ integrity_status: readIntegrityStatus(row.integrity_status, status),
+ flagged_at: readString(row.flagged_at),
+ flagged_by_user_id: readString(row.flagged_by_user_id),
+ flag_reason: readString(row.flag_reason),
+ voided_at:
+ readString(row.voided_at) ??
+ (status === "voided" ? (submittedAt ?? updatedAt) : null),
+ voided_by_user_id: readString(row.voided_by_user_id),
+ void_reason: readString(row.void_reason),
+ graded_at: gradedAt,
+ graded_by_user_id: readString(row.graded_by_user_id),
+ results_released_at: releasedAt,
+ results_released_by_user_id: readString(row.results_released_by_user_id),
+ created_at: createdAt,
+ updated_at: updatedAt,
+ }
+}
+
+async function loadExamsByClass(admin: SupabaseClient, classId: string) {
+ const rows = await loadExamRowsWithCompatibility(admin, (select) =>
+ admin
+ .from("exams")
+ .select(select)
+ .eq("class_id", classId)
+ .order("start_at", { ascending: false, nullsFirst: false })
+ .order("created_at", { ascending: false }),
+ )
+ const publishedAtByExamId = await loadExamPublishedAtById(admin, rows)
+ const passcodeHashesByExamId = await loadExamPasscodeHashesByExamId(
+ admin,
+ rows,
+ )
+
+ return normalizeExamRows(rows, publishedAtByExamId, passcodeHashesByExamId)
+}
+
+async function loadExamById(
+ admin: SupabaseClient,
+ examId: string,
+ classId: string,
+) {
+ const rows = await loadExamRowsWithCompatibility(
+ admin,
+ (select) =>
+ admin
+ .from("exams")
+ .select(select)
+ .eq("id", examId)
+ .eq("class_id", classId),
+ {
+ single: true,
+ },
+ )
+ const [row] = rows
+ if (!row) throw new Error("Exam not found.")
+
+ const publishedAtByExamId = await loadExamPublishedAtById(admin, rows)
+ const passcodeHashesByExamId = await loadExamPasscodeHashesByExamId(
+ admin,
+ rows,
+ )
+ return normalizeExamRow(
+ row,
+ publishedAtByExamId.get(examId) ?? null,
+ passcodeHashesByExamId.get(examId) ?? null,
+ )
+}
+
+async function loadExamRowsWithCompatibility(
+ admin: SupabaseClient,
+ buildQuery: (select: string) => any,
+ options?: {
+ single?: boolean
+ },
+) {
+ const candidates = getExamSelectCandidates()
+ let lastError: Error | null = null
+
+ for (const candidate of candidates) {
+ try {
+ const query = buildQuery(candidate.select)
+ const response = (
+ options?.single ? await query.maybeSingle() : await query
+ ) as {
+ data: unknown
+ error: { message?: string } | null
+ }
+
+ if (response.error) {
+ throw new Error(response.error.message ?? "Could not load exams.")
+ }
+
+ const rawData = response.data
+ examSelectMode = candidate.mode
+ if (options?.single) {
+ return rawData ? [rawData as RawRow] : []
+ }
+
+ return (rawData ?? []) as RawRow[]
+ } catch (error) {
+ if (!isMissingSchemaColumnError(error, "exams")) {
+ throw error
+ }
+
+ lastError =
+ error instanceof Error ? error : new Error("Could not load exams.")
+ }
+ }
+
+ if (lastError) throw lastError
+ return [] as RawRow[]
+}
+
+function getExamSelectCandidates() {
+ if (examSelectMode === "extended") {
+ return examSelectCandidates
+ }
+
+ if (examSelectMode === "settings") {
+ return [
+ examSelectCandidates[1],
+ examSelectCandidates[2],
+ examSelectCandidates[0],
+ ]
+ }
+
+ if (examSelectMode === "base") {
+ return [
+ examSelectCandidates[2],
+ examSelectCandidates[1],
+ examSelectCandidates[0],
+ ]
+ }
+
+ return examSelectCandidates
+}
+
+async function loadExamPublishedAtById(admin: SupabaseClient, rows: RawRow[]) {
+ const needsFallback = rows.some(
+ (row) => readString(row.published_at) === null,
+ )
+ if (!needsFallback) {
+ return new Map()
+ }
+
+ const examIds = rows
+ .map((row) => readString(row.id))
+ .filter((value): value is string => Boolean(value))
+
+ if (examIds.length === 0) {
+ return new Map()
+ }
+
+ const { data, error } = await admin
+ .from("audit_logs")
+ .select("entity_id, created_at, payload")
+ .eq("entity_type", "exam")
+ .eq("action", "exam.published")
+ .in("entity_id", examIds)
+ .order("created_at", { ascending: false })
+
+ if (error) {
+ const errorMessage =
+ typeof error === "object" &&
+ error !== null &&
+ "message" in error &&
+ typeof error.message === "string"
+ ? error.message
+ : "Could not load exam publish records."
+ throw new Error(errorMessage)
+ }
+
+ const publishedAtByExamId = new Map()
+
+ for (const row of (data ?? []) as RawRow[]) {
+ const entityId = readString(row.entity_id)
+ if (!entityId || publishedAtByExamId.has(entityId)) continue
+
+ const payload = readJsonRecord(row.payload)
+ publishedAtByExamId.set(
+ entityId,
+ readString(payload.publishedAt) ?? readString(row.created_at),
+ )
+ }
+
+ const missingExamIds = examIds.filter(
+ (examId) => !publishedAtByExamId.has(examId),
+ )
+ if (missingExamIds.length === 0) {
+ return publishedAtByExamId
+ }
+
+ const { data: attemptRows, error: attemptError } = await admin
+ .from("exam_attempts")
+ .select("exam_id, created_at")
+ .in("exam_id", missingExamIds)
+ .order("created_at", { ascending: true })
+
+ if (attemptError) {
+ throw new Error(attemptError.message)
+ }
+
+ const examRowsById = new Map(
+ rows
+ .map((row) => {
+ const examId = readString(row.id)
+ return examId ? ([examId, row] as const) : null
+ })
+ .filter((entry): entry is readonly [string, RawRow] => entry !== null),
+ )
+
+ for (const row of (attemptRows ?? []) as RawRow[]) {
+ const examId = readString(row.exam_id)
+ if (!examId || publishedAtByExamId.has(examId)) continue
+
+ const examRow = examRowsById.get(examId)
+ publishedAtByExamId.set(
+ examId,
+ readString(examRow?.start_at) ??
+ readString(row.created_at) ??
+ readString(examRow?.created_at),
+ )
+ }
+
+ return publishedAtByExamId
+}
+
+async function loadExamPasscodeHashesByExamId(
+ admin: SupabaseClient,
+ rows: RawRow[],
+) {
+ const needsFallback = rows.some(
+ (row) => readString(row.passcode_hash) === null,
+ )
+ if (!needsFallback) {
+ return new Map()
+ }
+
+ const examIds = rows
+ .map((row) => readString(row.id))
+ .filter((value): value is string => Boolean(value))
+ const classIds = Array.from(
+ new Set(
+ rows
+ .map((row) => readString(row.class_id))
+ .filter((value): value is string => Boolean(value)),
+ ),
+ )
+
+ if (examIds.length === 0 || classIds.length === 0) {
+ return new Map()
+ }
+
+ const { data, error } = await admin
+ .from("class_feature_settings")
+ .select("class_id, config")
+ .eq("feature_key", EXAM_FEATURE_KEY)
+ .in("class_id", classIds)
+
+ if (error) {
+ throw new Error(error.message)
+ }
+
+ const passcodeHashesByExamId = new Map()
+
+ for (const row of (data ?? []) as RawRow[]) {
+ const config = readJsonRecord(row.config)
+ const passcodeHashes = readExamPasscodeHashesFromConfig(config)
+
+ for (const examId of examIds) {
+ const passcodeHash = passcodeHashes[examId]
+ if (typeof passcodeHash === "string" && passcodeHash.length > 0) {
+ passcodeHashesByExamId.set(examId, passcodeHash)
+ }
+ }
+ }
+
+ return passcodeHashesByExamId
+}
+
+async function loadExamQuestions(admin: SupabaseClient, examId: string) {
+ return runSchemaCompatible(
+ "questions",
+ async () => {
+ const { data, error } = await admin
+ .from("exam_questions")
+ .select(QUESTION_SELECT_EXTENDED)
+ .eq("exam_id", examId)
+ .order("position", { ascending: true })
+
+ if (error) throw new Error(error.message)
+ return normalizeQuestionRows((data ?? []) as RawRow[])
+ },
+ async () => {
+ const { data, error } = await admin
+ .from("exam_questions")
+ .select(QUESTION_SELECT_BASE)
+ .eq("exam_id", examId)
+ .order("position", { ascending: true })
+
+ if (error) throw new Error(error.message)
+ return normalizeQuestionRows((data ?? []) as RawRow[])
+ },
+ )
+}
+
+async function loadQuestionById(admin: SupabaseClient, questionId: string) {
+ return runSchemaCompatible(
+ "questions",
+ async () => {
+ const { data, error } = await admin
+ .from("exam_questions")
+ .select(QUESTION_SELECT_EXTENDED)
+ .eq("id", questionId)
+ .maybeSingle()
+
+ if (error) throw new Error(error.message)
+ if (!data) throw new Error("Question not found.")
+ return normalizeQuestionRow(data as RawRow)
+ },
+ async () => {
+ const { data, error } = await admin
+ .from("exam_questions")
+ .select(QUESTION_SELECT_BASE)
+ .eq("id", questionId)
+ .maybeSingle()
+
+ if (error) throw new Error(error.message)
+ if (!data) throw new Error("Question not found.")
+ return normalizeQuestionRow(data as RawRow)
+ },
+ )
+}
+
+async function loadExamAttempts(admin: SupabaseClient, examIds: string[]) {
+ if (examIds.length === 0) return [] as ExamAttemptRow[]
+
+ return runSchemaCompatible(
+ "attempts",
+ async () => {
+ const { data, error } = await admin
+ .from("exam_attempts")
+ .select(ATTEMPT_SELECT_EXTENDED)
+ .in("exam_id", examIds)
+ .order("created_at", { ascending: false })
+
+ if (error) throw new Error(error.message)
+ const rows = (data ?? []) as RawRow[]
+ const releasedAtByAttemptId = await loadAttemptReleasedAtById(admin, rows)
+ return normalizeAttemptRows(rows, releasedAtByAttemptId)
+ },
+ async () => {
+ const { data, error } = await admin
+ .from("exam_attempts")
+ .select(ATTEMPT_SELECT_BASE)
+ .in("exam_id", examIds)
+ .order("created_at", { ascending: false })
+
+ if (error) throw new Error(error.message)
+ const rows = (data ?? []) as RawRow[]
+ const releasedAtByAttemptId = await loadAttemptReleasedAtById(admin, rows)
+ return normalizeAttemptRows(rows, releasedAtByAttemptId)
+ },
+ )
+}
+
+async function loadExamAttemptsForStudent(
+ admin: SupabaseClient,
+ examId: string,
+ userId: string,
+) {
+ return runSchemaCompatible(
+ "attempts",
+ async () => {
+ const { data, error } = await admin
+ .from("exam_attempts")
+ .select(ATTEMPT_SELECT_EXTENDED)
+ .eq("exam_id", examId)
+ .eq("student_user_id", userId)
+ .order("attempt_number", { ascending: false })
+
+ if (error) throw new Error(error.message)
+ const rows = (data ?? []) as RawRow[]
+ const releasedAtByAttemptId = await loadAttemptReleasedAtById(admin, rows)
+ return normalizeAttemptRows(rows, releasedAtByAttemptId)
+ },
+ async () => {
+ const { data, error } = await admin
+ .from("exam_attempts")
+ .select(ATTEMPT_SELECT_BASE)
+ .eq("exam_id", examId)
+ .eq("student_user_id", userId)
+ .order("created_at", { ascending: false })
+
+ if (error) throw new Error(error.message)
+ const rows = (data ?? []) as RawRow[]
+ const releasedAtByAttemptId = await loadAttemptReleasedAtById(admin, rows)
+ return normalizeAttemptRows(rows, releasedAtByAttemptId)
+ },
+ )
+}
+
+async function loadAttemptsForStudentByClass(
+ admin: SupabaseClient,
+ classId: string,
+ userId: string,
+) {
+ return runSchemaCompatible(
+ "attempts",
+ async () => {
+ const { data, error } = await admin
+ .from("exam_attempts")
+ .select(ATTEMPT_SELECT_EXTENDED)
+ .eq("class_id", classId)
+ .eq("student_user_id", userId)
+ .order("created_at", { ascending: false })
+
+ if (error) throw new Error(error.message)
+ const rows = (data ?? []) as RawRow[]
+ const releasedAtByAttemptId = await loadAttemptReleasedAtById(admin, rows)
+ return normalizeAttemptRows(rows, releasedAtByAttemptId)
+ },
+ async () => {
+ const { data, error } = await admin
+ .from("exam_attempts")
+ .select(ATTEMPT_SELECT_BASE)
+ .eq("class_id", classId)
+ .eq("student_user_id", userId)
+ .order("created_at", { ascending: false })
+
+ if (error) throw new Error(error.message)
+ const rows = (data ?? []) as RawRow[]
+ const releasedAtByAttemptId = await loadAttemptReleasedAtById(admin, rows)
+ return normalizeAttemptRows(rows, releasedAtByAttemptId)
+ },
+ )
+}
+
+async function loadAttemptById(admin: SupabaseClient, attemptId: string) {
+ return runSchemaCompatible(
+ "attempts",
+ async () => {
+ const { data, error } = await admin
+ .from("exam_attempts")
+ .select(ATTEMPT_SELECT_EXTENDED)
+ .eq("id", attemptId)
+ .maybeSingle()
+
+ if (error) throw new Error(error.message)
+ if (!data) throw new Error("Attempt not found.")
+ const releasedAtByAttemptId = await loadAttemptReleasedAtById(admin, [
+ data as RawRow,
+ ])
+ return normalizeAttemptRow(
+ data as RawRow,
+ 1,
+ releasedAtByAttemptId.get(attemptId) ?? null,
+ )
+ },
+ async () => {
+ const { data, error } = await admin
+ .from("exam_attempts")
+ .select(ATTEMPT_SELECT_BASE)
+ .eq("id", attemptId)
+ .maybeSingle()
+
+ if (error) throw new Error(error.message)
+ if (!data) throw new Error("Attempt not found.")
+ const releasedAtByAttemptId = await loadAttemptReleasedAtById(admin, [
+ data as RawRow,
+ ])
+ return normalizeAttemptRow(
+ data as RawRow,
+ 1,
+ releasedAtByAttemptId.get(attemptId) ?? null,
+ )
+ },
+ )
+}
+
+async function loadExamAnswers(admin: SupabaseClient, attemptIds: string[]) {
+ if (attemptIds.length === 0) return [] as ExamAnswerRow[]
+
+ const { data, error } = await admin
+ .from("exam_answers")
+ .select(
+ "id, organization_id, exam_attempt_id, exam_question_id, answer_json, auto_score, teacher_score, created_at, updated_at",
+ )
+ .in("exam_attempt_id", attemptIds)
+
+ if (error) throw new Error(error.message)
+ return (data ?? []) as ExamAnswerRow[]
+}
+
+async function loadProfiles(admin: SupabaseClient, userIds: string[]) {
+ const uniqueUserIds = Array.from(new Set(userIds))
+ if (uniqueUserIds.length === 0) return [] as ProfileRow[]
+
+ const { data, error } = await admin
+ .from("profiles")
+ .select("id, display_name, email")
+ .in("id", uniqueUserIds)
+
+ if (error) throw new Error(error.message)
+ return (data ?? []) as ProfileRow[]
+}
+
+async function loadAttemptIntegrityEvents(
+ admin: SupabaseClient,
+ attemptIds: string[],
+) {
+ const uniqueAttemptIds = Array.from(new Set(attemptIds.filter(Boolean)))
+ if (uniqueAttemptIds.length === 0) {
+ return new Map()
+ }
+
+ const { data, error } = await admin
+ .from("audit_logs")
+ .select("entity_id, created_at, payload")
+ .eq("entity_type", "exam_attempt")
+ .eq("action", "exam.attempt_event")
+ .in("entity_id", uniqueAttemptIds)
+ .order("created_at", { ascending: false })
+
+ if (error) throw new Error(error.message)
+
+ const eventsByAttemptId = new Map()
+
+ for (const row of (data ?? []) as RawRow[]) {
+ const attemptId = readString(row.entity_id)
+ if (!attemptId) continue
+
+ const payload = readJsonRecord(row.payload)
+ const events = eventsByAttemptId.get(attemptId) ?? []
+ events.push({
+ key: `${attemptId}:${readString(row.created_at) ?? new Date(0).toISOString()}:${readString(payload.eventType) ?? "event"}`,
+ eventType: readString(payload.eventType) ?? "event",
+ createdAt: readString(row.created_at) ?? new Date(0).toISOString(),
+ payload: readJsonRecord(payload.payload),
+ })
+ eventsByAttemptId.set(attemptId, events)
+ }
+
+ return eventsByAttemptId
+}
+
+async function loadPasscodeFailureAuditRows(input: {
+ admin: SupabaseClient
+ examId: string
+ studentUserId: string
+}) {
+ const since = new Date(Date.now() - EXAM_PASSCODE_COOLDOWN_MS).toISOString()
+ const { data, error } = await input.admin
+ .from("audit_logs")
+ .select("created_at")
+ .eq("entity_type", "exam")
+ .eq("entity_id", input.examId)
+ .eq("actor_user_id", input.studentUserId)
+ .eq("action", "exam.passcode_failed")
+ .gte("created_at", since)
+ .order("created_at", { ascending: false })
+
+ if (error) throw new Error(error.message)
+
+ return (data ?? []) as Array<{
+ created_at: string
+ }>
+}
+
+async function loadAttemptReleasedAtById(
+ admin: SupabaseClient,
+ rows: RawRow[],
+) {
+ const attemptIds = Array.from(
+ new Set(
+ rows
+ .map((row) => readString(row.id))
+ .filter((attemptId): attemptId is string => Boolean(attemptId)),
+ ),
+ )
+
+ const releasedAtByAttemptId = new Map()
+
+ if (attemptIds.length === 0) {
+ return releasedAtByAttemptId
+ }
+
+ const { data, error } = await admin
+ .from("audit_logs")
+ .select("entity_id, created_at, payload")
+ .eq("entity_type", "exam_attempt")
+ .eq("action", "exam.results_released")
+ .in("entity_id", attemptIds)
+ .order("created_at", { ascending: false })
+
+ if (error) throw new Error(error.message)
+
+ for (const row of (data ?? []) as RawRow[]) {
+ const attemptId = readString(row.entity_id)
+ if (!attemptId || releasedAtByAttemptId.has(attemptId)) continue
+
+ const payload = readJsonRecord(row.payload)
+ releasedAtByAttemptId.set(
+ attemptId,
+ readString(payload.releasedAt) ?? readString(row.created_at),
+ )
+ }
+
+ return releasedAtByAttemptId
+}
+
+async function loadAvailableRetakeCount(
+ admin: SupabaseClient,
+ examId: string,
+ studentUserId: string,
+) {
+ return (
+ (await loadAvailableRetakeCounts(admin, examId, [studentUserId])).get(
+ studentUserId,
+ ) ?? 0
+ )
+}
+
+async function loadAvailableRetakeCounts(
+ admin: SupabaseClient,
+ examId: string,
+ studentUserIds: string[],
+) {
+ const uniqueStudentUserIds = Array.from(
+ new Set(studentUserIds.filter(Boolean)),
+ )
+ const counts = new Map(uniqueStudentUserIds.map((userId) => [userId, 0]))
+
+ if (uniqueStudentUserIds.length === 0) {
+ return counts
+ }
+
+ const { data, error } = await admin
+ .from("audit_logs")
+ .select("action, payload, created_at")
+ .eq("entity_type", "exam")
+ .eq("entity_id", examId)
+ .in("action", ["exam.retake_granted", "exam.retake_consumed"])
+ .order("created_at", { ascending: true })
+
+ if (error) throw new Error(error.message)
+
+ for (const row of (data ?? []) as RawRow[]) {
+ const payload = readJsonRecord(row.payload)
+ const studentUserId = readString(payload.studentUserId)
+ if (!studentUserId || !counts.has(studentUserId)) continue
+
+ const action = readString((row as RawRow).action)
+ const currentCount = counts.get(studentUserId) ?? 0
+ counts.set(
+ studentUserId,
+ action === "exam.retake_consumed"
+ ? Math.max(0, currentCount - 1)
+ : currentCount + 1,
+ )
+ }
+
+ return counts
+}
+
+function readRequiredString(value: unknown, label: string) {
+ if (typeof value === "string" && value.length > 0) return value
+ throw new Error(`${label} is missing.`)
+}
+
+function readString(value: unknown) {
+ return typeof value === "string" ? value : null
+}
+
+function readBoolean(value: unknown) {
+ return typeof value === "boolean" ? value : null
+}
+
+function readNumber(value: unknown) {
+ const numericValue = readOptionalNumber(value)
+ return numericValue ?? 0
+}
+
+function readOptionalNumber(value: unknown) {
+ if (typeof value === "number" && Number.isFinite(value)) return value
+ if (typeof value === "string" && value.trim().length > 0) {
+ const parsedValue = Number(value)
+ return Number.isFinite(parsedValue) ? parsedValue : null
+ }
+
+ return null
+}
+
+function readJsonArray(value: unknown) {
+ return Array.isArray(value) ? (value as JsonValue[]) : null
+}
+
+function readJsonValue(value: unknown) {
+ if (
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean" ||
+ value === null ||
+ Array.isArray(value) ||
+ isRecord(value)
+ ) {
+ return value as JsonValue
+ }
+
+ return null
+}
+
+function readJsonRecord(value: unknown) {
+ return isRecord(value) ? (value as Record) : {}
+}
+
+function readExamStatus(value: unknown): ExamStatus {
+ return value === "live" || value === "ended" ? value : "upcoming"
+}
+
+function readQuestionKind(value: unknown): ExamQuestionKind {
+ return value === "short" ? "short" : "mcq"
+}
+
+function readAttemptStatus(value: unknown): ExamAttemptStatus {
+ if (value === "submitted" || value === "graded" || value === "voided") {
+ return value
+ }
+
+ return "in_progress"
+}
+
+function readIntegrityStatus(
+ value: unknown,
+ status: ExamAttemptStatus,
+): ExamIntegrityStatus {
+ if (
+ value === "clear" ||
+ value === "reported" ||
+ value === "flagged" ||
+ value === "voided"
+ ) {
+ return value
+ }
+
+ if (status === "voided") return "voided"
+ return "clear"
+}
+
+function deriveLegacyPublishedAt(input: {
+ status: ExamStatus
+ startAt: string | null
+ createdAt: string
+}) {
+ if (input.status === "live" || input.status === "ended") {
+ return input.startAt ?? input.createdAt
+ }
+
+ return null
+}
+
+function isExamPublished>(
+ exam: TExam,
+) {
+ return Boolean(exam.published_at)
+}
+
+function compareRawAttemptsByCreatedAsc(left: RawRow, right: RawRow) {
+ return (
+ Date.parse(readString(left.created_at) ?? new Date(0).toISOString()) -
+ Date.parse(readString(right.created_at) ?? new Date(0).toISOString())
+ )
+}
+
+function toManagerExamSummary(
+ exam: ExamRow,
+ attempts: ExamAttemptRow[],
+): ManagerExamSummaryDto {
+ const examAttempts = attempts.filter((attempt) => attempt.exam_id === exam.id)
+ const enteredStudentIds = new Set(
+ examAttempts.map((attempt) => attempt.student_user_id),
+ )
+ const releasedStudentIds = new Set(
+ examAttempts
+ .filter((attempt) => Boolean(attempt.results_released_at))
+ .map((attempt) => attempt.student_user_id),
+ )
+
+ return {
+ id: exam.id,
+ title: exam.title,
+ durationMinutes: Number(exam.duration_minutes),
+ totalPoints: Number(exam.total_points),
+ startAt: exam.start_at,
+ endAt: exam.end_at,
+ status: normalizeExamStatus(exam),
+ publishedAt: exam.published_at,
+ createdAt: exam.created_at,
+ updatedAt: exam.updated_at,
+ attemptCounts: {
+ inProgress: examAttempts.filter(
+ (attempt) => attempt.status === "in_progress",
+ ).length,
+ submitted: examAttempts.filter(
+ (attempt) => attempt.status === "submitted",
+ ).length,
+ graded: examAttempts.filter((attempt) => attempt.status === "graded")
+ .length,
+ released: examAttempts.filter((attempt) => attempt.results_released_at)
+ .length,
+ },
+ enteredStudentCount: enteredStudentIds.size,
+ releasedStudentCount: releasedStudentIds.size,
+ }
+}
+
+function toManagerQuestionDto(question: ExamQuestionRow): ManagerQuestionDto {
+ return {
+ id: question.id,
+ position: question.position,
+ type: question.question_type,
+ prompt: question.prompt,
+ options: toStringArray(question.options_json),
+ points: Number(question.points),
+ correctAnswer: question.correct_answer_json ?? null,
+ }
+}
+
+export function toStudentQuestionDto(
+ question: ExamQuestionRow,
+ savedAnswer: JsonValue | null,
+): StudentQuestionDto {
+ return {
+ id: question.id,
+ position: question.position,
+ type: question.question_type,
+ prompt: question.prompt,
+ options: toStringArray(question.options_json),
+ points: Number(question.points),
+ savedAnswer,
+ }
+}
+
+function toStudentAttemptDto(
+ attempt: ExamAttemptRow,
+ exam: Pick,
+): StudentAttemptDto {
+ const deadlineAt = getAttemptDeadline(attempt, exam)
+ return {
+ id: attempt.id,
+ status: attempt.status,
+ startedAt: attempt.started_at ?? attempt.created_at,
+ submittedAt: attempt.submitted_at,
+ deadlineAt,
+ timeLeftSeconds: Math.max(
+ 0,
+ Math.floor((Date.parse(deadlineAt) - Date.now()) / 1000),
+ ),
+ attemptNumber: attempt.attempt_number,
+ needsManualReview: attempt.needs_manual_review,
+ integrityStatus: attempt.integrity_status,
+ }
+}
+
+function toManagerAttemptSummary(
+ attempt: ExamAttemptRow,
+ profile: ProfileRow | null,
+ answers: ExamAnswerRow[],
+ integrityEvents: ManagerIntegrityEventDto[],
+ availableRetakeCount: number,
+): ManagerAttemptSummaryDto {
+ return {
+ id: attempt.id,
+ studentUserId: attempt.student_user_id,
+ studentDisplayName: profile?.display_name ?? "Unknown student",
+ studentEmail: profile?.email ?? "",
+ status: attempt.status,
+ startedAt: attempt.started_at,
+ submittedAt: attempt.submitted_at,
+ totalScore:
+ attempt.total_score === null ? null : Number(attempt.total_score),
+ attemptNumber: attempt.attempt_number,
+ needsManualReview: attempt.needs_manual_review,
+ integrityStatus: attempt.integrity_status,
+ resultsReleasedAt: attempt.results_released_at,
+ availableRetakeCount,
+ answers: answers.map((answer) => ({
+ id: answer.id,
+ questionId: answer.exam_question_id,
+ answer: answer.answer_json,
+ autoScore: answer.auto_score === null ? null : Number(answer.auto_score),
+ teacherScore:
+ answer.teacher_score === null ? null : Number(answer.teacher_score),
+ })),
+ integrityEvents,
+ }
+}
+
+const RETAKE_REQUIRED_MESSAGE =
+ "A teacher must grant a retake before you can start this exam again."
+
+export function normalizeExamStatus<
+ TExam extends Pick,
+>(exam: TExam, now = Date.now()): ExamStatus {
+ if (!exam.published_at) return "upcoming"
+
+ if (exam.end_at && Date.parse(exam.end_at) <= now) return "ended"
+ if (!exam.start_at || Date.parse(exam.start_at) <= now) return "live"
+ return "upcoming"
+}
+
+function ensurePublishedExam(exam: ExamRow) {
+ if (!isExamPublished(exam)) {
+ throw new Error("Exam not found.")
+ }
+}
+
+async function ensureExamCanStart(exam: ExamRow) {
+ const status = normalizeExamStatus(exam)
+ if (status === "upcoming") {
+ throw new Error("This exam has not started yet.")
+ }
+ if (status === "ended") {
+ throw new Error("This exam is no longer active.")
+ }
+}
+
+export function isExamPasscodeValid(
+ passcodeHash: string | null,
+ passcode: string,
+) {
+ if (!passcodeHash) return false
+ const inputPasscodeHash = hashPasscode(passcode)
+
+ if (!inputPasscodeHash) {
+ return false
+ }
+
+ return safeCompareHex(passcodeHash, inputPasscodeHash)
+}
+
+export function hashExamPasscode(passcode: string | undefined): string {
+ return hashPasscode(requireExamPasscode(passcode))!
+}
+
+export function resolveExamPasscodeHash(input: {
+ existingPasscodeHash: string | null
+ nextPasscode: string | undefined | null
+}): string {
+ const nextPasscode = input.nextPasscode?.trim() ?? ""
+
+ if (nextPasscode.length > 0) {
+ return hashExamPasscode(nextPasscode)
+ }
+
+ if (input.existingPasscodeHash) {
+ return input.existingPasscodeHash
+ }
+
+ throw new Error(EXAM_PASSCODE_MISSING_MESSAGE)
+}
+
+export function resolvePasscodeCooldown(input: {
+ failedAttempts: Array<{
+ created_at: string
+ }>
+ now?: number
+}) {
+ const now = input.now ?? Date.now()
+ const recentFailures = input.failedAttempts.filter((attempt) => {
+ const createdAt = Date.parse(attempt.created_at)
+ return (
+ Number.isFinite(createdAt) && now - createdAt < EXAM_PASSCODE_COOLDOWN_MS
+ )
+ })
+ const mostRecentFailure = recentFailures[0]
+ const retryAfterSeconds =
+ recentFailures.length >= EXAM_PASSCODE_MAX_FAILURES && mostRecentFailure
+ ? Math.max(
+ 1,
+ Math.ceil(
+ (Date.parse(mostRecentFailure.created_at) +
+ EXAM_PASSCODE_COOLDOWN_MS -
+ now) /
+ 1000,
+ ),
+ )
+ : 0
+
+ return {
+ failureCount: recentFailures.length,
+ attemptsRemaining:
+ retryAfterSeconds > 0
+ ? 0
+ : Math.max(0, EXAM_PASSCODE_MAX_FAILURES - recentFailures.length),
+ retryAfterSeconds,
+ isBlocked: retryAfterSeconds > 0,
+ }
+}
+
+async function validateStartAttemptPasscode(input: {
+ admin: SupabaseClient
+ organizationId: string
+ classId: string
+ examId: string
+ studentUserId: string
+ passcodeHash: string | null
+ passcode: string
+}) {
+ if (!input.passcodeHash) {
+ throw new Error(EXAM_PASSCODE_MISSING_MESSAGE)
+ }
+
+ const failedAttempts = await loadPasscodeFailureAuditRows({
+ admin: input.admin,
+ examId: input.examId,
+ studentUserId: input.studentUserId,
+ })
+ const cooldown = resolvePasscodeCooldown({
+ failedAttempts,
+ })
+
+ if (cooldown.isBlocked) {
+ throw new Error(
+ `Too many invalid passcode attempts. Try again in ${cooldown.retryAfterSeconds} seconds.`,
+ )
+ }
+
+ if (isExamPasscodeValid(input.passcodeHash, input.passcode)) {
+ return
+ }
+
+ const failedAt = new Date().toISOString()
+ await writeExamAuditLog({
+ organizationId: input.organizationId,
+ actorUserId: input.studentUserId,
+ action: "exam.passcode_failed",
+ entityType: "exam",
+ entityId: input.examId,
+ payload: {
+ classId: input.classId,
+ failedAt,
+ },
+ })
+
+ const nextCooldown = resolvePasscodeCooldown({
+ failedAttempts: [{ created_at: failedAt }, ...failedAttempts],
+ })
+
+ if (nextCooldown.isBlocked) {
+ throw new Error(
+ `Too many invalid passcode attempts. Try again in ${nextCooldown.retryAfterSeconds} seconds.`,
+ )
+ }
+
+ throw new Error("Invalid passcode.")
+}
+
+function resolveExamWindow(startAt: string, durationMinutes: number) {
+ const parsedStartAt = new Date(startAt)
+
+ if (Number.isNaN(parsedStartAt.getTime())) {
+ throw new Error("Select a valid exam start date and time.")
+ }
+
+ if (!Number.isFinite(durationMinutes) || durationMinutes <= 0) {
+ throw new Error("Exam duration must be greater than zero.")
+ }
+
+ return {
+ startAt: parsedStartAt.toISOString(),
+ endAt: new Date(
+ parsedStartAt.getTime() + Number(durationMinutes) * 60_000,
+ ).toISOString(),
+ }
+}
+
+function computeAttemptDeadline(exam: ExamRow, startedAt: Date) {
+ const timedDeadline = new Date(
+ startedAt.getTime() + Number(exam.duration_minutes) * 60_000,
+ )
+ const endAt = exam.end_at ? new Date(exam.end_at) : null
+ const deadline = endAt && endAt < timedDeadline ? endAt : timedDeadline
+ return deadline.toISOString()
+}
+
+function getAttemptDeadline(
+ attempt: ExamAttemptRow,
+ exam: Pick | null,
+) {
+ if (attempt.deadline_at) return attempt.deadline_at
+ if (!attempt.started_at || !exam) return attempt.updated_at
+ return computeAttemptDeadline(exam as ExamRow, new Date(attempt.started_at))
+}
+
+function attemptExpired(
+ attempt: ExamAttemptRow,
+ exam: Pick | null = null,
+) {
+ return Date.parse(getAttemptDeadline(attempt, exam)) <= Date.now()
+}
+
+export function selectCurrentLiveExam<
+ TExam extends Pick<
+ ExamRow,
+ "published_at" | "start_at" | "end_at" | "created_at"
+ >,
+>(exams: TExam[], now = Date.now()) {
+ return [...exams]
+ .filter((exam) => normalizeExamStatus(exam, now) === "live")
+ .sort(compareExamsByStartDesc)[0]
+}
+
+export function selectScheduledExam<
+ TExam extends Pick,
+>(exams: TExam[], now = Date.now()) {
+ return [...exams]
+ .filter(
+ (exam) =>
+ isExamPublished(exam) &&
+ exam.start_at &&
+ Date.parse(exam.start_at) > now,
+ )
+ .sort(
+ (left, right) => Date.parse(left.start_at!) - Date.parse(right.start_at!),
+ )[0]
+}
+
+function compareExamsByStartDesc<
+ TExam extends Pick,
+>(left: TExam, right: TExam) {
+ return (
+ Date.parse(right.start_at ?? right.created_at) -
+ Date.parse(left.start_at ?? left.created_at)
+ )
+}
+
+function compareReleasedAttemptsDesc(
+ left: ExamAttemptRow,
+ right: ExamAttemptRow,
+) {
+ return (
+ Date.parse(right.results_released_at ?? right.updated_at) -
+ Date.parse(left.results_released_at ?? left.updated_at)
+ )
+}
+
+function getAttemptCompletionTimestamp(input: {
+ submitted_at: string | null
+ graded_at: string | null
+ results_released_at: string | null
+ updated_at: string
+}) {
+ return Date.parse(
+ input.submitted_at ??
+ input.graded_at ??
+ input.results_released_at ??
+ input.updated_at,
+ )
+}
+
+export function selectLatestCompletedAttemptWithExam<
+ TAttempt extends {
+ id: string
+ exam_id: string
+ status: string
+ submitted_at: string | null
+ graded_at: string | null
+ results_released_at: string | null
+ updated_at: string
+ },
+ TExam extends {
+ id: string
+ },
+>(attempts: TAttempt[], exams: TExam[]) {
+ const examsById = new Map(exams.map((exam) => [exam.id, exam]))
+ const completedAttempts = [...attempts]
+ .filter((attempt) => attempt.status !== "in_progress")
+ .sort(
+ (left, right) =>
+ getAttemptCompletionTimestamp(right) -
+ getAttemptCompletionTimestamp(left),
+ )
+
+ for (const attempt of completedAttempts) {
+ const exam = examsById.get(attempt.exam_id)
+ if (exam) {
+ return { attempt, exam }
+ }
+ }
+
+ return null
+}
+
+export function resolveStudentExamPageSelection<
+ TExam extends Pick<
+ ExamRow,
+ "id" | "published_at" | "start_at" | "end_at" | "created_at"
+ >,
+ TAttempt extends Pick<
+ ExamAttemptRow,
+ | "id"
+ | "exam_id"
+ | "status"
+ | "submitted_at"
+ | "graded_at"
+ | "results_released_at"
+ | "updated_at"
+ >,
+>(input: {
+ allExams: TExam[]
+ publishedExams: TExam[]
+ attempts: TAttempt[]
+ now?: number
+}) {
+ const now = input.now ?? Date.now()
+ const examsById = new Map(input.allExams.map((exam) => [exam.id, exam]))
+ const activeAttempt =
+ input.attempts.find((attempt) => attempt.status === "in_progress") ?? null
+ const activeExam = activeAttempt
+ ? (examsById.get(activeAttempt.exam_id) ?? null)
+ : (selectCurrentLiveExam(input.publishedExams, now) ?? null)
+
+ if (activeExam) {
+ return {
+ state: "active" as const,
+ activeAttempt,
+ activeExam,
+ scheduledExam: null,
+ latestCompleted: null,
+ }
+ }
+
+ const scheduledExam = selectScheduledExam(input.publishedExams, now) ?? null
+ if (scheduledExam) {
+ return {
+ state: "scheduled" as const,
+ activeAttempt: null,
+ activeExam: null,
+ scheduledExam,
+ latestCompleted: null,
+ }
+ }
+
+ return {
+ state: "none" as const,
+ activeAttempt: null,
+ activeExam: null,
+ scheduledExam: null,
+ latestCompleted: null,
+ }
+}
+
+export function resolveExamAttemptAvailability<
+ TAttempt extends Pick<
+ ExamAttemptRow,
+ "status" | "attempt_number" | "integrity_status"
+ >,
+>(input: { attempts: TAttempt[]; availableRetakeCount: number }) {
+ const hasActiveAttempt = input.attempts.some(
+ (attempt) => attempt.status === "in_progress",
+ )
+ const nextAttemptNumber =
+ input.attempts.reduce(
+ (highest, attempt) => Math.max(highest, attempt.attempt_number),
+ 0,
+ ) + 1
+
+ if (hasActiveAttempt) {
+ return {
+ canStart: false,
+ reason:
+ "Return to your current in-progress attempt to continue the exam.",
+ nextAttemptNumber: null,
+ }
+ }
+
+ if (input.attempts.length > 0 && input.availableRetakeCount <= 0) {
+ return {
+ canStart: false,
+ reason: RETAKE_REQUIRED_MESSAGE,
+ nextAttemptNumber: null,
+ }
+ }
+
+ return {
+ canStart: true,
+ reason: null,
+ nextAttemptNumber,
+ }
+}
+
+function compareCompletedAttemptsDesc(
+ left: ExamAttemptRow,
+ right: ExamAttemptRow,
+) {
+ return (
+ getAttemptCompletionTimestamp(right) - getAttemptCompletionTimestamp(left)
+ )
+}
+
+function hashPasscode(passcode: string) {
+ const normalized = passcode.trim()
+ if (!normalized) return null
+ return createHash("sha256").update(normalized).digest("hex")
+}
+
+function requireExamPasscode(passcode: string | undefined) {
+ const normalized = passcode?.trim() ?? ""
+
+ if (!normalized) {
+ throw new Error(EXAM_PASSCODE_REQUIRED_MESSAGE)
+ }
+
+ if (normalized.length < EXAM_PASSCODE_MIN_LENGTH) {
+ throw new Error(
+ `Exam passcode must be at least ${EXAM_PASSCODE_MIN_LENGTH} characters.`,
+ )
+ }
+
+ return normalized
+}
+
+function safeCompareHex(expectedHex: string, actualHex: string) {
+ try {
+ const expected = Buffer.from(expectedHex, "hex")
+ const actual = Buffer.from(actualHex, "hex")
+
+ if (
+ expected.length === 0 ||
+ actual.length === 0 ||
+ expected.length !== actual.length
+ ) {
+ return false
+ }
+
+ return timingSafeEqual(expected, actual)
+ } catch {
+ return false
+ }
+}
+
+async function syncExamPasscodeHashStorage(input: {
+ admin: SupabaseClient
+ organizationId: string
+ classId: string
+ examId: string
+ passcodeHash: string
+}) {
+ if (schemaModes.exams === "base") {
+ await upsertExamPasscodeHashFallback(input)
+ return
+ }
+
+ await removeExamPasscodeHashFallback(input)
+}
+
+async function upsertExamPasscodeHashFallback(input: {
+ admin: SupabaseClient
+ organizationId: string
+ classId: string
+ examId: string
+ passcodeHash: string
+}) {
+ const existing = await loadExamClassFeatureSetting(
+ input.admin,
+ input.organizationId,
+ input.classId,
+ )
+ const nextConfig = {
+ ...existing.config,
+ [EXAM_PASSCODE_HASHES_CONFIG_KEY]: {
+ ...readExamPasscodeHashesFromConfig(existing.config),
+ [input.examId]: input.passcodeHash,
+ },
+ } satisfies Record
+
+ const { error } = await input.admin.from("class_feature_settings").upsert(
+ {
+ organization_id: input.organizationId,
+ class_id: input.classId,
+ feature_key: EXAM_FEATURE_KEY,
+ enabled: existing.enabled,
+ config: nextConfig,
+ },
+ {
+ onConflict: "class_id,feature_key",
+ },
+ )
+
+ if (error) {
+ throw new Error(error.message)
+ }
+}
+
+async function removeExamPasscodeHashFallback(input: {
+ admin: SupabaseClient
+ organizationId: string
+ classId: string
+ examId: string
+}) {
+ const existing = await loadExamClassFeatureSetting(
+ input.admin,
+ input.organizationId,
+ input.classId,
+ )
+ const currentHashes = readExamPasscodeHashesFromConfig(existing.config)
+
+ if (!currentHashes[input.examId]) {
+ return
+ }
+
+ const nextHashes = { ...currentHashes }
+ delete nextHashes[input.examId]
+
+ const nextConfig = { ...existing.config } satisfies Record
+ if (Object.keys(nextHashes).length > 0) {
+ nextConfig[EXAM_PASSCODE_HASHES_CONFIG_KEY] = nextHashes
+ } else {
+ delete nextConfig[EXAM_PASSCODE_HASHES_CONFIG_KEY]
+ }
+
+ const { error } = await input.admin.from("class_feature_settings").upsert(
+ {
+ organization_id: input.organizationId,
+ class_id: input.classId,
+ feature_key: EXAM_FEATURE_KEY,
+ enabled: existing.enabled,
+ config: nextConfig,
+ },
+ {
+ onConflict: "class_id,feature_key",
+ },
+ )
+
+ if (error) {
+ throw new Error(error.message)
+ }
+}
+
+async function loadExamClassFeatureSetting(
+ admin: SupabaseClient,
+ organizationId: string,
+ classId: string,
+) {
+ const { data, error } = await admin
+ .from("class_feature_settings")
+ .select("enabled, config")
+ .eq("organization_id", organizationId)
+ .eq("class_id", classId)
+ .eq("feature_key", EXAM_FEATURE_KEY)
+ .maybeSingle()
+
+ if (error) {
+ throw new Error(error.message)
+ }
+
+ return {
+ enabled: typeof data?.enabled === "boolean" ? data.enabled : true,
+ config: readJsonRecord(data?.config),
+ }
+}
+
+function readExamPasscodeHashesFromConfig(config: Record) {
+ const value = config[EXAM_PASSCODE_HASHES_CONFIG_KEY]
+ if (!isRecord(value)) {
+ return {} as Record
+ }
+
+ return Object.fromEntries(
+ Object.entries(value).filter(
+ (entry): entry is [string, string] => typeof entry[1] === "string",
+ ),
+ )
+}
+
+function toStringArray(value: JsonValue[] | null) {
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string")
+ : []
+}
+
+function isManagerRole(
+ role: AppRole | null,
+): role is Exclude {
+ return role === "org_owner" || role === "org_admin" || role === "teacher"
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+function sanitizePayload(value: Record) {
+ return Object.entries(value).reduce>(
+ (next, entry) => {
+ const [key, candidate] = entry
+ if (typeof candidate === "string") next[key] = candidate
+ else if (typeof candidate === "number") next[key] = candidate
+ else if (typeof candidate === "boolean") next[key] = candidate
+ else if (candidate === null) next[key] = null
+ else next[key] = JSON.stringify(candidate)
+ return next
+ },
+ {},
+ )
+}
+
+function toScheduledExam(exam: ExamRow) {
+ return {
+ id: exam.id,
+ title: exam.title,
+ durationMinutes: Number(exam.duration_minutes),
+ totalPoints: Number(exam.total_points),
+ startAt: exam.start_at,
+ endAt: exam.end_at,
+ status: normalizeExamStatus(exam),
+ }
+}
+
+function toReleasedExamSummary(result: ReleasedExamResultDto) {
+ return {
+ attemptId: result.attemptId,
+ examId: result.examId,
+ title: result.title,
+ totalScore: result.totalScore ?? 0,
+ totalPoints: result.totalPoints,
+ releasedAt:
+ result.releasedAt ?? result.submittedAt ?? new Date(0).toISOString(),
+ submittedAt: result.submittedAt,
+ integrityStatus: result.integrityStatus,
+ } satisfies ReleasedExamResultSummaryDto
+}
diff --git a/lib/exams/types.ts b/lib/exams/types.ts
new file mode 100644
index 0000000..4493913
--- /dev/null
+++ b/lib/exams/types.ts
@@ -0,0 +1,259 @@
+export type ExamQuestionKind = "mcq" | "short"
+
+export type ExamStatus = "upcoming" | "live" | "ended"
+
+export type ExamAttemptStatus =
+ | "in_progress"
+ | "submitted"
+ | "graded"
+ | "voided"
+
+export type ExamIntegrityStatus = "clear" | "reported" | "flagged" | "voided"
+
+export type ExamTestCase = {
+ id: string
+ input: string
+ expectedOutput: string
+ label?: string
+}
+
+export type UpsertExamQuestionInput = {
+ id?: string
+ type: "mcq" | "short"
+ prompt: string
+ options: string[]
+ correctAnswer: JsonValue | null
+ points: number
+}
+
+export type UpsertExamInput = {
+ title: string
+ durationMinutes: number
+ startAt: string
+ passcode?: string
+ questions: UpsertExamQuestionInput[]
+}
+
+export type StartAttemptInput = {
+ passcode: string
+}
+
+export type SaveAnswerInput = {
+ questionId: string
+ answer: JsonValue | null
+}
+
+export type GradeAttemptInput = {
+ answers: Array<{
+ answerId?: string
+ questionId?: string
+ teacherScore: number | null
+ }>
+}
+
+export type IntegrityActionInput = {
+ action: "flag" | "void" | "clear"
+ reason?: string
+}
+
+export type IntegrityEventInput = {
+ eventType: string
+ payload: Record
+}
+
+export type JsonPrimitive = string | number | boolean | null
+
+export type JsonValue =
+ | JsonPrimitive
+ | JsonValue[]
+ | { [key: string]: JsonValue }
+
+export type StudentQuestionDto = {
+ id: string
+ position: number
+ type: ExamQuestionKind
+ prompt: string
+ options: string[]
+ points: number
+ savedAnswer: JsonValue | null
+}
+
+export type StudentAttemptDto = {
+ id: string
+ status: ExamAttemptStatus
+ startedAt: string
+ submittedAt: string | null
+ deadlineAt: string
+ timeLeftSeconds: number
+ attemptNumber: number
+ needsManualReview: boolean
+ integrityStatus: ExamIntegrityStatus
+}
+
+export type StudentActiveExamDto = {
+ id: string
+ title: string
+ classId: string
+ durationMinutes: number
+ totalPoints: number
+ questionCount: number
+ startAt: string | null
+ endAt: string | null
+ status: ExamStatus
+ requiresPasscode: boolean
+ examModeEnabled: boolean
+ canStartAttempt: boolean
+ startBlockedReason: string | null
+ attempt: StudentAttemptDto | null
+ questions: StudentQuestionDto[]
+}
+
+export type ReleasedExamQuestionResultDto = {
+ id: string
+ position: number
+ prompt: string
+ type: ExamQuestionKind
+ points: number
+ score: number | null
+ status: "correct" | "incorrect" | "reviewed" | "unanswered"
+ selectedOptionIndex: number | null
+ selectedTextAnswer: string | null
+ correctOptionIndex: number | null
+ correctTextAnswer: string | null
+}
+
+export type ReleasedExamResultDto = {
+ attemptId: string
+ examId: string
+ title: string
+ status: ExamAttemptStatus
+ totalScore: number | null
+ totalPoints: number
+ submittedAt: string | null
+ releasedAt: string | null
+ gradedAt: string | null
+ isReleased: boolean
+ needsManualReview: boolean
+ integrityStatus: ExamIntegrityStatus
+ questions: ReleasedExamQuestionResultDto[]
+}
+
+export type ReleasedExamResultSummaryDto = {
+ attemptId: string
+ examId: string
+ title: string
+ totalScore: number
+ totalPoints: number
+ releasedAt: string
+ submittedAt: string | null
+ integrityStatus: ExamIntegrityStatus
+}
+
+export type ScheduledExamDto = {
+ id: string
+ title: string
+ durationMinutes: number
+ totalPoints: number
+ startAt: string | null
+ endAt: string | null
+ status: ExamStatus
+}
+
+export type StudentExamPageState = "none" | "scheduled" | "active"
+
+export type StudentExamPageDto = {
+ state: StudentExamPageState
+ scheduledExam: ScheduledExamDto | null
+ activeExam: StudentActiveExamDto | null
+ releasedResults: ReleasedExamResultDto[]
+ history: ReleasedExamResultSummaryDto[]
+}
+
+export type ManagerExamSummaryDto = {
+ id: string
+ title: string
+ durationMinutes: number
+ totalPoints: number
+ startAt: string | null
+ endAt: string | null
+ status: ExamStatus
+ publishedAt: string | null
+ createdAt: string
+ updatedAt: string
+ attemptCounts: {
+ inProgress: number
+ submitted: number
+ graded: number
+ released: number
+ }
+ enteredStudentCount: number
+ releasedStudentCount: number
+}
+
+export type ManagerQuestionDto = {
+ id: string
+ position: number
+ type: ExamQuestionKind
+ prompt: string
+ options: string[]
+ points: number
+ correctAnswer: JsonValue | null
+}
+
+export type ManagerAnswerDto = {
+ id: string
+ questionId: string
+ answer: JsonValue | null
+ autoScore: number | null
+ teacherScore: number | null
+}
+
+export type ManagerIntegrityEventDto = {
+ key: string
+ eventType: string
+ createdAt: string
+ payload: Record
+}
+
+export type ManagerAttemptSummaryDto = {
+ id: string
+ studentUserId: string
+ studentDisplayName: string
+ studentEmail: string
+ status: ExamAttemptStatus
+ startedAt: string | null
+ submittedAt: string | null
+ totalScore: number | null
+ attemptNumber: number
+ needsManualReview: boolean
+ integrityStatus: ExamIntegrityStatus
+ resultsReleasedAt: string | null
+ availableRetakeCount: number
+ answers: ManagerAnswerDto[]
+ integrityEvents: ManagerIntegrityEventDto[]
+}
+
+export type ManagerExamDetailDto = {
+ exam: ManagerExamSummaryDto & {
+ classId: string
+ organizationId: string
+ createdByUserId: string | null
+ passcodeProtected: boolean
+ }
+ questions: ManagerQuestionDto[]
+ attempts: ManagerAttemptSummaryDto[]
+}
+
+export type ClassExamApiDto =
+ | {
+ canManage: true
+ manager: {
+ exams: ManagerExamSummaryDto[]
+ }
+ student: null
+ }
+ | {
+ canManage: false
+ manager: null
+ student: StudentExamPageDto
+ }
diff --git a/lib/features/feature-registry.test.ts b/lib/features/feature-registry.test.ts
index 34f5008..e6e6435 100644
--- a/lib/features/feature-registry.test.ts
+++ b/lib/features/feature-registry.test.ts
@@ -87,6 +87,21 @@ describe("getClassNavFeatures", () => {
})
})
+describe("getFeatureByRouteSegment", () => {
+ test("normalizes leaderboard routes to results", () => {
+ const features = resolveClassFeatures({
+ definitions,
+ organizationSettings: [createSetting("leaderboard", true)],
+ classSettings: [createSetting("leaderboard", true)],
+ })
+
+ expect(getFeatureByRouteSegment(features, "results")?.key).toEqual(
+ "leaderboard",
+ )
+ expect(getFeatureByRouteSegment(features, "leaderboard")).toEqual(undefined)
+ })
+})
+
function createDefinition(
key: string,
label: string,
diff --git a/lib/features/feature-registry.ts b/lib/features/feature-registry.ts
index f57525e..5027dd9 100644
--- a/lib/features/feature-registry.ts
+++ b/lib/features/feature-registry.ts
@@ -120,7 +120,7 @@ export const FEATURE_REGISTRY = [
label: "Results",
icon: ChartColumn,
parentKey: null,
- routeSegment: "leaderboard",
+ routeSegment: "results",
defaultEnabled: true,
sortOrder: 70,
renderInClassNav: true,
@@ -196,7 +196,10 @@ export function resolveClassFeatures({
const resolved: ResolvedClassFeature = {
...feature,
label: definition?.label ?? feature.label,
- routeSegment: definition?.route_segment ?? feature.routeSegment,
+ routeSegment: normalizeFeatureRouteSegment(
+ feature.key,
+ definition?.route_segment ?? feature.routeSegment,
+ ),
defaultEnabled: definition?.default_enabled ?? feature.defaultEnabled,
sortOrder: definition?.sort_order ?? feature.sortOrder,
enabled,
@@ -295,3 +298,14 @@ export function getFeatureByRouteSegment(
function toSettingsMap(settings: FeatureSetting[]) {
return new Map(settings.map((setting) => [setting.feature_key, setting]))
}
+
+function normalizeFeatureRouteSegment(
+ featureKey: string,
+ routeSegment: string | null,
+) {
+ if (featureKey === "leaderboard") {
+ return "results"
+ }
+
+ return routeSegment
+}
diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts
new file mode 100644
index 0000000..f29ef61
--- /dev/null
+++ b/lib/supabase/server.ts
@@ -0,0 +1,28 @@
+import {
+ createClient as createSupabaseClient,
+ type SupabaseClient,
+} from "@supabase/supabase-js"
+
+let serverClient: SupabaseClient | null = null
+
+export function createServerClient() {
+ if (serverClient) return serverClient
+
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
+ const supabaseSecretKey = process.env.SUPABASE_SECRET_KEY
+
+ if (!supabaseUrl || !supabaseSecretKey) {
+ throw new Error(
+ "Supabase env vars are missing. Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SECRET_KEY.",
+ )
+ }
+
+ serverClient = createSupabaseClient(supabaseUrl, supabaseSecretKey, {
+ auth: {
+ persistSession: false,
+ autoRefreshToken: false,
+ },
+ })
+
+ return serverClient
+}
diff --git a/supabase/migrations/20260503190000_create_exam_system.sql b/supabase/migrations/20260503190000_create_exam_system.sql
new file mode 100644
index 0000000..c3124e0
--- /dev/null
+++ b/supabase/migrations/20260503190000_create_exam_system.sql
@@ -0,0 +1,482 @@
+do $$
+begin
+ create type public.exam_status as enum ('upcoming', 'live', 'ended');
+exception
+ when duplicate_object then null;
+end $$;
+
+do $$
+begin
+ create type public.exam_question_kind as enum ('mcq', 'short', 'code');
+exception
+ when duplicate_object then null;
+end $$;
+
+do $$
+begin
+ create type public.exam_attempt_status as enum (
+ 'in_progress',
+ 'submitted',
+ 'graded',
+ 'voided'
+ );
+exception
+ when duplicate_object then null;
+end $$;
+
+create table if not exists public.exams (
+ id uuid primary key default gen_random_uuid(),
+ organization_id uuid not null references public.organizations (id) on delete cascade,
+ class_id uuid not null references public.classes (id) on delete cascade,
+ title text not null,
+ duration_minutes integer not null check (duration_minutes > 0),
+ total_points integer not null default 0 check (total_points >= 0),
+ start_at timestamptz,
+ end_at timestamptz,
+ status public.exam_status not null default 'upcoming',
+ created_by_user_id uuid references auth.users (id) on delete set null,
+ published_at timestamptz,
+ passcode_hash text,
+ rules_override_json jsonb not null default '{}'::jsonb,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint exams_title_not_blank check (btrim(title) <> ''),
+ constraint exams_rules_override_json_object check (jsonb_typeof(rules_override_json) = 'object'),
+ constraint exams_schedule_valid check (
+ start_at is null
+ or end_at is null
+ or end_at >= start_at
+ )
+);
+
+alter table public.exams
+ add column if not exists published_at timestamptz,
+ add column if not exists passcode_hash text,
+ add column if not exists rules_override_json jsonb not null default '{}'::jsonb;
+
+create index if not exists idx_exams_class_start
+ on public.exams (class_id, start_at asc);
+
+create index if not exists idx_exams_class_published
+ on public.exams (class_id, published_at desc);
+
+create table if not exists public.exam_questions (
+ id uuid primary key default gen_random_uuid(),
+ organization_id uuid not null references public.organizations (id) on delete cascade,
+ exam_id uuid not null references public.exams (id) on delete cascade,
+ position integer not null check (position > 0),
+ question_type public.exam_question_kind not null,
+ prompt text not null,
+ options_json jsonb,
+ correct_answer_json jsonb,
+ points integer not null check (points >= 0),
+ language text,
+ starter_code text,
+ visible_tests_json jsonb not null default '[]'::jsonb,
+ hidden_tests_json jsonb not null default '[]'::jsonb,
+ evaluator_key text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint exam_questions_prompt_not_blank check (btrim(prompt) <> ''),
+ constraint exam_questions_visible_tests_is_array check (jsonb_typeof(visible_tests_json) = 'array'),
+ constraint exam_questions_hidden_tests_is_array check (jsonb_typeof(hidden_tests_json) = 'array')
+);
+
+alter table public.exam_questions
+ add column if not exists visible_tests_json jsonb not null default '[]'::jsonb,
+ add column if not exists hidden_tests_json jsonb not null default '[]'::jsonb,
+ add column if not exists evaluator_key text;
+
+create unique index if not exists idx_exam_questions_exam_position
+ on public.exam_questions (exam_id, position);
+
+create table if not exists public.exam_attempts (
+ id uuid primary key default gen_random_uuid(),
+ organization_id uuid not null references public.organizations (id) on delete cascade,
+ class_id uuid not null references public.classes (id) on delete cascade,
+ exam_id uuid not null references public.exams (id) on delete cascade,
+ student_user_id uuid not null references auth.users (id) on delete cascade,
+ status public.exam_attempt_status not null default 'in_progress',
+ started_at timestamptz,
+ submitted_at timestamptz,
+ total_score numeric,
+ attempt_number integer not null default 1 check (attempt_number > 0),
+ deadline_at timestamptz,
+ rules_snapshot_json jsonb not null default '{}'::jsonb,
+ needs_manual_review boolean not null default false,
+ auto_submitted_at timestamptz,
+ integrity_status text not null default 'clear',
+ flagged_at timestamptz,
+ flagged_by_user_id uuid references auth.users (id) on delete set null,
+ flag_reason text,
+ voided_at timestamptz,
+ voided_by_user_id uuid references auth.users (id) on delete set null,
+ void_reason text,
+ graded_at timestamptz,
+ graded_by_user_id uuid references auth.users (id) on delete set null,
+ results_released_at timestamptz,
+ results_released_by_user_id uuid references auth.users (id) on delete set null,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint exam_attempts_score_non_negative check (total_score is null or total_score >= 0),
+ constraint exam_attempts_rules_snapshot_json_object check (jsonb_typeof(rules_snapshot_json) = 'object'),
+ constraint exam_attempts_integrity_status_valid check (
+ integrity_status in ('clear', 'reported', 'flagged', 'voided')
+ ),
+ constraint exam_attempts_submission_order_valid check (
+ started_at is null
+ or submitted_at is null
+ or submitted_at >= started_at
+ )
+);
+
+alter table public.exam_attempts
+ add column if not exists attempt_number integer not null default 1,
+ add column if not exists deadline_at timestamptz,
+ add column if not exists rules_snapshot_json jsonb not null default '{}'::jsonb,
+ add column if not exists needs_manual_review boolean not null default false,
+ add column if not exists auto_submitted_at timestamptz,
+ add column if not exists integrity_status text not null default 'clear',
+ add column if not exists flagged_at timestamptz,
+ add column if not exists flagged_by_user_id uuid references auth.users (id) on delete set null,
+ add column if not exists flag_reason text,
+ add column if not exists voided_at timestamptz,
+ add column if not exists voided_by_user_id uuid references auth.users (id) on delete set null,
+ add column if not exists void_reason text,
+ add column if not exists graded_at timestamptz,
+ add column if not exists graded_by_user_id uuid references auth.users (id) on delete set null,
+ add column if not exists results_released_at timestamptz,
+ add column if not exists results_released_by_user_id uuid references auth.users (id) on delete set null;
+
+create unique index if not exists idx_exam_attempts_exam_student_attempt_number
+ on public.exam_attempts (exam_id, student_user_id, attempt_number);
+
+create index if not exists idx_exam_attempts_exam_status
+ on public.exam_attempts (exam_id, status, created_at desc);
+
+create index if not exists idx_exam_attempts_student_created
+ on public.exam_attempts (student_user_id, created_at desc);
+
+create table if not exists public.exam_answers (
+ id uuid primary key default gen_random_uuid(),
+ organization_id uuid not null references public.organizations (id) on delete cascade,
+ exam_attempt_id uuid not null references public.exam_attempts (id) on delete cascade,
+ exam_question_id uuid not null references public.exam_questions (id) on delete cascade,
+ answer_json jsonb,
+ auto_score numeric,
+ teacher_score numeric,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint exam_answers_auto_score_non_negative check (auto_score is null or auto_score >= 0),
+ constraint exam_answers_teacher_score_non_negative check (teacher_score is null or teacher_score >= 0)
+);
+
+create unique index if not exists idx_exam_answers_attempt_question
+ on public.exam_answers (exam_attempt_id, exam_question_id);
+
+create or replace function public.validate_exam()
+returns trigger
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ target_class_org_id uuid;
+begin
+ select organization_id
+ into target_class_org_id
+ from public.classes
+ where id = new.class_id
+ and is_archived = false;
+
+ if target_class_org_id is null then
+ raise exception 'Class % does not exist', new.class_id;
+ end if;
+
+ if new.organization_id <> target_class_org_id then
+ raise exception 'Exam organization mismatch';
+ end if;
+
+ if new.published_at is not null then
+ if new.end_at is not null and new.end_at <= now() then
+ new.status := 'ended';
+ elsif new.start_at is not null and new.start_at <= now() then
+ new.status := 'live';
+ else
+ new.status := 'upcoming';
+ end if;
+ else
+ new.status := 'upcoming';
+ end if;
+
+ return new;
+end;
+$$;
+
+create or replace function public.validate_exam_question()
+returns trigger
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ target_exam public.exams;
+begin
+ select *
+ into target_exam
+ from public.exams
+ where id = new.exam_id;
+
+ if target_exam.id is null then
+ raise exception 'Exam % does not exist', new.exam_id;
+ end if;
+
+ if new.organization_id <> target_exam.organization_id then
+ raise exception 'Exam question organization mismatch';
+ end if;
+
+ if new.question_type = 'mcq' then
+ if new.options_json is null or jsonb_typeof(new.options_json) <> 'array' then
+ raise exception 'MCQ questions require options_json array';
+ end if;
+
+ if jsonb_array_length(new.options_json) = 0 then
+ raise exception 'MCQ questions require at least one option';
+ end if;
+
+ if new.correct_answer_json is null then
+ raise exception 'MCQ questions require correct_answer_json';
+ end if;
+ end if;
+
+ if new.question_type <> 'code' then
+ new.visible_tests_json := '[]'::jsonb;
+ new.hidden_tests_json := '[]'::jsonb;
+ new.evaluator_key := null;
+ end if;
+
+ return new;
+end;
+$$;
+
+create or replace function public.validate_exam_attempt()
+returns trigger
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ target_exam public.exams;
+begin
+ select *
+ into target_exam
+ from public.exams
+ where id = new.exam_id;
+
+ if target_exam.id is null then
+ raise exception 'Exam % does not exist', new.exam_id;
+ end if;
+
+ if new.organization_id <> target_exam.organization_id
+ or new.class_id <> target_exam.class_id then
+ raise exception 'Exam attempt class mismatch';
+ end if;
+
+ if new.total_score is not null and new.total_score > target_exam.total_points then
+ raise exception 'Exam attempt total score exceeds exam total points';
+ end if;
+
+ return new;
+end;
+$$;
+
+create or replace function public.validate_exam_answer()
+returns trigger
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ target_attempt public.exam_attempts;
+ target_question public.exam_questions;
+begin
+ select *
+ into target_attempt
+ from public.exam_attempts
+ where id = new.exam_attempt_id;
+
+ if target_attempt.id is null then
+ raise exception 'Exam attempt % does not exist', new.exam_attempt_id;
+ end if;
+
+ select *
+ into target_question
+ from public.exam_questions
+ where id = new.exam_question_id;
+
+ if target_question.id is null then
+ raise exception 'Exam question % does not exist', new.exam_question_id;
+ end if;
+
+ if new.organization_id <> target_attempt.organization_id
+ or new.organization_id <> target_question.organization_id
+ or target_attempt.exam_id <> target_question.exam_id then
+ raise exception 'Exam answer exam mismatch';
+ end if;
+
+ if new.auto_score is not null and new.auto_score > target_question.points then
+ raise exception 'Auto score exceeds question points';
+ end if;
+
+ if new.teacher_score is not null and new.teacher_score > target_question.points then
+ raise exception 'Teacher score exceeds question points';
+ end if;
+
+ return new;
+end;
+$$;
+
+drop trigger if exists set_exams_updated_at on public.exams;
+create trigger set_exams_updated_at
+ before update on public.exams
+ for each row execute procedure public.set_updated_at();
+
+drop trigger if exists validate_exam on public.exams;
+create trigger validate_exam
+ before insert or update on public.exams
+ for each row execute procedure public.validate_exam();
+
+drop trigger if exists set_exam_questions_updated_at on public.exam_questions;
+create trigger set_exam_questions_updated_at
+ before update on public.exam_questions
+ for each row execute procedure public.set_updated_at();
+
+drop trigger if exists validate_exam_question on public.exam_questions;
+create trigger validate_exam_question
+ before insert or update on public.exam_questions
+ for each row execute procedure public.validate_exam_question();
+
+drop trigger if exists set_exam_attempts_updated_at on public.exam_attempts;
+create trigger set_exam_attempts_updated_at
+ before update on public.exam_attempts
+ for each row execute procedure public.set_updated_at();
+
+drop trigger if exists validate_exam_attempt on public.exam_attempts;
+create trigger validate_exam_attempt
+ before insert or update on public.exam_attempts
+ for each row execute procedure public.validate_exam_attempt();
+
+drop trigger if exists set_exam_answers_updated_at on public.exam_answers;
+create trigger set_exam_answers_updated_at
+ before update on public.exam_answers
+ for each row execute procedure public.set_updated_at();
+
+drop trigger if exists validate_exam_answer on public.exam_answers;
+create trigger validate_exam_answer
+ before insert or update on public.exam_answers
+ for each row execute procedure public.validate_exam_answer();
+
+alter table public.exams enable row level security;
+alter table public.exam_questions enable row level security;
+alter table public.exam_attempts enable row level security;
+alter table public.exam_answers enable row level security;
+
+drop policy if exists "class members can read published exams" on public.exams;
+create policy "class members can read published exams"
+ on public.exams
+ for select
+ using (
+ public.can_manage_class(organization_id, class_id)
+ or (
+ published_at is not null
+ and public.is_class_member(organization_id, class_id)
+ )
+ );
+
+drop policy if exists "class managers can manage exams" on public.exams;
+create policy "class managers can manage exams"
+ on public.exams
+ for all
+ using (public.can_manage_class(organization_id, class_id))
+ with check (public.can_manage_class(organization_id, class_id));
+
+drop policy if exists "class managers can read exam questions" on public.exam_questions;
+create policy "class managers can read exam questions"
+ on public.exam_questions
+ for select
+ using (
+ exists (
+ select 1
+ from public.exams
+ where exams.id = exam_questions.exam_id
+ and public.can_manage_class(exams.organization_id, exams.class_id)
+ )
+ );
+
+drop policy if exists "class managers can manage exam questions" on public.exam_questions;
+create policy "class managers can manage exam questions"
+ on public.exam_questions
+ for all
+ using (
+ exists (
+ select 1
+ from public.exams
+ where exams.id = exam_questions.exam_id
+ and public.can_manage_class(exams.organization_id, exams.class_id)
+ )
+ )
+ with check (
+ exists (
+ select 1
+ from public.exams
+ where exams.id = exam_questions.exam_id
+ and public.can_manage_class(exams.organization_id, exams.class_id)
+ )
+ );
+
+drop policy if exists "class managers can read exam attempts" on public.exam_attempts;
+create policy "class managers can read exam attempts"
+ on public.exam_attempts
+ for select
+ using (public.can_manage_class(organization_id, class_id));
+
+drop policy if exists "class managers can manage exam attempts" on public.exam_attempts;
+create policy "class managers can manage exam attempts"
+ on public.exam_attempts
+ for all
+ using (public.can_manage_class(organization_id, class_id))
+ with check (public.can_manage_class(organization_id, class_id));
+
+drop policy if exists "class managers can read exam answers" on public.exam_answers;
+create policy "class managers can read exam answers"
+ on public.exam_answers
+ for select
+ using (
+ exists (
+ select 1
+ from public.exam_attempts
+ where exam_attempts.id = exam_answers.exam_attempt_id
+ and public.can_manage_class(exam_attempts.organization_id, exam_attempts.class_id)
+ )
+ );
+
+drop policy if exists "class managers can manage exam answers" on public.exam_answers;
+create policy "class managers can manage exam answers"
+ on public.exam_answers
+ for all
+ using (
+ exists (
+ select 1
+ from public.exam_attempts
+ where exam_attempts.id = exam_answers.exam_attempt_id
+ and public.can_manage_class(exam_attempts.organization_id, exam_attempts.class_id)
+ )
+ )
+ with check (
+ exists (
+ select 1
+ from public.exam_attempts
+ where exam_attempts.id = exam_answers.exam_attempt_id
+ and public.can_manage_class(exam_attempts.organization_id, exam_attempts.class_id)
+ )
+ );
diff --git a/supabase/migrations/20260505124000_fix_exam_attempt_retake_constraint.sql b/supabase/migrations/20260505124000_fix_exam_attempt_retake_constraint.sql
new file mode 100644
index 0000000..10e3c63
--- /dev/null
+++ b/supabase/migrations/20260505124000_fix_exam_attempt_retake_constraint.sql
@@ -0,0 +1,9 @@
+alter table public.exam_attempts
+ drop constraint if exists exam_attempts_exam_id_student_user_id_key;
+
+drop index if exists public.exam_attempts_exam_id_student_user_id_key;
+drop index if exists public.idx_exam_attempts_single_active_per_student;
+
+create unique index if not exists idx_exam_attempts_single_active_per_student
+ on public.exam_attempts (exam_id, student_user_id)
+ where status = 'in_progress';