Skip to content

Commit ed97b40

Browse files
authored
Merge pull request #16 from jvcByte/preview
feat: add per-user session termination for instructors
2 parents c6a56b9 + befe311 commit ed97b40

3 files changed

Lines changed: 78 additions & 2 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getServerSession } from 'next-auth';
3+
import { authOptions } from '@/lib/auth';
4+
import { sql } from '@/lib/db';
5+
import { audit } from '@/lib/audit';
6+
import { recalculateSessionScore } from '@/lib/scoring';
7+
8+
// DELETE /api/instructor/sessions/[id] — force-close a session
9+
export async function DELETE(
10+
_req: NextRequest,
11+
{ params }: { params: { id: string } }
12+
) {
13+
const session = await getServerSession(authOptions);
14+
if (!session?.user?.id || session.user.role !== 'instructor') {
15+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
16+
}
17+
18+
const sessionId = params.id;
19+
20+
const rows = await sql`
21+
SELECT id, closed_at FROM sessions WHERE id = ${sessionId} LIMIT 1
22+
`;
23+
24+
if (rows.length === 0) {
25+
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
26+
}
27+
28+
if (rows[0].closed_at) {
29+
return NextResponse.json({ error: 'Session already closed' }, { status: 409 });
30+
}
31+
32+
await sql`
33+
UPDATE sessions SET closed_at = now() WHERE id = ${sessionId}
34+
`;
35+
36+
await recalculateSessionScore(sessionId).catch(() => {});
37+
38+
await audit(session.user.id, 'session.force_closed', 'session', sessionId, {});
39+
40+
return NextResponse.json({ success: true });
41+
}

app/instructor/exercises/[id]/submissions/SubmissionsTable.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState } from 'react';
44
import Link from 'next/link';
5-
import { Flag, ChevronDown, ChevronRight, ShieldCheck, ShieldX, RotateCcw } from 'lucide-react';
5+
import { Flag, ChevronDown, ChevronRight, ShieldCheck, ShieldX, RotateCcw, XCircle } from 'lucide-react';
66
import SearchInput from '@/app/components/SearchInput';
77
import Pagination from '@/app/components/Pagination';
88
import { toast } from 'sonner';
@@ -16,6 +16,8 @@ export default function SubmissionsTable({ participants }: { participants: Parti
1616
const [expanded, setExpanded] = useState<Set<string>>(new Set());
1717
const [overriding, setOverriding] = useState<string | null>(null);
1818
const [localOverride, setLocalOverride] = useState<Record<string, boolean | null>>({});
19+
const [terminating, setTerminating] = useState<string | null>(null);
20+
const [localClosed, setLocalClosed] = useState<Set<string>>(new Set());
1921
const [page, setPage] = useState(1);
2022

2123
async function handleOverride(sessionId: string, value: boolean | null) {
@@ -36,6 +38,21 @@ export default function SubmissionsTable({ participants }: { participants: Parti
3638
}
3739
}
3840

41+
async function handleTerminate(sessionId: string, username: string) {
42+
if (!confirm(`Terminate ${username}'s session? This only affects them and cannot be undone.`)) return;
43+
setTerminating(sessionId);
44+
try {
45+
const res = await fetch(`/api/instructor/sessions/${sessionId}`, { method: 'DELETE' });
46+
if (!res.ok) throw new Error('Failed');
47+
setLocalClosed((prev) => new Set(prev).add(sessionId));
48+
toast.success(`${username}'s session terminated`);
49+
} catch {
50+
toast.error('Failed to terminate session');
51+
} finally {
52+
setTerminating(null);
53+
}
54+
}
55+
3956
const filtered = participants.filter((p) => {
4057
const matchSearch = p.username.toLowerCase().includes(search.toLowerCase());
4158
const matchFlag = !filterFlagged || p.is_flagged;
@@ -98,6 +115,7 @@ export default function SubmissionsTable({ participants }: { participants: Parti
98115
? localOverride[p.session_id]
99116
: p.passed;
100117
const isOverridden = p.passed_override !== null || p.session_id in localOverride;
118+
const isClosed = localClosed.has(p.session_id) || !!p.closed_at;
101119

102120
return (
103121
<>
@@ -121,6 +139,9 @@ export default function SubmissionsTable({ participants }: { participants: Parti
121139
{p.final_count === p.total_questions && (
122140
<span className="badge badge-green" style={{ marginLeft: 6, fontSize: 10 }}>Done</span>
123141
)}
142+
{isClosed && p.final_count < p.total_questions && (
143+
<span className="badge badge-gray" style={{ marginLeft: 6, fontSize: 10 }}>Closed</span>
144+
)}
124145
</td>
125146
<td>
126147
{p.is_flagged
@@ -175,6 +196,17 @@ export default function SubmissionsTable({ participants }: { participants: Parti
175196
<RotateCcw size={11} />
176197
</button>
177198
)}
199+
{!isClosed && (
200+
<button
201+
className="btn btn-sm btn-danger"
202+
disabled={terminating === p.session_id}
203+
onClick={() => handleTerminate(p.session_id, p.username)}
204+
title="Terminate session"
205+
style={{ fontSize: 11 }}
206+
>
207+
<XCircle size={11} /> End
208+
</button>
209+
)}
178210
</div>
179211
</td>
180212
</tr>

app/instructor/exercises/[id]/submissions/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface ParticipantRow {
1515
score: number | null;
1616
passed: boolean | null;
1717
passed_override: boolean | null;
18+
closed_at: string | null;
1819
total_questions: number;
1920
answered: number;
2021
final_count: number;
@@ -47,6 +48,7 @@ export default async function SubmissionListPage({ params }: Props) {
4748
s.score,
4849
s.passed,
4950
s.passed_override,
51+
s.closed_at,
5052
e.question_count AS total_questions,
5153
COUNT(sub.id)::int AS answered,
5254
COUNT(sub.id) FILTER (WHERE sub.is_final = true)::int AS final_count,
@@ -67,7 +69,7 @@ export default async function SubmissionListPage({ params }: Props) {
6769
JOIN exercises e ON e.id = s.exercise_id
6870
LEFT JOIN submissions sub ON sub.session_id = s.id
6971
WHERE s.exercise_id = ${exerciseId}
70-
GROUP BY s.id, u.username, s.score, s.passed, s.passed_override, e.question_count
72+
GROUP BY s.id, u.username, s.score, s.passed, s.passed_override, s.closed_at, e.question_count
7173
ORDER BY u.username
7274
`;
7375

@@ -77,6 +79,7 @@ export default async function SubmissionListPage({ params }: Props) {
7779
score: r.score != null ? Number(r.score) : null,
7880
passed: r.passed != null ? (r.passed as boolean) : null,
7981
passed_override: r.passed_override != null ? (r.passed_override as boolean) : null,
82+
closed_at: r.closed_at as string | null,
8083
total_questions: r.total_questions as number,
8184
answered: r.answered as number,
8285
final_count: r.final_count as number,

0 commit comments

Comments
 (0)