From befe3117cda6dc4d6601e01f6d65543ebbebc671 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Thu, 11 Jun 2026 12:41:47 +0100 Subject: [PATCH] feat: add per-user session termination for instructors --- app/api/instructor/sessions/[id]/route.ts | 41 +++++++++++++++++++ .../[id]/submissions/SubmissionsTable.tsx | 34 ++++++++++++++- .../exercises/[id]/submissions/page.tsx | 5 ++- 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 app/api/instructor/sessions/[id]/route.ts diff --git a/app/api/instructor/sessions/[id]/route.ts b/app/api/instructor/sessions/[id]/route.ts new file mode 100644 index 0000000..e9b80f3 --- /dev/null +++ b/app/api/instructor/sessions/[id]/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; +import { audit } from '@/lib/audit'; +import { recalculateSessionScore } from '@/lib/scoring'; + +// DELETE /api/instructor/sessions/[id] — force-close a session +export async function DELETE( + _req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.id || session.user.role !== 'instructor') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const sessionId = params.id; + + const rows = await sql` + SELECT id, closed_at FROM sessions WHERE id = ${sessionId} LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + } + + if (rows[0].closed_at) { + return NextResponse.json({ error: 'Session already closed' }, { status: 409 }); + } + + await sql` + UPDATE sessions SET closed_at = now() WHERE id = ${sessionId} + `; + + await recalculateSessionScore(sessionId).catch(() => {}); + + await audit(session.user.id, 'session.force_closed', 'session', sessionId, {}); + + return NextResponse.json({ success: true }); +} diff --git a/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx b/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx index ccf78c2..d2123dd 100644 --- a/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx +++ b/app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Flag, ChevronDown, ChevronRight, ShieldCheck, ShieldX, RotateCcw } from 'lucide-react'; +import { Flag, ChevronDown, ChevronRight, ShieldCheck, ShieldX, RotateCcw, XCircle } from 'lucide-react'; import SearchInput from '@/app/components/SearchInput'; import Pagination from '@/app/components/Pagination'; import { toast } from 'sonner'; @@ -16,6 +16,8 @@ export default function SubmissionsTable({ participants }: { participants: Parti const [expanded, setExpanded] = useState>(new Set()); const [overriding, setOverriding] = useState(null); const [localOverride, setLocalOverride] = useState>({}); + const [terminating, setTerminating] = useState(null); + const [localClosed, setLocalClosed] = useState>(new Set()); const [page, setPage] = useState(1); async function handleOverride(sessionId: string, value: boolean | null) { @@ -36,6 +38,21 @@ export default function SubmissionsTable({ participants }: { participants: Parti } } + async function handleTerminate(sessionId: string, username: string) { + if (!confirm(`Terminate ${username}'s session? This only affects them and cannot be undone.`)) return; + setTerminating(sessionId); + try { + const res = await fetch(`/api/instructor/sessions/${sessionId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed'); + setLocalClosed((prev) => new Set(prev).add(sessionId)); + toast.success(`${username}'s session terminated`); + } catch { + toast.error('Failed to terminate session'); + } finally { + setTerminating(null); + } + } + const filtered = participants.filter((p) => { const matchSearch = p.username.toLowerCase().includes(search.toLowerCase()); const matchFlag = !filterFlagged || p.is_flagged; @@ -98,6 +115,7 @@ export default function SubmissionsTable({ participants }: { participants: Parti ? localOverride[p.session_id] : p.passed; const isOverridden = p.passed_override !== null || p.session_id in localOverride; + const isClosed = localClosed.has(p.session_id) || !!p.closed_at; return ( <> @@ -121,6 +139,9 @@ export default function SubmissionsTable({ participants }: { participants: Parti {p.final_count === p.total_questions && ( Done )} + {isClosed && p.final_count < p.total_questions && ( + Closed + )} {p.is_flagged @@ -175,6 +196,17 @@ export default function SubmissionsTable({ participants }: { participants: Parti )} + {!isClosed && ( + + )} diff --git a/app/instructor/exercises/[id]/submissions/page.tsx b/app/instructor/exercises/[id]/submissions/page.tsx index 53acf1e..a0a1c23 100644 --- a/app/instructor/exercises/[id]/submissions/page.tsx +++ b/app/instructor/exercises/[id]/submissions/page.tsx @@ -15,6 +15,7 @@ export interface ParticipantRow { score: number | null; passed: boolean | null; passed_override: boolean | null; + closed_at: string | null; total_questions: number; answered: number; final_count: number; @@ -47,6 +48,7 @@ export default async function SubmissionListPage({ params }: Props) { s.score, s.passed, s.passed_override, + s.closed_at, e.question_count AS total_questions, COUNT(sub.id)::int AS answered, COUNT(sub.id) FILTER (WHERE sub.is_final = true)::int AS final_count, @@ -67,7 +69,7 @@ export default async function SubmissionListPage({ params }: Props) { JOIN exercises e ON e.id = s.exercise_id LEFT JOIN submissions sub ON sub.session_id = s.id WHERE s.exercise_id = ${exerciseId} - GROUP BY s.id, u.username, s.score, s.passed, s.passed_override, e.question_count + GROUP BY s.id, u.username, s.score, s.passed, s.passed_override, s.closed_at, e.question_count ORDER BY u.username `; @@ -77,6 +79,7 @@ export default async function SubmissionListPage({ params }: Props) { score: r.score != null ? Number(r.score) : null, passed: r.passed != null ? (r.passed as boolean) : null, passed_override: r.passed_override != null ? (r.passed_override as boolean) : null, + closed_at: r.closed_at as string | null, total_questions: r.total_questions as number, answered: r.answered as number, final_count: r.final_count as number,