diff --git a/app/api/feedback/route.ts b/app/api/feedback/route.ts index fa94cd3..339441f 100644 --- a/app/api/feedback/route.ts +++ b/app/api/feedback/route.ts @@ -5,26 +5,33 @@ import { sql } from '@/lib/db'; export async function POST(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - let body: { rating?: number; comments?: string; challenges?: string; improvements?: string; malfunctions?: string; attachment_url?: string }; + let body: { + rating?: number; + comments?: string; + challenges?: string; + improvements?: string; + malfunctions?: string; + attachment_url?: string; + anonymous_name?: string; + anonymous_email?: string; + anonymous_matric?: string; + }; try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } - const { rating, comments, challenges, improvements, malfunctions, attachment_url } = body; + const { rating, comments, challenges, improvements, malfunctions, attachment_url, anonymous_name, anonymous_email, anonymous_matric } = body; if (rating && (rating < 1 || rating > 5)) { return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 }); } await sql` - INSERT INTO feedback (user_id, rating, comments, challenges, improvements, malfunctions, attachment_url) - VALUES (${session.user.id}, ${rating ?? null}, ${comments ?? null}, ${challenges ?? null}, ${improvements ?? null}, ${malfunctions ?? null}, ${attachment_url ?? null}) + INSERT INTO feedback (user_id, rating, comments, challenges, improvements, malfunctions, attachment_url, anonymous_name, anonymous_email, anonymous_matric) + VALUES (${session?.user?.id ?? null}, ${rating ?? null}, ${comments ?? null}, ${challenges ?? null}, ${improvements ?? null}, ${malfunctions ?? null}, ${attachment_url ?? null}, ${anonymous_name ?? null}, ${anonymous_email ?? null}, ${anonymous_matric ?? null}) `; return NextResponse.json({ success: true }); diff --git a/app/api/instructor/exercises/[id]/batch-test-run/route.ts b/app/api/instructor/exercises/[id]/batch-test-run/route.ts new file mode 100644 index 0000000..186d7a6 --- /dev/null +++ b/app/api/instructor/exercises/[id]/batch-test-run/route.ts @@ -0,0 +1,170 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; + +interface TestCase { + input: string; + expected_output: string; +} + +async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 2): Promise { + let lastError: Error | null = null; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const res = await fetch(url, options); + if (res.status >= 500 && attempt < maxRetries) { + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + continue; + } + return res; + } catch (err) { + lastError = err as Error; + if (attempt < maxRetries) await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + } + } + throw lastError ?? new Error('Max retries exceeded'); +} + +async function runAgainstTestCases( + code: string, + language: string, + testCases: TestCase[], + runnerUrl: string, + apiKey?: string, +): Promise { + for (const tc of testCases) { + try { + const res = await fetchWithRetry(`${runnerUrl}/run`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ code, language, stdin: tc.input }), + }); + if (!res.ok) return false; + const data = await res.json() as { stdout: string; exit_code: number }; + if ((data.stdout ?? '').trim() !== tc.expected_output.trim()) return false; + } catch { + return false; + } + } + return true; +} + +export async function POST( + _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 RUNNER_URL = process.env.RUNNER_URL; + if (!RUNNER_URL) { + return NextResponse.json( + { error: 'Code execution is not configured. Set RUNNER_URL in your environment.' }, + { status: 503 } + ); + } + + const exerciseId = params.id; + + const exRows = await sql`SELECT id FROM exercises WHERE id = ${exerciseId} LIMIT 1`; + if (exRows.length === 0) { + return NextResponse.json({ error: 'Exercise not found' }, { status: 404 }); + } + + // Fetch all final submissions for questions that have test cases. + // Skip submissions that already have tests_passed set (resumable runs). + const rows = await sql` + SELECT + sub.id AS submission_id, + sub.response_text, + sub.question_index, + q.test_cases, + q.language + FROM submissions sub + JOIN sessions s ON s.id = sub.session_id + JOIN questions q ON q.exercise_id = s.exercise_id + AND q.question_index = sub.question_index + WHERE s.exercise_id = ${exerciseId} + AND sub.is_final = true + AND sub.status != 'skipped' + AND q.test_cases IS NOT NULL + AND jsonb_array_length(q.test_cases) > 0 + AND sub.tests_passed IS NULL + `; + + let processed = 0; + let passed = 0; + let failed = 0; + + // Process in parallel batches of 10 to avoid overwhelming the runner + const BATCH_SIZE = 10; + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const batch = rows.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(async (row) => { + const code = (row.response_text as string) ?? ''; + const testCases = row.test_cases as TestCase[]; + const language = (row.language as string) ?? 'go'; + + let testsPassed = false; + if (code.trim().length > 0) { + testsPassed = await runAgainstTestCases(code, language, testCases, RUNNER_URL, process.env.RUNNER_API_KEY); + } + // Write immediately so progress is saved even if request times out + await sql`UPDATE submissions SET tests_passed = ${testsPassed} WHERE id = ${row.submission_id}`; + return testsPassed; + }) + ); + processed += batchResults.length; + passed += batchResults.filter(Boolean).length; + failed += batchResults.filter((r) => !r).length; + } + + // Trigger recalculate-scores + let recalculated = 0; + let warning: string | undefined; + try { + const recalcRes = await fetch( + `${process.env.NEXTAUTH_URL ?? 'http://localhost:3000'}/api/instructor/exercises/${exerciseId}/recalculate-scores`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: _req.headers.get('cookie') ?? '', + }, + } + ); + if (recalcRes.ok) { + const data = await recalcRes.json() as { recalculated?: number }; + recalculated = data.recalculated ?? 0; + } else { + warning = 'Scores could not be recalculated automatically. Please use the Recalculate button.'; + } + } catch (err) { + console.error('[batch-test-run] recalculate-scores failed:', err); + warning = 'Scores could not be recalculated automatically. Please use the Recalculate button.'; + } + + // Count how many are still unprocessed after this run + const remainingRows = await sql` + SELECT COUNT(*)::int AS count + FROM submissions sub + JOIN sessions s ON s.id = sub.session_id + JOIN questions q ON q.exercise_id = s.exercise_id AND q.question_index = sub.question_index + WHERE s.exercise_id = ${exerciseId} + AND sub.is_final = true + AND sub.status != 'skipped' + AND q.test_cases IS NOT NULL + AND jsonb_array_length(q.test_cases) > 0 + AND sub.tests_passed IS NULL + `; + const remaining = (remainingRows[0]?.count as number) ?? 0; + + return NextResponse.json({ processed, passed, failed, recalculated, remaining, ...(warning ? { warning } : {}) }); +} diff --git a/app/api/instructor/exercises/[id]/questions/[qid]/route.ts b/app/api/instructor/exercises/[id]/questions/[qid]/route.ts index ba38fa4..126ec46 100644 --- a/app/api/instructor/exercises/[id]/questions/[qid]/route.ts +++ b/app/api/instructor/exercises/[id]/questions/[qid]/route.ts @@ -14,7 +14,7 @@ export async function PUT( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - let body: { text?: string; type?: string; language?: string; starter?: string }; + let body: { text?: string; type?: string; language?: string; starter?: string; test_cases?: Array<{ input: string; expected_output: string }> | null }; try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } @@ -25,10 +25,14 @@ export async function PUT( type = COALESCE(${body.type ?? null}, type), language = COALESCE(${body.language ?? null}, language), starter = COALESCE(${body.starter ?? null}, starter), + test_cases = CASE WHEN ${'test_cases' in body ? 'yes' : 'no'} = 'yes' + THEN ${body.test_cases ? JSON.stringify(body.test_cases) : null}::jsonb + ELSE test_cases + END, updated_at = now() WHERE id = ${params.qid} AND exercise_id = ${params.id} - RETURNING id, question_index, text, type, language, starter + RETURNING id, question_index, text, type, language, starter, test_cases `; if (rows.length === 0) { diff --git a/app/api/instructor/exercises/[id]/questions/route.ts b/app/api/instructor/exercises/[id]/questions/route.ts index cb4ebf9..63bf52b 100644 --- a/app/api/instructor/exercises/[id]/questions/route.ts +++ b/app/api/instructor/exercises/[id]/questions/route.ts @@ -15,7 +15,7 @@ export async function GET( } const rows = await sql` - SELECT id, question_index, text, type, language, starter, updated_at + SELECT id, question_index, text, type, language, starter, test_cases, updated_at FROM questions WHERE exercise_id = ${params.id} ORDER BY question_index diff --git a/app/instructor/exercises/[id]/QuestionManager.tsx b/app/instructor/exercises/[id]/QuestionManager.tsx index 5d05f72..cbf0a23 100644 --- a/app/instructor/exercises/[id]/QuestionManager.tsx +++ b/app/instructor/exercises/[id]/QuestionManager.tsx @@ -4,6 +4,7 @@ import { useState, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import { toast } from 'sonner'; import { Plus, Trash2, Edit3, ChevronDown, ChevronUp, Save, X, Upload, FileText, RefreshCw, AlertTriangle } from 'lucide-react'; +import TestCaseEditor, { type TestCase } from './TestCaseEditor'; interface Question { id: string; @@ -12,6 +13,7 @@ interface Question { type: 'written' | 'code'; language: string; starter: string; + test_cases?: TestCase[] | null; } const CODE_EXERCISE_SLUGS = new Set(['ascii-art', 'ascii-art-web', 'go-reloaded']); @@ -263,6 +265,14 @@ export default function QuestionManager({ exerciseId, exerciseSlug, initialQuest
{q.starter}
)} + {q.type === 'code' && ( + setQuestions((qs) => qs.map((x) => x.id === q.id ? { ...x, test_cases: updated } : x))} + /> + )} )} diff --git a/app/instructor/exercises/[id]/TestCaseEditor.tsx b/app/instructor/exercises/[id]/TestCaseEditor.tsx new file mode 100644 index 0000000..373508d --- /dev/null +++ b/app/instructor/exercises/[id]/TestCaseEditor.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useState } from 'react'; +import { Plus, Trash2, Save, X } from 'lucide-react'; +import { toast } from 'sonner'; + +export interface TestCase { + input: string; + expected_output: string; +} + +interface Props { + exerciseId: string; + questionId: string; + initialTestCases: TestCase[]; + onSaved?: (testCases: TestCase[]) => void; +} + +async function saveTestCases(exerciseId: string, questionId: string, testCases: TestCase[]): Promise { + const res = await fetch(`/api/instructor/exercises/${exerciseId}/questions/${questionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ test_cases: testCases }), + }); + if (!res.ok) throw new Error(await res.text()); +} + +export default function TestCaseEditor({ exerciseId, questionId, initialTestCases, onSaved }: Props) { + const [testCases, setTestCases] = useState(initialTestCases ?? []); + const [newInput, setNewInput] = useState(''); + const [newExpected, setNewExpected] = useState(''); + const [inputError, setInputError] = useState(''); + const [saving, setSaving] = useState<'add' | number | null>(null); + const [editingIndex, setEditingIndex] = useState(null); + const [editDraft, setEditDraft] = useState({ input: '', expected_output: '' }); + + async function handleAdd() { + if (!newInput.trim()) { + setInputError('Input is required'); + return; + } + setInputError(''); + const updated = [...testCases, { input: newInput, expected_output: newExpected }]; + setSaving('add'); + try { + await saveTestCases(exerciseId, questionId, updated); + setTestCases(updated); + setNewInput(''); + setNewExpected(''); + onSaved?.(updated); + toast.success('Test case added'); + } catch { + toast.error('Failed to save test case'); + } finally { + setSaving(null); + } + } + + async function handleDelete(index: number) { + const updated = testCases.filter((_, i) => i !== index); + setSaving(index); + try { + await saveTestCases(exerciseId, questionId, updated); + setTestCases(updated); + onSaved?.(updated); + toast.success('Test case removed'); + } catch { + toast.error('Failed to remove test case'); + } finally { + setSaving(null); + } + } + + async function handleSaveEdit(index: number) { + if (!editDraft.input.trim()) { + toast.error('Input is required'); + return; + } + const updated = testCases.map((tc, i) => (i === index ? editDraft : tc)); + setSaving(index); + try { + await saveTestCases(exerciseId, questionId, updated); + setTestCases(updated); + setEditingIndex(null); + onSaved?.(updated); + toast.success('Test case updated'); + } catch { + toast.error('Failed to update test case'); + } finally { + setSaving(null); + } + } + + return ( +
+
+ Test Cases ({testCases.length}) +
+ + {/* Existing test cases */} + {testCases.length > 0 && ( +
+ {testCases.map((tc, i) => ( +
+ {editingIndex === i ? ( +
+
+
+ +