-
-
+
)
}
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
-
+
@@ -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 (
-