Skip to content
21 changes: 14 additions & 7 deletions app/api/feedback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
170 changes: 170 additions & 0 deletions app/api/instructor/exercises/[id]/batch-test-run/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<boolean> {
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 } : {}) });
}
8 changes: 6 additions & 2 deletions app/api/instructor/exercises/[id]/questions/[qid]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }); }

Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion app/api/instructor/exercises/[id]/questions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions app/instructor/exercises/[id]/QuestionManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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']);
Expand Down Expand Up @@ -263,6 +265,14 @@ export default function QuestionManager({ exerciseId, exerciseSlug, initialQuest
<pre style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '0.75rem', fontSize: 12, overflowX: 'auto', color: 'var(--text2)', fontFamily: "'Fira Code', monospace" }}>{q.starter}</pre>
</div>
)}
{q.type === 'code' && (
<TestCaseEditor
exerciseId={exerciseId}
questionId={q.id}
initialTestCases={q.test_cases ?? []}
onSaved={(updated) => setQuestions((qs) => qs.map((x) => x.id === q.id ? { ...x, test_cases: updated } : x))}
/>
)}
</div>
)}
</div>
Expand Down
Loading
Loading