diff --git a/app/api/run-code/route.ts b/app/api/run-code/route.ts index a04aa99..d0e9e6e 100644 --- a/app/api/run-code/route.ts +++ b/app/api/run-code/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; import { readFileSync } from 'fs'; import path from 'path'; @@ -34,14 +35,22 @@ export async function POST(req: NextRequest) { }, { status: 503 }); } - let body: { code: string; language: string; stdin?: string; exercise?: string }; + let body: { + code: string; + language: string; + stdin?: string; + exercise?: string; + session_id?: string; + question_index?: number; + test_cases?: Array<{ input: string; expected_output: string }>; + }; try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } - const { code, language, stdin = '', exercise = '' } = body; + const { code, language, stdin = '', exercise = '', session_id, question_index, test_cases } = body; if (!code || typeof code !== 'string') { return NextResponse.json({ error: 'code is required' }, { status: 400 }); @@ -86,6 +95,22 @@ export async function POST(req: NextRequest) { // Categorize errors for better user feedback const categorized = categorizeRunResult(result); + + // If test cases provided, run each one and evaluate correctness + if (test_cases && test_cases.length > 0) { + const testResults = await runTestCases(code, language, exercise, test_cases, RUNNER_URL); + const allPassed = testResults.every((r) => r.passed); + + // Persist tests_passed on the submission if session context provided + if (session_id != null && question_index != null) { + await persistTestResult(session_id, question_index, allPassed, session.user.id).catch((err) => { + console.error('[run-code] Failed to persist test result:', err); + }); + } + + return NextResponse.json({ ...categorized, test_results: testResults, tests_passed: allPassed }); + } + return NextResponse.json(categorized); } catch (err) { console.error('[run-code] fetch error:', (err as Error).message); @@ -149,3 +174,69 @@ function categorizeRunResult(result: RunResult): RunResult { return result; } + +interface TestCaseResult { + input: string; + expected: string; + actual: string; + passed: boolean; + error?: string; +} + +async function runTestCases( + code: string, + language: string, + exercise: string, + testCases: Array<{ input: string; expected_output: string }>, + runnerUrl: string +): Promise { + const results: TestCaseResult[] = []; + + for (const tc of testCases) { + try { + const res = await fetchWithRetry(`${runnerUrl}/run`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(process.env.RUNNER_API_KEY ? { 'Authorization': `Bearer ${process.env.RUNNER_API_KEY}` } : {}), + }, + body: JSON.stringify({ code, language, stdin: tc.input }), + }); + + if (!res.ok) { + results.push({ input: tc.input, expected: tc.expected_output, actual: '', passed: false, error: 'Runner error' }); + continue; + } + + const data = await res.json() as { stdout: string; stderr: string; exit_code: number; compile_output: string }; + const actual = (data.stdout ?? '').trim(); + const expected = tc.expected_output.trim(); + results.push({ input: tc.input, expected, actual, passed: actual === expected }); + } catch (err) { + results.push({ input: tc.input, expected: tc.expected_output, actual: '', passed: false, error: (err as Error).message }); + } + } + + return results; +} + +async function persistTestResult( + sessionId: string, + questionIndex: number, + testsPassed: boolean, + userId: string +): Promise { + // Verify session belongs to user + const sessionRows = await sql` + SELECT id FROM sessions WHERE id = ${sessionId} AND user_id = ${userId} LIMIT 1 + `; + if (sessionRows.length === 0) return; + + // Upsert submission with tests_passed result + await sql` + INSERT INTO submissions (session_id, question_index, response_text, submitted_at, tests_passed) + VALUES (${sessionId}, ${questionIndex}, '', now(), ${testsPassed}) + ON CONFLICT (session_id, question_index) + DO UPDATE SET tests_passed = ${testsPassed} + `; +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 89b895e..fa81d51 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -23,7 +23,7 @@ function LoginForm() { const searchParams = useSearchParams(); setLoading(false); if (!result || result.error) { - toast.error(result?.error, { + toast.error(result?.error ?? 'Sign in failed', { style: { border: 'red 1px solid', background: '#ef44440f', diff --git a/app/participant/page.tsx b/app/participant/page.tsx index d77b31f..05f6c2b 100644 --- a/app/participant/page.tsx +++ b/app/participant/page.tsx @@ -27,6 +27,7 @@ export default async function ExerciseCataloguePage() { const userId = session.user.id; let exercises: Exercise[] = []; let fetchError = false; + let fetchErrorMessage = ''; let sessionStatusMap = new Map(); let sessionScoreMap = new Map(); let sessionPassedMap = new Map(); @@ -42,8 +43,14 @@ export default async function ExerciseCataloguePage() { ORDER BY e.title `; exercises = rows as unknown as Exercise[]; + } catch (err) { + fetchError = true; + fetchErrorMessage = (err as Error).message ?? String(err); + console.error('[participant/page] Failed to load exercises:', err); + } - if (exercises.length > 0) { + if (!fetchError && exercises.length > 0) { + try { const ids = exercises.map((e) => e.id); const sessions = await sql` SELECT exercise_id, closed_at, score, passed, @@ -84,9 +91,10 @@ export default async function ExerciseCataloguePage() { sessionFailReasonsMap.set(eid, reasons); } } + } catch (err) { + // Session data failed — exercises still show, just without status + console.error('[participant/page] Failed to load session data:', err); } - } catch { - fetchError = true; } return ( @@ -101,7 +109,7 @@ export default async function ExerciseCataloguePage() { {fetchError && (
- Failed to load exercises. Please refresh the page. + Failed to load exercises: {fetchErrorMessage || 'Unknown error'}. Please refresh the page.
)} diff --git a/app/participant/session/[id]/CodeEditor.tsx b/app/participant/session/[id]/CodeEditor.tsx index 46c5f52..307c2db 100644 --- a/app/participant/session/[id]/CodeEditor.tsx +++ b/app/participant/session/[id]/CodeEditor.tsx @@ -85,6 +85,10 @@ interface RunResult { stderr: string; compile_output: string; exit_code: number | null; + error_category?: string; + error_message?: string; + tests_passed?: boolean; + test_results?: Array<{ input: string; expected: string; actual: string; passed: boolean }>; } interface EditEvent { @@ -330,10 +334,19 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter const res = await fetch('/api/run-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code: codeRef.current, language, stdin, exercise: exerciseSlug }), + body: JSON.stringify({ + code: codeRef.current, + language, + stdin, + exercise: exerciseSlug, + // Pass context so server can run test cases and persist result + session_id: sessionId, + question_index: questionIndex, + test_cases: testCases, + }), }); const data = await res.json(); - const runResult = res.ok ? data as RunResult : { stdout: '', stderr: data.error ?? 'Unknown error', compile_output: '', exit_code: 1 }; + const runResult = res.ok ? data as RunResult : { stdout: '', stderr: data.error ?? 'Unknown error', compile_output: '', exit_code: 1, test_results: undefined as RunResult['test_results'] }; setResult(runResult); // Surface compile errors as syntax warning if (runResult.compile_output && runResult.compile_output.trim().length > 0) { @@ -341,6 +354,10 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter } else { setSyntaxWarning(null); } + // If server ran test cases, show results inline + if (runResult.test_results) { + setTestResults(runResult.test_results); + } } catch (err) { setResult({ stdout: '', stderr: (err as Error).message, compile_output: '', exit_code: 1 }); } finally { @@ -355,24 +372,28 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter if (!testCases || testCases.length === 0) return; setRunningTests(true); setTestResults(null); - const results: Array<{ input: string; expected: string; actual: string; passed: boolean }> = []; - for (const tc of testCases) { - try { - const res = await fetch('/api/run-code', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code: codeRef.current, language, stdin: tc.input, exercise: exerciseSlug }), - }); - const data = await res.json(); - const actual = (data.stdout ?? '').trim(); - const expected = tc.expected_output.trim(); - results.push({ input: tc.input, expected, actual, passed: actual === expected }); - } catch { - results.push({ input: tc.input, expected: tc.expected_output, actual: 'Error', passed: false }); + try { + const res = await fetch('/api/run-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: codeRef.current, + language, + exercise: exerciseSlug, + session_id: sessionId, + question_index: questionIndex, + test_cases: testCases, + }), + }); + const data = await res.json(); + if (data.test_results) { + setTestResults(data.test_results); } + } catch (err) { + setTestResults(testCases.map((tc) => ({ input: tc.input, expected: tc.expected_output, actual: 'Error', passed: false }))); + } finally { + setRunningTests(false); } - setTestResults(results); - setRunningTests(false); } return ( diff --git a/app/participant/session/[id]/SessionView.tsx b/app/participant/session/[id]/SessionView.tsx index 33b69ae..ce195fd 100644 --- a/app/participant/session/[id]/SessionView.tsx +++ b/app/participant/session/[id]/SessionView.tsx @@ -119,47 +119,35 @@ export default function SessionView({ exerciseId }: { exerciseId: string }) { // Local countdown — ticks every second, synced from server every 10s const [localRemaining, setLocalRemaining] = useState(null); - // Centralized focus tracking to prevent duplicates + // Centralized focus tracking — use visibilitychange only to avoid double-firing useEffect(() => { if (!sessionState || sessionClosed) return; let focusLostAt: number | null = null; - const recordFocusLoss = () => { - if (focusLostAt !== null) return; // Already lost - focusLostAt = Date.now(); - }; - - const recordFocusRegain = async () => { - if (focusLostAt === null) return; // Wasn't lost - const duration = Date.now() - focusLostAt; - focusLostAt = null; - - try { - await fetch('/api/events/focus', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionState.session_id, duration_ms: duration }), - }); - } catch (err) { - console.error('Failed to record focus event:', err); + const handleVisibility = async () => { + if (document.visibilityState === 'hidden') { + // Tab hidden — record loss time + if (focusLostAt === null) focusLostAt = Date.now(); + } else { + // Tab visible again — record the event + if (focusLostAt === null) return; + const duration = Date.now() - focusLostAt; + focusLostAt = null; + try { + await fetch('/api/events/focus', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionState.session_id, duration_ms: duration }), + }); + } catch (err) { + console.error('Failed to record focus event:', err); + } } }; - const handleVisibility = () => { - if (document.visibilityState === 'hidden') recordFocusLoss(); - else recordFocusRegain(); - }; - document.addEventListener('visibilitychange', handleVisibility); - window.addEventListener('blur', recordFocusLoss); - window.addEventListener('focus', recordFocusRegain); - - return () => { - document.removeEventListener('visibilitychange', handleVisibility); - window.removeEventListener('blur', recordFocusLoss); - window.removeEventListener('focus', recordFocusRegain); - }; + return () => document.removeEventListener('visibilitychange', handleVisibility); }, [sessionState, sessionClosed]); const fetchSession = useCallback(async () => { @@ -370,6 +358,39 @@ export default function SessionView({ exerciseId }: { exerciseId: string }) { ) :
} + {/* When viewing a non-current (skipped/previous) question — allow submitting it as final */} + {!isViewingCurrent && !sessionClosed && ( +
+ {advanceError && {advanceError}} + +
+ )} + {isViewingCurrent && sessionState.current_question_index + 1 < sessionState.question_count && (
{advanceError && {advanceError}} diff --git a/lib/auth.ts b/lib/auth.ts index 63c731d..3ec6aa3 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -27,13 +27,13 @@ export const authOptions: NextAuthOptions = { `; const user = rows[0]; - if (!user) return null; + if (!user) throw new Error('Invalid username or password'); const passwordMatch = await bcrypt.compare( credentials.password, user.password_hash as string ); - if (!passwordMatch) return null; + if (!passwordMatch) throw new Error('Incorrect password'); return { id: user.id as string, diff --git a/lib/scoring.ts b/lib/scoring.ts index 15e48d5..72f9f41 100644 --- a/lib/scoring.ts +++ b/lib/scoring.ts @@ -46,16 +46,25 @@ export async function recalculateSessionScore(sessionId: string): Promise 0 - OR EXISTS (SELECT 1 FROM edit_events ee WHERE ee.submission_id = submissions.id) + SELECT SUM(CASE WHEN sub.is_final AND ( + -- Written question or code with no test cases: presence check + (q.test_cases IS NULL AND ( + LENGTH(TRIM(COALESCE(sub.response_text, ''))) > 0 + OR EXISTS (SELECT 1 FROM edit_events ee WHERE ee.submission_id = sub.id) + )) + OR + -- Code question with test cases: must have passed all tests + (q.test_cases IS NOT NULL AND sub.tests_passed = true) ) THEN 1 ELSE 0 END)::int AS count - FROM submissions - WHERE session_id = ${sessionId} + FROM submissions sub + LEFT JOIN sessions sess ON sess.id = sub.session_id + LEFT JOIN questions q ON q.exercise_id = sess.exercise_id AND q.question_index = sub.question_index + WHERE sub.session_id = ${sessionId} `; const finalCount = (finalRows[0]?.count as number) ?? 0; diff --git a/migrations/0005_scoring_and_flags.sql b/migrations/0005_scoring_and_flags.sql new file mode 100644 index 0000000..e643a59 --- /dev/null +++ b/migrations/0005_scoring_and_flags.sql @@ -0,0 +1,15 @@ +-- Migration 0005: scoring, pass/fail constraints, and flag controls + +-- Pass/fail result per session +ALTER TABLE sessions + ADD COLUMN IF NOT EXISTS passed BOOLEAN DEFAULT NULL, + ADD COLUMN IF NOT EXISTS passed_override BOOLEAN DEFAULT NULL; + +-- Per-exercise pass/fail constraints +ALTER TABLE exercises + ADD COLUMN IF NOT EXISTS min_questions_required INTEGER DEFAULT NULL, + ADD COLUMN IF NOT EXISTS flag_fails BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS max_paste_chars INTEGER DEFAULT NULL, + ADD COLUMN IF NOT EXISTS max_focus_loss INTEGER DEFAULT NULL, + ADD COLUMN IF NOT EXISTS min_edit_events INTEGER DEFAULT NULL, + ADD COLUMN IF NOT EXISTS min_response_length INTEGER DEFAULT NULL; diff --git a/migrations/0006_submissions_extras.sql b/migrations/0006_submissions_extras.sql new file mode 100644 index 0000000..2cb854d --- /dev/null +++ b/migrations/0006_submissions_extras.sql @@ -0,0 +1,5 @@ +-- Migration 0006: additional submission fields + +-- Manual pass override per submission +ALTER TABLE submissions + ADD COLUMN IF NOT EXISTS manually_passed BOOLEAN DEFAULT NULL; diff --git a/migrations/0014_submission_tests_passed.sql b/migrations/0014_submission_tests_passed.sql new file mode 100644 index 0000000..4a1f473 --- /dev/null +++ b/migrations/0014_submission_tests_passed.sql @@ -0,0 +1,6 @@ +-- Migration: Add tests_passed column to submissions +-- NULL = no test cases configured (not applicable) +-- TRUE = all test cases passed +-- FALSE = test cases exist but not all passed yet +ALTER TABLE submissions + ADD COLUMN IF NOT EXISTS tests_passed BOOLEAN DEFAULT NULL; diff --git a/migrations/prod_db_1.zip b/migrations/prod_db_1.zip new file mode 100644 index 0000000..135732a Binary files /dev/null and b/migrations/prod_db_1.zip differ diff --git a/migrations/run_all.sql b/migrations/run_all.sql new file mode 100644 index 0000000..d5e4b4c --- /dev/null +++ b/migrations/run_all.sql @@ -0,0 +1,221 @@ +-- ============================================================ +-- Master migration script — safe to run on any DB at any state +-- All statements use IF NOT EXISTS / IF EXISTS guards +-- Run with: psql $DATABASE_URL -f migrations/run_all.sql +-- ============================================================ + +-- ── 0001: Initial schema ───────────────────────────────────── + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('participant', 'instructor')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS exercises ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT false, + question_count INT NOT NULL +); + +CREATE TABLE IF NOT EXISTS exercise_assignments ( + exercise_id UUID REFERENCES exercises(id), + user_id UUID REFERENCES users(id), + PRIMARY KEY (exercise_id, user_id) +); + +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + exercise_id UUID NOT NULL REFERENCES exercises(id), + user_id UUID NOT NULL REFERENCES users(id), + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + duration_limit INTERVAL, + started_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + current_question_index INT NOT NULL DEFAULT 0, + UNIQUE (exercise_id, user_id) +); + +CREATE TABLE IF NOT EXISTS submissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(id), + question_index INT NOT NULL, + response_text TEXT NOT NULL DEFAULT '', + is_final BOOLEAN NOT NULL DEFAULT false, + is_flagged BOOLEAN NOT NULL DEFAULT false, + flag_reasons TEXT[], + review_note TEXT, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (session_id, question_index) +); + +CREATE TABLE IF NOT EXISTS autosave_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES submissions(id), + response_text TEXT NOT NULL, + saved_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS paste_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES submissions(id), + char_count INT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS focus_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(id), + lost_at TIMESTAMPTZ NOT NULL, + regained_at TIMESTAMPTZ, + duration_ms INT +); + +CREATE TABLE IF NOT EXISTS edit_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES submissions(id), + event_type TEXT NOT NULL CHECK (event_type IN ('insert', 'delete')), + position INT NOT NULL, + char_count INT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL +); + +-- ── 0002: Audit log ────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_id UUID NOT NULL REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + detail JSONB, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS audit_log_actor_idx ON audit_log (actor_id); +CREATE INDEX IF NOT EXISTS audit_log_target_idx ON audit_log (target_type, target_id); +CREATE INDEX IF NOT EXISTS audit_log_time_idx ON audit_log (occurred_at DESC); + +-- ── 0003: Exercise timing ──────────────────────────────────── + +ALTER TABLE exercises + ADD COLUMN IF NOT EXISTS start_time TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS end_time TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS duration_limit INTERVAL; + +-- ── 0004: Questions table ──────────────────────────────────── + +CREATE TABLE IF NOT EXISTS questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + question_index INT NOT NULL, + text TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'written' CHECK (type IN ('written', 'code')), + language TEXT NOT NULL DEFAULT 'text', + starter TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (exercise_id, question_index) +); + +CREATE INDEX IF NOT EXISTS questions_exercise_idx ON questions (exercise_id, question_index); + +-- ── 0005: Scoring and flags ────────────────────────────────── + +ALTER TABLE sessions + ADD COLUMN IF NOT EXISTS passed BOOLEAN DEFAULT NULL, + ADD COLUMN IF NOT EXISTS passed_override BOOLEAN DEFAULT NULL; + +ALTER TABLE exercises + ADD COLUMN IF NOT EXISTS min_questions_required INTEGER DEFAULT NULL, + ADD COLUMN IF NOT EXISTS flag_fails BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS max_paste_chars INTEGER DEFAULT NULL, + ADD COLUMN IF NOT EXISTS max_focus_loss INTEGER DEFAULT NULL, + ADD COLUMN IF NOT EXISTS min_edit_events INTEGER DEFAULT NULL, + ADD COLUMN IF NOT EXISTS min_response_length INTEGER DEFAULT NULL; + +-- ── 0006: Submission extras ────────────────────────────────── + +ALTER TABLE submissions + ADD COLUMN IF NOT EXISTS manually_passed BOOLEAN DEFAULT NULL; + +-- ── 0007: Feedback table ───────────────────────────────────── + +CREATE TABLE IF NOT EXISTS feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER CHECK (rating >= 1 AND rating <= 5), + comments TEXT, + challenges TEXT, + improvements TEXT, + malfunctions TEXT, + attachment_url TEXT, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_feedback_user ON feedback (user_id); +CREATE INDEX IF NOT EXISTS idx_feedback_submitted ON feedback (submitted_at DESC); + +-- ── 0008: Platform improvements ────────────────────────────── + +ALTER TABLE paste_events + ADD COLUMN IF NOT EXISTS pasted_text TEXT; + +ALTER TABLE exercises + ADD COLUMN IF NOT EXISTS pass_mark NUMERIC; + +ALTER TABLE sessions + ADD COLUMN IF NOT EXISTS score NUMERIC; + +ALTER TABLE submissions + ADD COLUMN IF NOT EXISTS dismissed_flags JSONB NOT NULL DEFAULT '[]'::jsonb; + +-- ── 0009: Submission status ────────────────────────────────── + +ALTER TABLE submissions + ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'not_started' + CHECK (status IN ('not_started', 'draft', 'skipped', 'final')); + +-- Backfill existing data +UPDATE submissions SET status = 'final' WHERE is_final = TRUE AND status = 'not_started'; +UPDATE submissions SET status = 'draft' + WHERE is_final = FALSE + AND status = 'not_started' + AND response_text IS NOT NULL + AND response_text != ''; + +-- ── 0010: Question points ──────────────────────────────────── + +ALTER TABLE questions + ADD COLUMN IF NOT EXISTS points INTEGER DEFAULT NULL; + +-- ── 0011: Paste event context ──────────────────────────────── + +ALTER TABLE paste_events + ADD COLUMN IF NOT EXISTS tab_was_blurred BOOLEAN DEFAULT FALSE; + +ALTER TABLE paste_events + ADD COLUMN IF NOT EXISTS source_type TEXT DEFAULT 'unknown' + CHECK (source_type IN ('internal', 'external', 'unknown')); + +-- ── 0012: Question test cases ──────────────────────────────── + +ALTER TABLE questions + ADD COLUMN IF NOT EXISTS test_cases JSONB DEFAULT NULL; + +-- ── 0013: Question package metadata ───────────────────────── + +ALTER TABLE questions + ADD COLUMN IF NOT EXISTS allowed_packages JSONB DEFAULT NULL, + ADD COLUMN IF NOT EXISTS required_package TEXT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS documentation_links JSONB DEFAULT NULL; + +-- ── 0014: Submission tests_passed ─────────────────────────── + +ALTER TABLE submissions + ADD COLUMN IF NOT EXISTS tests_passed BOOLEAN DEFAULT NULL; diff --git a/package.json b/package.json index 9b574ac..89ce372 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "recoding-exercise-platform", - "version": "0.1.0", + "version": "1.1.0", "private": true, "scripts": { "dev": "next dev", @@ -8,8 +8,8 @@ "start": "next start", "lint": "next lint", "test": "vitest run", + "migrate": "psql $DATABASE_URL -f migrations/run_all.sql", "test:watch": "vitest", - "migrate": "tsx scripts/migrate.ts", "migrate:fresh": "tsx scripts/migrate.ts fresh && tsx scripts/seed.ts && tsx scripts/import-questions.ts", "seed": "tsx scripts/seed.ts", "create-users": "tsx scripts/create-users.ts",