Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/api/instructor/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
34 changes: 33 additions & 1 deletion app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +16,8 @@ export default function SubmissionsTable({ participants }: { participants: Parti
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [overriding, setOverriding] = useState<string | null>(null);
const [localOverride, setLocalOverride] = useState<Record<string, boolean | null>>({});
const [terminating, setTerminating] = useState<string | null>(null);
const [localClosed, setLocalClosed] = useState<Set<string>>(new Set());
const [page, setPage] = useState(1);

async function handleOverride(sessionId: string, value: boolean | null) {
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<>
Expand All @@ -121,6 +139,9 @@ export default function SubmissionsTable({ participants }: { participants: Parti
{p.final_count === p.total_questions && (
<span className="badge badge-green" style={{ marginLeft: 6, fontSize: 10 }}>Done</span>
)}
{isClosed && p.final_count < p.total_questions && (
<span className="badge badge-gray" style={{ marginLeft: 6, fontSize: 10 }}>Closed</span>
)}
</td>
<td>
{p.is_flagged
Expand Down Expand Up @@ -175,6 +196,17 @@ export default function SubmissionsTable({ participants }: { participants: Parti
<RotateCcw size={11} />
</button>
)}
{!isClosed && (
<button
className="btn btn-sm btn-danger"
disabled={terminating === p.session_id}
onClick={() => handleTerminate(p.session_id, p.username)}
title="Terminate session"
style={{ fontSize: 11 }}
>
<XCircle size={11} /> End
</button>
)}
</div>
</td>
</tr>
Expand Down
5 changes: 4 additions & 1 deletion app/instructor/exercises/[id]/submissions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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
`;

Expand All @@ -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,
Expand Down
Loading